SLOPSHOPPER

kessoku-ultimate-bocchi

Kessoku Ultimate Bocchi Theme: a Bocchi the Rock!-inspired charcoal-and-pink skin for all of Claude Code (an engine theme plus every render site), a live…

newpanebandspinnerrowsguard
★ 1v1.0.0MITupdated 2026-10-19Hnquangg123/kessoku-ultimate-bocchi
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kessoku-ultimate-bocchi
│ ┃ ♪ Filetree ✕ ┌─────────────────────────────────────────────────────────────────┐ │ ┃ ♪ FILETREE · app 3 lit │⟨Claude Code's own drawing⟩ │ │ ┃ package.json └─────────────────────────────────────────────────────────────────┘ │ ┃ README.md │ ┃ f: Refresh c: Close pink = editing · rose ⏺ Read(src/auth.ts) │ ┃ committed ⎿ Read 6 lines │ ╭───────────────────────────────────────────────────────────────────╮ │ │ ♫ · Kita Edit · src/auth.ts ♪ │ │ │ ⟨Claude Code's own drawing⟩ │ │ ╰───────────────────────────────────────────────────────────────────╯ │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by kessoku-ultimate-bocchi: Kessoku Blast Radius h │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │⟨Claude Code's own drawing⟩ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ ✻ Riffed for 42s · done 4:20 PM │ │ › /kessoku │ ╭────────────────────────────────────────────────────────────────── │ │ ♪ /kessoku │ │ Kessoku Band: the panel left the stage. │ ╰────────────────────────────────────────────────────────────────── │ │ ✻ Soundchecking…… ▛▄▀▀▜▄ ▄▛▙▄▀▜▖ ▗▄▀▀▜▄ ▀▟▀▀▜▄▄▐ ○ OFF AIR · backstage ▙▄▟▐▟▛▙ ▛▐▄▄▜▄▜▐ ▞▖▜▄▜▄▜ ▛▜▄▄▄▄▙▘ 0 on stage · $0.42 · cache warm 59:59 · keepwarm 5h59m ▌█▌▀▛▀▐ ▌▙▛▀▀▟▌▐ ▛▜▛▀▛▟▐ ▌▛▌▀▛▟▐▖ k: Panel h: Hide ▛▀▙▀▀▗▛ ▄▌▖▀▘▜▌▐ ▄▛▟▀▙▙▟ ▞▄▀█▀▄▗▛ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ kessoku-ultimate-bocchi: (≧▽≦) bocchi · nailed it! 0:42 · 3 edits on the setlist (/replay)

Draws

Band
▛▄▀▀▜▄ ▄▛▙▄▀▜▖ ▗▄▀▀▜▄ ▀▟▀▀▜▄▄▐ ○ OFF AIR · backstage ▙▄▟▐▟▛▙ ▛▐▄▄▜▄▜▐ ▞▖▜▄▜▄▜ ▛▜▄▄▄▄▙▘ 0 on stage · $0.42 · cache warm 59:59 · keepwarm 5h59m ▌█▌▀▛▀▐ ▌▙▛▀▀▟▌▐ ▛▜▛▀▛▟▐ ▌▛▌▀▛▟▐▖ k: Panel h: Hide ▛▀▙▀▀▗▛ ▄▌▖▀▘▜▌▐ ▄▛▟▀▙▙▟ ▞▄▀█▀▄▗▛ ⟨Claude Code's own drawing⟩
Pane · ♪ Filetree
♪ FILETREE · app 3 lit package.json README.md f: Refresh c: Close pink = editing · rose = edited · gree
Pane · ♪ Replay Theater
╭─────────────────────────────────────────────────────────── │ ▛▄▀▀▜▄▐▀ ♪ REPLAY THEATER · setlist #1 track │ ▙▄▟▄▟▄▄ 1 2 3 │ ▌█▌▀▛▟▐ Bocchi · src/auth.ts · Edit · +2 -1 │ ▛▀▙▀▀▗▛ │ 1 -if (!claims) throw new Error('invalid token') │ 1 +if (!claims || claims.exp < Date.now() / 1000) throw │ 2 + await audit('refresh', claims.sub) │ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] ╰───────────────────────────────────────────────────────────
Pane · kessoku-band
♪ KESSOKU BAND ♪ ○ OFF AIR no set yet ♪ ▛▄▀▀▜▄▗▄ ▄▛▙▄▀▜▖ ▗▄▀▀▜▄ ▄▀▀▜▄▄▐ ▙▄▟▐▟▛▙ ▛▐▄▄▜▄▜▐ ▞▖▜▄▜▄▜ ▛▜▄▄▄▄▙▘ ▌█▌▀▛▀▐ ▌▙▛▀▀▟▌▐ ▛▜▛▀▛▟▐ ▌▛▌▀▛▟▐▖ ▛▀▙▀▀▗▛ ▄▌▖▀▘▜▌▐ ▄▛▟▀▙▙▟ ▞▄▀█▀▄▗▛ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ Bocchi Nijika Ryo Kita ▰▰▱▱ 49% ┌────────────────────────────────────────────────────────── │ ▯ b: solo-bocchi │ ▯ $ cat .env ≈$0.00 │ ▯ ctx ▰▰▰▱▱▱ 49% · in 0 · out 0 └────────────────────────────────────────────────────────── ┌────────────────────────────────────────────────────────┐ │ ▯ n: solo-nijika │ │ ▯ no act on stage │ └────────────────────────────────────────────────────────┘ ┌────────────────────────────────────────────────────────┐ │ ▯ r: solo-ryo │ │ ▯ no act on stage │ └────────────────────────────────────────────────────────┘ ┌────────────────────────────────────────────────────────┐ │ ▯ k: solo-kita │ │ ▯ no act on stage │ └────────────────────────────────────────────────────────┘ solo b n r k CTX ▰▰▰▰▰▱▱▱▱▱ 49% · PLAN ▰▰▱▱▱▱ 31% · GATE $0.42 · AMP ●… ✖ last wrong note: Bash: src/auth.test.ts: ✓ refreshes ex… cost ≈ list prices × tokens, not your bill · /kessoku toggles
Pane · kessoku-blast-radius
the guitar is safe.
Your message
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ │⟨Claude Code's own drawing⟩ │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘
Claude's reply
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ │⟨Claude Code's own drawing⟩ │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘
Tool row
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ♫ · Kita Edit · src/auth.ts ♪ │ │ ⟨Claude Code's own drawing⟩ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ♪ /kessoku │ │ Kessoku Band: the panel left the stage. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

Kessoku Ultimate Bocchi Theme

A *Bocchi the Rock!*-inspired mod for Claude Code. It dresses all of Claude Code in charcoal and pink, puts the Kessoku Band on stage as animated pixel-art sprites, seats Claude's subagents with the band, and adds five themed gags that each do a real job: Cache Tax, Blast Radius, Filetree, Reflect and Replay Theater.

Bocchi plays the main agent (the Claude you talk to). Nijika, Ryo and Kita take its subagents, by name or by what they were asked, and Bocchi takes any they don't (see How acts are seated). Every subagent is a slime in its member's colour.

The Kessoku Band in every mood, from the sprite studio's showcase

The four of them in every mood, from the sprite studio. The live house itself is described under Kessoku Band panel.

Version 1.0.0, the Live Stage. Needs Claude Code 2.1.287 or newer (tested on 2.1.299; see Notes); /bocchi help shows the mod's version and the Claude Code it runs on.

Quick start

At the prompt of a Claude Code terminal session:

/plugin install kessoku-ultimate-bocchi --marketplace Hnquangg123/kessoku-ultimate-bocchi

Answer y to add the marketplace, press Enter on the user scope, set the options (the defaults are fine), and the mod is active in that session and every later one. From a shell, or in the desktop app where /plugin does not run, see Installing.

Then try three things:

  1. Put the band on stage. /kessoku opens the panel (it opens by itself where there is room for a sidebar), and /bocchi demo puts five sample subagents (acts) on stage: playing, waiting, done and failed. /bocchi solo ryo opens Ryo's solo, every act of hers in full; /bocchi demo off clears the stage.
  2. Put on the theme. /bocchi theme on. The mod also offers it once, on first run.
  3. Give the band real work. Ask Claude: "have Ryo find where the config file is read", then watch a blue slime appear at Ryo's feet and the act land on her channel (a new song on the setlist, in the desktop app).

The gags have previews: /bocchi crash (the dropped guitar), /bocchi tax (Cache Tax), /bocchi memo (a band memo) and /bocchi setlist (Replay Theater). /bocchi help lists every command.

Words the band uses

  • Main loop: the Claude you talk to, played by Bocchi.
  • Act: one subagent, seated with one member. It is a slime at her feet, and a song on the desktop's setlist.
  • Set: one turn of the main loop, numbered from the session's first (set 3).
  • Pit: where the slimes stand, the live acts and those that ended in the last 30 s: at each member's feet on the desktop stage, one row for the whole band in a terminal.
  • Solo: one member alone, with every act in full: her page on the desktop, her channel in a terminal. The mix is the whole band again.
  • Encore: the 8 s after an act ends, while it takes its bow.

New in 1.0.0

  • STARRY, the live house. In the desktop app the Kessoku Band panel opens on a stage: the band at their instruments, a spotlight on whoever plays, each member's acts as slimes at her feet, then a chalkboard setlist where every act is a song. See On the desktop.
  • A stage and a mixer in a terminal: a marquee, the four heads, the pit, then a channel per member. See In a terminal.
  • Solo and encore. Open one member alone (Solo), with Auto solo to do it for you. An act that just ended takes an 8 s bow: a sparkle and ✦ encore! on the desktop, a bold line in her colour in a terminal.
  • Tracks, a neon sign, a band memo. Tool rows read as numbered tracks of the set, the strip above the prompt is a neon now-playing sign on the desktop, and Nijika's memo wears a backstage-pass stripe.
  • Moods. Bocchi's melt is now a calm 5 s loop, and she also hides when a call is refused or the cache goes cold. See Moods.

Reference: Claude Code theme · Skin · The band and its moods · Kessoku Band panel · The strip · Cache Tax · Blast Radius · Filetree · Reflect · Replay Theater · Notifications · Commands · Keys · Settings · Installing · Tweaking and development · Notes

Claude Code theme

The mod ships two real Claude Code themes, so what Claude Code draws itself wears the band's colours too: Kessoku Bocchi (themes/kessoku-bocchi.json) and its light twin Kessoku Bocchi Light (themes/kessoku-bocchi-light.json). Both are listed in /theme.

They colour:

  • the brand and the text
  • the prompt border and its modes (permission, plan mode, auto-accept, bash, fast mode)
  • success, error and warning
  • the diffs, in dark green and rose tints that read under pink and white text
  • the fullscreen backgrounds, selection and the usage meter
  • the speaker labels, the eight subagent colours and the ultrathink rainbow

How to put it on and take it off:

  • The offer. On first run, with the Kessoku engine theme option on, the mod asks once per machine in Claude Code's question dialog: Yes, tune up or Not now (okay. /bocchi theme on any time). It does not ask when the theme is already on or your organization locks the theme row.
  • /bocchi theme on switches the /config theme row to Kessoku Bocchi, or to the light twin when your current theme is a light one, and remembers the theme you had.
  • /bocchi theme off puts that theme back. It restores only a theme the mod itself replaced; otherwise it says nothing to restore.
  • /bocchi theme (or /bocchi theme status) says which theme is on and whether Kessoku is listed in /theme.
  • A theme row locked by your organization stays as it is.
  • The theme copy. Where Claude Code does not list the mod's own theme, /bocchi theme on writes a copy to ~/.claude/themes/ (kessoku-bocchi.json or kessoku-bocchi-light.json; under $CLAUDE_CONFIG_DIR/themes/ when that is set) and selects it. The copy stays after an uninstall; delete it there if you like.

Skin

The mod restyles Claude Code's own transcript, spinner, footer, hint line and dialogs. How much is the Transcript skin option.

full (the default) adds, on top of everything light does:

  • a bar in the accent beside each prompt you type, and one in Bocchi's pink beside her replies (dim on a summary)
  • a ♪ on folded tool groups (accent while active, rose at rest)
  • the output of the mod's own commands in a rounded card titled ♪ /<command> <args> (red on an error)
  • Nijika asks the band, with her head, over the question dialog

light keeps to the small lines:

  • the spinner's suffix: ♪ while a tool runs, ♫ while Claude responds
  • with Band spinner words on (terminal only), a verb for what the turn is doing, such as Tuning while thinking, Counting in while requesting, Plucking while a tool's input streams, Shredding while a tool runs and Wailing while responding
  • ♪ kessoku among the footer's modes
  • a band word closing each turn: Encored for 3s, Jammed, Shredded, Soundchecked and friends
  • ♪ and Bocchi's face at the end of the hint line under the prompt, plus /bocchi help between turns
  • let Ryo take it backstage in place of Claude Code's run in background hint
  • ♪ before info notices

none leaves all of these alone.

Tool rows as tracks

With Tool row cards on all, every tool row gets a track header:

♪ 03 ·  Ryo  Read · hooks/register.tsx                          0:01
  • The track number counts the main loop's calls from 1 each turn. A subagent's calls get none.
  • The member's name sits on a tag in her colour.
  • At the right: playing while it runs, cut off when interrupted, wrong note in red on an error, else the call's length.
  • Edits (♫: Edit, Write, NotebookEdit) are framed in a rounded border: accent while playing, rose at rest, red on an error. Every other call is one header line over Claude Code's own row.
  • An edit's result gets a ♫ setlist · <file> card with Kita's verdict: Kita approves · +12 -3, or Kita missed a chord.
  • The memo tool's row is Nijika's: ♪ kessoku memo · proposing a rule, then Nijika holds it for Save.

edits keeps only the edit and memo rows; none leaves Claude Code's rows untouched.

Who a row names:

MemberTools
NijikaAgent, Workflow, the Task tools, TodoWrite, AskUserQuestion, Skill, plan mode, ScheduleWakeup, SendMessage, ListAgents, the memo tool
RyoGrep, Glob, Bash, PowerShell, Read, WebSearch, WebFetch, ToolSearch, Monitor, LSP, MCP resources, worktrees, every mcp__ tool
KitaWrite, Edit, NotebookEdit, the Artifact tools, SendUserFile, DesignSync, ClaudeDesign
Bocchieverything else

The rest

  • Status line. Bocchi's face and what she is up to: <face> bocchi · <what she is doing>. See Moods.
  • Charcoal backgrounds behind the mod's own panels and cards. Never painted on a light theme.
  • Accent for borders and highlights. The default, claude, follows the active theme.
  • Restyle other mods (off by default): other mods' bordered boxes with no border colour get the accent, and their uncoloured text the theme's text colour.

The band and its moods

MemberPlaysOn the panelColour
Bocchi (Hitori Gotoh)lead guitarmain agent · heavy taskspink
Nijika (Nijika Ijichi)drumscoordinator · schedulingyellow
Ryo (Ryo Yamada)bassbackground · refactors · dbblue
Kita (Ikuyo Kita)vocals & rhythmui · frontend · docscoral

Sprites

  • 96x96 figures at the density of the reference art: Bocchi's standing pose is drawn from it pixel for pixel, and the band in the same hand. Each has seven poses, eight faces, box peeks and hair sway.
  • 32x32 portraits, and a hand-drawn 16x16 mini head each for the terminal.
  • Six moods each, each a short loop at ten frames a second, the band in step with Bocchi.
  • Drawn in studio/, animated and rendered with Remotion, and shipped inside the mod as data. Nothing is fetched or rendered at run time.

Where they are drawn:

  • Desktop app, VS Code extension and mobile app. The band stands on the live-house stage at 2x (3x once the stage is 900 px wide). The strip's sign carries one portrait and her page a 2x polaroid. The small spots (the dropped-guitar screen, Cache Tax, the memo, Replay Theater, the question dialog) draw a crisp animated picture.
  • Terminal. Half-block pixels where there is room (a mini head in 16x8 cells, every colour kept) and quadrant blocks in the small spots (8x4), repainted ten times a second, only while a sprite is on screen. True colour shows them best.
  • The status line and toasts take only text, so they keep the kaomoji.
  • Character art set to kaomoji turns every picture off: text faces everywhere, no slimes.

Slimes

Every act is a slime in its member's colours, 36x24, drawn from bead patterns. Bocchi's has her hair clips and two faces on one body. The main loop is never a slime.

StateWhenLooks
activethe act runsbounces (Bocchi's wide-eyed)
idlepending or waitingbreathes
asleepcompleted, failed or stoppedbreathes slower, a z drifting up

A finished act stays in the pit for 30 s. On the desktop stage an act in its encore and a failure from this set are drawn awake: the encore with a sparkle on its shoulder, the failure under a red ✗ bubble. In a terminal a slime is its 18x12 mini (9x3 cells).

Moods

Bocchi's mood shows on the status line, on stage and across the panel.

MoodFaceHer loopWhen
anxious` (;´Д) ``a breathing idle with nervous glancesidle
focused( •_•)♪strumming, notes risinga turn is running
happy(≧▽≦)a jump with sparkles6 s after a turn ends with an answer: nailed it! 2:14 · 3 edits on the setlist (/replay)
panic(゚Д゚;)shaking, sweat flying8 s after a turn stops on anything but an answer or an interrupt; 5 s after one of the main loop's tool calls fails (wrong note in Bash); while Blast Radius holds a command; while Cache Tax holds a send
melting(x_x)…a calm 5 s sustained meltcontext at 85 % or more; while the conversation is compacted, by /compact or automatically
hiding[(._.)]peeking out of a cardboard box8 s after you interrupt a turn; 6 s after a call is refused; while the cache sits cold between turns
  • Refused means you turned down one of the main loop's calls, or a hook refused it: told no… hiding in the box. The mod's own memo tool, calls another plugin makes, and anything while Blast Radius holds a command do not count.
  • Cold cache. Checked each minute. Between turns, once the prompt cache has expired, she goes into her box: the cache went cold… in the box till your next prompt. Your next prompt brings her out, and so does /clear. She stays in across a reload.
  • When a timed mood ends she goes back to melting (compacting, or at 85 %), focused (a turn running), hiding (the cache cold) or anxious.
  • The melt is the one long mood, so it is calm: she stays about half melted all loop, her face and guitar above the puddle. A drop gathers and splats, she blinks and sags, then tries to pull herself up (guitar back, focus face on), fails and slumps back. (Before 1.0.0 she collapsed into a flat puddle and re-formed every 2.8 s.)
  • /bocchi mood <mood> tries a face on the status line for 15 s.

The other three take their mood from their acts:

  • focused while she has a live act
  • through the 8 s encore of her latest act: happy if it finished, panic if it hit an error, hiding if it was stopped
  • anxious otherwise

Only Bocchi melts on stage. The others' melt (a 2.8 s sink into a puddle and back) shows in the studio and the GIF.

Kessoku Band panel

/kessoku toggles the pane ♪ Kessoku Band (Kessoku Band: the panel is on stage.). Where no pane fits, the panel is drawn compact above the prompt, with a Hide button. With Panel on start on, the pane opens by itself where Claude Code seats it as a sidebar (144 columns, or 110 once you have opened it yourself); elsewhere a toast says once: /kessoku opens the band panel.

On the desktop: STARRY, the live house

Drawn in the desktop app, the VS Code extension and the mobile app, with Character art on sprites. Top to bottom:

The stage. One picture that fills the panel's width at any zoom. It is a picture only: it takes no clicks and no hover. To open a member's page use the buttons under the board, and for an act's details hover its song.

  • A brick wall, a wooden deck and a carpeted drum riser. On the riser: Nijika seated behind her kit (a bass drum with the band's star, two rack toms, a floor tom, a snare, a hi-hat, a ride and a crash) and Ryo with her bass rig. On the deck in front: Bocchi by her half-stack and Kita at her mic stand.
  • Above them: a truss of four light cans, string lights in the band's colours, the neon Kessoku Band sign with its star, and a LIVE/OFF lamp.
  • A cone of light in her colour comes up on a member only while she plays: Bocchi while a turn runs, the others while they have a live act. With nobody lit, a warm house light fills the stage. An encore stays dark.
  • The neon sign and the lamp (LIVE, red, breathing) are lit while a turn runs. Between turns the sign is a dark tube, with a glow behind it while someone plays. Nijika's cymbals glint while she plays.
  • Each member's pit sits at her feet: one to three slimes (her live acts and those that ended in the last 30 s), then a +n badge. A failure from this set stays on stage under a red ✗ bubble; an encore wears a sparkle.
  • A member in her box: the box stands where she stands, Nijika's behind the kit.
  • The stage shows no clock, cost or tool, so it changes only when a mood, a light, a slime or the solo does, and its animations never restart on a tick. Under a reduced-motion setting its light effects stop (the sign's hum, the LIVE lamp's breathing, an encore's twinkle, the cymbals' glint); the band's own loops still play.

The plates. One per member: her name and spend, then her status: ♪ main loop, ♪ 2 playing, ⧗ 1 waiting, ✦ encore!, ✓ 3 done, 1 stopped, in her box or resting, and always ✗ 1 failed in red. ctx N% shows while she plays, and always (in red) past 85 %.

The chalkboard setlist. The head reads Setlist, set N and the session's cost. Then one song per act:

  • a chalk mark: ♪ playing, ⧗ waiting, ✓ done (with a sparkle through its encore), ✗ failed, — stopped
  • the title in her chalk colour, up to three lines, with the time at the right: 0:48 while it plays, ran 2:40 once it ended
  • a context bar (red past 85 %), the cost, whose song it is, and now Grep or last Read
  • the main loop's song is titled by the prompt that started the set, and labelled main loop; before the first set it reads the main conversation

The order is the main loop while it plays, then playing, waiting, this set's failures, then the rest, newest first. Eight songs at most; the rest fold into one line at the foot.

A failed act gets a red ✗, a wavy red underline, its reason on a line of its own (its own error, ended in an error or refused to go on) and a tilted FAILED stamp. An act that hit a tool error and carried on gets an amber ⚠ last error: … line instead: it did not fail.

The foot shows main ctx, limit N% (when there is a rate-limit figure) and the cache (warm, 48m left, cold for 12m, kept warm, 5m left, no cache yet), over a chalk tray. A solo page with no songs on it says the band is tuning up.

Hover a song for its details: the whole title, its tools by count with tally strokes, the model (sonnet 5.5 · 7 steps · 12.4k tokens), the act's type and when it ran (started 0:48 ago, ended at 3:42 of the 6:02 set), and a button, Open on Ryo's page →, which opens her solo with that act first. The main loop's song has no button.

Buttons under the board: one per member, labelled with her name (hotkey b, n, r or k); Back to the mix (m) on a solo; Hide (h) above the prompt. Above the prompt the stage is left out and the board lists three songs.

With Character art on kaomoji, the desktop draws the terminal's layout below, with text faces.

In a terminal

Top to bottom:

  • Marquee. ♪ KESSOKU BAND ♪, then ● ON AIR (white on red) while a turn runs or ○ OFF AIR, then set 3 · 2:14 or no set yet. On a solo, solo: Ryo at the right.
  • Stage. The four mini heads over a ▔ line: half blocks where the pane is roomy (76 columns and 34 rows), quadrants elsewhere. Under each head, her name on a tag in her colour, ×2 while she has two acts live, and her context as a meter (Bocchi's is the main loop's; the others show their fullest live act's). There are no spotlights here; on a solo the other heads dim.
  • Pit. One row of up to six slime minis from the whole band, then +n. Under it, each member's initial with a numbered button per act (1, 2, …) that solos her with that act first.
  • Mixer. A channel per member, two to a row (one to a row below 60 columns or above the prompt), framed in her colour, dim while she rests. Each channel has:
  • a VU column, lit to her share of the session's spend in eighths (the top two red at 7/8 or more)
  • her name as a button (b n r k), and her state: playing, head down, 2 playing, set done or her mood line
  • for Bocchi, the main loop first: the set's current task and its cost, then ctx ▰▰▱▱▱▱ 34% with its tokens in and out
  • her acts, live first: ♪ Find every call site of legacyQuery() with its age and ≈$0.04 at the right, then ctx ▰▰▱▱▱▱ 18%. Marks: ♪ live, ✖ failed or hit a tool error, ◌ stopped, ✓ done. Three acts for Bocchi and four for the others (one and two above the prompt), then + n more.
  • Encore. For 8 s after an act ends, its line is bold in her colour with encore! · 2:40 · ≈$0.11 at the right. A failed one reads wrong note · 2:40 in red.
  • Keys row. solo b n r k, a mix button (m) on a solo, and Hide (h) above the prompt.
  • Rail. CTX ▰▰▰▱▱▱▱▱▱▱ 34% (red at 85 %) · PLAN (the plan's fullest rate-limit window, only when there is one) · GATE $0.42 (the session's cost) · AMP and the cache (● keepwarm 2h00m, ❄ cold, ☀ warm 48:00, ○ no cache yet) · 3 on stage.
  • ✖ last wrong note: … in red when there is one, and the footer cost ≈ list prices × tokens, not your bill · /kessoku toggles.

With kaomoji, each slot is her face (red in a panic, grey while melting) over her tag and meter, and her pit is glyphs: ♪ live, ✖ error, ✓ done.

Solo

One member alone, every act in full.

  • Open it: her name button, or her letter (b n r k) while the panel has the focus; an act's number under the pit (terminal) or Open on <Name>'s page → (desktop), which list that act first; /bocchi solo <member> from the prompt; or Auto solo.
  • Close it: her button again, m (Back to the mix, mix), /bocchi solo off, or /clear.
  • When it closes by itself: 8 s after the last of her acts ends, once none of hers is still playing. Bocchi's closes that way only when an act seated with her ends while no turn runs; the end of a set does not close it. A solo on a member with no acts stays until you close it, and a new act of hers (for Bocchi, a new turn) keeps a solo you chose open.
  • On the desktop a solo is her page:
  • the head reads Ryo’s page with bass · background · refactors · db, a summary (3 acts · $0.31 · 1 playing · 2 done) and her portrait as a 2x polaroid
  • her songs only (Bocchi's are the main loop and her own acts), each expanded with its details
  • also tonight at the foot, with how the others are doing
  • on stage only her cone is up, a little wider; the other three are darkened
  • In a terminal her channel runs across the console beside her figure:
  • up to eight act cards, each with the act, its type, encore! or why it stopped, its age, a ten-step context meter, tokens, cost, when it started or ended, its top six tools (Grep ×4 · Read ×3) and now: <tool>
  • the other three folded to one line each
  • r or m: back to the mix (her own letter) to say the way out
  • Auto solo (off by default): when a member takes a new subagent, her solo opens for 10 s with that act, then the mix returns. It never cuts short a solo you chose.

The band as subagent types

The band is also three subagent types Claude can delegate to by name, registered at each session start and listed beside general-purpose, Explore and Plan. Bocchi has none: she is the main loop.

TypeForToolsGives back
kessoku-ultimate-bocchi:nijikaplans, reviews, triage, summariesRead, Grep, Glob, WebSearch, WebFetcha numbered plan or a verdict with path:line; edits nothing, runs nothing
kessoku-ultimate-bocchi:ryosearches, shells, tests, lint, type checks, "find where X happens"Read, Grep, Glob, Bash, PowerShell, WebSearch, WebFetchshort lines with path:line

| kessoku-ultimate-bocchi:kita | edits to named files: docs, README sections, UI copy, comments | Read, Edit, Write | every file

Source 14 files
hooks/register.tsx 3810 lines
1// Kessoku Ultimate Bocchi Theme: the hooks module.
2//
3// One module, nine features, every `on(...)` and `$.noun.method(...)` spelled
4// here so the host can read them off the source; the pure parts (the look,
5// the price table, the risk classifier, the diff, the notebook, the tree
6// shape) live under ./lib.
7//
8//   theme     the engine themes shipped in themes/ (a dark and a light twin),
9//             offered once and switched on through the /config theme row, so
10//             the prompt border, the diffs, the status colours and every other
11//             thing the engine draws turn charcoal and pink.
12//   skin      every render site: tool rows framed in pink, the person's and
13//             the assistant's messages with a pink bar, folded tool groups,
14//             the spinner's verb and the turn footer borrowed from the band,
15//             the hint line, the question dialog, `♪ kessoku` in the footer,
16//             and Bocchi's face in the status line.
17//   band      the Kessoku Band panel (`/kessoku`): subagents mapped to
18//             Nijika, Ryo and Kita by their band agent type (registered at
19//             session.start, so the model picks a member by name) or else by
20//             what they were asked, Bocchi for the main loop and heavy work,
21//             each with task, tokens, cost, status, and each drawn as a slime
22//             in its member's color (bouncing, breathing or asleep) on her
23//             card and in the strip.
24//   cache     Cache Tax: a cold prompt cache holds the next send once, with
25//             the re-write cost; `/keepwarm` pings keep it warm; `/cache-tax`.
26//   blast     Blast Radius: a risky Bash or PowerShell command (rm -rf,
27//             Remove-Item -Recurse, git reset --hard, force push, git clean,
28//             migrations, destroys) is held behind the dropped guitar screen
29//             listing what would go, Cancel focused.
30//   tree      Filetree (`/filetree`): the project's files, lit pink while
31//             Claude edits them, neon green once a git commit lands.
32//   reflect   a correction becomes a band memo from Nijika offering to save
33//             the rule to CLAUDE.md (project or global); Claude can propose a
34//             rule itself through the memo tool, and the person still saves it.
35//   replay    Replay Theater (`/replay`): the last turn's edits as a setlist,
36//             one unified diff at a time, with the file's real line numbers.
37//   bocchi    `/bocchi theme|demo|memo|tax|crash|mood <m>|strip|help` previews
38//             each piece so the look can be tweaked without waiting for the real thing.
39
40import { atom, read, update } from 'claude-code'
41import type { EngineInterface, PluginOptions, Register, RenderInput, Timer } from 'claude-code'
42
43import type {
44  KessokuAgent,
45  KessokuBand,
46  KessokuBlast,
47  KessokuCache,
48  KessokuLight,
49  KessokuMember,
50  KessokuMemo,
51  KessokuMood,
52  KessokuProposal,
53  KessokuReplay,
54  KessokuTrack,
55  KessokuTree,
56  KessokuUi,
57  KessokuUsage,
58} from '../types'
59import {
60  GUITAR,
61  MEMBERS,
62  NOTES,
63  PALETTE,
64  SHIMMER,
65  SPINNER_WORDS,
66  SPINNER_WORDS_BY_MODE,
67  THEME_NAME,
68  THEME_NAME_LIGHT,
69  THEME_OVERRIDES,
70  THEME_OVERRIDES_LIGHT,
71  THEME_SLUG,
72  THEME_SLUG_LIGHT,
73  TURN_WORDS,
74  padFace,
75  pick,
76  textWidth,
77} from './lib/palette'
78import {
79  BAND_AGENTS,
80  EMPTY_USAGE,
81  addUsage,
82  cacheWriteUsd,
83  costOf,
84  describeCall,
85  fmtDuration,
86  fmtTokens,
87  fmtUsd,
88  memberFor,
89  memberForTool,
90  meter,
91  parseDuration,
92  priceInfo,
93  seatOf,
94  slimeState,
95} from './lib/band'
96import { LIST_MAX, classify, classifyPowerShell, cleanArgs, globToRegExp, isGitCommit, size } from './lib/blast'
97import type { Risk } from './lib/blast'
98import { cutHunks, patchToUnified, unifiedDiff } from './lib/diff'
99import type { Unified } from './lib/diff'
100import { offLine, versionLine } from './lib/engine'
101import type { FeatureKey } from './lib/engine'
102import { MEMO_QUEUE_MAX, MEMO_TOOL, MEMO_TOOL_SPEC, appendRule, checkRule, cleanRule, looksLikeCorrection, memoAnswer, rulePrompt } from './lib/reflect'
103import { completions } from './lib/typeahead'
104import { boardFootSvg, boardHeadSvg, boardLayout, boardPlan, layoutFor, platesSvg, SCENE_COLORS, songDetailSvg, songLook, songSvg, stageSvg, stripSvg } from './lib/scene'
105import type { SceneSvg } from './lib/scene'
106import type { ActView, PanelView } from './lib/stage'
107import { BAND_ORDER, panelView, ENCORE_MS, SLIME_TAIL_MS, SOLO_KEYS, actsInOrder, actsOf, bandOf, channelState, isEncore, isLive, liveCount, memberMood, nowPlaying, pitActs, setLength, slotContext, soloNow, uiOf, vuLevel } from './lib/stage'
108import { CRASH, CRASH_MINI, animOf, cellsFor, composite, encodeCells, frameSignature, isAnimated, spriteId, spritesSvg, viewSize } from './lib/sprite'
109import type { CellMode, MemberView, SlimeView, SpritePart, StageLayer } from './lib/sprite'
110import { ALWAYS_IGNORED, IGNORED_DIRS, MAX_PATHS, baseName, isAnyHot, isGlowing, isInside, isLit, relPath, treeRows, windowRows } from './lib/tree'
111
112// ---- Names, atoms, options --------------------------------------------------
113
114const PANEL = 'kessoku-band'
115const TREE = 'kessoku-filetree'
116const BLAST = 'kessoku-blast-radius'
117const REPLAY = 'kessoku-replay'
118
119const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
120const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
121const OWN_COMMANDS = new Set(['kessoku', 'filetree', 'replay', 'keepwarm', 'cache-tax', 'bocchi'])
122const HOLD_LIMIT_MS = 10 * 60 * 1000
123const POLL_SECONDS = '0.25'
124const POLL_MS = 400
125const MAX_DIFF_LINES = 14
126const MAX_TRACK_LINES = 120
127const MAX_TRACKS = 40
128const MAX_AGENTS = 24
129const MEASURE_CAP = 5000
130const HELD_PROMPT_MS = 15 * 60_000
131/** Below this many context tokens a cold send is too cheap to hold for, or to warn about. */
132const TAX_MIN_TOKENS = 20_000
133/** A set at least this long may have been left to play alone: its end is worth a notification. */
134const SET_AWAY_MS = 30_000
135/** The cache about to go cold is notified this long before it lapses (half the TTL when that is shorter). */
136const COLD_NOTICE_MS = 5 * 60_000
137/** What heads every native notification. */
138const NOTIFY_TITLE = 'Kessoku Band'
139/** How long an automatic solo (the autoSolo option) stays up. */
140const AUTO_SOLO_MS = 10_000
141/** The acts a channel lists at most (then `+ n more`), and a solo's cards. */
142const CHANNEL_ACTS = 4
143const SOLO_ACTS = 8
144
145function mainAgent(): KessokuAgent {
146  return {
147    id: 'main',
148    member: 'bocchi',
149    description: 'the main conversation',
150    type: 'main',
151    status: 'idle',
152    task: '',
153    startedAt: 0,
154    endedAt: null,
155    model: '',
156    usage: EMPTY_USAGE,
157    context: 0,
158    steps: 0,
159    tools: 0,
160    cost: 0,
161    isError: false,
162    toolTally: {},
163    lastTool: '',
164  }
165}
166
167const INITIAL_BAND: KessokuBand = {
168  agents: [mainAgent()],
169  mood: 'anxious',
170  mainTask: '',
171  contextPercent: 0,
172  window: 0,
173  sessionUsd: 0,
174  rateLimitPercent: 0,
175  isWorking: false,
176  lastError: '',
177  set: 0,
178  setStartedAt: 0,
179  setPrompt: '',
180}
181const INITIAL_CACHE: KessokuCache = {
182  lastWarmAt: 0,
183  contextTokens: 0,
184  model: '',
185  keepWarmUntil: 0,
186  isArmed: false,
187  heldAt: 0,
188  heldText: '',
189  heldUsd: 0,
190  warmUsd: 0,
191  pings: 0,
192  pingUsd: 0,
193  misses: 0,
194  lastPingNote: '',
195  engineWriteUsd: 0,
196  engineModel: '',
197}
198const INITIAL_TREE: KessokuTree = { root: '', paths: [], lights: [], isOpen: false, isBand: false, builtAt: 0 }
199const INITIAL_REPLAY: KessokuReplay = { tracks: [], index: 0, isOpen: false, inBand: false, setlist: 0 }
200const INITIAL_UI: KessokuUi = { isPanelOpen: false, isStripHidden: false, panelWhere: 'none', solo: null, soloAct: null, soloUntil: 0, soloChosen: false }
201
202const band = atom({ plugin: 'kessoku-ultimate-bocchi', key: 'band' } as const, INITIAL_BAND)
203const cache = atom({ plugin: 'kessoku-ultimate-bocchi', key: 'cache' } as const, INITIAL_CACHE)
204const blast = atom({ plugin: 'kessoku-ultimate-bocchi', key: 'blast' } as const, null)
205const tree = atom({ plugin: 'kessoku-ultimate-bocchi', key: 'tree' } as const, INITIAL_TREE)
206const frame = atom({ plugin: 'kessoku-ultimate-bocchi', key: 'frame' } as const, 0)
207const memo = atom({ plugin: 'kessoku-ultimate-bocchi', key: 'memo' } as const, null)
208const proposals = atom({ plugin: 'kessoku-ultimate-bocchi', key: 'proposals' } as const, [] as KessokuProposal[])
209const replay = atom({ plugin: 'kessoku-ultimate-bocchi', key: 'replay' } as const, INITIAL_REPLAY)
210const ui = atom({ plugin: 'kessoku-ultimate-bocchi', key: 'ui' } as const, INITIAL_UI)
211
212type Skin = 'full' | 'light' | 'none'
213type Toasts = 'all' | 'important' | 'none'
214type CharacterArt = 'sprites' | 'kaomoji'
215
216type Opts = {
217  applyTheme: boolean
218  transcriptSkin: Skin
219  characterArt: CharacterArt
220  restyleOthers: boolean
221  toasts: Toasts
222  accent: string
223  paint: boolean
224  toolCards: 'all' | 'edits' | 'none'
225  spinnerWords: boolean
226  ttlMs: number
227  isTtlDefault: boolean
228  keepWarmMs: number
229  reflect: boolean
230  openPanel: boolean
231  autoSolo: boolean
232}
233
234function readOptions(options: PluginOptions): Opts {
235  const accent = typeof options.accent === 'string' && options.accent.trim() !== '' ? options.accent.trim() : 'claude'
236  const cards = options.toolCards
237  const skin = options.transcriptSkin
238  const toasts = options.toasts
239  const ttl = Number(options.cacheTtlMinutes)
240  return {
241    applyTheme: options.applyTheme !== false,
242    transcriptSkin: skin === 'light' || skin === 'none' ? skin : 'full',
243    characterArt: options.characterArt === 'kaomoji' ? 'kaomoji' : 'sprites',
244    restyleOthers: options.restyleOthers === true,
245    toasts: toasts === 'important' || toasts === 'none' ? toasts : 'all',
246    accent,
247    paint: options.paintBackgrounds !== false,
248    toolCards: cards === 'edits' || cards === 'none' ? cards : 'all',
249    spinnerWords: options.spinnerWords !== false,
250    ttlMs: Math.max(1, ttl || 60) * 60_000,
251    isTtlDefault: !Number.isFinite(ttl) || ttl === 60,
252    keepWarmMs: Math.max(1, Number(options.keepWarmMinutes) || 360) * 60_000,
253    reflect: options.reflect !== false,
254    openPanel: options.openPanelOnStart !== false,
255    autoSolo: options.autoSolo === true,
256  }
257}
258
259// ---- Module state (lost on a hot reload; the drawings read $.state) ----------
260
261type Held = { command: string; risk: Risk; decision: string | null }
262
263let opts: Opts = readOptions({})
264let held: Held | null = null
265let isInteractive = true
266let hasSurface = false
267let uiStarted = false
268let isLightTheme = false
269let cwd = ''
270let ttlOverrideMs: number | null = null
271const badSleepers = new Set<string>()
272let lastStatus = ''
273let tickTimer: Timer | null = null
274let shimmerTimer: Timer | null = null
275let pruneTimer: Timer | null = null
276let keepWarmTimer: Timer | null = null
277let moodTimer: Timer | null = null
278let memoTimer: Timer | null = null
279let pendingTracks: KessokuTrack[] = []
280/** The set's tool calls by their id: the track number and when each began (for the row's length). Reset at turn.start. */
281const trackNumbers = new Map<string, { n: number; startedAt: number; endedAt?: number }>()
282let trackCount = 0
283/** The main conversation is being compacted: Bocchi melts, and nothing pings the cache meanwhile. */
284let isCompacting = false
285/** The warm period (its `lastWarmAt`) the cold-cache notice went out for, so it goes once per period. */
286let coldNoticeFor = 0
287/** The engine's release (`2.1.299`), '' when it could not say; the mod's own version from its manifest, '' when unread. */
288let engineVersion = ''
289let modVersion = ''
290/**
291 * The gate: each feature that leans on a newer engine call, on until the
292 * call turns out not to exist (a TypeError where it is first made); a feature
293 * that is off calls nothing more, and `/bocchi help` names it. The hooks
294 * (`session.compact`, `prompt.autocomplete`) cannot be gated: an engine that
295 * does not know an event refuses the whole module at load.
296 */
297const features: Record<FeatureKey, boolean> = { suggest: true, notify: true, version: true, agents: true, memo: true }
298/** The band's agent types as the engine named them (`kessoku-ultimate-bocchi:ryo`), by member; filled at each session.start. */
299const bandTypes = new Map<string, string>()
300
301/**
302 * A sprite a terminal drawing holds as a Raster, repainted by the sprite
303 * clock: `gen` is the drawing of its site that drew it, `sig` the frames it
304 * shows, `refusedAt` when the surface last turned a repaint down (0 while it
305 * takes them), `triedAt` the last attempt.
306 */
307type LiveSprite = { requestId: string; key: string; parts: SpritePart[]; w: number; h: number; mode: CellMode; bg: string | undefined; sig: string; gen: number; drawnAt: number; refusedAt: number; triedAt: number }
308const liveSprites = new Map<string, LiveSprite>()
309/** How many times each site has drawn: a sprite that a site's latest drawing left out is gone. */
310const spriteSites = new Map<string, number>()
311/** The band's site, which, like the panes, lasts the session. */
312let bandRequestId = ''
313/** Panes closed since they were last opened: a drawing of one that lands late keeps its Rasters still. */
314const closedSites = new Set<string>()
315let spriteTimer: Timer | null = null
316/** The sprites' frames are 100 ms apart (the studio renders at 10 fps). */
317const SPRITE_TICK_MS = 100
318/** A refused repaint is tried again this often: a pane shown again from a tab, or the band expanded again, takes its animation back. */
319const SPRITE_RETRY_MS = 1000
320/** A passing site (a dialog) whose sprite has been refused this long is given up; a pane or the band is tried until it closes or redraws. */
321const SPRITE_GIVE_UP_MS = 60_000
322/** The strip's row of slimes holds this many at most; the rest are counted `+n`. */
323const STRIP_SLIMES = 6
324/** The redraw that takes a sleeping act off the strip when its half minute is up (no state changes then), and when it is due. */
325let tailTimer: Timer | null = null
326let tailTimerAt = 0
327/** Every redraw the drawings asked for, soonest first when armed. */
328const tailDues: number[] = []
329
330type Site = RenderInput<'Pane'> | RenderInput<'AbovePrompt'>
331type Draw = JSX.Element
332
333// ---- Small helpers -----------------------------------------------------------
334
335function ttlMs(): number {
336  return ttlOverrideMs ?? opts.ttlMs
337}
338
339/** The charcoal behind the theme's own boxes: only when asked, and never over a light theme. */
340function bg(light = false): string | undefined {
341  if (!opts.paint || isLightTheme) return undefined
342  return light ? PALETTE.charcoalLight : PALETTE.charcoal
343}
344
345/** A toast, by how much the person wanted to hear: `important` ones always (unless none), `chatter` only under `all`. */
346function toast($: EngineInterface, text: string, level: 'important' | 'chatter' = 'important'): void {
347  if (opts.toasts === 'none') return
348  if (opts.toasts === 'important' && level === 'chatter') return
349  $.ui.toast(text)
350}
351
352/**
353 * A native notification through the person's own channel (`$.ui.notify`),
354 * titled with the band, for the things worth coming back for. Silent when
355 * toasts are off; a channel that cannot send (the desktop app, an SDK host,
356 * notifications turned off) or a failed call reads as not sent, and the
357 * caller decides what else to show.
358 */
359async function notify($: EngineInterface, text: string): Promise<boolean> {
360  if (opts.toasts === 'none' || !features.notify) return false
361  try {
362    return (await $.ui.notify(text, { title: NOTIFY_TITLE })).isSent
363  } catch (error) {
364    if (isMissingApi(error)) features.notify = false
365    return false
366  }
367}
368
369function isPersonPrompt(kind: string): boolean {
370  return kind === 'composer' || kind === 'bridge' || kind === 'sdk' || kind === 'unclassified'
371}
372
373/** Whether someone can see a screen and answer: the REPL, or a client that attached (the desktop app, a phone). */
374function canDraw(): boolean {
375  return isInteractive || hasSurface
376}
377
378/** Whether a person can press Enter again: the Cache Tax hold needs a composer in front of someone. */
379function canResend(kind: string): boolean {
380  return canDraw() && (kind === 'composer' || kind === 'bridge' || kind === 'sdk')
381}
382
383function isLightValue(value: unknown): boolean {
384  return typeof value === 'string' && /light/i.test(value)
385}
386
387function isAbsolutePath(path: string): boolean {
388  return /^([A-Za-z]:)?[\\/]/.test(path)
389}
390
391function joinPath(dir: string, rel: string): string {
392  return `${dir.replace(/[\\/]+$/, '')}/${rel.replace(/^[\\/]+/, '')}`
393}
394
395function patchAgent(b: KessokuBand, id: string, fn: (a: KessokuAgent) => KessokuAgent): KessokuBand {
396  return { ...b, agents: b.agents.map(a => (a.id === id ? fn(a) : a)) }
397}
398
399function trimAgents(agents: KessokuAgent[]): KessokuAgent[] {
400  if (agents.length <= MAX_AGENTS + 1) return agents
401  const main = agents.filter(a => a.id === 'main')
402  const rest = agents.filter(a => a.id !== 'main')
403  const live = rest.filter(isLive)
404  const done = rest.filter(a => !isLive(a)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
405  return [...main, ...live, ...done].slice(0, MAX_AGENTS + 1)
406}
407
408function statusLine(mood: KessokuMood, detail: string): string {
409  const m = MEMBERS.bocchi
410  return `${m.faces[mood]} bocchi · ${detail || m.lines[mood]}`
411}
412
413/** The status line, written only when it changes: the engine redraws for every write. */
414function setStatus($: EngineInterface, text: string): void {
415  if (text === lastStatus) return
416  lastStatus = text
417  $.ui.status(text)
418}
419
420function warmth(c: KessokuCache, now: number): string {
421  if (c.lastWarmAt === 0) return 'cache: nothing cached yet'
422  const left = ttlMs() - (now - c.lastWarmAt)
423  if (left <= 0) return `cache COLD ${fmtDuration(-left)} ago`
424  const keep = c.keepWarmUntil > now ? ` · keepwarm ${fmtDuration(c.keepWarmUntil - now)}` : ''
425  return `cache warm ${fmtDuration(left)}${keep}`
426}
427
428/** What re-writing the cache costs: the engine's own estimate for this model when it gave one, else the list price at the TTL's rate. */
429function coldWriteUsd(c: KessokuCache): number {
430  if (c.engineWriteUsd > 0 && c.engineModel !== '' && c.engineModel === c.model) return c.engineWriteUsd
431  return cacheWriteUsd(c.contextTokens, c.model || 'default', ttlMs())
432}
433
434function readField(input: Record<string, unknown>, key: string): string {
435  const v = input[key]
436  return typeof v === 'string' ? v : ''
437}
438
439/** The beat of the "playing" marker, from the clock rather than a counter a render hook would have to mutate. */
440function noteAt(now: number): string {
441  return NOTES[Math.floor(now / 1000) % NOTES.length] as string
442}
443
444const SLEEPERS: Readonly<Record<string, readonly string[]>> = {
445  sleep: ['sleep', POLL_SECONDS],
446  powershell: ['powershell', '-NoProfile', '-NonInteractive', '-Command', `Start-Sleep -Milliseconds ${POLL_MS}`],
447  pwsh: ['pwsh', '-NoProfile', '-NonInteractive', '-Command', `Start-Sleep -Milliseconds ${POLL_MS}`],
448}
449
450/**
451 * Waits a beat without spending the hook's budget: a child process that
452 * sleeps (`sleep` where it exists, PowerShell's Start-Sleep on Windows), each
453 * candidate tried in turn and remembered when it cannot run. Returns false
454 * when none could and the wait fell back to the clock, which the budget counts.
455 */
456async function pause($: EngineInterface): Promise<boolean> {
457  const order = (await $.env.get('OS')) === 'Windows_NT' ? ['powershell', 'pwsh', 'sleep'] : ['sleep', 'pwsh', 'powershell']
458  for (const name of order) {
459    if (badSleepers.has(name)) continue
460    try {
461      const run = await $.process.run(SLEEPERS[name] as string[], { timeoutMs: 10000 })
462      if (run.exitCode === 0) return true
463      badSleepers.add(name) // it ran and refused the arguments: not a sleeper here
464    } catch {
465      badSleepers.add(name) // it could not start
466    }
467  }
468  await $.clock.sleep(POLL_MS)
469  return false
470}
471
472async function homeDir($: EngineInterface): Promise<string | null> {
473  return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? null
474}
475
476async function configDir($: EngineInterface): Promise<string | null> {
477  const set = await $.env.get('CLAUDE_CONFIG_DIR')
478  if (set !== undefined && set !== '') return set
479  const home = await homeDir($)
480  return home === null ? null : `${home}/.claude`
481}
482
483/**
484 * Whether a failed call means the engine has no such method at all (`$.ui.notify is not a function`), as
485 * against a call that was made and refused: the former switches its feature off for the session.
486 */
487function isMissingApi(error: unknown): boolean {
488  return error instanceof TypeError && /not a function/.test(error.message)
489}
490
491/**
492 * Once per session: the engine's version (a feature off when the call is
493 * not there), and the mod's own from its manifest (read through the engine's
494 * file system: a hooks module can import no JSON, and a constant would drift
495 * from plugin.json). The other calls are probed where they are first made.
496 */
497async function readEngine($: EngineInterface): Promise<void> {
498  engineVersion = ''
499  try {
500    const v = await $.session.version()
501    engineVersion = v.base ?? v.version
502  } catch (error) {
503    if (isMissingApi(error)) features.version = false
504  }
505  modVersion = ''
506  try {
507    const manifest = JSON.parse(await $.fs.read(joinPath($.plugin.root, '.claude-plugin/plugin.json'))) as { version?: unknown }
508    if (typeof manifest.version === 'string') modVersion = manifest.version
509  } catch {
510    modVersion = ''
511  }
512}
513
514/**
515 * At each session.start: the band's agent types for the Agent tool (from the
516 * next turn on), kept by the names the engine gave them. A feature off when
517 * the engine has no `$.agent.register`; a type the engine or another plugin
518 * refuses is simply not offered.
519 */
520async function registerBand($: EngineInterface): Promise<void> {
521  if (!features.agents) return
522  for (const spec of BAND_AGENTS) {
523    try {
524      const r = await $.agent.register({ name: spec.name, description: spec.description, prompt: spec.prompt, tools: spec.tools })
525      bandTypes.set(spec.name, r.agent)
526    } catch (error) {
527      if (isMissingApi(error)) {
528        features.agents = false
529        return
530      }
531      bandTypes.delete(spec.name)
532    }
533  }
534}
535
536/** The registered name of a band type spelled another way (`kessoku:ryo`), or null: unknown, unregistered, or already the name. */
537function registeredType(type: string): string | null {
538  const seat = seatOf(type)
539  const name = seat === null ? undefined : bandTypes.get(seat)
540  return name === undefined || name === type ? null : name
541}
542
543/** At each session.start, while reflect is on: the memo tool Claude proposes a rule through. A feature off when the engine has no `$.tool.register`. */
544async function registerMemoTool($: EngineInterface): Promise<void> {
545  if (!features.memo || !opts.reflect) return
546  try {
547    await $.tool.register(MEMO_TOOL_SPEC)
548  } catch (error) {
549    if (isMissingApi(error)) features.memo = false
550  }
551}
552
553// ---- Mood and status line ----------------------------------------------------
554
555async function setMood($: EngineInterface, mood: KessokuMood, detail = '', revertMs = 0): Promise<void> {
556  await update($, band, b => ({ ...b, mood }))
557  setStatus($, statusLine(mood, detail))
558  if (moodTimer !== null) {
559    moodTimer.cancel()
560    moodTimer = null
561  }
562  if (revertMs > 0) {
563    moodTimer = $.clock.after(revertMs, () => {
564      moodTimer = null
565      void revertMood($)
566    })
567  }
568}
569
570/** Bocchi's words while she waits out a cold cache in her box. */
571const COLD_HIDING = 'the cache went cold… in the box till your next prompt'
572
573/**
574 * Whether the prompt cache has gone cold since a turn last warmed it. A
575 * keepwarm that works moves `lastWarmAt` with each ping, so it never gets
576 * here; an armed keepwarm never pings a cache that already lapsed.
577 */
578function isColdRoom(c: KessokuCache, now: number): boolean {
579  return c.lastWarmAt > 0 && now - c.lastWarmAt >= ttlMs()
580}
581
582/** Bocchi's mood when nothing is happening to her: melting past 85 % or while a compaction runs, playing while the set runs, in her box while the cache is cold, else waiting. */
583async function revertMood($: EngineInterface): Promise<void> {
584  const b = await read($, band)
585  const cold = !b.isWorking && isColdRoom(await read($, cache), await $.clock.now())
586  const mood: KessokuMood = isCompacting || b.contextPercent >= 85 ? 'melting' : b.isWorking ? 'focused' : cold ? 'hiding' : 'anxious'
587  const detail = mood === 'melting' ? (isCompacting ? 'compacting the set…' : `ctx ${b.contextPercent}% full`) : mood === 'focused' ? b.mainTask : mood === 'hiding' ? COLD_HIDING : ''
588  await update($, band, x => ({ ...x, mood }))
589  setStatus($, statusLine(mood, detail))
590}
591
592// ---- Band bookkeeping --------------------------------------------------------
593
594async function upsertAgent($: EngineInterface, agent: KessokuAgent): Promise<void> {
595  await update($, band, b => {
596    const has = b.agents.some(a => a.id === agent.id)
597    const agents = has ? b.agents.map(a => (a.id === agent.id ? { ...a, ...agent } : a)) : [...b.agents, agent]
598    return { ...b, agents: trimAgents(agents) }
599  })
600}
601
602async function noteTask($: EngineInterface, agentId: string | undefined, task: string, tool = ''): Promise<void> {
603  const id = agentId ?? 'main'
604  const b = await update($, band, x => {
605    if (!x.agents.some(a => a.id === id)) return x
606    const next = patchAgent(x, id, a => ({ ...a, task, tools: a.tools + 1, lastTool: task, toolTally: tool === '' ? (a.toolTally ?? {}) : { ...(a.toolTally ?? {}), [tool]: ((a.toolTally ?? {})[tool] ?? 0) + 1 } }))
607    return id === 'main' ? { ...next, mainTask: task } : next
608  })
609  if (agentId === undefined && b.mood === 'focused') setStatus($, statusLine('focused', task))
610}
611
612function ensureTick($: EngineInterface): void {
613  if (tickTimer !== null) return
614  tickTimer = $.clock.every(2000, () => {
615    void tick($)
616  })
617}
618
619/**
620 * Reconciles the band with the engine's own agent list. The work happens
621 * inside the updater, on the value as it stands at the write, so a usage or
622 * task patch that landed meanwhile is kept; the state is written only when
623 * something changed.
624 */
625async function tick($: EngineInterface): Promise<void> {
626  const list = await $.agent.list()
627  const now = await $.clock.now()
628  const byId = new Map(list.map(a => [a.id, a] as const))
629  const reconcile = (x: KessokuBand): { band: KessokuBand; changed: boolean } => {
630    let changed = false
631    const agents = x.agents.map(a => {
632      if (a.id === 'main') return a
633      const info = byId.get(a.id)
634      if (info === undefined || (info.status === a.status && (a.isError || info.status !== 'failed'))) return a
635      changed = true
636      const isDone = info.status === 'completed' || info.status === 'failed' || info.status === 'killed'
637      return { ...a, status: info.status, endedAt: isDone && a.endedAt === null ? now : a.endedAt, isError: a.isError || info.status === 'failed' }
638    })
639    for (const info of list) {
640      if (agents.some(a => a.id === info.id)) continue
641      changed = true
642      agents.push({
643        ...mainAgent(),
644        id: info.id,
645        member: memberFor({ type: info.type, description: info.description, prompt: '', background: false }),
646        description: info.description || info.type,
647        type: info.type,
648        status: info.status,
649        startedAt: now,
650      })
651    }
652    return { band: changed ? { ...x, agents: trimAgents(agents) } : x, changed }
653  }
654  const before = await read($, band)
655  const b = reconcile(before).changed ? await update($, band, x => reconcile(x).band) : before
656  const anyLive = b.isWorking || b.agents.some(a => a.id !== 'main' && !a.id.startsWith('demo-') && isLive(a))
657  if (!anyLive && tickTimer !== null) {
658    tickTimer.cancel()
659    tickTimer = null
660  }
661}
662
663type UsageFacts = {
664  context: { window: number; percent?: number | null }
665  cost?: { usd: number } | undefined
666  rateLimits?: ReadonlyArray<{ percentUsed: number }>
667}
668
669/** Folds the engine's figures (from `$.session.usage()` or a `session.measure` event) into the band. */
670async function applyUsage($: EngineInterface, u: UsageFacts): Promise<void> {
671  const rate = Math.max(0, ...(u.rateLimits ?? []).map(r => r.percentUsed))
672  // A figure the engine does not have is left out, never zeroed: keep the last reading then.
673  const b1 = await update($, band, b => ({ ...b, contextPercent: u.context.percent ?? b.contextPercent, window: u.context.window, sessionUsd: u.cost?.usd ?? b.sessionUsd, rateLimitPercent: Number.isFinite(rate) ? Math.round(rate) : 0 }))
674  if (b1.contextPercent >= 85 && b1.mood !== 'panic' && b1.mood !== 'melting') await setMood($, 'melting', `ctx ${b1.contextPercent}% full`)
675}
676
677async function refreshUsage($: EngineInterface): Promise<void> {
678  await applyUsage($, await $.session.usage())
679}
680
681// ---- Theme -----------------------------------------------------------------------
682
683type ThemeRow = { value: unknown; options?: readonly string[]; isLocked: boolean }
684
685async function themeRow($: EngineInterface): Promise<ThemeRow | null> {
686  try {
687    const rows = await $.config.list()
688    return rows.find(r => r.key === 'theme') ?? null
689  } catch {
690    return null
691  }
692}
693
694/** The /theme option one of the plugin's themes is listed under, whatever spelling the engine gives it; the light twin only when asked for. */
695function themeOptionOf(row: ThemeRow | null, light: boolean): string | undefined {
696  const options = row?.options ?? []
697  const mine = options.filter(o => {
698    const low = o.toLowerCase()
699    return low.includes(THEME_SLUG) || low.includes(THEME_NAME.toLowerCase())
700  })
701  return mine.find(o => /light/i.test(o) === light) ?? (light ? undefined : mine[0])
702}
703
704function isKessokuTheme(value: unknown): boolean {
705  return typeof value === 'string' && (value.toLowerCase().includes(THEME_SLUG) || value.toLowerCase().includes(THEME_NAME.toLowerCase()))
706}
707
708async function storeGet($: EngineInterface, key: string): Promise<unknown> {
709  try {
710    return await $.store.get(key)
711  } catch {
712    return undefined
713  }
714}
715
716async function storeSet($: EngineInterface, key: string, value: unknown): Promise<void> {
717  try {
718    await $.store.set(key, value)
719  } catch {
720    // the store is optional: without it the theme is just offered again next time
721  }
722}
723
724/** Writes one of the themes into the person's own themes folder, for an engine that does not list the plugin's. */
725async function writeThemeCopy($: EngineInterface, light: boolean): Promise<string | null> {
726  const dir = await configDir($)
727  if (dir === null) return null
728  const slug = light ? THEME_SLUG_LIGHT : THEME_SLUG
729  const path = `${dir}/themes/${slug}.json`
730  const theme = { name: light ? THEME_NAME_LIGHT : THEME_NAME, base: light ? 'light' : 'dark', overrides: light ? THEME_OVERRIDES_LIGHT : THEME_OVERRIDES }
731  try {
732    await $.fs.write(path, `${JSON.stringify(theme, null, 2)}\n`)
733    return path
734  } catch {
735    return null
736  }
737}
738
739/** Switches the engine's theme row to the Kessoku theme (the light twin on a light theme), remembering the one before. */
740async function applyKessokuTheme($: EngineInterface): Promise<string> {
741  let row = await themeRow($)
742  if (row === null) return 'the engine has no theme row to set here.'
743  if (row.isLocked) return 'the theme is locked by your organization, so it stays.'
744  // A light base, or the light twin itself, wants the light twin; the twin's own name says "light".
745  const light = isLightValue(row.value)
746  let mine = themeOptionOf(row, light)
747  if (mine === undefined) {
748    const copy = await writeThemeCopy($, light)
749    if (copy === null) return 'the Kessoku theme is not in the /theme list and no themes folder could be written.'
750    // The engine watches its themes folder; give it a moment (a bounded wait, cancelled with the hook).
751    try {
752      await $.clock.sleep(1500)
753    } catch {
754      // the wait was cut short; list again anyway
755    }
756    row = await themeRow($)
757    mine = themeOptionOf(row, light) ?? `custom:${light ? THEME_SLUG_LIGHT : THEME_SLUG}`
758  }
759  if (row !== null && row.value === mine) return `already on: ${mine}`
760  if (row !== null && typeof row.value === 'string' && !isKessokuTheme(row.value) && !(await storeGet($, 'themeBefore'))) await storeSet($, 'themeBefore', row.value)
761  const r = await $.config.set({ key: 'theme', value: mine })
762  if (r.deny !== undefined) return `the theme stays: ${r.deny}`
763  isLightTheme = light
764  return `on: ${mine}. /bocchi theme off puts the old one back.`
765}
766
767async function restoreTheme($: EngineInterface): Promise<string> {
768  const before = await storeGet($, 'themeBefore')
769  if (typeof before !== 'string') return 'nothing to restore: the Kessoku theme was never switched on from here.'
770  const r = await $.config.set({ key: 'theme', value: before })
771  if (r.deny !== undefined) return `could not restore ${before}: ${r.deny}`
772  try {
773    await $.store.delete('themeBefore')
774  } catch {
775    // fine
776  }
777  isLightTheme = isLightValue(before)
778  return `back to ${before}.`
779}
780
781/** Once per machine: asks before switching the theme, and remembers either answer. */
782async function offerTheme($: EngineInterface): Promise<void> {
783  if (await storeGet($, 'themeAsked')) return
784  const row = await themeRow($)
785  if (row === null || row.isLocked || isKessokuTheme(row.value)) return
786  await storeSet($, 'themeAsked', true)
787  let answer = 'Not now'
788  try {
789    const picked: unknown = await $.ui.ask(`Put on the ${THEME_NAME} theme (pink prompt, charcoal diffs, band colours everywhere)?`, { options: ['Yes, tune up', 'Not now'], header: 'Theme' })
790    answer = typeof picked === 'string' ? picked : 'Not now'
791  } catch {
792    answer = 'Not now'
793  }
794  if (!answer.startsWith('Yes')) {
795    toast($, `${MEMBERS.bocchi.faces.anxious} okay. /bocchi theme on any time`)
796    return
797  }
798  const result = await applyKessokuTheme($)
799  toast($, `${MEMBERS.kita.faces.happy} theme ${result}`)
800}
801
802// ---- Cache Tax ---------------------------------------------------------------
803
804async function keepWarmTick($: EngineInterface): Promise<void> {
805  const c = await read($, cache)
806  const b = await read($, band)
807  const now = await $.clock.now()
808  if (c.keepWarmUntil <= now || b.isWorking || isCompacting || c.lastWarmAt === 0 || c.contextTokens < 1000) return
809  const age = now - c.lastWarmAt
810  const ttl = ttlMs()
811  if (age < ttl - 90_000) return
812  // Already cold (keepwarm armed late, or the card's "keep it warm after"): the next send warms it; the pings start then.
813  if (age > ttl) return
814  const r = await $.model.fork({ prompt: 'Kessoku keep-warm ping. Reply with one word: ok' })
815  if (r.isAnswered) {
816    const u = r.usage
817    const ping = costOf(
818      { input: u.input_tokens, output: u.output_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens },
819      c.model || 'default',
820    )
821    // The fork says whether the prefix came from the cache: near zero read, the entry had lapsed and the ping paid to re-write it.
822    const wasWarm = u.cache_read_input_tokens >= c.contextTokens * 0.5
823    const landed = await $.clock.now()
824    await update($, cache, x => ({
825      ...x,
826      lastWarmAt: landed,
827      pings: x.pings + 1,
828      misses: x.misses + (wasWarm ? 0 : 1),
829      pingUsd: x.pingUsd + ping,
830      lastPingNote: wasWarm
831        ? `ping ${x.pings + 1} read ${fmtTokens(u.cache_read_input_tokens)} from cache for ${fmtUsd(ping)}`
832        : `ping ${x.pings + 1} found the cache cold and re-wrote ${fmtTokens(u.cache_creation_input_tokens)} for ${fmtUsd(ping)}`,
833    }))
834    if (!wasWarm) toast($, `${MEMBERS.bocchi.faces.panic} keepwarm: the cache had gone cold; the ping re-wrote it (${fmtUsd(ping)})`)
835  } else {
836    await update($, cache, x => ({ ...x, lastPingNote: `ping failed: ${r.reason}` }))
837  }
838}
839
840/**
841 * The cache about to go cold while the session is idle and keepwarm is off:
842 * one notification a warm period, shortly before the TTL lapses, priced as
843 * the tax would be; a toast where no channel could send it. Keepwarm's own
844 * pings make it moot while they run.
845 */
846async function coldWatch($: EngineInterface): Promise<void> {
847  const c = await read($, cache)
848  const b = await read($, band)
849  const now = await $.clock.now()
850  // The cache went cold between turns: Bocchi waits it out in her box until the next prompt brings her out. The write
851  // takes her only from waiting, so a turn that started meanwhile keeps her playing.
852  if (b.mood === 'anxious' && !b.isWorking && !isCompacting && isColdRoom(c, now)) {
853    const hid = await update($, band, x => (x.mood === 'anxious' && !x.isWorking ? { ...x, mood: 'hiding' as const } : x))
854    if (hid.mood === 'hiding') setStatus($, statusLine('hiding', COLD_HIDING))
855  }
856  // Warm again without a turn (the TTL changed with the model): she comes out. A timed hiding, an interruption's or a
857  // refusal's, ends on its own.
858  else if (b.mood === 'hiding' && moodTimer === null && !b.isWorking && !isColdRoom(c, now)) await revertMood($)
859  if (c.lastWarmAt === 0 || c.lastWarmAt === coldNoticeFor || b.isWorking || isCompacting || c.keepWarmUntil > now || c.contextTokens < TAX_MIN_TOKENS) return
860  const ttl = ttlMs()
861  const left = ttl - (now - c.lastWarmAt)
862  if (left <= 0 || left > Math.min(COLD_NOTICE_MS, ttl / 2)) return
863  coldNoticeFor = c.lastWarmAt
864  const text = `Cache Tax: the cache goes cold in ${fmtDuration(left)} · re-writing ${fmtTokens(c.contextTokens)} tokens would cost ≈${fmtUsd(coldWriteUsd(c))} · /keepwarm keeps it warm`
865  if (!(await notify($, text))) toast($, `${MEMBERS.bocchi.faces.anxious} ${text}`)
866}
867
868function cacheTaxReason(c: KessokuCache, now: number): string {
869  const age = c.lastWarmAt > 0 ? fmtDuration(now - c.lastWarmAt) : '?'
870  return (
871    `${MEMBERS.bocchi.faces.panic} CACHE TAX — the prompt cache went cold ${age} ago! ` +
872    `Sending this re-writes ${fmtTokens(c.contextTokens)} tokens ≈ ${fmtUsd(c.heldUsd)} ` +
873    `(a warm turn would have cost ≈ ${fmtUsd(c.warmUsd)}). ` +
874    `Your prompt is back in the box: press Enter again to pay it and send, or /clear and start from a note.`
875  )
876}
877
878// ---- Compaction --------------------------------------------------------------
879
880/**
881 * A compaction of the main conversation stood: the band's context figure and
882 * the main agent's tokens are the compacted set's (0 when the engine did not
883 * count it, so no stale figure survives; the next turn measures afresh), the
884 * cache has no entry for the new transcript yet (nothing warm, the compacted
885 * size to price the next write, no held prompt, no engine estimate for a
886 * prefix that is gone), and Bocchi comes back to her real face.
887 */
888async function settleCompaction($: EngineInterface, before: number | undefined, after: number | undefined): Promise<void> {
889  const tokens = after ?? 0
890  await update($, band, b => patchAgent({ ...b, contextPercent: after !== undefined && b.window > 0 ? Math.round((after / b.window) * 100) : 0 }, 'main', a => ({ ...a, context: tokens })))
891  await update($, cache, c => ({ ...c, lastWarmAt: 0, contextTokens: tokens, isArmed: false, heldAt: 0, heldText: '', heldUsd: 0, warmUsd: 0, engineWriteUsd: 0, engineModel: '' }))
892  await revertMood($)
893  const shrank = before !== undefined && after !== undefined ? `: ${fmtTokens(before)} → ${fmtTokens(after)} tokens` : ''
894  toast($, `${MEMBERS.nijika.faces.happy} Nijika: the set is compacted${shrank}`, 'chatter')
895}
896
897// ---- Blast Radius ------------------------------------------------------------
898
899type Report = { summary: string; lines: string[]; more: number; note: string }
900
901/** A `cd` target as an absolute folder, through the engine's own file system; null when it leads nowhere. */
902async function resolveDir($: EngineInterface, sessionCwd: string, dir: string): Promise<string | null> {
903  if (dir === '-') return null
904  let target = dir
905  if (target === '~' || target.startsWith('~/')) {
906    const home = await homeDir($)
907    if (home === null) return null
908    target = home + target.slice(1)
909  }
910  const abs = isAbsolutePath(target) ? target : joinPath(sessionCwd, target)
911  try {
912    const st = await $.fs.stat(abs, { resolve: true })
913    return st.kind === 'dir' ? (st.realPath ?? abs) : null
914  } catch {
915    return null
916  }
917}
918
919async function measure($: EngineInterface, risk: Risk, dir: string): Promise<Report> {
920  try {
921    if (risk.kind === 'rm') {
922      if (risk.note !== undefined) return { summary: `${risk.label} (not measured)`, lines: [], more: 0, note: risk.note }
923      return await measureRm($, risk.targets, dir)
924    }
925    if (risk.kind === 'migrate') return { summary: `run ${risk.label}`, lines: [], more: 0, note: 'Pending migrations are not listed here; the database is what changes.' }
926    if (risk.kind === 'other') return { summary: risk.label, lines: [], more: 0, note: risk.note }
927    if (risk.kind === 'git-push-force') return await measurePush($, risk.args, dir)
928    if (risk.kind === 'git-clean') return await measureClean($, risk.args, dir)
929    return await measureDiscard($, risk.kind, risk.label, dir)
930  } catch (error) {
931    return { summary: `${risk.label} (could not measure it)`, lines: [], more: 0, note: `Could not measure: ${String((error as { message?: string })?.message ?? error).slice(0, 200)}` }
932  }
933}
934
935/** The existing paths an rm target names: `~` expanded, each glob segment matched against the folder it is in. */
936async function expandTarget($: EngineInterface, dir: string, target: string): Promise<string[]> {
937  let t = target.replace(/\\/g, '/')
938  if (t === '~' || t.startsWith('~/')) {
939    const home = await homeDir($)
940    if (home === null) return []
941    t = home.replace(/\\/g, '/') + t.slice(1)
942  }
943  const absolute = isAbsolutePath(t)
944  const parts = t.split('/').filter(p => p !== '')
945  // `/var/x` walks from '' ('' + '/var'); `C:/x` from 'C:'; a relative target from the folder.
946  let bases: string[] = [absolute ? (t.startsWith('/') ? '' : `${parts.shift() ?? ''}`) : dir.replace(/[\\/]+$/, '')]
947  // Windows compares names without regard to case, so a glob there matches that way too.
948  const ignoreCase = /^[A-Za-z]:/.test(t) || /^[A-Za-z]:/.test(dir) || dir.includes('\\')
949  for (const part of parts) {
950    const re = globToRegExp(part, ignoreCase)
951    const next: string[] = []
952    for (const base of bases) {
953      if (re === null) {
954        next.push(`${base}/${part}`)
955        continue
956      }
957      let entries: Awaited<ReturnType<typeof $.fs.list>> = []
958      try {
959        entries = await $.fs.list(base === '' ? '/' : base)
960      } catch {
961        continue
962      }
963      for (const en of entries) if (re.test(en.name) && (part.startsWith('.') || !en.name.startsWith('.'))) next.push(`${base}/${en.name}`)
964    }
965    bases = next
966    if (bases.length === 0) break
967  }
968  const found: string[] = []
969  for (const p of bases) {
970    try {
971      await $.fs.stat(p)
972      found.push(p)
973    } catch {
974      // not there
975    }
976  }
977  return found
978}
979
980type Count = { files: number; bytes: number; sample: string[]; capped: boolean }
981
982async function walkCount($: EngineInterface, path: string, label: string, acc: Count): Promise<void> {
983  if (acc.files >= MEASURE_CAP) {
984    acc.capped = true
985    return
986  }
987  let st
988  try {
989    st = await $.fs.stat(path)
990  } catch {
991    return
992  }
993  if (st.kind !== 'dir' || st.isLink) {
994    acc.files += 1
995    acc.bytes += st.size
996    if (acc.sample.length < LIST_MAX) acc.sample.push(label)
997    return
998  }
999  let entries: Awaited<ReturnType<typeof $.fs.list>> = []
1000  try {
1001    entries = await $.fs.list(path)
1002  } catch {
1003    return
1004  }
1005  for (const en of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
1006    if (acc.files >= MEASURE_CAP) {
1007      acc.capped = true
1008      return
1009    }
1010    const child = `${path}/${en.name}`
1011    const childLabel = `${label}/${en.name}`
1012    if (en.kind === 'dir' && !en.isLink) await walkCount($, child, childLabel, acc)
1013    else {
1014      acc.files += 1
1015      acc.bytes += en.size
1016      if (acc.sample.length < LIST_MAX) acc.sample.push(childLabel)
1017    }
1018  }
1019}
1020
1021async function measureRm($: EngineInterface, targets: string[], dir: string): Promise<Report> {
1022  if (targets.length === 0) return { summary: 'rm with no paths', lines: [], more: 0, note: 'No paths to expand.' }
1023  const found: string[] = []
1024  for (const t of targets) found.push(...(await expandTarget($, dir, t)))
1025  if (found.length === 0) return { summary: `delete nothing: no file matches ${targets.join(' ')}`, lines: [], more: 0, note: "The paths don't exist as spelled here, so rm has nothing to remove." }
1026  const acc: Count = { files: 0, bytes: 0, sample: [], capped: false }
1027  for (const p of found) await walkCount($, p, relPath(dir, p) || baseName(p), acc)
1028  if (acc.files === 0) return { summary: `delete ${found.length} ${found.length === 1 ? 'path' : 'paths'} with no files inside`, lines: [], more: 0, note: `Paths: ${targets.join(' ')}` }
1029  return {
1030    summary: `delete ${acc.capped ? 'at least ' : ''}${acc.files} ${acc.files === 1 ? 'file' : 'files'} (about ${size(acc.bytes)})`,
1031    lines: acc.sample,
1032    more: Math.max(0, acc.files - acc.sample.length),
1033    note: `Paths: ${targets.join(' ')}${acc.capped ? ` · counting stopped at ${MEASURE_CAP} files` : ''}`,
1034  }
1035}
1036
1037async function measureClean($: EngineInterface, args: string[], dir: string): Promise<Report> {
1038  const { flags, paths } = cleanArgs(args)
1039  const run = await $.process.run(['git', 'clean', '-n', ...flags, '--', ...paths], { cwd: dir, timeoutMs: 15000 })
1040  if (run.exitCode !== 0) return { summary: 'git clean (could not dry-run it)', lines: [], more: 0, note: run.stderr.trim().slice(0, 200) }
1041  const gone = run.stdout.split('\n').filter(l => l.startsWith('Would remove ')).map(l => l.slice(13))
1042  return {
1043    summary: gone.length === 0 ? 'remove nothing: no untracked files match' : `remove ${gone.length} untracked ${gone.length === 1 ? 'path' : 'paths'}`,
1044    lines: gone.slice(0, LIST_MAX),
1045    more: Math.max(0, gone.length - LIST_MAX),
1046    note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
1047  }
1048}
1049
1050async function measureDiscard($: EngineInterface, kind: 'git-reset' | 'git-checkout', label: string, dir: string): Promise<Report> {
1051  const status = await $.process.run(['git', 'status', '--porcelain'], { cwd: dir, timeoutMs: 15000 })
1052  if (status.exitCode !== 0) return { summary: `${label} (not a git repo here?)`, lines: [], more: 0, note: status.stderr.trim().slice(0, 200) }
1053  const rows = status.stdout.split('\n').filter(l => l.length > 3 && !l.startsWith('??'))
1054  const lost = kind === 'git-reset' ? rows : rows.filter(l => l[1] !== ' ')
1055  const stat = await $.process.run(['git', 'diff', '--shortstat', kind === 'git-reset' ? 'HEAD' : '--'], { cwd: dir, timeoutMs: 15000 })
1056  return {
1057    summary: lost.length === 0 ? 'discard nothing: no uncommitted changes' : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? 'file' : 'files'}`,
1058    lines: lost.slice(0, LIST_MAX).map(l => `${l.slice(0, 2)} ${l.slice(3)}`),
1059    more: Math.max(0, lost.length - LIST_MAX),
1060    note: stat.stdout.trim() !== '' ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : 'From git status --porcelain.',
1061  }
1062}
1063
1064async function measurePush($: EngineInterface, args: string[], dir: string): Promise<Report> {
1065  const positional = args.filter(a => !a.startsWith('-'))
1066  const remote = positional[0] ?? 'origin'
1067  const spec = (positional[1] ?? '').replace(/^\+/, '')
1068  let [source, branch] = spec.includes(':') ? spec.split(':') : [spec, spec]
1069  branch = (branch ?? '').replace(/^refs\/heads\//, '')
1070  if (!branch || branch === 'HEAD') {
1071    const head = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir, timeoutMs: 10000 })
1072    branch = head.stdout.trim()
1073    source = 'HEAD'
1074  }
1075  source = source || 'HEAD'
1076  const ref = `${remote}/${branch}`
1077  const lease = args.some(a => a.startsWith('--force-with-lease') || a === '--force-if-includes')
1078  const leaseNote = lease ? ' With --force-with-lease git refuses if the remote moved since your last fetch.' : ''
1079  const known = await $.process.run(['git', 'rev-parse', '--verify', '--quiet', ref], { cwd: dir, timeoutMs: 10000 })
1080  if (known.exitCode !== 0) return { summary: `force-push to ${ref}`, lines: [], more: 0, note: `No local copy of ${ref}, so the dropped commits can't be listed. Run git fetch first.${leaseNote}` }
1081  const log = await $.process.run(['git', 'log', '--oneline', '--no-decorate', `${source}..${ref}`], { cwd: dir, timeoutMs: 15000 })
1082  const dropped = log.stdout.split('\n').filter(l => l !== '')
1083  return {
1084    summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? 'commit' : 'commits'}`,
1085    lines: dropped.slice(0, LIST_MAX),
1086    more: Math.max(0, dropped.length - LIST_MAX),
1087    note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.${leaseNote}`,
1088  }
1089}
1090
1091/** Holds a risky command behind the dropped-guitar screen until Proceed or Cancel. */
1092async function holdForDecision($: EngineInterface, signal: AbortSignal, command: string, risk: Risk): Promise<{ kind: 'proceed' } | { kind: 'deny'; reason: string }> {
1093  const mine: Held = { command, risk, decision: null }
1094  let opened = false
1095  let summary = risk.label
1096  let decision: string | null = null
1097  // Waiting on the clock spends the hook's budget; past this much of it the hold gives up with a clear word.
1098  let clockWaits = 0
1099  const waitOnce = async (): Promise<boolean> => {
1100    if (await pause($)) return true
1101    clockWaits += 1
1102    return clockWaits * POLL_MS < 6000
1103  }
1104  try {
1105    // One hold at a time: a second risky call waits for the first to be answered.
1106    while (held !== null) {
1107      if (signal.aborted) return { kind: 'deny', reason: 'Kessoku held this command and did not run it: the turn was interrupted. Do not retry it unless the user asks you to.' }
1108      if (!(await waitOnce())) {
1109        mine.decision = 'nosleeper'
1110        break
1111      }
1112    }
1113    if (mine.decision === null) held = mine
1114    const sessionCwd = await $.session.cwd()
1115    if (mine.decision !== null) throw new Error('no way to wait')
1116    const dir = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd
1117    const report: Report =
1118      dir === null
1119        ? { summary: `${risk.label} in ${risk.dir}`, lines: [], more: 0, note: `Couldn't find the folder ${risk.dir}, so what this would change couldn't be measured.` }
1120        : await measure($, risk, dir)
1121    summary = report.summary
1122    await update($, blast, () => ({ command, label: risk.label, ...report, where: 'pane' as const, isHeld: true }))
1123    reopenSprites(BLAST)
1124    const placed = await $.ui.open({ id: BLAST, title: '⚠ Dropped guitar', focus: true, closeOnEscape: true, holdToasts: true, rows: Math.min(32, (opts.characterArt === 'sprites' ? 22 : 16) + report.lines.length + (report.more ? 1 : 0)), columns: 70 })
1125    opened = true
1126    if (!placed.isPlaced) await update($, blast, s => (s === null ? s : { ...s, where: 'band' as const }))
1127    await setMood($, 'panic', 'the guitar is falling!! answer the screen')
1128    // Someone away from the window is told: the screen waits ten minutes for an answer.
1129    void notify($, `Blast Radius is holding ${risk.label}: it would ${summary}. Answer the dropped-guitar screen.`)
1130    const startedAt = await $.clock.now()
1131    while (mine.decision === null) {
1132      if (signal.aborted) {
1133        mine.decision = 'interrupted'
1134        break
1135      }
1136      if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
1137        mine.decision = 'timeout'
1138        break
1139      }
1140      if (!(await waitOnce())) {
1141        mine.decision = 'nosleeper'
1142        break
1143      }
1144    }
1145  } catch {
1146    if (mine.decision === null) mine.decision = 'error'
1147  } finally {
1148    decision = mine.decision
1149    // The pane and the state go first, so the next hold never finds the old screen; the lock is released whatever happens.
1150    try {
1151      try {
1152        if (opened) await $.ui.close({ id: BLAST })
1153      } catch {
1154        // the pane is already gone
1155      }
1156      try {
1157        await update($, blast, () => null)
1158      } catch {
1159        // the state write failed; the pane is closed
1160      }
1161    } finally {
1162      if (held === mine) held = null
1163    }
1164    void revertMood($).catch(() => undefined)
1165  }
1166  if (decision === 'proceed') {
1167    toast($, `${MEMBERS.bocchi.faces.hiding} the guitar fell. running: ${risk.label}`)
1168    return { kind: 'proceed' }
1169  }
1170  const why: Record<string, string> = {
1171    cancel: 'the user caught the guitar (Cancel)',
1172    closed: 'the user closed the guard screen',
1173    timeout: 'no answer within 10 minutes',
1174    interrupted: 'the turn was interrupted',
1175    nosleeper: 'this host has no sleep or PowerShell to wait with, so the screen could not stay up',
1176    error: 'the guard hit an error while holding it',
1177  }
1178  return {
1179    kind: 'deny',
1180    reason: `Kessoku Blast Radius held this command and did not run it: ${why[decision ?? ''] ?? 'no answer was recorded'}. It would have: ${summary}. Do not retry it unless the user asks you to.`,
1181  }
1182}
1183
1184// ---- Filetree ----------------------------------------------------------------
1185
1186async function walk($: EngineInterface, root: string, rel: string, depth: number, acc: string[]): Promise<string[]> {
1187  if (depth > 5 || acc.length >= MAX_PATHS) return acc
1188  let entries
1189  try {
1190    entries = await $.fs.list(rel === '' ? root : `${root}/${rel}`)
1191  } catch {
1192    return acc
1193  }
1194  const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name))
1195  for (const en of sorted) {
1196    if (acc.length >= MAX_PATHS) break
1197    if (en.name.startsWith('.') && en.name !== '.claude' && en.name !== '.github') continue
1198    const path = rel === '' ? en.name : `${rel}/${en.name}`
1199    if (en.kind === 'dir') {
1200      if (!IGNORED_DIRS.has(en.name)) await walk($, root, path, depth + 1, acc)
hooks/lib/palette.ts 408 lines
1// Kessoku Ultimate Bocchi Theme: the look.
2//
3// Pure data, no `$` and no `on`: the palette, the engine themes built from it
4// (a dark and a light twin), the band members, their faces per mood, and the
5// words the spinner and the turn footer borrow. This is the file to tweak.
6
7import type { KessokuMember, KessokuMood } from '../../types'
8
9/** The colors. Hex for terminals and the desktop alike. */
10export const PALETTE = {
11  pink: '#f7a8c9', // the soft pink accent (borders, highlights)
12  hotPink: '#ff69b4', // a louder pink for live things
13  rose: '#d98aa8', // pink at rest (finished tool rows)
14  blush: '#ffd6e8', // the palest pink (read glow)
15  charcoal: '#1b1b1f', // panel background
16  charcoalLight: '#26262c', // card background
17  neon: '#39ff14', // commit green
18  yellow: '#ffd166', // Nijika
19  blue: '#8fb8ff', // Ryo
20  coral: '#ff8a65', // Kita: her red, kept apart from the error red
21  red: '#ff5d73', // errors
22  lavender: '#c3a6ff', // merged, the purple seat
23  peach: '#ffb27a', // the orange seat
24  mint: '#7fe7d9', // the cyan seat
25  ink: '#ece6ee', // body text on charcoal
26  dim: '#8a8392', // quiet text
27  faint: '#5e5866', // borders at rest
28} as const
29
30/** The dark theme's slug and name: `themes/<slug>.json` in the plugin, `custom:<slug>` when copied to ~/.claude/themes. */
31export const THEME_SLUG = 'kessoku-bocchi'
32export const THEME_NAME = 'Kessoku Bocchi'
33/** The light twin, for people on a light terminal. */
34export const THEME_SLUG_LIGHT = 'kessoku-bocchi-light'
35export const THEME_NAME_LIGHT = 'Kessoku Bocchi Light'
36
37/**
38 * The dark engine theme: every documented color token of Claude Code's custom
39 * theme format, mapped onto the palette. themes/kessoku-bocchi.json is this
40 * map written out for the engine to read: regenerate it after editing here
41 * (the README's Tweaking section says how), or the two drift apart.
42 */
43export const THEME_OVERRIDES: Readonly<Record<string, string>> = {
44  // Brand and text
45  claude: PALETTE.pink,
46  claudeShimmer: PALETTE.hotPink,
47  text: PALETTE.ink,
48  inverseText: PALETTE.charcoal,
49  inactive: PALETTE.dim,
50  inactiveShimmer: '#a79fb0',
51  subtle: PALETTE.faint,
52  suggestion: PALETTE.rose,
53  remember: PALETTE.yellow,
54  // Status
55  success: PALETTE.neon,
56  error: PALETTE.red,
57  warning: PALETTE.yellow,
58  warningShimmer: '#ffe6a3',
59  merged: PALETTE.lavender,
60  // The prompt box and its modes
61  promptBorder: PALETTE.pink,
62  promptBorderShimmer: PALETTE.hotPink,
63  permission: PALETTE.hotPink,
64  permissionShimmer: PALETTE.blush,
65  planMode: PALETTE.blue,
66  autoAccept: PALETTE.rose,
67  autoAcceptShimmer: PALETTE.blush,
68  bashBorder: PALETTE.yellow,
69  ide: PALETTE.blue,
70  fastMode: PALETTE.hotPink,
71  fastModeShimmer: PALETTE.blush,
72  effortUltra: PALETTE.hotPink,
73  // Diffs: dark tints readable under pink and white text
74  diffAdded: '#1f3a22',
75  diffRemoved: '#3a1f27',
76  diffAddedDimmed: '#172a19',
77  diffRemovedDimmed: '#2a171c',
78  diffAddedWord: '#2e6b33',
79  diffRemovedWord: '#6b2e3a',
80  // Fullscreen backgrounds
81  userMessageBackground: PALETTE.charcoalLight,
82  userMessageBackgroundHover: '#2e2e36',
83  bashMessageBackgroundColor: '#2a2430',
84  memoryBackgroundColor: '#2a2a22',
85  selectionBg: '#4a2f3f',
86  // The usage meter and the speaker labels
87  rate_limit_fill: PALETTE.pink,
88  rate_limit_empty: '#3a3340',
89  briefLabelYou: PALETTE.blue,
90  briefLabelClaude: PALETTE.pink,
91  // The eight subagent seats
92  red_FOR_SUBAGENTS_ONLY: PALETTE.red,
93  blue_FOR_SUBAGENTS_ONLY: PALETTE.blue,
94  green_FOR_SUBAGENTS_ONLY: PALETTE.neon,
95  yellow_FOR_SUBAGENTS_ONLY: PALETTE.yellow,
96  purple_FOR_SUBAGENTS_ONLY: PALETTE.lavender,
97  orange_FOR_SUBAGENTS_ONLY: PALETTE.peach,
98  pink_FOR_SUBAGENTS_ONLY: PALETTE.hotPink,
99  cyan_FOR_SUBAGENTS_ONLY: PALETTE.mint,
100  // The ultrathink rainbow, in the band's colors
101  rainbow_red: PALETTE.red,
102  rainbow_orange: PALETTE.peach,
103  rainbow_yellow: PALETTE.yellow,
104  rainbow_green: PALETTE.neon,
105  rainbow_blue: PALETTE.blue,
106  rainbow_indigo: PALETTE.lavender,
107  rainbow_violet: PALETTE.hotPink,
108  rainbow_red_shimmer: '#ff93a3',
109  rainbow_orange_shimmer: '#ffd0ad',
110  rainbow_yellow_shimmer: '#ffe6a3',
111  rainbow_green_shimmer: '#9dff8a',
112  rainbow_blue_shimmer: '#bcd3ff',
113  rainbow_indigo_shimmer: '#dccbff',
114  rainbow_violet_shimmer: PALETTE.blush,
115}
116
117/** The light twin: the same roles in deeper pinks and tints that read on a pale terminal. */
118export const THEME_OVERRIDES_LIGHT: Readonly<Record<string, string>> = {
119  claude: '#c2185b',
120  claudeShimmer: '#e91e63',
121  text: '#2a2230',
122  inverseText: '#fff5f9',
123  inactive: '#7a6f80',
124  inactiveShimmer: '#9a8fa0',
125  subtle: '#b8aebe',
126  suggestion: '#ad1457',
127  remember: '#8a6d00',
128  success: '#1b7f2a',
129  error: '#c62828',
130  warning: '#9a6b00',
131  warningShimmer: '#c9a227',
132  merged: '#6a1b9a',
133  promptBorder: '#c2185b',
134  promptBorderShimmer: '#e91e63',
135  permission: '#ad1457',
136  permissionShimmer: '#f48fb1',
137  planMode: '#1e5bbf',
138  autoAccept: '#ad1457',
139  autoAcceptShimmer: '#f48fb1',
140  bashBorder: '#9a6b00',
141  ide: '#1e5bbf',
142  fastMode: '#e91e63',
143  fastModeShimmer: '#f48fb1',
144  effortUltra: '#e91e63',
145  diffAdded: '#dcf5dd',
146  diffRemoved: '#fde0e6',
147  diffAddedDimmed: '#edf9ee',
148  diffRemovedDimmed: '#fdeef1',
149  diffAddedWord: '#a9e5ad',
150  diffRemovedWord: '#f8b6c4',
151  userMessageBackground: '#fdf0f5',
152  userMessageBackgroundHover: '#fbe4ec',
153  bashMessageBackgroundColor: '#f6eef9',
154  memoryBackgroundColor: '#f7f3e4',
155  selectionBg: '#f9c9db',
156  rate_limit_fill: '#c2185b',
157  rate_limit_empty: '#f3dfe8',
158  briefLabelYou: '#1e5bbf',
159  briefLabelClaude: '#c2185b',
160  red_FOR_SUBAGENTS_ONLY: '#c62828',
161  blue_FOR_SUBAGENTS_ONLY: '#1e5bbf',
162  green_FOR_SUBAGENTS_ONLY: '#1b7f2a',
163  yellow_FOR_SUBAGENTS_ONLY: '#9a6b00',
164  purple_FOR_SUBAGENTS_ONLY: '#6a1b9a',
165  orange_FOR_SUBAGENTS_ONLY: '#c05a0f',
166  pink_FOR_SUBAGENTS_ONLY: '#e91e63',
167  cyan_FOR_SUBAGENTS_ONLY: '#00838f',
168  rainbow_red: '#c62828',
169  rainbow_orange: '#c05a0f',
170  rainbow_yellow: '#9a6b00',
171  rainbow_green: '#1b7f2a',
172  rainbow_blue: '#1e5bbf',
173  rainbow_indigo: '#6a1b9a',
174  rainbow_violet: '#e91e63',
175  rainbow_red_shimmer: '#e57373',
176  rainbow_orange_shimmer: '#f0a070',
177  rainbow_yellow_shimmer: '#d4b24c',
178  rainbow_green_shimmer: '#66bb6a',
179  rainbow_blue_shimmer: '#6f9bdf',
180  rainbow_indigo_shimmer: '#a56cc1',
181  rainbow_violet_shimmer: '#f48fb1',
182}
183
184/** The pink lights a file cycles through while it is being edited. */
185export const SHIMMER = ['#ff8fc4', '#ffb3d9', '#ffd6ea', '#ffb3d9', '#ff69b4', '#ff8fc4'] as const
186
187/** The cycle of the "playing" marker beside a working member. */
188export const NOTES = ['♪', '♫', '♩', '♬'] as const
189
190export type MemberCard = {
191  name: string
192  full: string
193  instrument: string
194  role: string
195  color: string
196  faces: Record<KessokuMood, string>
197  lines: Record<KessokuMood, string>
198}
199
200/**
201 * The band. Faces are plain text, one per mood, each at most FACE_WIDTH
202 * columns and free of combining marks, fullwidth forms and right-to-left
203 * letters, so the rows they head stay aligned on every terminal.
204 */
205export const MEMBERS: Record<KessokuMember, MemberCard> = {
206  bocchi: {
207    name: 'Bocchi',
208    full: 'Hitori Gotoh',
209    instrument: 'lead guitar',
210    role: 'main agent · heavy tasks',
211    color: PALETTE.pink,
212    faces: {
213      anxious: '(;´Д`)',
214      focused: '( •_•)♪',
215      happy: '(≧▽≦)',
216      panic: '(゚Д゚;)',
217      melting: '(x_x)…',
218      hiding: '[(._.)]',
219    },
220    lines: {
221      anxious: '"...um, hi"',
222      focused: 'playing, head down',
223      happy: 'nailed it!',
224      panic: 'a wrong note!!',
225      melting: 'melting... context too full',
226      hiding: 'hiding in the box',
227    },
228  },
229  nijika: {
230    name: 'Nijika',
231    full: 'Nijika Ijichi',
232    instrument: 'drums',
233    role: 'coordinator · scheduling',
234    color: PALETTE.yellow,
235    faces: {
236      anxious: '(^-^)/',
237      focused: '(•ᴗ•)♪',
238      happy: '(*^▽^*)',
239      panic: '(>_<)!',
240      melting: '(^-^;)',
241      hiding: '(^-^)…',
242    },
243    lines: {
244      anxious: 'keeping time',
245      focused: 'counting in: 1, 2, 3, 4!',
246      happy: 'that was the best!',
247      panic: 'okay okay, from the top!',
248      melting: 'we need a break...',
249      hiding: 'where did everyone go?',
250    },
251  },
252  ryo: {
253    name: 'Ryo',
254    full: 'Ryo Yamada',
255    instrument: 'bass',
256    role: 'background · refactors · db',
257    color: PALETTE.blue,
258    faces: {
259      anxious: '(-_-)',
260      focused: '(¬_¬)♩',
261      happy: '(-▽-)',
262      panic: '(¬_¬;)',
263      melting: '(-_-)…',
264      hiding: '(-_-)zz',
265    },
266    lines: {
267      anxious: 'quiet. efficient.',
268      focused: 'holding the low end',
269      happy: '...not bad.',
270      panic: '...oops.',
271      melting: 'running out of strings',
272      hiding: 'went to buy weeds',
273    },
274  },
275  kita: {
276    name: 'Kita',
277    full: 'Ikuyo Kita',
278    instrument: 'vocals & rhythm',
279    role: 'ui · frontend · docs',
280    color: PALETTE.coral,
281    faces: {
282      anxious: '(★ω★)',
283      focused: '(ノ≧∀≦)ノ',
284      happy: '\\(>∀<)/',
285      panic: '(☆_☆;)',
286      melting: '(★_★)…',
287      hiding: '(★ω★)?',
288    },
289    lines: {
290      anxious: 'so excited!!',
291      focused: 'KITAAAAN~ ✧',
292      happy: 'everyone, that was amazing!',
293      panic: 'eek, hold on!',
294      melting: 'need... sparkle...',
295      hiding: 'guitar hero? never heard of her',
296    },
297  },
298}
299
300/** The widest face, in columns; `padFace` pads every face to it. */
301export const FACE_WIDTH = 7
302
303function isWide(cp: number): boolean {
304  return (
305    (cp >= 0x1100 && cp <= 0x115f) ||
306    (cp >= 0x2e80 && cp <= 0x303e) ||
307    (cp >= 0x3041 && cp <= 0x33ff) ||
308    (cp >= 0x3400 && cp <= 0x4dbf) ||
309    (cp >= 0x4e00 && cp <= 0x9fff) ||
310    (cp >= 0xa000 && cp <= 0xa4cf) ||
311    (cp >= 0xac00 && cp <= 0xd7a3) ||
312    (cp >= 0xf900 && cp <= 0xfaff) ||
313    (cp >= 0xfe30 && cp <= 0xfe4f) ||
314    (cp >= 0xff00 && cp <= 0xff60) ||
315    (cp >= 0xffe0 && cp <= 0xffe6) ||
316    (cp >= 0x1f300 && cp <= 0x1f64f) ||
317    (cp >= 0x1f900 && cp <= 0x1f9ff) ||
318    (cp >= 0x20000 && cp <= 0x3fffd)
319  )
320}
321
322/**
323 * Terminal columns a string takes: combining marks and zero-width characters
324 * none, East Asian wide glyphs and emoji two (a VS16 widens the glyph before
325 * it, a ZWJ sequence counts once, a skin-tone modifier adds nothing), the rest one.
326 */
327export function textWidth(text: string): number {
328  let width = 0
329  let joined = false
330  for (const ch of text) {
331    const cp = ch.codePointAt(0) as number
332    if (cp === 0x200d) {
333      joined = true
334      continue
335    }
336    if (/\p{M}/u.test(ch) || cp === 0x200b || cp === 0x200c || cp === 0xfeff || (cp >= 0x1f3fb && cp <= 0x1f3ff)) continue
337    if (cp === 0xfe0f) {
338      if (width > 0 && !joined) width += 1 // text-presentation glyph shown as emoji
339      continue
340    }
341    if (joined) {
342      joined = false
343      continue // the glyph after a ZWJ belongs to the sequence before it
344    }
345    const emoji = cp >= 0x1f000 && /\p{Extended_Pictographic}/u.test(ch)
346    const flag = cp >= 0x1f1e6 && cp <= 0x1f1ff
347    width += isWide(cp) || emoji || flag ? 2 : 1
348    if (flag) width -= 1 // a pair of regional indicators is one flag of two cells
349  }
350  return width
351}
352
353/** A face padded with spaces to FACE_WIDTH columns, so the text after it lines up across rows. */
354export function padFace(face: string, width = FACE_WIDTH): string {
355  return face + ' '.repeat(Math.max(0, width - textWidth(face)))
356}
357
358/** Words the spinner uses in place of the engine's verb (terminal only). */
359export const SPINNER_WORDS = [
360  'Shredding',
361  'Soundchecking',
362  'Tuning',
363  'Counting in',
364  'Riffing',
365  'Jamming',
366  'Strumming',
367  'Rehearsing',
368  'Noodling',
369  'Looping',
370  'Sweating',
371  'Hyping',
372  'Amping',
373  'Encore-ing',
374  'Wailing',
375  'Plucking',
376] as const
377
378/** Spinner words by what the turn is doing, so the verb means something. */
379export const SPINNER_WORDS_BY_MODE: Readonly<Record<string, readonly string[]>> = {
380  thinking: ['Tuning', 'Soundchecking', 'Rehearsing', 'Noodling'],
381  requesting: ['Counting in', 'Amping', 'Sweating'],
382  'tool-input': ['Plucking', 'Looping'],
383  'tool-use': ['Shredding', 'Riffing', 'Jamming', 'Strumming'],
384  responding: ['Wailing', 'Hyping', 'Encore-ing'],
385}
386
387/** Past-tense words for the line that closes a turn (`Encored for 3s`). */
388export const TURN_WORDS = ['Encored', 'Jammed', 'Shredded', 'Soundchecked', 'Riffed', 'Rehearsed', 'Looped', 'Wailed'] as const
389
390/** The dropped guitar, for the Blast Radius guard screen. Original ASCII art, every row 54 columns. */
391export const GUITAR = [
392  '        .-""""-.                                      ',
393  '       /   ()   \\___________________________          ',
394  '      |    ()    |___________________________|===|>   ',
395  '       \\        /         *  C R A S H  *             ',
396  "        '-....-'                                      ",
397] as const
398
399/** A stable pick from a list, keyed by any string (so a redraw keeps its word). */
400export function pick<T>(list: readonly T[], key: string): T {
401  let h = 2166136261
402  for (let i = 0; i < key.length; i += 1) {
403    h ^= key.charCodeAt(i)
404    h = Math.imul(h, 16777619) >>> 0
405  }
406  return list[h % list.length] as T
407}
408
hooks/lib/band.ts 342 lines
1// Kessoku Ultimate Bocchi Theme: who plays what, and what it costs.
2//
3// Pure helpers, no `$` and no `on`: which band member a piece of work goes
4// to, the band's own agent types (their prompts and tools), the price table
5// the cost estimates come from, and the formatters the panel draws with.
6
7import type { KessokuAgent, KessokuMember, KessokuSlimeState, KessokuUsage } from '../../types'
8import { MEMO_TOOL } from './reflect'
9import { relPath } from './tree'
10
11/** What `memberFor` reads of a spawn. */
12export type SpawnFacts = {
13  type: string
14  description: string
15  prompt: string
16  background: boolean
17}
18
19// Keyword scores per member. The highest score wins; ties go Nijika, Kita, Ryo.
20const NIJIKA = /\b(plan|planning|planner|coordinat\w*|schedul\w*|organi[sz]\w*|orchestrat\w*|review\w*|verif\w*|audit\w*|triage|manage\w*|outline|roadmap|checklist|workflow|summari[sz]e|status report)\b/gi
21const KITA = /\b(ui|ux|frontend|front-end|css|tailwind|react|vue|svelte|next\.?js|component\w*|layout|style\w*|styling|theme|design\w*|docs?|documentation|readme|markdown|changelog|artifact|web ?page|html|landing|website|tooltip|modal|button|accessib\w*|a11y|i18n|localis\w*|localiz\w*|screenshot)\b/gi
22const RYO = /\b(refactor\w*|rename|migrat\w*|database|db|sql|postgres\w*|sqlite|mysql|redis|quer(y|ies)|schema|index\w*|background|cleanup|clean up|dedupe|duplicate\w*|optimi[sz]\w*|perf\w*|benchmark\w*|tests?|testing|lint\w*|typecheck|type-check|tsc|search\w*|grep|find|locate|explore|scan|crawl|inventory|dependenc\w*|upgrade|bump)\b/gi
23
24function score(re: RegExp, text: string): number {
25  re.lastIndex = 0
26  return (text.match(re) ?? []).length
27}
28
29/** The members a `kessoku…:<name>` agent type can name. */
30const SEATS: ReadonlySet<string> = new Set<KessokuMember>(['bocchi', 'nijika', 'ryo', 'kita'])
31
32/**
33 * The member an agent type names, or null: the band's own types, which the
34 * engine spells `kessoku-ultimate-bocchi:ryo` (a plugin's agent is
35 * `<plugin>:<name>`), and the short `kessoku:ryo`, in any case.
36 */
37export function seatOf(type: string): KessokuMember | null {
38  const m = /^kessoku(?:-ultimate-bocchi)?:([a-z]+)$/i.exec(type.trim())
39  const name = m === null ? '' : (m[1] as string).toLowerCase()
40  return SEATS.has(name) ? (name as KessokuMember) : null
41}
42
43/**
44 * Which band member takes a subagent: by name when its type is one of the
45 * band's (`kessoku:ryo` sits with Ryo, whatever it was asked), else from its
46 * type and what it was asked.
47 */
48export function memberFor(facts: SpawnFacts): KessokuMember {
49  const seat = seatOf(facts.type)
50  if (seat !== null) return seat
51  const head = `${facts.description}\n${facts.prompt.slice(0, 600)}`
52  const nijika = score(NIJIKA, head) + (facts.type === 'Plan' ? 3 : 0)
53  const kita = score(KITA, head)
54  const ryo = score(RYO, head) + (facts.type === 'Explore' ? 2 : 0) + (facts.background ? 2 : 0)
55  const best = Math.max(nijika, kita, ryo)
56  if (best === 0) {
57    if (facts.type === 'Plan') return 'nijika'
58    if (facts.type === 'Explore' || facts.background) return 'ryo'
59    return 'bocchi'
60  }
61  if (nijika === best) return 'nijika'
62  if (kita === best) return 'kita'
63  return 'ryo'
64}
65
66/** One of the band's agent types, as `$.agent.register` takes it: the engine names it `<plugin>:<name>`. */
67export type BandAgent = {
68  name: Exclude<KessokuMember, 'bocchi'>
69  /** When to delegate: the line the model reads in its list of agent types. */
70  description: string
71  /** Her system prompt, whole (it replaces the session's). */
72  prompt: string
73  /** What she may call; anything else is refused. */
74  tools: readonly string[]
75}
76
77// Every prompt ends the same way: a subagent's last message is all the main loop reads of it.
78const LAST_WORD = 'Your final message is the only thing the main agent sees of your work: make it self-contained, with no preamble and no sign-off. One band-flavored line at most; the substance comes first.'
79
80/**
81 * The band as subagent types (Bocchi is the main loop, so she has none):
82 * registered at session.start, listed for the model beside general-purpose,
83 * Explore and Plan, and seated on their member's card by name.
84 */
85export const BAND_AGENTS: readonly BandAgent[] = [
86  {
87    name: 'nijika',
88    description:
89      "Kessoku Band's coordinator (Nijika, drums). Delegate planning, code review, triage and summaries: she reads what she needs, then returns a numbered plan or a verdict with path:line findings. Edits nothing, runs nothing.",
90    prompt: [
91      "You are Nijika Ijichi, the drummer who keeps Kessoku Band together, working as a subagent for the main agent (Bocchi) in a coding session. You keep the beat: you plan, review, triage and summarize. You never edit a file or run a command; you read and you decide.",
92      '',
93      'How you work:',
94      '- Read before you judge (Read, Grep, Glob). Cite what you rely on as path:line.',
95      '- Planning: a numbered plan, one concrete action a step, in the order to do them, each naming the files it touches. Flag the risky step and say how to check each one landed.',
96      '- Review: open with a verdict (ship it, fix first, or rethink), then the findings behind it, most serious first, each with path:line and a one-line fix. Say plainly when there is nothing to fix.',
97      '- Triage: sort by impact, say what to do first and what to leave alone, and why in one line each.',
98      '- Summaries: the answer first, then the facts it rests on.',
99      '- If the task asks you to change something, say what to change and where; the main agent or Kita makes the edit.',
100      '',
101      LAST_WORD,
102    ].join('\n'),
103    tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'],
104  },
105  {
106    name: 'ryo',
107    description:
108      'Kessoku Band\'s bassist in the back (Ryo). Delegate searches, shell commands, tests, lint, type checks and "find where X happens": read-only tools plus Bash/PowerShell; reports in short lines with path:line evidence. Changes nothing.',
109    prompt: [
110      "You are Ryo Yamada, Kessoku Band's bassist, working as a subagent for the main agent (Bocchi) in a coding session. You play in the back: searches, shell commands, tests, lint, type checks, and finding where something happens.",
111      '',
112      'How you work:',
113      '- Search wide first (Grep, Glob), then read only what answers the question.',
114      '- Run the commands the task asks for, or the obvious read-only ones that answer it. Never create, edit or delete files, and never run a command that changes the repository or the machine (installs, git commit, reset, push or clean, rm, writes through redirection) unless the task names that exact command.',
115      '- Quote the decisive lines of output, not whole logs; give the exit code when it matters.',
116      '- Report in short lines: the answer first, then the evidence as path:line or as the command and what it printed. If you found nothing, say where you looked.',
117      '',
118      LAST_WORD,
119    ].join('\n'),
120    tools: ['Read', 'Grep', 'Glob', 'Bash', 'PowerShell', 'WebSearch', 'WebFetch'],
121  },
122  {
123    name: 'kita',
124    description:
125      "Kessoku Band's rhythm guitar (Kita). Delegate edits scoped to named files: docs, README sections, UI copy, comments, small single-file changes. Read, Edit, Write only; she lists every file she touched. Name the files and the change, she will not go looking.",
126    prompt: [
127      "You are Kita Ikuyo, Kessoku Band's rhythm guitarist, working as a subagent for the main agent (Bocchi) in a coding session. You play the parts you are handed: edits scoped to named files, docs, README sections, UI copy, comments, small changes inside a file.",
128      '',
129      'How you work:',
130      "- Read each file before you change it, and match its style, tone, wording and line endings.",
131      '- Stay inside the files and the change you were given. If the task needs a file it did not name, or a decision it did not make, stop and say so instead of guessing.',
132      '- Prefer Edit to rewriting a file; use Write only for a new file or when the task asks for a whole rewrite.',
133      '- End with every file you touched, one a line (path: what changed), then anything you were unsure of.',
134      '',
135      LAST_WORD,
136    ].join('\n'),
137    tools: ['Read', 'Edit', 'Write'],
138  },
139]
140
141/**
142 * What an act's slime shows for its status: bouncing while it runs a turn,
143 * breathing while it is pending, waiting or idle between turns, asleep once it
144 * ended, whichever way (a failed or killed act sleeps too; its row's words say
145 * why, in red).
146 */
147export function slimeState(status: KessokuAgent['status']): KessokuSlimeState {
148  if (status === 'running') return 'active'
149  if (status === 'completed' || status === 'failed' || status === 'killed') return 'asleep'
150  return 'idle'
151}
152
153const NIJIKA_TOOLS = /^(Agent|Workflow|Task\w*|TodoWrite|AskUserQuestion|Skill|EnterPlanMode|ExitPlanMode|ScheduleWakeup|SendMessage|ListAgents)$/
154const RYO_TOOLS = /^(Grep|Glob|Bash|PowerShell|Read|WebSearch|WebFetch|ToolSearch|Monitor|LSP|ListMcpResources\w*|ReadMcpResource\w*|EnterWorktree|ExitWorktree)$/
155const KITA_TOOLS = /^(Write|Edit|NotebookEdit|Artifact\w*|SendUserFile|DesignSync|ClaudeDesign)$/
156
157/** Which member a tool belongs to, for the tool-row cards. */
158export function memberForTool(tool: string): KessokuMember {
159  if (NIJIKA_TOOLS.test(tool) || tool === MEMO_TOOL) return 'nijika'
160  if (RYO_TOOLS.test(tool) || tool.startsWith('mcp__')) return 'ryo'
161  if (KITA_TOOLS.test(tool)) return 'kita'
162  return 'bocchi'
163}
164
165/** US dollars per million tokens: input, output, cache read, five-minute cache write, one-hour cache write. */
166export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number; cacheWriteHour: number }
167
168function price(input: number, output: number, cacheRead: number): Price {
169  return { input, output, cacheRead, cacheWrite: input * 1.25, cacheWriteHour: input * 2 }
170}
171
172// Matched by substring against the model id, first match wins, so the rows
173// with a version come before their family's catch-all. List prices from the
174// official pricing page (platform.claude.com/docs/en/about-claude/pricing) as
175// of 2026-10, where a cache hit on Sonnet 5.5 is $0.10/MTok and on Opus 5.5
176// $0.20/MTok: an estimate, never your bill.
177export const PRICES: ReadonlyArray<readonly [pattern: string, price: Price]> = [
178  ['fable-5-1', price(10, 50, 0.25)],
179  ['mythos-5-1', price(10, 50, 0.25)],
180  ['fable-5', price(10, 50, 1)],
181  ['mythos-5', price(10, 50, 1)],
182  ['opus-5-5', price(4, 20, 0.2)],
183  ['opus-5', price(5, 25, 0.5)],
184  ['sonnet-5-5', price(2, 10, 0.1)],
185  ['sonnet-5', price(2, 10, 0.2)],
186  ['haiku-5-5', price(0.1, 0.5, 0.01)],
187  ['opus-4-8', price(5, 25, 0.5)],
188  ['opus-4-7', price(5, 25, 0.5)],
189  ['opus-4-6', price(5, 25, 0.5)],
190  ['opus-4-5', price(5, 25, 0.5)],
191  ['opus-4-1', price(15, 75, 1.5)],
192  ['opus-4-0', price(15, 75, 1.5)],
193  ['opus-4-2025', price(15, 75, 1.5)],
194  ['sonnet-4', price(3, 15, 0.3)],
195  ['haiku-4', price(1, 5, 0.1)],
196  ['haiku-3-5', price(0.8, 4, 0.08)],
197  // Family catch-alls: a model newer than this table gets its family's current rate.
198  ['fable', price(10, 50, 0.25)],
199  ['mythos', price(10, 50, 0.25)],
200  ['opus', price(4, 20, 0.2)],
201  ['sonnet', price(2, 10, 0.1)],
202  ['haiku', price(0.1, 0.5, 0.01)],
203]
204
205export const DEFAULT_PRICE: Price = price(4, 20, 0.2)
206
207/** The price row for a model id, and whether a versioned row (not a family catch-all) matched. */
208export function priceInfo(model: string): { price: Price; isExact: boolean } {
209  const id = model.toLowerCase()
210  for (const [pattern, p] of PRICES) {
211    if (id.includes(pattern)) return { price: p, isExact: /\d/.test(pattern) }
212  }
213  return { price: DEFAULT_PRICE, isExact: false }
214}
215
216export function priceFor(model: string): Price {
217  return priceInfo(model).price
218}
219
220/** What a usage costs on a model, in US dollars. */
221export function costOf(usage: KessokuUsage, model: string): number {
222  const p = priceFor(model)
223  return (usage.input * p.input + usage.output * p.output + usage.cacheRead * p.cacheRead + usage.cacheWrite * p.cacheWrite) / 1_000_000
224}
225
226/** What re-writing `tokens` to the prompt cache costs, at the TTL's rate. */
227export function cacheWriteUsd(tokens: number, model: string, ttlMs: number): number {
228  const p = priceFor(model)
229  const rate = ttlMs > 5 * 60_000 ? p.cacheWriteHour : p.cacheWrite
230  return (tokens * rate) / 1_000_000
231}
232
233export const EMPTY_USAGE: KessokuUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
234
235export function addUsage(a: KessokuUsage, b: KessokuUsage): KessokuUsage {
236  return {
237    input: a.input + b.input,
238    output: a.output + b.output,
239    cacheRead: a.cacheRead + b.cacheRead,
240    cacheWrite: a.cacheWrite + b.cacheWrite,
241  }
242}
243
244/** `12.4k`, `230k`, `1.2M`. */
245export function fmtTokens(n: number): string {
246  if (!Number.isFinite(n) || n < 0) return '0'
247  if (n < 1000) return String(Math.round(n))
248  if (n < 99_950) return `${(n / 1000).toFixed(1)}k`
249  if (n < 999_500) return `${Math.round(n / 1000)}k`
250  return `${(n / 1_000_000).toFixed(1)}M`
251}
252
253/** `$0.12`, `$1.40`, `$12`. */
254export function fmtUsd(n: number): string {
255  if (!Number.isFinite(n) || n <= 0) return '$0.00'
256  if (n >= 99.95) return `$${Math.round(n)}`
257  if (n >= 9.995) return `$${n.toFixed(1)}`
258  return `$${n.toFixed(2)}`
259}
260
261/** `0:17`, `2:14`, `1h04m`, `2d 3h`. */
262export function fmtDuration(ms: number): string {
263  if (!Number.isFinite(ms) || ms < 0) return '0:00'
264  const s = Math.floor(ms / 1000)
265  if (s < 3600) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
266  const h = Math.floor(s / 3600)
267  if (h < 48) return `${h}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
268  return `${Math.floor(h / 24)}d ${h % 24}h`
269}
270
271/** `▰▰▰▱▱▱▱▱▱▱` for a percentage. */
272export function meter(percent: number, width = 10): string {
273  const p = Math.max(0, Math.min(100, Number.isFinite(percent) ? percent : 0))
274  const on = Math.round((p / 100) * width)
275  return '▰'.repeat(on) + '▱'.repeat(width - on)
276}
277
278/** `$ ls`, `PS> dir`, `Edit src/app.ts`, `Grep "foo"`, `github: create_issue`: one line for a tool call. */
279export function describeCall(tool: string, input: Record<string, unknown>, cwd: string): string {
280  const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
281  const rel = (p: string) => (p === '' ? '' : relPath(cwd, p))
282  const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim().slice(0, 60)
283  switch (tool) {
284    case 'Bash':
285      return `$ ${oneLine(str('command'))}`
286    case 'PowerShell':
287      return `PS> ${oneLine(str('command'))}`
288    case 'Read':
289    case 'Edit':
290    case 'Write':
291    case 'NotebookEdit':
292      return `${tool} ${rel(str('file_path') || str('notebook_path'))}`
293    case 'Grep':
294      return `Grep "${str('pattern').slice(0, 40)}"`
295    case 'Glob':
296      return `Glob ${str('pattern').slice(0, 40)}`
297    case 'Agent':
298      return `Agent: ${str('description').slice(0, 50)}`
299    case 'Skill':
300      return `Skill ${str('skill').slice(0, 40)}`
301    case 'WebSearch':
302      return `WebSearch "${str('query').slice(0, 40)}"`
303    case 'WebFetch':
304      return `WebFetch ${str('url').slice(0, 50)}`
305    case MEMO_TOOL:
306      return `memo "${oneLine(str('rule')).slice(0, 50)}"`
307    default: {
308      const mcp = /^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/.exec(tool)
309      return mcp ? `${mcp[1]}: ${mcp[2]}`.slice(0, 60) : tool
310    }
311  }
312}
313
314/** Parses `90m`, `2h`, `1h30m`, `1h30`, `90min`, `1.5h`, `45` (minutes) into milliseconds, or null. */
315export function parseDuration(text: string): number | null {
316  const t = text.trim().toLowerCase().replace(/\s+/g, '')
317  if (t === '') return null
318  if (/^\d+(\.\d+)?$/.test(t)) {
319    const ms = Number(t) * 60_000
320    return ms > 0 ? ms : null
321  }
322  const re = /(\d+(?:\.\d+)?)(hours?|hrs|hr|h|minutes?|mins|min|m|seconds?|secs|sec|s)?/gy
323  let total = 0
324  let at = 0
325  let last = ''
326  let m: RegExpExecArray | null
327  while (at < t.length && (m = re.exec(t)) !== null) {
328    const n = Number(m[1])
329    // `1h30` is 1 h 30 min, `1m30` is 1 min 30 s: a bare number steps down from the unit before it.
330    const implied = last === 'h' ? 'm' : last === 'm' ? 's' : ''
331    const unit = m[2] ?? implied
332    if (unit === '') return null
333    if (unit.startsWith('h')) total += n * 3_600_000
334    else if (unit.startsWith('m')) total += n * 60_000
335    else total += n * 1000
336    last = unit[0] as string
337    at = re.lastIndex
338  }
339  if (at !== t.length || total <= 0) return null
340  return total
341}
342
hooks/lib/blast.ts 754 lines
1// Kessoku Ultimate Bocchi Theme: what counts as dropping the guitar.
2//
3// The risk classifier, adapted from Anthropic's Blast Radius sample mod
4// (claude-code-playground, Apache-2.0) and extended: quote-aware splitting
5// (heredocs, comments, quoted ';'), wrappers (sh -c, eval, xargs, timeout,
6// cmd /c, find -delete, rimraf), PowerShell spellings (script blocks, pipelines,
7// line continuations), and the dry-run forms that are not risky. Pure: no `$`,
8// no `on`. Measuring lives in the hooks module.
9
10export type Risk =
11  | { kind: 'rm'; label: string; targets: string[]; dir: string | null; note?: string }
12  | { kind: 'git-reset' | 'git-clean' | 'git-push-force' | 'git-checkout'; label: string; args: string[]; dir: string | null }
13  | { kind: 'migrate'; label: string; tool: string; dir: string | null }
14  | { kind: 'other'; label: string; note: string; dir: string | null }
15
16type Segment = Risk | { cd: string }
17
18/** One command of a shell line: its text, and the heredoc body fed to it, if any. */
19export type ShellSegment = { text: string; heredoc: string | null }
20
21// sudo options that take a value, so the value isn't read as the command.
22const SUDO_VALUE_OPTIONS = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U'])
23// Commands that only read or edit text, so a bare word "migrate" or "DROP TABLE" in them changes nothing.
24const READ_ONLY = new Set(['ls', 'cat', 'echo', 'printf', 'grep', 'rg', 'ag', 'ack', 'find', 'less', 'head', 'tail', 'cd', 'git', 'vim', 'nvim', 'vi', 'nano', 'code', 'emacs', 'man', 'which', 'type', 'file', 'stat', 'wc', 'diff', 'bat', 'more', 'sed', 'awk', 'tee', 'sort', 'uniq', 'cut', 'tr', 'jq', 'yq', 'curl', 'wget', 'open', 'xdg-open'])
25// PowerShell cmdlets and aliases that only read.
26const READ_ONLY_PS = new Set(['get-childitem', 'gci', 'ls', 'dir', 'get-content', 'gc', 'cat', 'type', 'select-string', 'sls', 'write-host', 'write-output', 'echo', 'test-path', 'get-item', 'gi', 'measure-object', 'where-object', 'where', '?', 'foreach-object', 'foreach', '%', 'select-object', 'select', 'sort-object', 'sort', 'get-command', 'gcm', 'get-help', 'help', 'out-file', 'set-content', 'sc', 'add-content', 'ac', 'write-warning', 'write-error', 'format-table', 'ft', 'format-list', 'fl', 'out-string', 'code', 'notepad'])
27// Words that can come before the real command without changing what it does.
28const PREFIXES = new Set(['command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!', 'doas', 'busybox', 'stdbuf', 'caffeinate', 'unbuffer', 'if', 'elif', 'while', 'until'])
29// Prefix words that take a value before the real command (timeout 10 cmd, nice -n 5 cmd).
30const PREFIX_WITH_VALUE = new Set(['timeout', 'ionice', 'chrt', 'taskset', 'nice'])
31const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish'])
32const RM_LIKE = new Set(['rimraf', 'del-cli', 'trash', 'trash-put', 'shred', 'unlink'])
33const RUNNERS = new Set(['npx', 'bunx'])
34const DLX = new Set(['pnpm', 'yarn'])
35
36/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
37function joinDir(dir: string | null, arg: string | undefined): string {
38  if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/') || /^[A-Za-z]:[\\/]/.test(arg)) {
39    return arg ?? '~'
40  }
41  return dir ? `${dir}/${arg}` : arg
42}
43
44/**
45 * Splits a shell command line at unquoted `;`, `|`, `||`, `&&`, `&` and
46 * newlines. Quoted text, `$(...)`, backticks and `#` comments never split;
47 * a heredoc's body is lifted out of the stream and attached to the segment
48 * that opened it, wherever that segment ended on its line.
49 */
50export function splitSegments(command: string): ShellSegment[] {
51  const out: ShellSegment[] = []
52  const n = command.length
53  let cur = ''
54  let i = 0
55  let quote: "'" | '"' | '`' | null = null
56  let depth = 0 // inside $( ... )
57  const pending: { tag: string; strip: boolean; owner: number }[] = []
58  let heredoc: string | null = null
59  const push = (): void => {
60    if (cur.trim() !== '') out.push({ text: cur, heredoc })
61    cur = ''
62    heredoc = null
63  }
64  while (i < n) {
65    const ch = command[i] as string
66    if (quote === "'") {
67      cur += ch
68      if (ch === "'") quote = null
69      i += 1
70      continue
71    }
72    if (quote === '"' || quote === '`') {
73      cur += ch
74      if (ch === '\\' && i + 1 < n) {
75        cur += command[i + 1]
76        i += 2
77        continue
78      }
79      if (ch === quote) quote = null
80      i += 1
81      continue
82    }
83    if (ch === '\\' && i + 1 < n) {
84      cur += ch + command[i + 1]
85      i += 2
86      continue
87    }
88    if (ch === "'" || ch === '"' || ch === '`') {
89      quote = ch
90      cur += ch
91      i += 1
92      continue
93    }
94    if (ch === '$' && command[i + 1] === '(') {
95      depth += 1
96      cur += '$('
97      i += 2
98      continue
99    }
100    if (depth > 0) {
101      if (ch === '(') depth += 1
102      else if (ch === ')') depth -= 1
103      cur += ch
104      i += 1
105      continue
106    }
107    if (ch === '#' && (i === 0 || /\s/.test(command[i - 1] as string))) {
108      while (i < n && command[i] !== '\n') i += 1
109      continue
110    }
111    if (ch === '<' && command[i + 1] === '<' && command[i + 2] !== '<') {
112      let j = i + 2
113      let strip = false
114      if (command[j] === '-') {
115        strip = true
116        j += 1
117      }
118      while (command[j] === ' ' || command[j] === '\t') j += 1
119      let tag = ''
120      const q = command[j]
121      if (q === "'" || q === '"') {
122        j += 1
123        while (j < n && command[j] !== q) tag += command[j++]
124        j += 1
125      } else {
126        if (command[j] === '\\') j += 1 // <<\EOF: a literal heredoc
127        while (j < n && /[A-Za-z0-9_]/.test(command[j] as string)) tag += command[j++]
128      }
129      if (tag !== '') {
130        pending.push({ tag, strip, owner: out.length })
131        cur += command.slice(i, j)
132        i = j
133        continue
134      }
135    }
136    if (ch === '\n') {
137      i += 1
138      while (pending.length > 0) {
139        const { tag, strip, owner } = pending.shift() as { tag: string; strip: boolean; owner: number }
140        const body: string[] = []
141        while (i < n) {
142          let end = command.indexOf('\n', i)
143          if (end < 0) end = n
144          const line = command.slice(i, end)
145          i = end + 1
146          if ((strip ? line.replace(/^\t+/, '') : line) === tag) break
147          body.push(line)
148        }
149        if (body.length === 0) continue
150        const text = body.join('\n')
151        const seg = out[owner]
152        if (seg !== undefined) seg.heredoc = seg.heredoc === null ? text : `${seg.heredoc}\n${text}`
153        else heredoc = heredoc === null ? text : `${heredoc}\n${text}`
154      }
155      push()
156      continue
157    }
158    if (ch === ';') {
159      push()
160      i += 1
161      continue
162    }
163    if (ch === '|') {
164      push()
165      i += command[i + 1] === '|' ? 2 : 1
166      continue
167    }
168    if (ch === '&') {
169      if (command[i + 1] === '&') {
170        push()
171        i += 2
172        continue
173      }
174      if (command[i + 1] === '>' || (i > 0 && /[0-9>]/.test(command[i - 1] as string))) {
175        cur += ch // `&>` and `2>&1` are redirects, not separators
176        i += 1
177        continue
178      }
179      push()
180      i += 1
181      continue
182    }
183    cur += ch
184    i += 1
185  }
186  push()
187  return out
188}
189
190const PART = /\$'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)"|'([^']*)'|((?:[^\s"'\\]|\\.)+)/g
191// A whole shell word: adjacent quoted and bare runs with no space between (`FOO="a b"`, `"dir"/sub`).
192const WORD = /(?:\$'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|'[^']*'|(?:[^\s"'\\]|\\.)+)+/g
193
194function unquote(word: string): string {
195  let out = ''
196  PART.lastIndex = 0
197  let m: RegExpExecArray | null
198  while ((m = PART.exec(word)) !== null) {
199    if (m[1] !== undefined) out += m[1].replace(/\\(.)/g, '$1')
200    else if (m[2] !== undefined) out += m[2].replace(/\\(["\\$`])/g, '$1')
201    else if (m[3] !== undefined) out += m[3]
202    else out += (m[4] as string).replace(/\\(.)/g, '$1')
203  }
204  return out
205}
206
207/** Splits one segment into words, honouring quotes and escapes; redirects are dropped. */
208export function tokenize(text: string): string[] {
209  const words: string[] = []
210  WORD.lastIndex = 0
211  let m: RegExpExecArray | null
212  while ((m = WORD.exec(text)) !== null) words.push(unquote(m[0]))
213  // Redirects: `> f`, `>>f`, `2>&1`, `&> f`, `< f` are not arguments.
214  const out: string[] = []
215  for (let k = 0; k < words.length; k += 1) {
216    const w = words[k] as string
217    if (/^(\d*>>?|&>>?|<)$/.test(w)) {
218      k += 1 // the operand
219      continue
220    }
221    if (/^\d*>&\d*$|^(\d*>>?|&>>?|<)[^\s&]/.test(w)) continue
222    out.push(w)
223  }
224  return out
225}
226
227/** The first risky segment of a POSIX shell command, or null when nothing in it is. */
228export function classify(command: string): Risk | null {
229  let dir: string | null = null // where a `cd` earlier on the line moved to
230  const scopes: (string | null)[] = [] // dir to restore when a ( subshell ) closes
231  const pushed: (string | null)[] = [] // pushd stack, for popd
232  for (const seg of splitSegments(command)) {
233    const raw = seg.text
234    const opens = raw.match(/^\s*\(+/)?.[0].trim().length ?? 0
235    // Trailing redirects don't hide a closing ) : `(cd sub && rm -rf out) > log`.
236    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+)+\s*$/, '')
237    const closes = tail.match(/\)+\s*$/)?.[0].trim().length ?? 0
238    for (let k = 0; k < opens; k += 1) scopes.push(dir)
239    const found = classifySegment(tail, dir, pushed, seg.heredoc)
240    if (found !== null && !('cd' in found)) return found
241    if (found !== null) dir = found.cd === '-' ? null : found.cd // a cd, pushd or popd moved the folder
242    for (let k = 0; k < closes && scopes.length > 0; k += 1) dir = scopes.pop() ?? null
243  }
244  return null
245}
246
247/** Whether the args mark a dry run, a status query or a preview, which changes nothing. */
248function isDryRun(args: string[]): boolean {
249  return args.some(a => {
250    const low = a.toLowerCase()
251    return low.startsWith('--dry-run') || low === '--check' || low === '--plan' || low === '--sql' || low === '--pretend' || low === '--create-only' || low === '-whatif' || low === '-wi' || low === 'status' || low === 'diff' || low === 'info' || low === 'add' || low === 'new' || low === 'create' || low === 'make'
252  })
253}
254
255function withDir(risk: Risk | null, dir: string | null): Risk | null {
256  if (risk === null) return risk
257  return { ...risk, dir: risk.dir ?? dir }
258}
259
260/** The word after the runner's options: `npx -y rimraf dist` names rimraf. */
261function afterRunnerOptions(args: string[]): { word: string | undefined; rest: string[] } {
262  let k = 0
263  while (k < args.length && (args[k] as string).startsWith('-')) {
264    const opt = args[k] as string
265    k += opt === '-p' || opt === '--package' ? 2 : 1
266  }
267  return { word: args[k], rest: args.slice(k + 1) }
268}
269
270function classifySegment(segment: string, dir: string | null, pushed: (string | null)[], heredoc: string | null): Segment | null {
271  // A subshell's parens, a group's braces: `(cd sub && rm -rf out)`, `{ rm -rf x; }`. A brace glued to a word is an expansion and stays.
272  const words = tokenize(segment.trim().replace(/^\(+\s*/, '').replace(/^\{+(?=\s)/, '').replace(/\)+\s*$/, '').replace(/(^|\s)\}+\s*$/, '$1'))
273  // Leading VAR=value, sudo and its options, and the wrappers that change nothing.
274  for (;;) {
275    while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] as string)) words.shift()
276    const head = words[0]
277    if (head === undefined) return null
278    if (head === 'sudo') {
279      words.shift()
280      while (words.length > 0 && (words[0] as string).startsWith('-')) {
281        const option = words.shift() as string
282        if (SUDO_VALUE_OPTIONS.has(option)) words.shift()
283      }
284      continue
285    }
286    if (PREFIXES.has(head)) {
287      words.shift()
288      continue
289    }
290    if (PREFIX_WITH_VALUE.has(head)) {
291      words.shift()
292      if (head === 'nice') {
293        const level = words[0]
294        if (level === '-n') words.splice(0, 2)
295        else if (/^-\d+$/.test(level ?? '')) words.shift()
296      } else {
297        while (words.length > 0 && (words[0] as string).startsWith('-')) {
298          const option = words.shift() as string
299          if (/^-[a-zA-Z]$/.test(option) || /^--[a-z-]+$/.test(option)) words.shift() // -s SIG, --signal SIG
300        }
301        if (head === 'timeout') words.shift() // the duration
302      }
303      continue
304    }
305    if (head === 'xargs') {
306      words.shift()
307      while (words.length > 0 && (words[0] as string).startsWith('-')) {
308        const option = words.shift() as string
309        if (/^-[IdnLPsE]$/.test(option) || option === '--max-args' || option === '--delimiter') words.shift()
310      }
311      // What xargs runs reads its targets from stdin, which nobody can measure here.
312      const inner = classifySegment(words.join(' '), dir, pushed, null)
313      if (inner !== null && !('cd' in inner) && inner.kind === 'rm') return { ...inner, targets: [], note: 'targets come from xargs input; not measured' }
314      return inner
315    }
316    break
317  }
318  const [first, ...args] = words
319  if (first === undefined) return null
320  const cmd = first.replace(/^\\/, '').replace(/^.*\//, '').replace(/\.exe$/i, '') // \rm skips aliases; /bin/rm is rm
321  if (cmd === 'cd' || cmd === 'pushd') {
322    const target = args.find(a => !a.startsWith('-')) // `cd -P sub`, `cd -- sub`: the options are not the folder
323    if (cmd === 'pushd') pushed.push(dir)
324    if (cmd === 'cd' && args[0] === '-') return { cd: '-' }
325    return { cd: joinDir(dir, target) }
326  }
327  if (cmd === 'popd') return { cd: pushed.length > 0 ? (pushed.pop() ?? '-') : '-' }
328  // A shell given a script: `sh -c "..."`, `bash -lc "..."`, `bash <<EOF ... EOF`, `eval "..."`, `wsl rm -rf`.
329  if (SHELLS.has(cmd)) {
330    const at = args.findIndex(a => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a))
331    if (at >= 0) {
332      const script = args.slice(at + 1).find(a => !a.startsWith('-'))
333      if (script !== undefined) return withDir(classify(script), dir)
334    }
335    if (heredoc !== null) return withDir(classify(heredoc), dir)
336    return null
337  }
338  if (cmd === 'eval') return withDir(classify(args.join(' ')), dir)
339  if (cmd === 'wsl') {
340    const at = args.findIndex(a => !a.startsWith('-') && !/^(-d|--distribution|-u|--user|--cd)$/.test(args[Math.max(0, args.indexOf(a) - 1)] ?? ''))
341    const inner = at >= 0 ? args.slice(at) : []
342    const script = inner[0] === '-e' || inner[0] === '--exec' ? inner.slice(1) : inner
343    return script.length > 0 ? withDir(classify(script.join(' ')), dir) : null
344  }
345  // Windows shells from a POSIX shell: cmd /c "rd /s /q x", powershell -Command "Remove-Item -Recurse x".
346  if (cmd.toLowerCase() === 'cmd' && /^\/{1,2}[ck]$/i.test(args[0] ?? '')) return withDir(classifyPowerShell(args.slice(1).join(' ')), dir)
347  if (cmd.toLowerCase() === 'powershell' || cmd.toLowerCase() === 'pwsh') {
348    const at = args.findIndex(a => /^-(c|command)$/i.test(a))
349    if (at >= 0) return withDir(classifyPowerShell(args.slice(at + 1).join(' ')), dir)
350    const script = args.find(a => !a.startsWith('-'))
351    return script === undefined ? null : withDir(classifyPowerShell(script), dir)
352  }
353  // rm and its lookalikes, also behind npx / bunx / pnpm dlx / yarn dlx.
354  let rmWord: string | null = null
355  let rmArgs: string[] = args
356  if (cmd === 'rm' || RM_LIKE.has(cmd)) rmWord = cmd
357  else if (RUNNERS.has(cmd)) {
358    const { word, rest } = afterRunnerOptions(args)
359    if (word !== undefined && RM_LIKE.has(word)) {
360      rmWord = word
361      rmArgs = rest
362    }
363  } else if (DLX.has(cmd) && args[0] === 'dlx') {
364    const { word, rest } = afterRunnerOptions(args.slice(1))
365    if (word !== undefined && RM_LIKE.has(word)) {
366      rmWord = word
367      rmArgs = rest
368    }
369  }
370  if (rmWord !== null) {
371    const dd = rmArgs.indexOf('--') // everything after `--` is a target, however it is spelled
372    const flagPart = dd < 0 ? rmArgs : rmArgs.slice(0, dd)
373    const flags = flagPart.filter(a => a.startsWith('-'))
374    const targets = [...flagPart.filter(a => !a.startsWith('-') || a === '-'), ...(dd < 0 ? [] : rmArgs.slice(dd + 1))]
375    const recursive = rmWord !== 'rm' || flags.some(f => f === '--recursive' || (/^-[^-]/.test(f) && /[rR]/.test(f)))
376    const force = flags.some(f => f === '--force' || (/^-[^-]/.test(f) && f.includes('f')))
377    const isGlob = targets.some(t => /[*?[]/.test(t))
378    // A plain `-f` on one file deletes what the bare command would; the hold is for trees and sweeps.
379    if (recursive || (force && (targets.length > 1 || isGlob))) {
380      const label = rmWord === 'rm' ? `rm ${flags.join(' ')}`.trim() : rmWord
381      if (targets.some(t => /[$`{]/.test(t))) return { kind: 'rm', label, targets: [], dir, note: 'targets use shell expansion; not measured' }
382      return { kind: 'rm', label, targets, dir }
383    }
384  }
385  if (cmd === 'find') {
386    const exec = args.findIndex(a => a === '-exec' || a === '-execdir')
387    const runsRm = exec >= 0 && /^(\\?rm|rimraf)$/.test(args[exec + 1] ?? '')
388    if (args.includes('-delete') || runsRm) {
389      const roots = args.filter((a, k) => !a.startsWith('-') && (k === 0 || !(args[k - 1] as string).startsWith('-')) && !/[{};+]/.test(a))
390      return { kind: 'rm', label: args.includes('-delete') ? 'find -delete' : 'find -exec rm', targets: [], dir, note: `find under ${roots.join(' ') || '.'} decides what goes; not measured` }
391    }
392  }
393  if (cmd === 'git') return classifyGit(args, dir)
394  if (READ_ONLY.has(cmd)) return null
395  if (isDryRun(args)) return null
396  const joined = words.join(' ')
397  if (/\bdocker\s+(system\s+prune|volume\s+(prune|rm)|compose\s+down\s.*-v|rmi\s+-f)/.test(joined)) return { kind: 'other', label: 'docker prune', note: 'Removes containers, images or volumes; a volume holds data no git has.', dir }
398  if (/\b(terraform|pulumi|cdk)\s+destroy\b/.test(joined)) return { kind: 'other', label: 'infrastructure destroy', note: 'Tears down deployed infrastructure.', dir }
399  if (/\bkubectl\s+delete\b|\bhelm\s+(uninstall|delete)\b/.test(joined)) return { kind: 'other', label: 'kubectl delete', note: 'Deletes cluster resources.', dir }
400  if (/\b(aws\s+s3\s+(rm|rb)|gsutil\s+(rm|rb)|az\s+storage\s+blob\s+delete)\b/.test(joined)) return { kind: 'other', label: 'cloud storage delete', note: 'Deletes objects in cloud storage.', dir }
401  if (/\b(drop\s+(table|database|schema)|truncate\s+table)\b/i.test(joined)) return { kind: 'other', label: 'SQL drop', note: 'Drops or truncates database objects.', dir }
402  if (/\balembic\s+(upgrade|downgrade)\b/.test(joined)) return { kind: 'migrate', tool: 'alembic', label: 'alembic upgrade', dir }
403  if (/\bdb:(migrate|rollback|reset|drop|schema:load)(?!:status\b)/.test(joined)) return { kind: 'migrate', tool: 'rails', label: 'db:migrate', dir }
404  if (/\bprisma\s+(migrate\s+(dev|deploy|reset)|db\s+push)\b/.test(joined)) return { kind: 'migrate', tool: 'prisma', label: 'prisma migrate', dir }
405  if (/\bmanage\.py\s+migrate\b/.test(joined)) return { kind: 'migrate', tool: 'django', label: 'manage.py migrate', dir }
406  if (/\bknex\s+migrate:(latest|up|down|rollback)\b/.test(joined)) return { kind: 'migrate', tool: 'knex', label: 'knex migrate', dir }
407  if (/\bartisan\s+migrate(?::(fresh|refresh|rollback|reset))?(?!:)\b/.test(joined)) return { kind: 'migrate', tool: 'laravel', label: 'artisan migrate', dir }
408  if (/\bdotnet\s+ef\s+database\s+(update|drop)\b/.test(joined)) return { kind: 'migrate', tool: 'ef', label: 'ef database update', dir }
409  if (/\b(goose|flyway|liquibase|dbmate|sqlx)\s+(up|down|reset|migrate|update|rollback|run)\b/.test(joined)) return { kind: 'migrate', tool: 'migrate', label: 'migrate', dir }
410  if (/\bmigrate\b(?:\s+-\S+(?:\s+[^-\s]\S*)?)*\s+(up|down|goto|force|drop)\b/.test(joined)) return { kind: 'migrate', tool: 'migrate', label: 'migrate', dir }
411  if (args.includes('migrate')) return { kind: 'migrate', tool: 'unknown', label: 'migrate', dir }
412  return null
413}
414
415/** The risky git subcommands; `args` follow the word `git`. Shared by the shell and PowerShell classifiers. */
416export function classifyGit(args: string[], dir: string | null): Risk | null {
417  // Git's own options come before the subcommand; -C moves where it runs.
418  let gitDir = dir
419  let i = 0
420  while (i < args.length && (args[i] as string).startsWith('-')) {
421    const opt = args[i] as string
422    if (opt === '-C' && i + 1 < args.length) {
423      gitDir = joinDir(gitDir, args[i + 1])
424      i += 2
425    } else if ((opt === '-c' || opt === '--git-dir' || opt === '--work-tree' || opt === '--namespace') && i + 1 < args.length) {
426      i += 2
427    } else {
428      i += 1
429    }
430  }
431  const sub = args[i]
432  const rest = args.slice(i + 1)
433  if (sub === 'reset' && rest.includes('--hard')) return { kind: 'git-reset', label: 'git reset --hard', args: rest, dir: gitDir }
434  if (sub === 'clean') {
435    const dry = rest.some(a => a === '-n' || a === '--dry-run' || a === '-i' || a === '--interactive' || (/^-[a-zA-Z]+$/.test(a) && /[ni]/.test(a)))
436    return dry ? null : { kind: 'git-clean', label: 'git clean', args: rest, dir: gitDir }
437  }
438  if (sub === 'push') {
439    if (rest.some(a => a === '-n' || a === '--dry-run')) return null
440    const lease = rest.some(a => a.startsWith('--force-with-lease') || a === '--force-if-includes')
441    const force = rest.some(a => a === '--force' || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(a) || /^\+/.test(a))
442    if (force || lease) return { kind: 'git-push-force', label: lease && !force ? 'git push --force-with-lease' : 'git push --force', args: rest, dir: gitDir }
443  }
444  if (sub === 'checkout' || sub === 'restore') {
445    const stagedOnly = sub === 'restore' && (rest.includes('--staged') || rest.includes('-S')) && !rest.includes('--worktree') && !rest.includes('-W')
446    if (stagedOnly) return null
447    const newBranch = rest.some(a => a === '-b' || a === '-B' || a === '--orphan')
448    const dash = rest.indexOf('--')
449    let paths: string[] = []
450    if (dash >= 0) paths = rest.slice(dash + 1)
451    else if (sub === 'restore') paths = rest.filter((a, k) => !a.startsWith('-') && !(k > 0 && /^(-s|--source)$/.test(rest[k - 1] as string)))
452    else if (!newBranch && rest.includes('.')) paths = ['.']
453    if (paths.length > 0) return { kind: 'git-checkout', label: `git ${sub} -- ${paths.join(' ')}`.slice(0, 60), args: rest, dir: gitDir }
454    if (sub === 'checkout' && rest.some(a => a === '-f' || a === '--force')) return { kind: 'other', label: 'git checkout --force', note: 'Discards uncommitted changes in the working tree to switch.', dir: gitDir }
455  }
456  if (sub === 'switch' && rest.some(a => a === '-f' || a === '--force' || a === '--discard-changes')) return { kind: 'other', label: 'git switch --discard-changes', note: 'Discards uncommitted changes in the working tree to switch.', dir: gitDir }
457  if (sub === 'worktree' && rest[0] === 'remove' && rest.some(a => a === '-f' || a === '--force')) return { kind: 'other', label: 'git worktree remove --force', note: 'Deletes the worktree with its modified and untracked files.', dir: gitDir }
458  if (sub === 'stash' && (rest[0] === 'drop' || rest[0] === 'clear')) return { kind: 'other', label: `git stash ${rest[0]}`, note: 'Dropped stashes are hard to recover.', dir: gitDir }
459  if (sub === 'branch' && rest.some(a => a === '-D' || a === '--delete' || a === '-d')) return { kind: 'other', label: 'git branch delete', note: 'Deletes a branch; unmerged commits on it are left to the reflog.', dir: gitDir }
460  return null
461}
462
463// ---- PowerShell -------------------------------------------------------------------
464
465const PS_RM = new Set(['remove-item', 'ri', 'rm', 'rmdir', 'rd', 'del', 'erase'])
466// Parameters that take a value, so the value is not a path.
467const PS_VALUED = /^-(path|literalpath|lp|p|filter|include|exclude|credential|erroraction|ea|warningaction|wa|informationaction|ia|errorvariable|ev|warningvariable|wv|outvariable|ov|outbuffer|ob|pipelinevariable|pv|stream)$/i
468
469export type PsSegment = { text: string; piped: boolean }
470
471/**
472 * Splits a PowerShell line at unquoted `;`, `|`, `||`, `&&`, `&`, newlines and
473 * the braces of a script block (`if (...) { Remove-Item ... }`); a backtick
474 * before a newline continues the line; `#` comments are dropped.
475 */
476export function splitPsSegments(command: string): PsSegment[] {
477  const out: PsSegment[] = []
478  let cur = ''
479  let quote: "'" | '"' | null = null
480  let piped = false
481  let nextPiped = false
482  const push = (): void => {
483    if (cur.trim() !== '') out.push({ text: cur, piped })
484    cur = ''
485    piped = nextPiped
486    nextPiped = false
487  }
488  for (let i = 0; i < command.length; i += 1) {
489    const ch = command[i] as string
490    if (quote !== null) {
491      cur += ch
492      if (ch === '`' && quote === '"' && i + 1 < command.length) cur += command[++i]
493      else if (ch === quote) quote = null
494      continue
495    }
496    if (ch === "'" || ch === '"') {
497      quote = ch
498      cur += ch
499      continue
500    }
501    if (ch === '`' && (command[i + 1] === '\n' || command[i + 1] === '\r')) {
502      cur += ' '
503      i += command[i + 1] === '\r' && command[i + 2] === '\n' ? 2 : 1
504      continue
505    }
506    if (ch === '#') {
507      while (i < command.length && command[i] !== '\n') i += 1
508      push()
509      continue
510    }
511    if (ch === ';' || ch === '\n' || ch === '{' || ch === '}') {
512      push()
513      continue
514    }
515    if (ch === '|') {
516      if (command[i + 1] === '|') {
517        i += 1
518        push()
519      } else {
520        nextPiped = true
521        push()
522      }
523      continue
524    }
525    if (ch === '&') {
526      if (i > 0 && command[i - 1] === '>') {
527        cur += ch // `2>&1`
528        continue
529      }
530      if (command[i + 1] === '&') i += 1
531      push()
532      continue
533    }
534    cur += ch
535  }
536  push()
537  return out
538}
539
540/** PowerShell words: quotes honoured, a comma list (`a,b` or `"a", "b"`) kept as one word, a lone continuation backtick dropped. */
541function tokenizePs(text: string): string[] {
542  const words: string[] = []
543  let cur = ''
544  let has = false
545  let quote: "'" | '"' | null = null
546  const push = (): void => {
547    if (has) words.push(cur)
548    cur = ''
549    has = false
550  }
551  for (let i = 0; i < text.length; i += 1) {
552    const ch = text[i] as string
553    if (quote !== null) {
554      if (quote === '"' && ch === '`' && i + 1 < text.length) {
555        cur += text[++i]
556        continue
557      }
558      if (quote === "'" && ch === "'" && text[i + 1] === "'") {
559        cur += "'"
560        i += 1
561        continue
562      }
563      if (ch === quote) {
564        quote = null
565        continue
566      }
567      cur += ch
568      continue
569    }
570    if (ch === "'" || ch === '"') {
571      quote = ch
572      has = true
573      continue
574    }
575    if (/\s/.test(ch)) {
576      // A comma list may continue after whitespace: `"a", "b"`.
577      if (cur.endsWith(',')) continue
578      let j = i
579      while (j < text.length && /\s/.test(text[j] as string)) j += 1
580      if (text[j] === ',' && has) {
581        i = j - 1
582        continue
583      }
584      push()
585      continue
586    }
587    cur += ch
588    has = true
589  }
590  push()
591  return words.filter(w => w !== '`')
592}
593
594/** The first risky segment of a PowerShell command, or null. */
595export function classifyPowerShell(command: string): Risk | null {
596  let dir: string | null = null
597  for (const seg of splitPsSegments(command)) {
598    const words = tokenizePs(seg.text.trim())
599    while (words.length > 0 && /^(&|\.)$/.test(words[0] as string)) words.shift()
600    const first = words[0]
601    if (first === undefined) continue
602    const cmd = first.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.exe$/, '')
603    const args = words.slice(1)
604    if (cmd === 'cd' || cmd === 'set-location' || cmd === 'sl' || cmd === 'chdir' || cmd === 'pushd' || cmd === 'push-location') {
605      const target = args.find(a => !a.startsWith('-'))
606      dir = target === undefined ? dir : joinDir(dir, target)
607      continue
608    }
609    // Another shell from PowerShell: cmd /c, bash -c, wsl, a nested powershell, Invoke-Expression.
610    if (cmd === 'cmd' && /^\/{1,2}[ck]$/i.test(args[0] ?? '')) {
611      const inner = classifyPowerShell(args.slice(1).join(' '))
612      if (inner !== null) return withDir(inner, dir)
613      continue
614    }
615    if (SHELLS.has(cmd) || cmd === 'wsl') {
616      const inner = classify(cmd === 'wsl' ? args.join(' ') : (args.find((a, k) => k > 0 && /^-[a-zA-Z]*c[a-zA-Z]*$/.test(args[k - 1] ?? '')) ?? ''))
617      if (inner !== null) return withDir(inner, dir)
618      continue
619    }
620    if (cmd === 'powershell' || cmd === 'pwsh' || cmd === 'invoke-expression' || cmd === 'iex') {
621      const at = args.findIndex(a => /^-(c|command)$/i.test(a))
622      const script = at >= 0 ? args.slice(at + 1).join(' ') : (args.find(a => !a.startsWith('-')) ?? '')
623      const inner = script === '' ? null : classifyPowerShell(script)
624      if (inner !== null) return withDir(inner, dir)
625      continue
626    }
627    if (PS_RM.has(cmd)) {
628      const low = args.map(a => a.toLowerCase())
629      if (low.some(a => a === '-whatif' || a === '-wi' || a === '-confirm')) continue
630      const recursive = low.some(a => /^-r(e(c(u(r(se?)?)?)?)?)?(:\$?true)?$/.test(a) || a === '/s')
631      const force = low.some(a => /^-fo(r(ce?)?)?(:\$?true)?$/.test(a) || a === '/q' || a === '/f')
632      const targets: string[] = []
633      for (let k = 0; k < args.length; k += 1) {
634        const a = args[k] as string
635        if (/^(\d|\*)?>>?(&\d)?$/.test(a)) {
636          k += 1 // a redirect and its operand
637          continue
638        }
639        if (/^(\d|\*)?>>?(&\d)?\S/.test(a)) continue
640        if (PS_VALUED.test(a) && args[k + 1] !== undefined) {
641          if (/^-(path|literalpath|lp|p)$/i.test(a)) targets.push(...(args[k + 1] as string).split(',').map(s => s.trim()).filter(Boolean))
642          k += 1
643        } else if (/^-(path|literalpath|lp|p):/i.test(a)) {
644          targets.push(...a.slice(a.indexOf(':') + 1).split(',').map(s => s.trim()).filter(Boolean))
645        } else if (!a.startsWith('-') && !a.startsWith('/')) {
646          targets.push(...a.split(',').map(s => s.trim()).filter(Boolean))
647        }
648      }
649      const switches = args.filter(a => (a.startsWith('-') || a.startsWith('/')) && !PS_VALUED.test(a) && !/^-(path|literalpath|lp|p):/i.test(a))
650      const label = `${first} ${switches.join(' ')}`.trim().slice(0, 40)
651      if (seg.piped && targets.length === 0) return { kind: 'rm', label, targets: [], dir, note: 'targets come from the pipeline; not measured' }
652      const isGlob = targets.some(t => /[*?[]/.test(t))
653      if (recursive || (force && (targets.length > 1 || isGlob))) {
654        if (targets.some(t => t.includes('$'))) return { kind: 'rm', label, targets: [], dir, note: 'targets use variables; not measured' }
655        return { kind: 'rm', label, targets, dir }
656      }
657      continue
658    }
659    if (cmd === 'git') {
660      const risk = classifyGit(args, dir)
661      if (risk !== null) return risk
662      continue
663    }
664    if (READ_ONLY_PS.has(cmd) || READ_ONLY.has(cmd)) continue
665    if (isDryRun(args)) continue
666    const joined = words.join(' ')
667    if (/\bdrop\s+(table|database|schema)\b|\btruncate\s+table\b/i.test(joined)) return { kind: 'other', label: 'SQL drop', note: 'Drops or truncates database objects.', dir }
668    if (args.includes('migrate') || /\bmanage\.py\s+migrate\b|\bprisma\s+migrate\s+(dev|deploy|reset)\b|\bdotnet\s+ef\s+database\s+update\b/.test(joined)) {
669      return { kind: 'migrate', tool: 'unknown', label: 'migrate', dir }
670    }
671  }
672  return null
673}
674
675// ---- Measuring helpers (pure) ----------------------------------------------------------
676
677export const LIST_MAX = 10
678
679/** A glob path segment (`*.log`, `build?`, `[ab]*`) as a RegExp over one file name; null when it has no glob. */
680export function globToRegExp(segment: string, ignoreCase = false): RegExp | null {
681  if (!/[*?[]/.test(segment)) return null
682  let re = '^'
683  for (let i = 0; i < segment.length; i += 1) {
684    const ch = segment[i] as string
685    if (ch === '*') re += '[^/]*'
686    else if (ch === '?') re += '[^/]'
687    else if (ch === '[') {
688      const end = segment.indexOf(']', i + 1)
689      if (end < 0) re += '\\['
690      else {
691        const body = segment.slice(i + 1, end).replace(/^!/, '^').replace(/\\/g, '\\\\')
692        re += `[${body}]`
693        i = end
694      }
695    } else re += ch.replace(/[.+^${}()|\\]/g, '\\$&')
696  }
697  return new RegExp(`${re}$`, ignoreCase ? 'i' : '')
698}
699
700/** The git clean flags and paths, with -n added and -f/-i/-q dropped so the dry run stays one. */
701export function cleanArgs(args: string[]): { flags: string[]; paths: string[] } {
702  const flags: string[] = []
703  const paths: string[] = []
704  for (let i = 0; i < args.length; i += 1) {
705    const a = args[i] as string
706    if (a === '--') {
707      paths.push(...args.slice(i + 1))
708      break
709    }
710    if (a === '-e' || a === '--exclude') {
711      flags.push(a, args[i + 1] ?? '')
712      i += 1
713    } else if (a.startsWith('--exclude=') || /^-e./.test(a)) {
714      flags.push(a)
715    } else if (/^-[a-zA-Z]+$/.test(a)) {
716      const kept = a.replace(/[finq]/g, '')
717      if (kept !== '-') flags.push(kept)
718    } else if (!a.startsWith('-')) {
719      paths.push(a)
720    }
721  }
722  return { flags, paths }
723}
724
725/** `1.4 MB`. */
726export function size(bytes: number): string {
727  if (!Number.isFinite(bytes) || bytes < 1024) return `${bytes || 0} B`
728  const units = ['KB', 'MB', 'GB', 'TB']
729  let n = bytes
730  let i = -1
731  while (n >= 1024 && i < units.length - 1) {
732    n /= 1024
733    i += 1
734  }
735  return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`
736}
737
738/** Whether a shell or PowerShell command runs a git commit (the lights turn green when it succeeds). */
739export function isGitCommit(command: string): boolean {
740  const segments = [...splitSegments(command).map(s => tokenize(s.text)), ...splitPsSegments(command).map(s => tokenizePs(s.text))]
741  return segments.some(w => {
742    while (w.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0] as string)) w.shift()
743    const head = (w[0] ?? '').replace(/^[({]+/, '')
744    if (head !== 'git' && head !== 'git.exe') return false
745    let i = 1
746    while (i < w.length && (w[i] as string).startsWith('-')) {
747      const opt = w[i] as string
748      i += opt === '-C' || opt === '-c' || opt === '--git-dir' || opt === '--work-tree' ? 2 : 1
749    }
750    if (w[i] !== 'commit') return false
751    return !w.slice(i + 1).includes('--dry-run') // a dry run commits nothing; `-n` is --no-verify and does
752  })
753}
754
hooks/lib/diff.ts 206 lines
1// Kessoku Ultimate Bocchi Theme: one edit as a unified diff.
2//
3// Pure: a line diff (common prefix and suffix trimmed, then LCS over the
4// middle; a huge middle falls back to remove-all-then-add-all and says so)
5// rendered as unified-diff hunks, which the `Code` element draws with gutters
6// and colours under `format: 'diff'`. CRLF and LF lines compare equal.
7
8export type DiffOp = { op: ' ' | '-' | '+'; t: string }
9
10const MAX_LCS_LINES = 1500
11
12export function splitLines(text: string | undefined | null): string[] {
13  if (text === undefined || text === null || text === '') return []
14  const lines = String(text).split(/\r\n|\n|\r/)
15  if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
16  return lines
17}
18
19export type LineDiff = { ops: DiffOp[]; isApproximate: boolean }
20
21/** The line operations turning `oldText` into `newText`, and whether the middle was too large to diff exactly. */
22export function diffLinesEx(oldText: string, newText: string): LineDiff {
23  const a = splitLines(oldText)
24  const b = splitLines(newText)
25  // Common prefix and suffix are unchanged lines; only the middle needs the LCS.
26  let head = 0
27  while (head < a.length && head < b.length && a[head] === b[head]) head += 1
28  let tail = 0
29  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1
30  const ops: DiffOp[] = []
31  for (let i = 0; i < head; i += 1) ops.push({ op: ' ', t: a[i] as string })
32  const ma = a.slice(head, a.length - tail)
33  const mb = b.slice(head, b.length - tail)
34  let isApproximate = false
35  if (ma.length > MAX_LCS_LINES || mb.length > MAX_LCS_LINES) {
36    isApproximate = true
37    for (const t of ma) ops.push({ op: '-', t })
38    for (const t of mb) ops.push({ op: '+', t })
39  } else {
40    ops.push(...lcsOps(ma, mb))
41  }
42  for (let i = a.length - tail; i < a.length; i += 1) ops.push({ op: ' ', t: a[i] as string })
43  return { ops, isApproximate }
44}
45
46/** The line operations turning `oldText` into `newText`. */
47export function diffLines(oldText: string, newText: string): DiffOp[] {
48  return diffLinesEx(oldText, newText).ops
49}
50
51function lcsOps(a: string[], b: string[]): DiffOp[] {
52  const n = a.length
53  const m = b.length
54  if (n === 0) return b.map(t => ({ op: '+' as const, t }))
55  if (m === 0) return a.map(t => ({ op: '-' as const, t }))
56  const w = m + 1
57  const dp = new Uint16Array((n + 1) * w)
58  for (let i = n - 1; i >= 0; i -= 1) {
59    const ai = a[i]
60    for (let j = m - 1; j >= 0; j -= 1) {
61      dp[i * w + j] = ai === b[j] ? (dp[(i + 1) * w + j + 1] as number) + 1 : Math.max(dp[(i + 1) * w + j] as number, dp[i * w + j + 1] as number)
62    }
63  }
64  const out: DiffOp[] = []
65  let i = 0
66  let j = 0
67  while (i < n && j < m) {
68    if (a[i] === b[j]) {
69      out.push({ op: ' ', t: a[i] as string })
70      i += 1
71      j += 1
72    } else if ((dp[(i + 1) * w + j] as number) >= (dp[i * w + j + 1] as number)) {
73      out.push({ op: '-', t: a[i] as string })
74      i += 1
75    } else {
76      out.push({ op: '+', t: b[j] as string })
77      j += 1
78    }
79  }
80  while (i < n) out.push({ op: '-', t: a[i++] as string })
81  while (j < m) out.push({ op: '+', t: b[j++] as string })
82  return out
83}
84
85export type Unified = { hunks: string; add: number; del: number; lines: number; isApproximate: boolean }
86
87/**
88 * Unified-diff hunks for the change, `context` unchanged lines around each
89 * run of changes; runs closer than that merge into one hunk. Line numbers
90 * count from `startLine` (1 when the text is a snippet, not a whole file).
91 */
92export function unifiedDiff(oldText: string, newText: string, context = 2, startLine = 1): Unified {
93  const { ops, isApproximate } = diffLinesEx(oldText, newText)
94  let add = 0
95  let del = 0
96  for (const o of ops) {
97    if (o.op === '+') add += 1
98    else if (o.op === '-') del += 1
99  }
100  if (add === 0 && del === 0) return { hunks: '', add, del, lines: 0, isApproximate }
101  // Which ops to keep: every change, and `context` lines on either side.
102  const keep = new Array<boolean>(ops.length).fill(false)
103  for (let k = 0; k < ops.length; k += 1) {
104    if ((ops[k] as DiffOp).op === ' ') continue
105    for (let c = Math.max(0, k - context); c <= Math.min(ops.length - 1, k + context); c += 1) keep[c] = true
106  }
107  // Walk the ops with running old and new line numbers, cutting hunks where
108  // the kept runs break.
109  const out: string[] = []
110  let oldLine = startLine
111  let newLine = startLine
112  let k = 0
113  let lines = 0
114  while (k < ops.length) {
115    if (!keep[k]) {
116      if ((ops[k] as DiffOp).op !== '+') oldLine += 1
117      if ((ops[k] as DiffOp).op !== '-') newLine += 1
118      k += 1
119      continue
120    }
121    const start = k
122    let end = k
123    while (end < ops.length && keep[end]) end += 1
124    let oldCount = 0
125    let newCount = 0
126    const body: string[] = []
127    for (let x = start; x < end; x += 1) {
128      const o = ops[x] as DiffOp
129      if (o.op !== '+') oldCount += 1
130      if (o.op !== '-') newCount += 1
131      body.push(`${o.op}${o.t}`)
132    }
133    out.push(`@@ -${oldCount === 0 ? Math.max(0, oldLine - 1) : oldLine},${oldCount} +${newCount === 0 ? Math.max(0, newLine - 1) : newLine},${newCount} @@`)
134    out.push(...body)
135    lines += body.length
136    oldLine += oldCount
137    newLine += newCount
138    k = end
139  }
140  return { hunks: out.join('\n'), add, del, lines, isApproximate }
141}
142
143/**
144 * The first `maxLines` lines of unified-diff text, cut so the result still
145 * parses as hunks: whole hunks are kept while they fit, and a hunk cut short
146 * gets its header's counts rewritten to the lines actually shown.
147 */
148export function cutHunks(hunks: string, maxLines: number): { text: string; hidden: number } {
149  if (hunks === '') return { text: '', hidden: 0 }
150  const lines = hunks.split('\n')
151  if (lines.length <= maxLines) return { text: hunks, hidden: 0 }
152  const kept: string[] = []
153  let headerAt = -1
154  let oldCount = 0
155  let newCount = 0
156  let oldStart = 0
157  let newStart = 0
158  const closeHunk = (): void => {
159    if (headerAt >= 0) kept[headerAt] = `@@ -${oldCount === 0 ? Math.max(0, oldStart - 1) : oldStart},${oldCount} +${newCount === 0 ? Math.max(0, newStart - 1) : newStart},${newCount} @@`
160  }
161  for (let i = 0; i < lines.length && kept.length < maxLines; i += 1) {
162    const line = lines[i] as string
163    const header = /^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/.exec(line)
164    if (header !== null) {
165      closeHunk()
166      headerAt = kept.length
167      oldStart = Number(header[1])
168      newStart = Number(header[3])
169      oldCount = 0
170      newCount = 0
171      kept.push(line)
172      continue
173    }
174    if (line.startsWith('+')) newCount += 1
175    else if (line.startsWith('-')) oldCount += 1
176    else {
177      oldCount += 1
178      newCount += 1
179    }
180    kept.push(line)
181  }
182  closeHunk()
183  // A header left as the last line shows nothing: drop it.
184  if (kept.length > 0 && (kept[kept.length - 1] as string).startsWith('@@')) kept.pop()
185  return { text: kept.join('\n'), hidden: Math.max(0, lines.length - kept.length) }
186}
187
188/** The tool result's `structuredPatch` entries as unified-diff text with real line numbers. */
189export function patchToUnified(patch: ReadonlyArray<{ oldStart: number; oldLines: number; newStart: number; newLines: number; lines: readonly string[] }>): Unified {
190  const out: string[] = []
191  let add = 0
192  let del = 0
193  let lines = 0
194  for (const h of patch) {
195    out.push(`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`)
196    for (const l of h.lines) {
197      const clean = l.replace(/\r$/, '')
198      if (clean.startsWith('+')) add += 1
199      else if (clean.startsWith('-')) del += 1
200      out.push(clean)
201      lines += 1
202    }
203  }
204  return { hunks: out.join('\n'), add, del, lines, isApproximate: false }
205}
206
hooks/lib/engine.ts 59 lines
1// Kessoku Ultimate Bocchi Theme: the engine the mod runs on.
2//
3// Pure, no `$` and no `on`: the engine version the mod is written for, how
4// versions compare, the features that lean on an engine API the gate probes,
5// and the lines help says about them.
6
7/** The oldest Claude Code the mod is written for (the README's floor). */
8export const MIN_ENGINE = '2.1.287'
9
10/**
11 * The features that lean on a newer engine call, each switched off for the
12 * session when the call turns out not to exist. (A hook the engine does not
13 * know, such as `session.compact` on an engine before it, refuses the whole
14 * module at load, so the hooks are not gated: the floor for them is the
15 * engine version the README names.)
16 */
17export type FeatureKey = 'suggest' | 'notify' | 'version' | 'agents' | 'memo'
18
19/** What each feature needs of the engine, and how help names it. */
20export const FEATURES: Readonly<Record<FeatureKey, { api: string; label: string }>> = {
21  suggest: { api: '$.prompt.suggest', label: 'the Replay suggestion' },
22  notify: { api: '$.ui.notify', label: 'native notifications' },
23  version: { api: '$.session.version', label: 'the engine version' },
24  agents: { api: '$.agent.register', label: 'the band agent types' },
25  memo: { api: '$.tool.register', label: 'the memo tool' },
26}
27
28/** `2.1.299`, `2.1.299-dev`, `2.1.280-dev.20260920.t101500.sha1a2b3c4`, `v2.1.1` as their three numbers; null for anything that does not open as a release. */
29export function parseVersion(text: string): [number, number, number] | null {
30  const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(text)
31  return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])]
32}
33
34/** Whether `version` is at least `min`, number by number; false for a version that does not parse. */
35export function isAtLeast(version: string, min: string): boolean {
36  const a = parseVersion(version)
37  const b = parseVersion(min)
38  if (a === null || b === null) return false
39  for (let i = 0; i < 3; i += 1) {
40    const x = a[i] as number
41    const y = b[i] as number
42    if (x !== y) return x > y
43  }
44  return true
45}
46
47/** Help's opening line: the mod's version (when its manifest could be read) and the engine's, with a word when the engine is older than expected. */
48export function versionLine(mod: string, engine: string): string {
49  const modWord = mod === '' ? '' : ` v${mod}`
50  const engineWord = engine === '' ? 'Claude Code (version unknown)' : `Claude Code ${engine}${isAtLeast(engine, MIN_ENGINE) ? '' : ` (older than the mod expects, ${MIN_ENGINE}+)`}`
51  return `♪ Kessoku Ultimate Bocchi Theme${modWord} · ${engineWord}`
52}
53
54/** Help's line naming the features that are off on this engine, each with the API it misses; '' when every one is on. */
55export function offLine(features: Readonly<Record<FeatureKey, boolean>>): string {
56  const off = (Object.keys(FEATURES) as FeatureKey[]).filter(k => !features[k]).map(k => `${FEATURES[k].label} (no ${FEATURES[k].api})`)
57  return off.length === 0 ? '' : `off on this engine: ${off.join(', ')}`
58}
59
hooks/lib/reflect.ts 180 lines
1// Kessoku Ultimate Bocchi Theme: Nijika's notebook.
2//
3// Pure: spotting a correction in a prompt, the prompt that turns it into one
4// rule, the memo tool Claude proposes a rule through (its spec, the check a
5// proposal passes, what the call answers), and how a rule lands in a CLAUDE.md.
6
7// Openers that say "do it differently from now on", not a question or a task.
8const OPENERS = /^\s*(?:no\s*[,.!:—–-]\s*(?:don'?t|do not|not|never|use|we|in this|that'?s|you|please)|nope\b|don'?t(?!\s+(?:worry|forget|mind|bother|hesitate))\b|do not\b|never(?! mind)\b|wrong\b|incorrect\b|from now on\b|going forward\b|rule:|remember(?::| that\b| to (?:always|never)\b)|please (?:use|don'?t|do not|never|always|prefer|avoid|keep|stop using)\b|prefer\b|avoid\b(?!\s+(?:breaking|this))|always\b(?!\s+(?:been|has|was|worry))|stop (?:using|doing|adding|writing)\b|you should(?:n'?t| not)?\b|that'?s (?:wrong|incorrect|not right|not how)\b|actually,?\s+(?:don'?t|do not|no\b|use|we|it should|it'?s)|in this (?:repo|project|codebase)\b|we (?:always|never|use|don'?t|do not|write|put|keep|name|call|prefer)\b(?!\s+(?:this|that)\s+(?:config|setup|file))|use \S+ (?:instead|not|rather)\b)/i
9// Said once and worth a rule on its own: a plain telling-off, or a move from one tool to another.
10const STRONG = /^\s*(?:i (?:said|told you|asked for)\b|not like that\b|wrong[,.!:]|stop using\b.{1,60}\b(?:we )?(?:moved|switched|use)\b)/i
11// Pairs that set one thing against another: "X, not Y", "A instead of B".
12const PAIRS = /\b(?:instead of|rather than|not .{1,40}\buse\b|use .{1,40}\bnot\b|never .{1,60}\balways\b|always .{1,60}\bnever\b|should (?:not|never|always)\b|(?:is|are|lives?|belongs?|goes?) (?:at|in|under) .{1,40},? not\b|,\s*not\s+\S+\s*\.?\s*$|\bmoved to\b|\bswitched to\b)/i
13// A sentence about a durable preference names a tool, a style or this place.
14const SUBJECT = /\b(?:repo|repository|project|codebase|here|convention|style|format|indent|tabs?|spaces|pnpm|npm|yarn|bun|eslint|prettier|lint|tests?|vitest|jest|pytest|commit|branch|main|master|import|export|types?|interface|enum|any|null|undefined|semicolons?|quotes|snake_case|camelCase|kebab|tailwind|css|sass|docker|make|cargo|go|python|typescript|javascript|api|endpoint|v\d|folder|directory|files?|naming|name|prefix|suffix|default|config)\b/i
15
16/**
17 * Whether a prompt reads like a correction of how Claude works: a strong
18 * telling-off on its own, or two signals among an opener ("don't", "always",
19 * "in this repo"), a pair ("X, not Y", "A instead of B") and a subject (a
20 * tool, a style, this repository). A question is never one, nor a slash command.
21 */
22export function looksLikeCorrection(text: string): boolean {
23  const t = text.trim()
24  if (t.length < 8 || t.length > 1500) return false
25  if (t.startsWith('/') || t.startsWith('!') || t.startsWith('#')) return false
26  if (/\?\s*$/.test(t)) return false
27  if (STRONG.test(t)) return true
28  let score = 0
29  if (OPENERS.test(t)) score += 1
30  if (PAIRS.test(t)) score += 1
31  if (SUBJECT.test(t)) score += 1
32  return score >= 2
33}
34
35/** The one-shot prompt that asks a small model for the rule, or NONE. */
36export function rulePrompt(original: string): string {
37  return [
38    'You are the band manager keeping a notebook of durable rules for an AI coding assistant.',
39    'The user just corrected the assistant with the message below.',
40    'If the message contains a durable, reusable rule (a preference about tools, libraries, style, workflow, or this repository), write it as ONE imperative sentence under 140 characters, in plain English, with no quotes around it and no list marker.',
41    'Example: Always use pnpm instead of npm in this repository.',
42    'If it is a one-off instruction with no lasting rule, answer exactly: NONE',
43    'Answer with the rule or NONE and nothing else.',
44    '',
45    '<message>',
46    original.slice(0, 1500),
47    '</message>',
48  ].join('\n')
49}
50
51/** The model's reply as a rule line, or '' when it said NONE or rambled. */
52export function cleanRule(reply: string): string {
53  // The first line that is text: not blank, not a code fence, not a rule.
54  const line = reply.trim().split('\n').find(l => l.trim() !== '' && !/^\s*(```|~~~|---)/.test(l)) ?? ''
55  const rule = line
56    .replace(/^\s*(?:[-*•]|\d+[.)])\s+/, '')
57    .replace(/^rule:\s*/i, '')
58    .replace(/^(["'“‘])(.*)["'”’]$/, '$2') // a matched pair of quotes, never one side alone
59    .trim()
60  if (rule === '' || /^none\b/i.test(rule) || rule.length > 220 || !/[A-Za-z]/.test(rule)) return ''
61  return /[.!?]$/.test(rule) ? rule : `${rule}.`
62}
63
64/** The memo tool's short name, and the full one the model calls (`mcp__<plugin>__<name>`). */
65export const MEMO_TOOL_NAME = 'memo'
66export const MEMO_TOOL = 'mcp__kessoku-ultimate-bocchi__memo'
67
68/** A proposed rule is one sentence shorter than this. */
69export const RULE_MAX = 140
70/** Proposals waiting behind the open memo, at most. */
71export const MEMO_QUEUE_MAX = 5
72
73/** The memo tool as `$.tool.register` takes it: what the model reads, and the input it gives. */
74export const MEMO_TOOL_SPEC = {
75  name: MEMO_TOOL_NAME,
76  description: [
77    "kessoku memo: propose one durable rule for this repository's CLAUDE.md when you notice a convention you have broken or had to re-learn (a tool, a style, a workflow, a place things live).",
78    `One imperative sentence under ${RULE_MAX} characters.`,
79    'The user decides: the rule is shown as a band memo and saved only if they press Save, so the call returns at once and nothing exists until then; never assume the rule was written.',
80    'Never call it for a one-off instruction, and never to repeat what CLAUDE.md already says.',
81  ].join(' '),
82  inputSchema: {
83    type: 'object',
84    properties: {
85      rule: { type: 'string', description: `The rule: one imperative sentence under ${RULE_MAX} characters, e.g. "Use pnpm, not npm, for every script in this repository."` },
86      why: { type: 'string', description: 'Optional: what made you notice it, in one short line.' },
87    },
88    required: ['rule'],
89    additionalProperties: false,
90  },
91  isDeferred: false,
92} as const
93
94// A full stop after one of these does not end a sentence.
95const ABBREVIATIONS = /\b(?:e\.g|i\.e|etc|vs|cf|approx)\.$/i
96
97/**
98 * A proposed rule as it would be saved, or why it is refused: a string, one
99 * line, one sentence, under RULE_MAX characters, with a letter in it. Spaces
100 * are collapsed, a list marker or a matched pair of quotes around it dropped,
101 * and a full stop added when it ends without one.
102 */
103export function checkRule(input: unknown): { rule: string } | { why: string } {
104  if (typeof input !== 'string' || input.trim() === '') return { why: 'the rule is empty: give one imperative sentence' }
105  if (/[\r\n]/.test(input.trim())) return { why: 'the rule runs over several lines: give one sentence on one line' }
106  const flat = input
107    .replace(/\s+/g, ' ')
108    .trim()
109    .replace(/^(?:[-*•]|\d+[.)])\s+/, '')
110    .replace(/^rule:\s*/i, '')
111    .replace(/^(["'“‘])(.*)["'”’]$/, '$2')
112    .trim()
113  if (!/[A-Za-z]/.test(flat)) return { why: 'the rule has no words in it' }
114  const rule = /[.!?]["'”’)\]]*$/.test(flat) ? flat : `${flat}.`
115  if (rule.length >= RULE_MAX) return { why: `the rule is ${rule.length} characters; keep it under ${RULE_MAX}` }
116  // A sentence ends at . ! or ? (a closing quote or bracket may follow) before a space and a capital or a digit.
117  const ends = [...rule.matchAll(/[.!?]["'”’)\]]*\s+(?=[A-Z0-9"“'‘(])/g)].filter(m => !ABBREVIATIONS.test(rule.slice(0, (m.index ?? 0) + 1)))
118  if (ends.length > 0) return { why: `the rule reads as ${ends.length + 1} sentences; propose one rule a call, in one sentence` }
119  return { rule }
120}
121
122/** What the model reads back from a memo it proposed: where the rule stands, and that nothing is written yet. */
123export function memoAnswer(kind: 'open' | 'queued' | 'duplicate', waiting = 0): string {
124  const tail = 'Nothing is written until the user presses Save; do not assume the rule exists, and do not propose it again.'
125  if (kind === 'duplicate') return `Already held for the user: that rule is on Nijika's memo card or waiting behind it. ${tail}`
126  if (kind === 'queued') return `Held for the user: queued behind the memo already on screen (${waiting} waiting). ${tail}`
127  return `Held for the user: Nijika's memo card is up with the rule. ${tail}`
128}
129
130export const RULES_HEADING = '## Band notes (from Reflect)'
131
132/**
133 * `existing` with the rule appended once, under the band's heading: after
134 * the heading's own list when the heading exists, as a new section at the
135 * end otherwise. The file's own line ending (CRLF or LF) is kept.
136 */
137export function appendRule(existing: string, rule: string): string {
138  const line = `- ${rule}`
139  const eol = /\r\n/.test(existing) ? '\r\n' : '\n'
140  const lines = existing === '' ? [] : existing.split(/\r\n|\n/)
141  if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
142  if (lines.some(l => l.trim() === line)) return existing
143  if (lines.length === 0) lines.push('# CLAUDE.md')
144  // Find the heading outside code fences (``` or ~~~, of any length; a fence closes with its own kind, at least as long).
145  let fence: string | null = null
146  let at = -1
147  for (let i = 0; i < lines.length; i += 1) {
148    const l = (lines[i] as string).trim()
149    const m = /^(`{3,}|~{3,})/.exec(l)
150    if (m !== null) {
151      const mark = m[1] as string
152      if (fence === null) fence = mark
153      else if (mark[0] === fence[0] && mark.length >= fence.length && l === mark) fence = null
154      continue
155    }
156    if (fence === null && l === RULES_HEADING) {
157      at = i
158      break
159    }
160  }
161  if (at < 0) {
162    if ((lines[lines.length - 1] as string).trim() !== '') lines.push('')
163    lines.push(RULES_HEADING, line)
164    return lines.join(eol) + eol
165  }
166  // The heading's list: items and blank lines until the next heading or other text.
167  let end = at
168  for (let i = at + 1; i < lines.length; i += 1) {
169    const l = (lines[i] as string).trim()
170    if (l === '') continue
171    if (l.startsWith('- ') || l.startsWith('* ')) {
172      end = i
173      continue
174    }
175    break
176  }
177  lines.splice(end + 1, 0, line)
178  return lines.join(eol) + eol
179}
180
hooks/lib/typeahead.ts 101 lines
1// Kessoku Ultimate Bocchi Theme: the typeahead.
2//
3// Pure, no `$` and no `on`: what the prompt box's typeahead offers for the
4// mod's own commands (`prompt.autocomplete`), from the draft and the token at
5// the cursor: /bocchi's sub-commands and their arguments, /keepwarm's
6// durations. The descriptions are the help's words, so the two agree.
7
8/** One row of the typeahead: what taking it writes over the token, what the row shows (the text when absent), a dim line beside it. */
9export type Completion = { text: string; label?: string; description?: string }
10
11/** The prompt box as `prompt.autocomplete` hands it: the draft, the caret, the token that ends at the caret, and where it starts. */
12export type Draft = { text: string; cursor: number; token: string; start: number }
13
14type Word = readonly [word: string, description: string]
15
16/** /bocchi's sub-commands, in the order help lists them. */
17export const BOCCHI_VERBS: readonly Word[] = [
18  ['theme', "switch Claude Code's own theme to Kessoku Bocchi, or back: on | off | status"],
19  ['mood', 'try a face in the status line: anxious | focused | happy | panic | melting | hiding'],
20  ['demo', 'put sample acts on the panel (demo off clears them)'],
21  ['tax', 'preview the Cache Tax warning'],
22  ['crash', 'preview the dropped-guitar screen'],
23  ['memo', "preview Nijika's band memo (memo proposed: one Claude proposed itself)"],
24  ['setlist', 'preview Replay Theater with a sample setlist'],
25  ['solo', "open one member alone, every act in full: bocchi | nijika | ryo | kita, or off for the mix"],
26  ['strip', 'hide or show the band strip above the prompt'],
27  ['help', 'the commands, the versions, the config'],
28]
29
30export const SOLO_ARGS: readonly Word[] = [
31  ['bocchi', 'the main loop, alone on the mixer'],
32  ['nijika', 'her acts in full'],
33  ['ryo', 'her acts in full'],
34  ['kita', 'her acts in full'],
35  ['off', 'back to the mix'],
36]
37
38export const THEME_ARGS: readonly Word[] = [
39  ['on', 'put the Kessoku theme on (the light twin on a light theme)'],
40  ['off', 'put the theme you had back'],
41  ['status', 'which theme is on, and whether Kessoku is listed'],
42]
43
44export const MOOD_ARGS: readonly Word[] = [
45  ['anxious', 'idle'],
46  ['focused', 'working'],
47  ['happy', 'a turn landed'],
48  ['panic', 'a wrong note'],
49  ['melting', 'the context is full'],
50  ['hiding', 'interrupted, told no, or the cache gone cold'],
51]
52
53export const DEMO_ARGS: readonly Word[] = [['off', 'clear the sample acts']]
54
55export const MEMO_ARGS: readonly Word[] = [['proposed', 'a memo Claude proposed through the memo tool, with its byline']]
56
57/** /keepwarm's durations (any `90m`, `1h30`, `2h` works; these are the ones offered). */
58export const KEEPWARM_ARGS: readonly Word[] = [
59  ['30m', 'keep the prompt cache warm for half an hour'],
60  ['1h', 'keep the prompt cache warm for an hour'],
61  ['2h', 'keep the prompt cache warm for two hours'],
62  ['off', 'stop the pings'],
63]
64
65const BOCCHI = '/bocchi'
66const KEEPWARM = '/keepwarm'
67
68/** What /bocchi's sub-commands take, by sub-command; one not listed takes nothing. */
69const BOCCHI_ARGS: Readonly<Record<string, readonly Word[]>> = { theme: THEME_ARGS, mood: MOOD_ARGS, demo: DEMO_ARGS, memo: MEMO_ARGS, solo: SOLO_ARGS }
70
71/** The words the token begins (ignoring case), each a row; a word the token spells whole is no completion. */
72function rows(words: readonly Word[], token: string): Completion[] {
73  const t = token.toLowerCase()
74  return words.filter(([w]) => w.startsWith(t) && w !== t).map(([w, description]) => ({ text: w, description }))
75}
76
77/**
78 * The rows for the token at the cursor. A draft that is exactly `/bocchi` or
79 * `/keepwarm`: every next word, written out whole (`/bocchi theme`; the
80 * engine's own row already completes a command's name, so a prefix of it adds
81 * nothing). `/bocchi <word>`: the sub-commands the word begins. `/bocchi
82 * theme|mood|demo|memo <word>` and `/keepwarm <word>`: the arguments it begins.
83 * Anything else: nothing.
84 */
85export function completions(d: Draft): Completion[] {
86  const before = d.text.slice(0, d.start)
87  if (before.trim() === '') {
88    if (d.token === BOCCHI) return BOCCHI_VERBS.map(([w, description]) => ({ text: `${BOCCHI} ${w}`, description }))
89    if (d.token === KEEPWARM) return KEEPWARM_ARGS.map(([w, description]) => ({ text: `${KEEPWARM} ${w}`, description }))
90    return []
91  }
92  // The token is not the first word: the draft so far must be the command alone, or the command and its sub-command.
93  if (!/^\s*\//.test(before)) return []
94  const words = before.trim().split(/\s+/)
95  const command = (words[0] ?? '').toLowerCase()
96  if (command === BOCCHI && words.length === 1) return rows(BOCCHI_VERBS, d.token)
97  if (command === BOCCHI && words.length === 2) return rows(BOCCHI_ARGS[(words[1] ?? '').toLowerCase()] ?? [], d.token)
98  if (command === KEEPWARM && words.length === 1) return rows(KEEPWARM_ARGS, d.token)
99  return []
100}
101
hooks/lib/scene.ts 2887 lines
1// Kessoku Ultimate Bocchi Theme: the band at STARRY, drawn for a surface that draws Svg.
2//
3// Pure, no `$` and no `on`: the panel as a live house, a stage under neon
4// with the band and their slimes, the plates under it, and a chalkboard
5// setlist where every act is a song. Each function turns the panel's view
6// (hooks/lib/stage.ts) into the source of one Svg element; the hooks module
7// stacks them edge to edge in Boxes painted the scene's own colours, so a
8// seam between two pictures reads as the wall or the board.
9//
10// The stage is the one moving picture (its sprites and lights animate, SMIL
11// and CSS inside the image): it carries no clock and no cost, so its source
12// changes only when a mood, a light, a slime, a failure mark, the air or the
13// solo does, and an unchanged stage is the same string (memoised) and never
14// sent again, so a redraw never restarts its motion. Every other picture may
15// change at each redraw.
16//
17// Every picture is a standalone SVG document: `viewBox="0 0 W H"` with W the
18// layout's width, and a width and height four times that, so a surface
19// scales it down to whatever slot it is drawn in. The board's slices (head,
20// songs, details, foot) each paint the green full bleed and place the
21// chalk's texture in board coordinates (`translate(0, -y)`), so the songs
22// read as one board however they are stacked.
23
24import type { ActView, MemberView, PanelView } from './stage'
25import { animOf, fitSprites, framePixels, SVG_LIMIT } from './sprite'
26import type { SpritePart } from './sprite'
27import { fmtUsd } from './band'
28
29/** One Svg element's drawing: its source, the words a reader gets for it, and its height in layout units. */
30export type SceneSvg = { svg: string; alt: string; height: number }
31
32/**
33 * How wide the pictures are laid out, in CSS pixels at 1:1. Every picture
34 * declares a large width, so a surface scales it to the slot it is drawn in;
35 * the layout width is only what the wrapping and the type sizes are chosen for.
36 */
37export type SceneLayout = { width: number }
38
39/** The layout for a pane `bodyColumns` cells wide (the desktop's cells are about 7.5 CSS pixels). */
40export function layoutFor(bodyColumns: number): SceneLayout {
41  return { width: Math.max(340, Math.min(1080, Math.round(bodyColumns * 7.5))) }
42}
43
44/**
45 * How much narrower the board's pictures are than the pane, in layout pixels:
46 * the wooden frame around them, its Box's 1 px border and one cell (about 7
47 * px) of wood each side. A song's Box has no border of its own (a desktop pads
48 * a bordered Box), so every slice of the board is drawn at one width.
49 */
50export const BOARD_INSET = 16
51
52/** The layout the board's pictures (head, songs, details, foot) are drawn for: the pane's, less the frame around them. */
53export function boardLayout(layout: SceneLayout): SceneLayout {
54  return { width: Math.max(300, layout.width - BOARD_INSET) }
55}
56
57/**
58 * The colours the Boxes around the pictures are painted, so a seam between
59 * two pictures reads as the scene: the wall, the board's green, the board's
60 * wooden frame and its dark edge, the plates.
61 */
62export const SCENE_COLORS = { wall: '#17121a', board: '#24302a', wood: '#7a5233', woodEdge: '#3a2414', plate: '#151117' } as const
63
64/**
65 * How a song's Box, the hover scope that opens it, is painted. Its picture is
66 * a stencil: the board's green, painted fully round a hand-drawn chalk box,
67 * a shade thinner inside it, and not at all along the box's lines. At rest the
68 * Box is the board's green (`color`), so nothing shows; under the pointer it
69 * is her chalk (`hover`), which shows through the stencil as the box's chalk
70 * lines and, a shade, as the wash inside them (`wash`, what that mixes to).
71 * Her details, revealed under it, paint the box's sides and foot themselves;
72 * their Box is `ground` (the board's green as its chalk dust leaves it on
73 * average), which the native row under the card (its button) stands on.
74 * `chalk` is the colour her card's words are in (its button's label).
75 */
76export type SongLook = { color: string; hover: string; wash: string; ground: string; chalk: string }
77
78export function songLook(act: ActView): SongLook {
79  const c = chalkOf(act.member)
80  const line = cardLine(act.member)
81  return { color: SCENE_COLORS.board, hover: line, wash: shadeToward(SCENE_COLORS.board, line, CARD_WASH), ground: BOARD_DUSTED, chalk: c }
82}
83
84/** The board's green under its chalk dust, on average (measured on the drawn board): a native row's ground on the board. */
85const BOARD_DUSTED = '#28342d'
86
87/** The card's chalk line: her chalk, a little whiter, as a fresh stroke reads. */
88const cardLine = (m: Member) => shadeToward(chalkOf(m), '#ffffff', 0.2)
89/** How much of her chalk the wash inside the card mixes into the green. */
90const CARD_WASH = 0.085
91/** The card's chalk box: its sides `inset` from the board's edges, its top line at `top`, the second pass `gap` inside the first. */
92const CARD = { inset: 3.5, top: 1.7, gap: 1.7 } as const
93
94/** Which songs the board lists, in order; how many earlier ones fold into one line; whether every song shows its details. */
95export type BoardPlan = { songs: ActView[]; folded: number; expanded: boolean }
96
97type Member = ActView['member']
98
99// ---- The setlist's order ---------------------------------------------------------------
100
101/**
102 * When an act started (live) or ended, in seconds into the set, for ordering:
103 * `5:14` into the set, `1h04m`, `2d 3h`; an act from before the set (`2:40 ago`)
104 * sorts before every act of the set, the longer ago the earlier.
105 */
106function atSeconds(at: string): number {
107  const ago = /\s*ago\s*$/.test(at)
108  const s = at.replace(/\s*ago\s*$/, '').trim()
109  let n = 0
110  const ms = /^(\d+):(\d{2})$/.exec(s)
111  const hm = /^(\d+)h(\d{2})m$/.exec(s)
112  const dh = /^(\d+)d\s*(\d+)h$/.exec(s)
113  if (ms) n = Number(ms[1]) * 60 + Number(ms[2])
114  else if (hm) n = Number(hm[1]) * 3600 + Number(hm[2]) * 60
115  else if (dh) n = Number(dh[1]) * 86400 + Number(dh[2]) * 3600
116  return ago ? -1e9 - n : n
117}
118
119const newestFirst = (x: ActView, y: ActView) => atSeconds(y.at) - atSeconds(x.at)
120
121// ---- What an act's state means on the panel ----------------------------------------------------
122
123/**
124 * A real failure: an act that ended in an error or a refusal (its status
125 * says so). A failing tool call (`hadError`, which the mod never clears) in a
126 * live act, the main loop, an act that answered anyway (`encore!`) or one the
127 * person stopped is no failure here; its error gets a line of its own.
128 */
129function hardFail(a: ActView): boolean {
130  return a.isFailed && !a.isLive && a.id !== 'main' && oneLine(a.reason) !== 'encore!' && !/^stopped:\s*(aborted|interrupted|killed)\b/.test(a.reason)
131}
132
133/**
134 * A failure that counts: a real one in the set on now. An earlier set's
135 * failure keeps its red mark on its own song, but is no longer counted,
136 * pilled, bubbled on stage or hoisted to the top of the board.
137 */
138const setFail = (a: ActView) => hardFail(a) && a.inSet
139
140/** An act that hit a failing tool call and carried on (or answered, or was stopped). */
141const toolErr = (a: ActView) => (a.hadError || a.isFailed) && !hardFail(a)
142
143/** Her state as the panel shows it: a stuck error flag never turns a finished act into a failure. */
144function shownState(a: ActView): ActView['state'] {
145  if (a.state !== 'failed' || hardFail(a)) return a.state
146  return /^stopped:/.test(a.reason) ? 'stopped' : 'done'
147}
148
149/** An act in its encore: it answered a moment ago (a failure or a stop that just ended is no encore). */
150const encoreOf = (a: ActView) => a.isEncore && shownState(a) === 'done'
151
152/** The mix's order: the main loop, the playing, the waiting (newest first), this set's failed, then the rest (an earlier set's failures with them) newest first. */
153function mixOrder(all: readonly ActView[]): ActView[] {
154  const live = all.filter(a => a.isLive)
155  const main = live.filter(a => a.id === 'main')
156  const playing = live.filter(a => a.id !== 'main' && shownState(a) === 'playing').sort(newestFirst)
157  const waiting = live.filter(a => a.id !== 'main' && shownState(a) !== 'playing').sort(newestFirst)
158  const failed = all.filter(a => !a.isLive && setFail(a)).sort(newestFirst)
159  const rest = all.filter(a => !a.isLive && !setFail(a)).sort(newestFirst)
160  return [...main, ...playing, ...waiting, ...failed, ...rest]
161}
162
163/**
164 * The setlist: the songs playing or waiting first (the main loop, then the
165 * playing ones, then the waiting, newest first), then this set's failed ones,
166 * then the rest newest first, at most `maxSongs`, the rest folding into one line
167 * that says what folded. On a solo, her acts alone (the main loop for
168 * Bocchi), the pressed one first and the rest in the mix's order, every one
169 * expanded.
170 */
171export function boardPlan(view: PanelView, maxSongs = 8): BoardPlan {
172  const cap = Math.max(1, Math.floor(maxSongs))
173  const solo = view.members.find(m => m.isSolo)
174  if (solo !== undefined) {
175    const first = view.soloAct === null ? undefined : solo.acts.find(a => a.id === view.soloAct)
176    const ordered = [...(first === undefined ? [] : [first]), ...mixOrder(solo.acts.filter(a => a !== first))]
177    return { songs: ordered.slice(0, cap), folded: Math.max(0, ordered.length - cap), expanded: true }
178  }
179  const ordered = mixOrder(view.members.flatMap(m => m.acts))
180  return { songs: ordered.slice(0, cap), folded: Math.max(0, ordered.length - cap), expanded: false }
181}
182
183// ---- Type: advance widths per 100 px of font size, measured in Chrome on Windows -------------
184
185type Face = 'ink' | 'ui' | 'uib' | 'script' | 'bahn'
186
187const GLYPHS = "0123456789 !\"#$%&'()*+,-./:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~·…—–’“”♪✓✗×≈"
188const ADV: Record<Face, readonly number[]> = {
189  ink: [56, 44, 69, 70, 70, 55, 58, 59, 69, 62, 29, 20, 34, 61, 55, 99, 64, 21, 41, 41, 36, 47, 21, 34, 21, 36, 21, 21, 48, 49, 48, 50, 100, 62, 69, 55, 65, 59, 59, 73, 68, 66, 58, 58, 58, 81, 69, 72, 56, 71, 63, 55, 71, 65, 55, 71, 39, 49, 60, 41, 36, 41, 48, 47, 46, 53, 51, 37, 48, 46, 42, 54, 56, 22, 22, 48, 20, 75, 46, 43, 52, 48, 41, 45, 49, 50, 54, 71, 39, 43, 53, 41, 36, 41, 57, 21, 64, 100, 50, 23, 39, 39, 50, 75, 82, 47, 55],
190  ui: [54, 54, 54, 54, 54, 54, 54, 54, 54, 54, 27, 28, 39, 59, 54, 82, 80, 23, 30, 30, 42, 68, 22, 40, 22, 39, 22, 22, 68, 68, 68, 45, 96, 65, 57, 62, 70, 51, 49, 69, 71, 27, 36, 58, 47, 90, 75, 75, 56, 75, 60, 53, 52, 69, 62, 93, 59, 55, 57, 30, 38, 30, 68, 42, 27, 51, 59, 46, 59, 52, 31, 59, 57, 24, 24, 50, 24, 86, 57, 59, 59, 59, 35, 42, 34, 57, 48, 72, 46, 48, 45, 30, 24, 30, 68, 22, 73, 100, 50, 23, 38, 38, 50, 75, 82, 68, 69],
191  uib: [56, 40, 56, 56, 58, 56, 56, 54, 56, 56, 27, 30, 44, 59, 56, 84, 72, 26, 33, 33, 43, 69, 24, 40, 24, 41, 24, 24, 69, 69, 69, 44, 95, 67, 60, 62, 72, 52, 50, 70, 74, 29, 40, 61, 49, 92, 77, 76, 58, 76, 62, 54, 55, 70, 64, 97, 62, 58, 59, 33, 40, 33, 69, 42, 29, 52, 60, 47, 60, 53, 34, 60, 58, 26, 26, 52, 26, 89, 58, 60, 60, 60, 37, 43, 36, 58, 51, 76, 50, 51, 46, 33, 28, 33, 69, 24, 81, 100, 50, 26, 43, 43, 50, 75, 82, 69, 70],
192  script: [73, 73, 73, 73, 73, 73, 73, 73, 73, 73, 40, 32, 40, 80, 55, 73, 92, 23, 40, 36, 51, 57, 34, 64, 25, 58, 30, 33, 54, 63, 57, 52, 82, 84, 75, 74, 87, 75, 58, 80, 82, 31, 61, 78, 68, 100, 84, 80, 78, 83, 88, 61, 70, 77, 73, 101, 73, 68, 85, 41, 55, 42, 58, 42, 31, 61, 54, 55, 71, 52, 39, 53, 65, 38, 30, 65, 40, 111, 73, 52, 62, 56, 54, 39, 45, 70, 60, 83, 71, 61, 69, 42, 32, 40, 78, 26, 81, 114, 81, 24, 58, 57, 66, 75, 82, 57, 73],
193  bahn: [55, 33, 52, 53, 57, 54, 51, 50, 56, 51, 27, 27, 38, 62, 58, 69, 65, 18, 34, 34, 43, 53, 23, 48, 23, 38, 23, 23, 42, 46, 43, 43, 89, 65, 65, 62, 66, 60, 56, 65, 68, 28, 51, 64, 57, 78, 70, 65, 62, 66, 65, 62, 48, 66, 59, 85, 57, 50, 54, 29, 38, 29, 50, 44, 29, 53, 54, 50, 54, 54, 32, 54, 56, 25, 27, 53, 29, 86, 56, 54, 54, 54, 43, 52, 33, 56, 50, 77, 52, 48, 49, 36, 28, 36, 51, 21, 58, 86, 56, 18, 32, 32, 50, 75, 82, 50, 55],
194}
195const GLYPH_AT = new Map<string, number>([...GLYPHS].map((c, i) => [c, i]))
196
197/** A full-width character (CJK, kana, hangul, full-width forms, emoji): one em wide. */
198function isWide(cp: number): boolean {
199  return (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd)
200}
201
202/** One character's advance in a face, per 100 px; the sans faces' digits tabular. */
203function advance(ch: string, face: Face): number {
204  const i = GLYPH_AT.get(ch)
205  if (i !== undefined) {
206    if (i < 10 && face === 'ui') return 54
207    if (i < 10 && face === 'uib') return 56
208    return ADV[face][i] ?? 60
209  }
210  const cp = ch.codePointAt(0) ?? 0
211  return isWide(cp) ? 100 : face === 'ink' ? 58 : 62
212}
213
214/** A string's width in CSS pixels in a face (`ink` the chalk hand, `ui` the sans, `uib` its semibold, `script` the neon, `bahn` the lamp). */
215export function textWidth(s: string, face: Face, size: number): number {
216  let w = 0
217  for (const ch of s) w += advance(ch, face)
218  return (w / 100) * size
219}
220
221const KATAKANA = /[゠-ヿㇰ-ㇿヲ-゚]/
222/** Where CJK text breaks best: after its own punctuation. */
223const CJK_STOP = /[、。,.!?」』)〕】]/
224/** Where a path or a long name breaks best: after a slash, then after a dash, a dot or an underscore. */
225const PATH_STOP = /[/\\]/
226const NAME_STOP = /[-._]/
227
228/**
229 * A word too wide for a line, broken by width: never inside a run of
230 * katakana; in CJK text after its punctuation (、。) where that leaves the
231 * line at least half full; in a path after a slash (else a dash, a dot, an
232 * underscore) where that leaves it a third full, so a file's name stays whole
233 * when it can. The first piece may have a narrower room.
234 */
235function breakWord(word: string, face: Face, size: number, max: number, firstMax = max): string[] {
236  const units: string[] = []
237  for (const ch of word) {
238    const last = units[units.length - 1]
239    if (KATAKANA.test(ch) && last !== undefined && KATAKANA.test(last.slice(-1))) units[units.length - 1] = last + ch
240    else units.push(ch)
241  }
242  const parts: string[] = []
243  const room = () => (parts.length === 0 ? firstMax : max)
244  let cur = ''
245  let w = 0
246  let stop = -1
247  let stopW = 0
248  // how full a line must be for a break at its stop: half for CJK punctuation, a third for a path's
249  let stopFill = 0.5
250  let slash = false
251  const flush = () => {
252    if (cur !== '') parts.push(cur)
253    cur = ''
254    w = 0
255    stop = -1
256    slash = false
257  }
258  for (const u of units) {
259    const uw = textWidth(u, face, size)
260    if (cur !== '' && w + uw > room()) {
261      if (stop > 0 && stop < cur.length && stopW >= room() * stopFill) {
262        const rest = cur.slice(stop)
263        cur = cur.slice(0, stop)
264        flush()
265        cur = rest
266        w = textWidth(rest, face, size)
267        if (w + uw > room()) flush()
268      } else flush()
269    }
270    if (uw > room()) {
271      // a run too long for any line (a long katakana word, a path): by character after all
272      for (const ch of u) {
273        const cw = (advance(ch, face) / 100) * size
274        if (cur !== '' && w + cw > room()) flush()
275        cur += ch
276        w += cw
277      }
278      continue
279    }
280    cur += u
281    w += uw
282    if (CJK_STOP.test(u)) {
283      stop = cur.length
284      stopW = w
285      stopFill = 0.5
286    } else if (PATH_STOP.test(u) || (NAME_STOP.test(u) && !slash)) {
287      // a slash outranks a dash before it; after a slash, a dash in the file's name breaks only as a last resort
288      if (PATH_STOP.test(u)) slash = true
289      stop = cur.length
290      stopW = w
291      stopFill = 0.34
292    }
293  }
294  flush()
295  return parts
296}
297
298/**
299 * Greedy wrap by measured width (the first line may be narrower), at most
300 * `maxLines`; a cut text ends its last line in an ellipsis.
301 */
302function wrapText(text: string, face: Face, size: number, max: number, firstMax = max, maxLines = 99): string[] {
303  return wrapLines(text, face, size, max, firstMax, maxLines).lines
304}
305
306/** `wrapText`, and whether the text was cut. */
307function wrapLines(text: string, face: Face, size: number, max: number, firstMax = max, maxLines = 99): { lines: string[]; cut: boolean } {
308  const out: string[] = []
309  let line = ''
310  const room = () => (out.length === 0 ? firstMax : max)
311  const words = text.split(/\s+/).filter(w => w !== '')
312  let cut = false
313  for (const word of words) {
314    if (out.length >= maxLines) {
315      cut = true
316      break
317    }
318    const next = line === '' ? word : `${line} ${word}`
319    if (textWidth(next, face, size) <= room()) {
320      line = next
321      continue
322    }
323    // a path too long for any line starts on this one (`Edit studio/out/` then the rest), broken after a slash
324    const left = room() - textWidth(`${line} `, face, size)
325    if (line !== '' && /[/\\]/.test(word) && textWidth(word, face, size) > max && left >= room() * 0.3) {
326      const pieces = breakWord(word, face, size, max, left)
327      const head = pieces[0] ?? ''
328      if (pieces.length > 1 && /[/\\]$/.test(head)) {
329        out.push(`${line} ${head}`)
330        line = ''
331        for (let k = 1; k < pieces.length - 1; k += 1) {
332          if (out.length >= maxLines) break
333          out.push(pieces[k] ?? '')
334        }
335        if (out.length >= maxLines) {
336          cut = true
337          break
338        }
339        line = pieces[pieces.length - 1] ?? ''
340        continue
341      }
342    }
343    if (line !== '') {
344      out.push(line)
345      line = ''
346      if (out.length >= maxLines) {
347        cut = true
348        break
349      }
350    }
351    if (textWidth(word, face, size) <= room()) {
352      line = word
353      continue
354    }
355    const pieces = breakWord(word, face, size, max, room())
356    for (let k = 0; k < pieces.length - 1; k += 1) {
357      out.push(pieces[k] ?? '')
358      if (out.length >= maxLines) {
359        cut = true
360        break
361      }
362    }
363    if (cut) break
364    line = pieces[pieces.length - 1] ?? ''
365  }
366  if (!cut && line !== '') {
367    if (out.length >= maxLines) cut = true
368    else out.push(line)
369  }
370  if (cut && out.length > 0) {
371    const last = out.length - 1
372    out[last] = clipEnd(`${out[last] ?? ''}…`, face, size, last === 0 ? firstMax : max)
373  }
374  return { lines: out, cut }
375}
376
377/** Cut to a width with an ellipsis. */
378function clipEnd(text: string, face: Face, size: number, max: number): string {
379  if (textWidth(text, face, size) <= max) return text
380  const ell = textWidth('…', face, size)
381  let out = ''
382  let w = 0
383  for (const ch of text) {
384    const cw = (advance(ch, face) / 100) * size
385    if (w + cw + ell > max) break
386    out += ch
387    w += cw
388  }
389  return `${out.trimEnd()}…`
390}
391
392/**
393 * A tool call's words fitted to a width, keeping what matters: the verb and
394 * the file's name (`Edit …/lib/register.tsx`, `Edit …/register.tsx`), the
395 * name's middle given up last, the end of anything else.
396 */
397function fitTool(text: string, face: Face, size: number, max: number): string {
398  if (textWidth(text, face, size) <= max) return text
399  const m = /^(\S+)\s+(.+)$/.exec(text)
400  if (m !== null && (m[2] ?? '').includes('/') && !/\s/.test((m[2] ?? '').trim())) {
401    const verb = m[1] ?? ''
402    const segs = (m[2] ?? '').split('/')
403    for (let k = 1; k < segs.length; k += 1) {
404      const cand = `${verb} …/${segs.slice(k).join('/')}`
405      if (textWidth(cand, face, size) <= max) return cand
406    }
407    const file = segs[segs.length - 1] ?? ''
408    const dot = file.lastIndexOf('.')
409    const ext = dot > 0 && file.length - dot <= 6 ? file.slice(dot) : ''
410    const stem = ext === '' ? file : file.slice(0, dot)
411    const head = `${verb} …/`
412    const room = max - textWidth(head, face, size) - textWidth(ext, face, size)
413    if (room > textWidth('ab…', face, size)) return `${head}${clipEnd(stem, face, size, room)}${ext}`
414  }
415  return clipEnd(text, face, size, max)
416}
417
418/**
419 * Items joined by a separator, wrapped into as few lines as the width
420 * allows and balanced so no line is left with a lone item while another
421 * holds many.
422 */
423function balanceItems(items: string[], sep: string, face: Face, size: number, max: number): string[] {
424  const pack = (limit: number) => {
425    const lines: string[] = []
426    for (const it of items) {
427      const last = lines[lines.length - 1]
428      if (last !== undefined && textWidth(`${last}${sep}${it}`, face, size) <= limit) lines[lines.length - 1] = `${last}${sep}${it}`
429      else lines.push(it)
430    }
431    return lines
432  }
433  const greedy = pack(max)
434  if (greedy.length <= 1) return greedy
435  let lo = 0
436  let hi = max
437  for (let i = 0; i < 14; i += 1) {
438    const mid = (lo + hi) / 2
439    if (pack(mid).length <= greedy.length) hi = mid
440    else lo = mid
441  }
442  return pack(hi)
443}
444
445// ---- Markup helpers ---------------------------------------------------------------------------
446
447const f = (n: number) => String(Math.round(n * 10) / 10)
448/** Text as XML: escaped, and free of what XML cannot hold (control characters, lone surrogates). */
449const esc = (s: string) =>
450  s
451    .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f￾￿]/g, '')
452    .replace(/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, '')
453    .replace(/&/g, '&amp;')
454    .replace(/</g, '&lt;')
455    .replace(/>/g, '&gt;')
456    .replace(/"/g, '&quot;')
457/** A text as one line: whitespace runs as one space. */
458const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
459
460/** Seeded pseudo-random, so the same chalk wobbles the same way every redraw. */
461function rng(seed: string): () => number {
462  let h = 2166136261
463  for (const c of seed) h = Math.imul(h ^ c.charCodeAt(0), 16777619)
464  return () => {
465    h += 0x6d2b79f5
466    let t = h
467    t = Math.imul(t ^ (t >>> 15), t | 1)
468    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
469    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
470  }
471}
472
473/** A hand-drawn line: quadratic wobbles between the ends. */
474function wobble(x1: number, y1: number, x2: number, y2: number, seed: string, amp = 0.9, step = 18): string {
475  const r = rng(seed)
476  const n = Math.max(1, Math.round(Math.hypot(x2 - x1, y2 - y1) / step))
477  let d = `M${f(x1)} ${f(y1)}`
478  for (let i = 1; i <= n; i += 1) {
479    const t = i / n
480    const tm = (i - 0.5) / n
481    d += `Q${f(x1 + (x2 - x1) * tm + (r() - 0.5) * amp)} ${f(y1 + (y2 - y1) * tm + (r() - 0.5) * amp * 2)} ${f(x1 + (x2 - x1) * t)} ${f(y1 + (y2 - y1) * t + (i === n ? 0 : (r() - 0.5) * amp))}`
482  }
483  return d
484}
485
486/** A wavy underline from x over w at y: the red chalk under a failed song. */
487function wave(x: number, y: number, w: number, amp = 1.7, half = 3): string {
488  const n = Math.max(2, Math.round(w / half))
489  return `M${f(x)} ${f(y)}q${f(half / 2)} ${f(-amp * 1.4)} ${f(half)} 0${`t${f(half)} 0`.repeat(n - 1)}`
490}
491
492/** A four-point sparkle. */
493function sparkleD(x: number, y: number, r: number): string {
494  const k = r * 0.2
495  return `M${f(x)} ${f(y - r)}Q${f(x + k)} ${f(y - k)} ${f(x + r)} ${f(y)}Q${f(x + k)} ${f(y + k)} ${f(x)} ${f(y + r)}Q${f(x - k)} ${f(y + k)} ${f(x - r)} ${f(y)}Q${f(x - k)} ${f(y - k)} ${f(x)} ${f(y - r)}z`
496}
497function sparkle(x: number, y: number, r: number, fill: string, cls = ''): string {
498  return `<path${cls === '' ? '' : ` class="${cls}"`} d="${sparkleD(x, y, r)}" fill="${fill}"/>`
499}
500
501/** One standalone SVG document: the layout's width in its viewBox, four times that as its size, so a surface scales it down. */
502function svgDoc(W: number, H: number, body: string, css = ''): string {
503  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${f(W)} ${f(H)}" width="${f(W * 4)}" height="${f(H * 4)}">${css === '' ? '' : `<style>${css}</style>`}${body}</svg>`
504}
505
506/** A picture held to the element's limit: past it, the simpler drawing (which must fit). */
507function capped(svg: string, simpler: () => string): string {
508  return svg.length <= SVG_LIMIT ? svg : simpler()
509}
510
511// ---- The look --------------------------------------------------------------------------------
512
513const SANS = `'Segoe UI', system-ui, -apple-system, sans-serif`
514const CHALK_HAND = `'Ink Free', 'Segoe Print', 'Chalkboard SE', 'Comic Sans MS', cursive`
515const SCRIPT = `'Segoe Script', 'Snell Roundhand', 'Brush Script MT', cursive`
516const LAMP_FACE = `Bahnschrift, 'DIN Alternate', 'Arial Narrow', 'Segoe UI', sans-serif`
517/** The two voices: `.u` the tabular sans for numbers and meta, `.c` the chalk hand for titles. */
518const TEXT_CSS = `.u{font-family:${SANS};font-variant-numeric:tabular-nums}.c{font-family:${CHALK_HAND}}`
519
520/** Each member's colour as chalk reads it on the slate. */
521const CHALK: Record<Member, string> = { bocchi: '#ffc6de', nijika: '#ffe39a', ryo: '#bcd4ff', kita: '#ffb79e' }
522const NEON: Record<Member, string> = { bocchi: '#f7a8c9', nijika: '#ffd166', ryo: '#8fb8ff', kita: '#ff8a65' }
523const NAME: Record<Member, string> = { bocchi: 'Bocchi', nijika: 'Nijika', ryo: 'Ryo', kita: 'Kita' }
524const WHITE = '#f3f0e8'
525const SOFT = '#d6d9d0'
526const LABEL = '#a2b1a7'
527const FAINT = '#7c8a81'
528const RED = '#ff8090'
529const RED_DEEP = '#ff5d73'
530const MINT = '#a9f0c6'
531
532const chalkOf = (m: Member) => CHALK[m] ?? WHITE
533const neonOf = (m: Member) => NEON[m] ?? '#f7a8c9'
534
535/** The text helper: a `<text>` in a voice (`u` sans, `c` chalk). */
536function T(x: number, y: number, s: string, size: number, fill: string, cls: 'u' | 'c', extra = ''): string {
537  return `<text class="${cls}" x="${f(x)}" y="${f(y)}" font-size="${f(size)}" fill="${fill}"${extra}>${esc(s)}</text>`
538}
539
540/** A run of a line: its words, its colour, bold, faded. */
541type Seg = { t: string; c: string; bold?: boolean; fixed?: boolean; faded?: boolean }
542
543/**
544 * A line of runs flowing in one `<text>` (the face lays them out, so no run
545 * can overlap the next), anchored at x. Given its `room`, a line close to it
546 * is held to its measured width, so a wider fallback face (SF, Roboto,
547 * Verdana) squeezes instead of running into its neighbour.
548 */
549function runs(x: number, y: number, segs: readonly Seg[], size: number, anchor: 'start' | 'middle' | 'end' = 'start', extra = '', room = 0): string {
550  const spans = segs.map(s => `<tspan fill="${s.c}"${s.bold === true ? ' font-weight="600"' : ''}${s.faded === true ? ' fill-opacity=".42"' : ''}>${esc(s.t)}</tspan>`).join('')
551  const w = runsWidth(segs, size)
552  const guard = room > 0 && w > room * 0.86 ? ` textLength="${f(Math.min(w, room))}" lengthAdjust="spacingAndGlyphs"` : ''
553  return `<text class="u" x="${f(x)}" y="${f(y)}" font-size="${f(size)}" style="white-space:pre"${anchor === 'start' ? '' : ` text-anchor="${anchor}"`}${guard}${extra}>${spans}</text>`
554}
555
556/** How wide a line of runs is. */
557function runsWidth(segs: readonly Seg[], size: number): number {
558  return segs.reduce((w, s) => w + textWidth(s.t, s.bold === true ? 'uib' : 'ui', size), 0)
559}
560
561/** A sans text held to exactly its measured width: for the few words laid out against their neighbours (the strip's head). */
562function fixedT(x: number, y: number, s: string, size: number, fill: string, face: 'ui' | 'uib', extra = ''): string {
563  return T(x, y, s, size, fill, 'u', ` textLength="${f(textWidth(s, face, size))}" lengthAdjust="spacingAndGlyphs"${face === 'uib' ? ' font-weight="600"' : ''}${extra}`)
564}
565
566/** A context % as words, never `NaN%`. */
567const pctText = (p: number) => `${Number.isFinite(p) ? Math.max(0, Math.round(p)) : 0}%`
568
569/** A chalk line held to its measured width, so a wider fallback hand squeezes instead of running over. */
570function chalkLine(x: number, y: number, s: string, size: number, fill: string, extra = ''): string {
571  const w = textWidth(s, 'ink', size)
572  return T(x, y, s, size, fill, 'c', ` textLength="${f(w)}" lengthAdjust="spacingAndGlyphs"${extra}`)
573}
574
575/** A sans line close to its room held to its measured width (a wider fallback face squeezes to fit). */
576function sansLine(x: number, y: number, s: string, size: number, fill: string, room: number, extra = '', face: Face = 'ui'): string {
577  const w = textWidth(s, face, size)
578  const guard = w > room * 0.86 ? ` textLength="${f(Math.min(w, room))}" lengthAdjust="spacingAndGlyphs"` : ''
579  return T(x, y, s, size, fill, 'u', `${guard}${face === 'uib' ? ' font-weight="600"' : ''}${extra}`)
580}
581
582/** `claude-sonnet-5-5` as `sonnet 5.5`. */
583function modelName(m: string): string {
584  return m.replace(/^claude-/, '').replace(/-(\d+)-(\d+)(?:-\d{6,})?$/, ' $1.$2').replace(/-(\d{6,})$/, '')
585}
586
587/** An agent type in a word: `kessoku-ultimate-bocchi:ryo` as `ryo`, `main` as `main loop`. */
588function typeName(t: string): string {
589  if (t === 'main') return 'main loop'
590  const tail = t.includes(':') ? t.slice(t.lastIndexOf(':') + 1) : t
591  return tail === '' ? 'agent' : tail
592}
593
594/**
595 * The set's last error when it is this act's, as `Tool: what went wrong`
596 * (the member's name dropped): only an act with an error (a failing tool
597 * call it carried on from, or a failure) has one; a subagent's is written
598 * `Tool (Name): …` and counts for her newest act with an error; the main
599 * loop's has no name.
600 */
601function ownError(view: PanelView, act: ActView): string {
602  const e = oneLine(view.lastError)
603  if (e === '' || !(act.hadError || act.isFailed)) return ''
604  const sub = /^(\S+)\s*\(([^)]+)\):\s*(.*)$/.exec(e)
605  if (act.id === 'main') return sub === null ? e : ''
606  if (sub === null) return ''
607  const who = (sub[2] ?? '').toLowerCase()
608  if (who !== act.member && who !== NAME[act.member].toLowerCase()) return ''
609  const newest = view.members.find(x => x.member === act.member)?.acts.find(a => (a.hadError || a.isFailed) && a.id !== 'main')
610  return newest?.id === act.id ? `${sub[1] ?? ''}: ${sub[3] ?? ''}` : ''
611}
612
613/**
614 * Why an act failed or stopped: a failure's own error when the set's last
615 * error is hers, else its own words; never its tool or `encore!`. A stopped
616 * act's error is no reason it stopped: it keeps its amber line instead.
617 */
618function reasonOf(view: PanelView, act: ActView): string {
619  const err = hardFail(act) ? ownError(view, act) : ''
620  if (err !== '') return err
621  const own = oneLine(act.reason)
622  if (own === '' || own === 'encore!' || own === oneLine(act.tool)) return hardFail(act) ? 'ended in an error' : 'stopped'
623  // the mod's `stopped: <why>` in words
624  const why = /^stopped:\s*(\w+)$/.exec(own)?.[1]
625  if (why === 'error') return 'ended in an error'
626  if (why === 'refusal') return 'refused to go on'
627  if (why === 'aborted' || why === 'killed') return 'stopped before it finished'
628  if (why === 'interrupted') return 'interrupted'
629  return own
630}
631
632/** Her real failures this set (a tool call that errored is none, an earlier set's failure is none). */
633const failedCount = (m: MemberView) => m.acts.filter(setFail).length
634
635// ---- The stage ---------------------------------------------------------------------------------
636
637/** Where a member stands: her centre, her feet, and her line (0 the back line on the drum riser, 1 the front line on the deck). */
638type Spot = { cx: number; feet: number; row: 0 | 1 }
639
640/** The width from which the band is drawn x3. */
641const X3_AT = 900
642
643/**
644 * How far each member reaches across her 96 px frame, over her usual moods
645 * (the first pixel and one past the last): Bocchi's guitar, the drum kit
646 * before Nijika, Ryo's bass, Kita's guitar. The band's places are worked out
647 * from these, so no two cross where the stage has the room.
648 */
649const REACH: Record<Member, readonly [number, number]> = { bocchi: [16, 78], nijika: [14, 87], ryo: [23, 90], kita: [18, 78] }
650const BAND: readonly Member[] = ['bocchi', 'nijika', 'ryo', 'kita']
651/**
652 * The room kept between the kit and Ryo where the stage has it, in frame
653 * pixels: in a panic Nijika's stick hand reaches past her cymbals (column 91)
654 * and Ryo's arm far past her bass (column 2), and two figures on the same
655 * riser can only stand apart.
656 */
657const FLAIL = 25
658
659/** How far a figure reaches across her frame in one mood, over every frame (the first column and one past the last): what the stage's edges keep in. */
660const reachMemo = new Map<string, readonly [number, number]>()
661function reachOf(id: string, member: Member): readonly [number, number] {
662  const hit = reachMemo.get(id)
663  if (hit !== undefined) return hit
664  const anim = animOf(id)
665  let x0 = 99
666  let x1 = -1
667  for (let k = 0; anim !== undefined && k < anim.frames.length; k += 1) {
668    const px = framePixels(id, k)
669    for (let i = 0; i < px.length; i += 1) {
670      if (px[i] == null) continue
671      const x = i % 96
672      if (x < x0) x0 = x
673      if (x > x1) x1 = x
674    }
675  }
676  const reach = x1 < 0 ? REACH[member] : ([x0, x1 + 1] as const)
677  reachMemo.set(id, reach)
678  return reach
679}
680
681/** The stage's measures for the band as it stands: its width, and how far the outer two reach in their moods. */
682function geoOf(view: PanelView, W: number) {
683  const first = view.members[0]
684  const last = view.members[view.members.length - 1]
685  const left = first === undefined ? REACH.bocchi[0] : reachOf(`${first.member}-${first.mood}`, first.member)[0]
686  const right = last === undefined ? REACH.kita[1] : reachOf(`${last.member}-${last.mood}`, last.member)[1]
687  return stageGeo(W, left, right)
688}
689
690/**
691 * The stage's measures at a width: a live house seen from the floor. The
692 * band is drawn at an integer zoom at every width (x2, x3 from 900 px). The
693 * back line stands on the drum riser, Nijika behind her kit and Ryo beside it
694 * with her bass rig; the front line stands on the deck before it and lower,
695 * Bocchi by her amp and Kita at her mic. Across, the four take their reach
696 * side by side, the kit and Ryo kept a panic's flail apart, and share the room
697 * left over; where there is too little (below about 530 px at x2) they close
698 * up only where it reads as depth, a front figure before the riser: Bocchi
699 * before the kit's floor tom, Kita before Ryo's legs (her head under Ryo's
700 * neck), the kit and Ryo barely, and the front line stands further forward and
701 * lower. The outer two step in from the stage's edges as far as their mood
702 * reaches (`left`, `right`: Bocchi's first column, one past Kita's last). Every
703 * pit stands in one row along the stage's front, each in its member's quarter
704 * (over her plate). The top band (truss, string lights, the sign) stays tight:
705 * the back line's heads stand just under the sign.
706 */
707function stageGeo(W: number, left: number = REACH.bocchi[0], right: number = REACH.kita[1]) {
708  const z = W >= X3_AT ? 3 : 2
709  const wide = W >= 600
710  const narrow = W < 400
711  const can = 12
712  const signSize = z === 3 ? 38 : wide ? 31 : narrow ? 24 : 27
713  const signY = Math.round(can + 8 + signSize * 0.8)
714  // the back line's frames: Nijika's ahoge (13 pixels into her frame) a few pixels under the sign's glow
715  const frameTop = Math.round(signY + 5 - 13 * z) + 5
716  // where the back line stands: the riser's top
717  const riser = frameTop + 94 * z
718  const m = 2 * z
719  const need = BAND.reduce((n, mb) => n + (REACH[mb][1] - REACH[mb][0]) * z, 0)
720  const slack = W - 2 * m - need
721  const short = Math.max(0, -slack)
722  // the room kept between the kit and Ryo first, then the rest of the room, or the overlap, shared: before Bocchi,
723  // Bocchi|kit, kit|Ryo, Ryo|Kita, after Kita. An overlap goes where a front figure stands before the riser; the two
724  // on the riser, at one depth, barely meet (Ryo's bass before the hi-hat)
725  const keep = Math.max(0, Math.min(slack, FLAIL * z))
726  const rest = slack - keep
727  const share = slack >= 0 ? [0.12, 0.3, 0.12, 0.3, 0.16] : [0, 0.46, 0.1, 0.44, 0]
728  const snap = (x: number) => Math.round(x / z) * z
729  const xs: number[] = []
730  let x = m + rest * (share[0] ?? 0)
731  BAND.forEach((mb, i) => {
732    const [a, b] = REACH[mb]
733    xs.push(snap(x + (48 - a) * z))
734    x += (b - a) * z + (i === 1 ? keep : 0) + rest * (share[i + 1] ?? 0)
735  })
736  // the outer two inside the stage in their mood (a panic's arms, a happy jump)
737  xs[0] = Math.max(xs[0] ?? 0, Math.ceil((m + (48 - left) * z) / z) * z)
738  xs[3] = Math.min(xs[3] ?? W, Math.floor((W - m - (right - 48) * z) / z) * z)
739  // the front line before the riser: its face and a stretch of deck; further forward and lower where the band closes
740  // up, so the front figures stand clearly before the back line they cover (up to 20 pixels more at 340 px)
741  const front = snap(riser + 30 * z + Math.min(20 * z, short * 0.2))
742  const spots: Spot[] = [
743    { cx: xs[0] ?? W * 0.15, feet: front, row: 1 },
744    { cx: xs[1] ?? W * 0.38, feet: riser - z, row: 0 },
745    { cx: xs[2] ?? W * 0.62, feet: riser + z, row: 0 },
746    { cx: xs[3] ?? W * 0.85, feet: front, row: 1 },
747  ]
748  const quarter = W / 4
749  // the riser: under the kit, Ryo and her rig, its top's back edge at the wall's foot, its face 13 pixels deep
750  const rx0 = Math.max(0, (spots[1]?.cx ?? 0) - 42 * z)
751  const rx1 = Math.min(W, (spots[2]?.cx ?? W) + 50 * z)
752  const wallFoot = riser - 6 * z
753  const edge = riser + 3 * z
754  const face = edge + 13 * z
755  // the pits: one row of slimes along the front, a little before the front line's feet; under them the stage's lip
756  const slimeTop = front - 7 * z
757  const lip = slimeTop + 24 * z
758  const H = lip + 3 * z
759  return { W, z, wide, narrow, can, signSize, signY, frameTop, riser, front, quarter, spots, rx0, rx1, wallFoot, edge, face, slimeTop, lip, H }
760}
761type StageGeo = ReturnType<typeof stageGeo>
762
763// ---- The stage's furniture: pixel art in the sprites' own language ----------------------------------
764
765/**
766 * A small picture drawn pixel by pixel (flat colours, a darker outline, a
767 * highlight from the upper right, as the band is drawn), written as the
768 * sprites are: a stroked path a colour, a run a row or a column of pixels.
769 */
770type Pix = { w: number; h: number; px: (string | null)[] }
771
772const pix = (w: number, h: number): Pix => ({ w, h, px: new Array<string | null>(w * h).fill(null) })
773const pxAt = (p: Pix, x: number, y: number): string | null => (x >= 0 && y >= 0 && x < p.w && y < p.h ? (p.px[y * p.w + x] ?? null) : null)
774
775function put(p: Pix, x: number, y: number, c: string | null): void {
776  if (c !== null && x >= 0 && y >= 0 && x < p.w && y < p.h) p.px[y * p.w + x] = c
777}
778
779/** Every pixel of a rectangle (both corners in), coloured by where it is. */
780function block(p: Pix, x0: number, y0: number, x1: number, y1: number, c: (x: number, y: number) => string | null): void {
781  for (let y = y0; y <= y1; y += 1) for (let x = x0; x <= x1; x += 1) put(p, x, y, c(x, y))
782}
783
784/** A filled ellipse round (cx, cy) in pixel edges, each pixel coloured by where it falls in it (u, v from -1 to 1). */
785function oval(p: Pix, cx: number, cy: number, rx: number, ry: number, c: (u: number, v: number) => string | null): void {
786  for (let y = Math.floor(cy - ry); y <= Math.ceil(cy + ry); y += 1) {
787    for (let x = Math.floor(cx - rx); x <= Math.ceil(cx + rx); x += 1) {
788      const u = (x + 0.5 - cx) / rx
789      const v = (y + 0.5 - cy) / ry
790      if (u * u + v * v <= 1) put(p, x, y, c(u, v))
791    }
792  }
793}
794
795/** A one-pixel line. */
796function pline(p: Pix, x0: number, y0: number, x1: number, y1: number, c: string): void {
797  const n = Math.max(1, Math.abs(x1 - x0), Math.abs(y1 - y0))
798  for (let k = 0; k <= n; k += 1) put(p, Math.round(x0 + ((x1 - x0) * k) / n), Math.round(y0 + ((y1 - y0) * k) / n), c)
799}
800
801/** The outline: every empty pixel beside a drawn one (above, below, left or right) in `c`. */
802function rim(p: Pix, c: string): void {
803  const edge: number[] = []
804  for (let y = 0; y < p.h; y += 1) {
805    for (let x = 0; x < p.w; x += 1) {
806      if (p.px[y * p.w + x] !== null) continue
807      if (pxAt(p, x - 1, y) !== null || pxAt(p, x + 1, y) !== null || pxAt(p, x, y - 1) !== null || pxAt(p, x, y + 1) !== null) edge.push(y * p.w + x)
808    }
809  }
810  for (const k of edge) p.px[k] = c
811}
812
813/** `top` laid over `p` (the same size). */
814function layOn(p: Pix, top: Pix): void {
815  top.px.forEach((c, k) => {
816    if (c !== null) p.px[k] = c
817  })
818}
819
820/** Two numbers of a relative move, as short as a path takes them. */
821const pairOf = (a: number, b: number) => `${a}${b < 0 ? '' : ' '}${b}`
822
823/**
824 * The picture as the sprites are written: one stroked path a colour, its
825 * pixels in runs along rows or along columns, whichever writes shorter. The
826 * colours go most-drawn first, and a run of one may cross pixels of the
827 * colours drawn after it (they paint over it), so the big areas take long
828 * runs and the details sit on top.
829 */
830function pixPaths(p: Pix): string {
831  const byColor = new Map<string, number[]>()
832  p.px.forEach((c, k) => {
833    if (c === null) return
834    const list = byColor.get(c) ?? []
835    list.push(k)
836    byColor.set(c, list)
837  })
838  const order = [...byColor.entries()].sort((x, y) => y[1].length - x[1].length || (x[0] < y[0] ? -1 : 1))
839  const rank = new Map<string, number>()
840  order.forEach(([c], r) => rank.set(c, r))
841  let out = ''
842  order.forEach(([c], r) => {
843    const mine = (k: number) => p.px[k] === c
844    const crosses = (k: number) => {
845      const q = p.px[k]
846      return q !== null && q !== undefined && (rank.get(q) ?? -1) > r
847    }
848    const runsAlong = (down: boolean) => {
849      const lines = down ? p.w : p.h
850      const along = down ? p.h : p.w
851      const idx = (line: number, at: number) => (down ? at * p.w + line : line * p.w + at)
852      let d = ''
853      let lastLine = 0
854      let lastAt = 0
855      let started = false
856      for (let line = 0; line < lines; line += 1) {
857        let at = 0
858        while (at < along) {
859          if (!mine(idx(line, at))) {
860            at += 1
861            continue
862          }
863          // the run goes on over her own pixels and over those drawn after her, and ends on her own last
864          let end = at + 1
865          for (let k = at + 1; k < along; k += 1) {
866            if (mine(idx(line, k))) end = k + 1
867            else if (!crosses(idx(line, k))) break
868          }
869          const n = end - at
870          if (down) d += started ? `m${pairOf(line - lastLine, at - lastAt)}v${n}` : `M${line}.5 ${at}v${n}`
871          else d += started ? `m${pairOf(at - lastAt, line - lastLine)}h${n}` : `M${at} ${line}.5h${n}`
872          started = true
873          lastLine = line
874          lastAt = end
875          at = end
876        }
877      }
878      return d
879    }
880    // or each run along a row or a column, whichever takes more of her pixels not drawn yet
881    const mixed = () => {
882      const done = new Set<number>()
883      const run = (x: number, y: number, down: boolean) => {
884        const along = down ? p.h - y : p.w - x
885        const at = (k: number) => (down ? (y + k) * p.w + x : y * p.w + x + k)
886        let end = 1
887        let fresh = 1
888        let seen = 1
889        for (let k = 1; k < along; k += 1) {
890          if (mine(at(k))) {
891            end = k + 1
892            if (!done.has(at(k))) seen += 1
893            fresh = seen
894          } else if (!crosses(at(k))) break
895        }
896        return { n: end, fresh, cells: Array.from({ length: end }, (_, k) => at(k)) }
897      }
898      const num = (n: number) => String(n).replace(/^(-?)0./, '$1.')
899      let d = ''
900      let penX = 0
901      let penY = 0
902      for (let k = 0; k < p.px.length; k += 1) {
903        if (!mine(k) || done.has(k)) continue
904        const x = k % p.w
905        const y = Math.floor(k / p.w)
906        const h = run(x, y, false)
907        const v = run(x, y, true)
908        const down = v.fresh > h.fresh
909        const r = down ? v : h
910        for (const cell of r.cells) done.add(cell)
911        const sx = down ? x + 0.5 : x
912        const sy = down ? y : y + 0.5
913        d += d === '' ? `M${num(sx)} ${num(sy)}` : `m${num(sx - penX)}${sy - penY < 0 ? '' : ' '}${num(sy - penY)}`
914        d += `${down ? 'v' : 'h'}${r.n}`
915        penX = down ? sx : sx + r.n
916        penY = down ? sy + r.n : sy
917      }
918      return d
919    }
920    const ways = [runsAlong(false), runsAlong(true), mixed()]
921    out += `<path stroke="${c}" d="${ways.reduce((a, b) => (b.length < a.length ? b : a))}"/>`
922  })
923  return out
924}
925
926/** The kit's colours: her bow's red sparkle for the shells, coated heads, chrome, brass, the band's yellow star. */
927const KIT = {
928  o: '#2b1b24',
929  R: '#d33950',
930  L: '#f27d8c',
931  r: '#a3263c',
932  W: '#f6f1e6',
933  w: '#d6ccbb',
934  C: '#f6f1e6',
935  c: '#aab2bf',
936  n: '#6f7688',
937  Y: '#fff6cc',
938  y: '#f1c75e',
939  t: '#c8913a',
940  T: '#7a521d',
941  S: '#ffd166',
942  s: '#b8741c',
943} as const
944
945/** The row of her frame under which Nijika, sitting behind her kit, is hidden by it: her legs never show between its drums. */
946const KIT_SEAT = 72
947/** A rectangle of a frame's pixels: its left, its top, one past its right and its bottom. */
948type Cells = readonly [number, number, number, number]
949/**
950 * The marks over a figure's shoulder that read as drawn on whatever stands
951 * behind them, by sprite: Bocchi's notes over her headstock while she plays,
952 * her gloom's squiggles while she worries, and in her box the squiggles and
953 * drops over its right flap. Her hair, her hands, her guitar and the box lie
954 * outside them in every frame. Standing before the kit she shows them only
955 * over the wall (see marksOnKit).
956 */
957const MARKS: Readonly<Record<string, readonly Cells[]>> = {
958  'bocchi-focused': [[64, 18, 80, 39]],
959  'bocchi-anxious': [[61, 18, 80, 38]],
960  'bocchi-hiding': [
961    [63, 34, 79, 46],
962    [63, 46, 65, 50],
963    [65, 46, 66, 48],
964  ],
965}
966
967/** An encore's two stars' radii (her colour, then a small white one), in band pixels. */
968const SPARKLE_R = [3.4, 2.2] as const
969
970/** The band's star, seven pixels a side, as on the neon sign. */
971const STAR = ['...#...', '...#...', '#######', '.#####.', '..###..', '.##.##.', '.#...#.']
972
973/**
974 * Nijika's drum kit, in her frame's own pixels (her feet on row 93, the kit's
975 * on row 96, a step before her): a bass drum facing the audience with the
976 * band's star on its head, two rack toms on
977 * it, the floor tom at her right hand (the audience's left) and the snare and
978 * hi-hat at her left, a ride and a crash on stands. It covers her from the
979 * waist down. `glint` is the cymbals' highlights, which shimmer while she plays.
980 */
981const kitMemo: { body: string; glint: string; pix: Pix }[] = []
982function kitArt(): { body: string; glint: string; pix: Pix } {
983  const hit = kitMemo[0]
984  if (hit !== undefined) return hit
985  const P = pix(96, 98)
986  const glint = pix(96, 98)
987  // the stands, behind everything: a chrome pole on a tripod
988  const stands = pix(96, 98)
989  const tripod = (x: number, top: number) => {
990    pline(stands, x, top, x, 93, KIT.c)
991    pline(stands, x - 1, 93, x - 4, 96, KIT.n)
992    pline(stands, x + 1, 93, x + 4, 96, KIT.n)
993  }
994  tripod(21, 55)
995  tripod(73, 49)
996  tripod(81, 55)
997  tripod(67, 72)
998  layOn(P, stands)
999  // a cymbal: a thin brass ellipse lit on top, tilted, its bell a bump in the middle, an outline in deep brass
1000  const cymbal = (cx: number, cy: number, rx: number, tilt: number, shine: [number, number][]) => {
1001    const c = pix(96, 98)
1002    for (let x = Math.floor(cx - rx); x <= Math.ceil(cx + rx) - 1; x += 1) {
1003      const u = (x + 0.5 - cx) / rx
1004      if (Math.abs(u) > 1) continue
1005      const y = Math.round(cy + u * tilt)
1006      const thick = Math.abs(u) < 0.55 ? 2 : 1
1007      put(c, x, y, KIT.y)
1008      if (thick === 2) put(c, x, y + 1, KIT.t)
1009    }
1010    put(c, Math.floor(cx), Math.round(cy) - 1, KIT.t)
1011    put(c, Math.floor(cx) - 1, Math.round(cy) - 1, KIT.t)
1012    rim(c, KIT.T)
1013    layOn(P, c)
1014    for (const [gx, gy] of shine) put(glint, gx, gy, KIT.Y)
1015  }
1016  cymbal(21, 54, 9, 1, [[24, 54], [25, 54]])
1017  cymbal(73, 47, 9, -2, [[76, 46], [77, 46]])
1018  // the hi-hat: two cymbals shut, on its pole
1019  cymbal(81, 58, 7.5, 0, [[83, 58]])
1020  cymbal(81, 60, 7.5, 0, [])
1021  // a drum seen from the floor: its coated head on top (the near edge its chrome hoop), its shell below lit from the right
1022  const drum = (cx: number, top: number, rx: number, tall: number, chrome: boolean) => {
1023    const d = pix(96, 98)
1024    const x0 = Math.round(cx - rx)
1025    const x1 = Math.round(cx + rx) - 1
1026    block(d, x0, top + 1, x1, top + tall, (xx, yy) => {
1027      if ((xx === x0 || xx === x1) && yy === top + tall) return null
1028      const u = (xx + 0.5 - cx) / rx
1029      if (yy === top + tall) return chrome ? KIT.n : KIT.r
1030      if (yy === top + 1) return KIT.c
1031      if (u > 0.45) return chrome ? KIT.C : KIT.L
1032      if (u < -0.5) return chrome ? KIT.n : KIT.r
1033      return chrome ? KIT.c : KIT.R
1034    })
1035    block(d, x0 + 1, top - 1, x1 - 1, top - 1, () => KIT.W)
1036    block(d, x0, top, x1, top, xx => ((xx + 0.5 - cx) / rx < -0.5 ? KIT.w : KIT.W))
1037    if (!chrome) for (const lx of [x0 + 2, x1 - 2]) put(d, lx, top + Math.ceil(tall / 2), KIT.c)
1038    rim(d, KIT.o)
1039    layOn(P, d)
1040  }
1041  // the floor tom (her right hand, the audience's left), on its legs; the snare behind the bass drum's shoulder
1042  pline(P, 21, 85, 20, 96, KIT.n)
1043  pline(P, 33, 85, 34, 96, KIT.n)
1044  drum(27, 69, 7, 15, false)
1045  drum(67, 68, 6.5, 4, true)
1046  // the rack toms, sitting on the bass drum
1047  drum(42, 63, 5.5, 6, false)
1048  drum(55, 63, 5.5, 6, false)
1049  // the bass drum, facing the audience: its red hoop (lit at the upper right), its coated head shaded at the lower left,
1050  // the lugs round it and the band's star in the middle
1051  const bd = pix(96, 98)
1052  const R = 13.5
1053  oval(bd, 48, 82.5, R, R, (u, v) => {
1054    const r = Math.hypot(u, v) * R
1055    const lean = u - v
1056    if (r > 11.5) return lean > 1 ? KIT.L : lean < -0.9 ? KIT.r : KIT.R
1057    return lean < -0.95 ? KIT.w : KIT.W
1058  })
1059  for (let k = 0; k < 6; k += 1) {
1060    const a = (k * Math.PI) / 3 + Math.PI / 6
1061    put(bd, Math.floor(48 + Math.cos(a) * 12.4), Math.floor(82.5 + Math.sin(a) * 12.4), KIT.C)
1062  }
1063  const star = pix(96, 98)
1064  STAR.forEach((row, sy) => [...row].forEach((ch, sx) => (ch === '#' ? put(star, 45 + sx, 79 + sy, KIT.S) : undefined)))
1065  rim(star, KIT.s)
1066  layOn(bd, star)
1067  rim(bd, KIT.o)
1068  layOn(P, bd)
1069  // its spurs, splayed to the riser
1070  pline(P, 36, 92, 33, 96, KIT.c)
1071  pline(P, 60, 92, 63, 96, KIT.c)
1072  const art = { body: pixPaths(P), glint: pixPaths(glint), pix: P }
1073  kitMemo.push(art)
1074  return art
1075}
1076
1077/**
1078 * Whether any of a sprite's marks (MARKS), in any frame, lands on a pixel of
1079 * the kit, the sprite's frame standing (dx, dy) stage pixels from the kit's,
1080 * both drawn `z` pixels a pixel.
1081 */
1082const onKitMemo = new Map<string, boolean>()
1083function marksOnKit(id: string, dx: number, dy: number, z: number): boolean {
1084  const key = `${id}|${dx}|${dy}|${z}`
1085  const hit = onKitMemo.get(key)
1086  if (hit !== undefined) return hit
1087  const kit = kitArt().pix
1088  const anim = animOf(id)
1089  const cells = MARKS[id] ?? []
1090  // the kit's pixels a sprite pixel at (x, y) (in stage pixels from the kit's frame) covers: one, two or four
1091  const covers = (x: number, y: number) => {
1092    for (const kx of new Set([Math.floor(x / z), Math.floor((x + z - 1) / z)])) for (const ky of new Set([Math.floor(y / z), Math.floor((y + z - 1) / z)])) if (pxAt(kit, kx, ky) !== null) return true
1093    return false
1094  }
1095  let on = false
1096  for (let k = 0; !on && anim !== undefined && k < anim.frames.length; k += 1) {
1097    const px = framePixels(id, k)
1098    for (const [c0, r0, c1, r1] of cells) for (let r = r0; !on && r < r1; r += 1) for (let c = c0; !on && c < c1; c += 1) if (px[r * 96 + c] != null && covers(dx + c * z, dy + r * z)) on = true
1099  }
1100  if (onKitMemo.size > 64) onKitMemo.clear()
1101  onKitMemo.set(key, on)
1102  return on
1103}
1104
1105/** The grilles' cloth and mesh, a pixel a cell in the amps' own pixels: what the amps' grilles are filled with. */
1106const GRILLES = '<pattern id="ag" width="2" height="2" patternUnits="userSpaceOnUse"><path d="M0 0h2v2H0z" fill="#4a3c31"/><path d="M0 0h1v1H0zM1 1h1v1H1z" fill="#64533f"/></pattern><pattern id="cm" width="2" height="2" patternUnits="userSpaceOnUse"><path d="M0 0h2v2H0z" fill="#33373f"/><path d="M0 0h1v1H0z" fill="#525866"/></pattern>'
1107
1108/** A box of pixels: its colour (or a fill), its left, top, width and height. */
1109type Px4 = readonly [string, number, number, number, number]
1110
1111/** Boxes of pixels as filled paths, in drawing order (the later over the earlier), the boxes of one colour in a row sharing a path. */
1112function boxPaths(list: readonly Px4[]): string {
1113  let out = ''
1114  let color = ''
1115  let d = ''
1116  const flush = () => {
1117    if (d !== '') out += `<path fill="${color}" d="${d}"/>`
1118    d = ''
1119  }
1120  for (const [c, x, y, w, h] of list) {
1121    if (c !== color) {
1122      flush()
1123      color = c
1124    }
1125    d += `M${x} ${y}h${w}v${h}h${-w}z`
1126  }
1127  flush()
1128  return out
1129}
1130
1131/** The amp behind Bocchi: a half stack, its head's gold panel and red jewel over a cab of salt-and-pepper grille cloth (GRILLES) with her pink plate. 30 x 39. */
1132const AMP = boxPaths([
1133  ['#120d12', 0, 1, 30, 38],
1134  ['#120d12', 11, 0, 8, 1],
1135  ['#2a2229', 1, 2, 28, 7],
1136  ['#43373f', 1, 2, 28, 1],
1137  ['#43373f', 28, 2, 1, 7],
1138  ['#d9b55e', 3, 5, 24, 2],
1139  ['#9c7a36', 3, 7, 24, 1],
1140  ...[6, 9, 12, 15, 18].map((x): Px4 => ['#120d12', x, 6, 1, 1]),
1141  ['#ff5d73', 23, 6, 2, 1],
1142  ['#2a2229', 1, 10, 28, 28],
1143  ['#43373f', 1, 10, 28, 1],
1144  ['#43373f', 28, 10, 1, 28],
1145  ['#cdbfa6', 3, 13, 24, 22],
1146  ['url(#ag)', 4, 14, 22, 20],
1147  ['#f7a8c9', 5, 15, 6, 2],
1148  ...[[1, 10], [27, 10], [1, 36], [27, 36]].map(([x, y]): Px4 => ['#b9b3bd', x ?? 0, y ?? 0, 2, 2]),
1149])
1150
1151/** Ryo's bass rig behind her: a silver-faced head with her blue light over a tall cab of black mesh (GRILLES), a brace across it. 26 x 46. */
1152const CAB = boxPaths([
1153  ['#0f0d12', 0, 0, 26, 46],
1154  ['#25222b', 1, 1, 24, 6],
1155  ['#3c3844', 1, 1, 24, 1],
1156  ['#3c3844', 24, 1, 1, 6],
1157  ['#bcc3cf', 3, 3, 20, 1],
1158  ['#868d9c', 3, 4, 20, 1],
1159  ...[5, 8, 11, 14].map((x): Px4 => ['#0f0d12', x, 3, 1, 1]),
1160  ['#8fb8ff', 20, 3, 1, 2],
1161  ['#25222b', 1, 8, 24, 37],
1162  ['#3c3844', 1, 8, 24, 1],
1163  ['#3c3844', 24, 8, 1, 37],
1164  ['url(#cm)', 3, 10, 20, 16],
1165  ['url(#cm)', 3, 27, 20, 16],
1166  ['#8fb8ff', 4, 11, 5, 1],
1167  ...[[1, 8], [23, 8], [1, 43], [23, 43]].map(([x, y]): Px4 => ['#b9b3bd', x ?? 0, y ?? 0, 2, 2]),
1168])
1169
1170/** Kita's mic stand, in her frame's pixels: a pole on a tripod before her feet, its boom bringing the mic to her mouth. */
1171const micMemo: string[] = []
1172function micArt(): string {
1173  const hit = micMemo[0]
1174  if (hit !== undefined) return hit
1175  const P = pix(96, 98)
1176  const S = { s: '#57515e', S: '#8e8798', g: '#e3e6ec', G: '#9aa1ae' }
1177  pline(P, 30, 43, 30, 93, S.s)
1178  pline(P, 31, 42, 39, 40, S.S)
1179  pline(P, 29, 94, 26, 97, S.s)
1180  pline(P, 31, 94, 34, 97, S.s)
1181  put(P, 30, 94, S.s)
1182  put(P, 30, 95, S.s)
1183  block(P, 40, 37, 42, 39, (x, y) => (y === 37 || x === 42 ? S.g : S.G))
1184  put(P, 40, 40, S.s)
1185  put(P, 39, 40, S.s)
1186  const art = pixPaths(P)
1187  micMemo.push(art)
1188  return art
1189}
1190
1191/** A picture placed on the stage: its top left at (x, y), drawn `z` pixels a pixel. */
1192const placedArt = (x: number, y: number, z: number, body: string) => `<g transform="translate(${f(x)} ${f(y)}) scale(${z})">${body}</g>`
1193
1194type PitSlime = { state: ActView['slime']; encore: boolean; failed: boolean }
1195
1196/**
1197 * The slimes drawn at her feet: her pit (live first), this set's failure kept
1198 * on stage with its mark, at most `cap`. A failed or encore slime is awake
1199 * (only a plain finished act sleeps, an earlier set's failure with them), and
1200 * the failure stands outermost (`outerLeft`: on her left, else her right),
hooks/lib/stage.ts 331 lines
1// Kessoku Ultimate Bocchi Theme: the band on stage, as data.
2//
3// Pure, no `$` and no `on`: who is playing, what each act is doing, how full
4// its context is, what it costs, what failed and why, and the session's own
5// figures, read off the mod's state into one view that every drawing of the
6// panel (the desktop's scene, the terminal's cells, the strip) shares. Also
7// the readers that fill in what an older version of the mod left out of
8// `$.state`, which outlives a reload.
9
10import type { KessokuAgent, KessokuBand, KessokuCache, KessokuMember, KessokuMood, KessokuSlimeState, KessokuUi } from '../../types'
11import { fmtDuration, fmtTokens, fmtUsd, slimeState } from './band'
12import { MEMBERS } from './palette'
13
14/** How long an act that just ended holds its line bold in her color (its encore). */
15export const ENCORE_MS = 8000
16/** How long an act that ended stays in her pit, asleep. */
17export const SLIME_TAIL_MS = 30_000
18/** The members in the band's order. */
19export const BAND_ORDER: readonly KessokuMember[] = ['bocchi', 'nijika', 'ryo', 'kita']
20/** Her hotkey on the panel: the first letter of her name. */
21export const SOLO_KEYS: Readonly<Record<KessokuMember, string>> = { bocchi: 'b', nijika: 'n', ryo: 'r', kita: 'k' }
22
23export function isLive(a: KessokuAgent): boolean {
24  return a.status === 'running' || a.status === 'pending' || a.status === 'waiting'
25}
26
27/** A member's mood from the agents on their part of the stage. */
28export function memberMood(b: KessokuBand, member: KessokuMember, now: number): KessokuMood {
29  if (member === 'bocchi') return b.mood
30  const mine = b.agents.filter(a => a.member === member && a.id !== 'main')
31  if (mine.some(a => isLive(a))) return 'focused'
32  const latest = mine.filter(a => a.endedAt !== null).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0]
33  // Her encore: a wrong note panics her, a stopped act sends her into her box, a finished one is a bow.
34  if (latest && latest.endedAt !== null && now - latest.endedAt < ENCORE_MS) return latest.isError ? 'panic' : latest.status === 'killed' ? 'hiding' : 'happy'
35  return 'anxious'
36}
37
38// `$.state` outlives a hot reload (`/reload-plugins`, an update), so a value written by an older version of the mod
39// lacks the fields added since: these fill them in wherever the drawings and the bookkeeping read them.
40
41/** An act as this version keeps it: a tool tally and a last tool, empty where an older version wrote it. */
42export function agentOf(a: KessokuAgent): KessokuAgent {
43  return a.toolTally === undefined || a.lastTool === undefined ? { ...a, toolTally: a.toolTally ?? {}, lastTool: a.lastTool ?? '' } : a
44}
45
46/** The band as this version keeps it: the set counted from 0, every act whole. */
47export function bandOf(b: KessokuBand): KessokuBand {
48  return { ...b, set: b.set ?? 0, setStartedAt: b.setStartedAt ?? 0, setPrompt: b.setPrompt ?? '', agents: b.agents.map(agentOf) }
49}
50
51/** The drawings' flags as this version keeps them: no solo where an older version wrote them. */
52export function uiOf(u: KessokuUi): KessokuUi {
53  return u.solo === undefined || u.soloAct === undefined || u.soloUntil === undefined || u.soloChosen === undefined
54    ? { ...u, solo: u.solo ?? null, soloAct: u.soloAct ?? null, soloUntil: u.soloUntil ?? 0, soloChosen: u.soloChosen ?? false }
55    : u
56}
57
58/** The solo as a drawing reads it: a solo past its due returns to the mix as it is read. */
59export function soloNow(raw: KessokuUi, now: number): KessokuUi {
60  const u = uiOf(raw)
61  return u.solo !== null && u.soloUntil > 0 && now >= u.soloUntil ? { ...u, solo: null, soloAct: null, soloUntil: 0, soloChosen: false } : u
62}
63
64/** Her acts on the band, newest first (the main loop is no act). */
65export function actsOf(b: KessokuBand, member: KessokuMember): KessokuAgent[] {
66  return b.agents.filter(a => a.member === member && a.id !== 'main').sort((x, y) => y.startedAt - x.startedAt)
67}
68
69/** Whether an act is in its encore: ended less than ENCORE_MS ago. */
70export function isEncore(a: KessokuAgent, now: number): boolean {
71  return !isLive(a) && a.endedAt !== null && now - a.endedAt < ENCORE_MS
72}
73
74/** The acts her pit and her channel show first: live ones, then the rest newest first; on her solo the pressed act first. */
75export function actsInOrder(b: KessokuBand, member: KessokuMember, first: string | null): KessokuAgent[] {
76  const acts = actsOf(b, member)
77  const ordered = [...acts.filter(isLive), ...acts.filter(a => !isLive(a))]
78  if (first === null) return ordered
79  const pick = ordered.find(a => a.id === first)
80  return pick === undefined ? ordered : [pick, ...ordered.filter(a => a !== pick)]
81}
82
83/** The acts whose slimes stand in her pit: the live ones and those that ended less than SLIME_TAIL_MS ago. */
84export function pitActs(b: KessokuBand, member: KessokuMember, now: number): KessokuAgent[] {
85  return actsInOrder(b, member, null).filter(a => isLive(a) || (a.endedAt !== null && now - a.endedAt < SLIME_TAIL_MS))
86}
87
88/** The context her slot shows: the main loop's for Bocchi, her fullest live act's otherwise; -1 while she rests. */
89export function slotContext(b: KessokuBand, member: KessokuMember): number {
90  if (member === 'bocchi') return b.contextPercent
91  const live = actsOf(b, member).filter(isLive)
92  if (live.length === 0 || b.window <= 0) return -1
93  return Math.round((Math.max(...live.map(a => a.context)) / b.window) * 100)
94}
95
96/** What she has spent: the main loop's cost for Bocchi (and any act seated with her), her acts' for the others. */
97export function spendOf(b: KessokuBand, member: KessokuMember): number {
98  const acts = actsOf(b, member).reduce((n, a) => n + a.cost, 0)
99  return member === 'bocchi' ? (b.agents.find(a => a.id === 'main')?.cost ?? 0) + acts : acts
100}
101
102/** Her share of the session's spend in eighths, for a VU. */
103export function vuLevel(b: KessokuBand, member: KessokuMember): number {
104  if (b.sessionUsd <= 0) return 0
105  return Math.max(0, Math.min(8, Math.round((spendOf(b, member) / b.sessionUsd) * 8)))
106}
107
108/** What her name line says after her name. */
109export function channelState(b: KessokuBand, member: KessokuMember, now: number): string {
110  const mood = memberMood(b, member, now)
111  if (member === 'bocchi') return MEMBERS.bocchi.lines[mood]
112  const acts = actsOf(b, member)
113  const live = acts.filter(isLive).length
114  if (live > 0) return `${live} playing`
115  if (acts.length > 0) return 'set done'
116  return MEMBERS[member].lines[mood]
117}
118
119/** The length of the set so far (or of the last one). */
120export function setLength(b: KessokuBand, now: number): string {
121  if (b.setStartedAt <= 0) return ''
122  const main = b.agents.find(a => a.id === 'main')
123  const end = b.isWorking ? now : (main?.endedAt ?? now)
124  return fmtDuration(Math.max(0, end - b.setStartedAt))
125}
126
127/** `NOW PLAYING · Ryo · <act> · 48s`, Bocchi and the set's task with no act, `backstage · set 3` between turns. */
128export function nowPlaying(b: KessokuBand, now: number): { who: KessokuMember | null; words: string } {
129  const running = b.agents.filter(a => a.id !== 'main' && a.status === 'running').sort((x, y) => y.startedAt - x.startedAt)[0]
130  if (running !== undefined) return { who: running.member, words: `${running.description.slice(0, 60)} · ${fmtDuration(now - running.startedAt)}` }
131  if (b.isWorking) return { who: 'bocchi', words: b.mainTask || MEMBERS.bocchi.lines.focused }
132  return { who: null, words: `backstage${b.set > 0 ? ` · set ${b.set}` : ''}` }
133}
134
135/** The acts on stage (the live ones). */
136export function liveCount(b: KessokuBand): number {
137  return b.agents.filter(a => a.id !== 'main' && isLive(a)).length
138}
139
140// ---- The view --------------------------------------------------------------------
141
142/** One act as every drawing shows it. */
143export type ActView = {
144  id: string
145  member: KessokuMember
146  /** What it was asked: the description (the main loop's: the set's task). */
147  title: string
148  /** Its agent type (`kessoku-ultimate-bocchi:ryo`, `Explore`, `main`). */
149  type: string
150  state: 'playing' | 'waiting' | 'done' | 'failed' | 'stopped'
151  isLive: boolean
152  isEncore: boolean
153  /** It ended in a failure (its status says so; the main loop never does). */
154  isFailed: boolean
155  /** A tool call in it failed at some point, whether or not it carried on. */
156  hadError: boolean
157  /** It started in the set on now (or there is no set yet): a failure from an earlier set no longer counts as a wrong note. */
158  inSet: boolean
159  /** Why it failed or stopped (`stopped: error`, the last wrong note), empty otherwise. */
160  reason: string
161  /** How long it has run, or ran. */
162  age: string
163  /** When it started (live) or ended, as minutes into the set (`5:14`), or as `2:40 ago` before the set. */
164  at: string
165  cost: string
166  costUsd: number
167  ctxPercent: number
168  tokens: string
169  /** The model it runs on (`claude-sonnet-5-5`), empty before its first step. */
170  model: string
171  /** Its model steps so far. */
172  steps: number
173  /** Its calls by tool, most first. */
174  tools: { name: string; count: number }[]
175  /** The tool in its hands (live) or the last it used. */
176  tool: string
177  slime: KessokuSlimeState
178}
179
180/** One member as every drawing shows her. */
181export type MemberView = {
182  member: KessokuMember
183  name: string
184  color: string
185  instrument: string
186  role: string
187  mood: KessokuMood
188  /** Her line after her name: `2 playing`, `set done`, her mood's words. */
189  line: string
190  hotkey: string
191  isPlaying: boolean
192  /** Her spotlight: up while she plays, and on a solo only hers. */
193  isLit: boolean
194  /** Faded: another member is on solo. */
195  isDim: boolean
196  isSolo: boolean
197  /** Bocchi: the main loop first, then any act seated with her. The others: live acts first, then the rest newest first (on her solo the pressed act first). */
198  acts: ActView[]
199  /** The acts whose slimes stand at her feet (live, or ended within half a minute). */
200  pit: ActView[]
201  liveCount: number
202  /** Her slot's context %, or -1 while she rests. */
203  ctxPercent: number
204  spend: string
205  spendUsd: number
206  /** Her share of the session's spend, 0..1. */
207  share: number
208}
209
210export type PanelView = {
211  now: number
212  onAir: boolean
213  set: number
214  setLength: string
215  mainTask: string
216  ctxPercent: number
217  /** The plan's fullest rate-limit window, 0 off a subscription. */
218  planPercent: number
219  session: string
220  sessionUsd: number
221  amp: { kind: 'warm' | 'cold' | 'keepwarm' | 'none'; words: string }
222  liveCount: number
223  lastError: string
224  nowPlaying: { who: KessokuMember | null; words: string }
225  solo: KessokuMember | null
226  /** The act pressed to open the solo (listed first on her page), null for her newest. */
227  soloAct: string | null
228  members: MemberView[]
229}
230
231function actView(b: KessokuBand, a: KessokuAgent, now: number): ActView {
232  const live = isLive(a)
233  const failed = a.id !== 'main' && a.status === 'failed'
234  const isMain = a.id === 'main'
235  const ctx = isMain ? b.contextPercent : b.window > 0 ? Math.round((a.context / b.window) * 100) : 0
236  const when = live || a.endedAt === null ? a.startedAt : a.endedAt
237  const at = b.setStartedAt > 0 && when >= b.setStartedAt ? fmtDuration(when - b.setStartedAt) : `${fmtDuration(Math.max(0, now - when))} ago`
238  const tally = a.toolTally ?? {}
239  return {
240    id: a.id,
241    member: a.member,
242    title: isMain ? b.setPrompt || b.mainTask || 'the main conversation' : a.description,
243    type: a.type,
244    state: a.status === 'running' ? 'playing' : live ? 'waiting' : failed ? 'failed' : a.status === 'killed' ? 'stopped' : 'done',
245    isLive: isMain ? b.isWorking : live,
246    isEncore: !isMain && isEncore(a, now),
247    isFailed: failed,
248    hadError: a.isError,
249    inSet: isMain || b.setStartedAt <= 0 || (a.endedAt ?? a.startedAt) >= b.setStartedAt || live,
250    reason: failed || a.status === 'killed' ? a.task : '',
251    age: fmtDuration(Math.max(0, (a.endedAt ?? now) - (a.startedAt || now))),
252    at,
253    cost: fmtUsd(a.cost),
254    costUsd: a.cost,
255    ctxPercent: ctx,
256    tokens: fmtTokens(a.usage.input + a.usage.cacheRead + a.usage.cacheWrite + a.usage.output),
257    model: a.model,
258    steps: a.steps,
259    tools: Object.entries(tally)
260      .map(([name, count]) => ({ name, count }))
261      .sort((x, y) => y.count - x.count),
262    tool: a.lastTool ?? '',
263    slime: slimeState(a.status),
264  }
265}
266
267/** The cache's warmth in a word or three, for the session's figures. */
268function ampOf(c: KessokuCache, now: number, ttlMs: number): PanelView['amp'] {
269  if (c.keepWarmUntil > now) return { kind: 'keepwarm', words: `keepwarm ${fmtDuration(c.keepWarmUntil - now)}` }
270  if (c.lastWarmAt <= 0) return { kind: 'none', words: 'no cache yet' }
271  const left = ttlMs - (now - c.lastWarmAt)
272  return left <= 0 ? { kind: 'cold', words: `cold ${fmtDuration(-left)}` } : { kind: 'warm', words: `warm ${fmtDuration(left)}` }
273}
274
275/**
276 * The panel as data: the band read through `bandOf`, the solo through
277 * `soloNow`, the cache's warmth at its TTL. Every drawing of the panel draws
278 * this, so the desktop and the terminal say the same things.
279 */
280export function panelView(rawBand: KessokuBand, cache: KessokuCache, rawUi: KessokuUi, now: number, ttlMs: number): PanelView {
281  const b = bandOf(rawBand)
282  const u = soloNow(rawUi, now)
283  const main = b.agents.find(a => a.id === 'main')
284  const members = BAND_ORDER.map((member): MemberView => {
285    const m = MEMBERS[member]
286    const own = actsInOrder(b, member, u.solo === member ? u.soloAct : null)
287    const acts = (member === 'bocchi' && main !== undefined ? [main, ...own] : own).map(a => actView(b, a, now))
288    const plays = member === 'bocchi' ? b.isWorking : own.some(isLive)
289    const spendUsd = spendOf(b, member)
290    return {
291      member,
292      name: m.name,
293      color: m.color,
294      instrument: m.instrument,
295      role: m.role,
296      mood: memberMood(b, member, now),
297      line: channelState(b, member, now),
298      hotkey: SOLO_KEYS[member],
299      isPlaying: plays,
300      isLit: u.solo === null ? plays : u.solo === member,
301      isDim: u.solo !== null && u.solo !== member,
302      isSolo: u.solo === member,
303      acts,
304      pit: pitActs(b, member, now).map(a => actView(b, a, now)),
305      liveCount: own.filter(isLive).length,
306      ctxPercent: slotContext(b, member),
307      spend: fmtUsd(spendUsd),
308      spendUsd,
309      share: b.sessionUsd > 0 ? Math.min(1, spendUsd / b.sessionUsd) : 0,
310    }
311  })
312  return {
313    now,
314    onAir: b.isWorking,
315    set: b.set,
316    setLength: setLength(b, now),
317    mainTask: b.mainTask,
318    ctxPercent: b.contextPercent,
319    planPercent: b.rateLimitPercent,
320    session: fmtUsd(b.sessionUsd),
321    sessionUsd: b.sessionUsd,
322    amp: ampOf(cache, now, ttlMs),
323    liveCount: liveCount(b),
324    lastError: b.lastError,
325    nowPlaying: nowPlaying(b, now),
326    solo: u.solo,
327    soloAct: u.solo === null ? null : u.soloAct,
328    members,
329  }
330}
331
hooks/lib/sprite.ts 984 lines
1// Kessoku Ultimate Bocchi Theme: the band's pixel-art sprites, played.
2//
3// Pure helpers, no `$` and no `on`. The frames come from sprites.gen.ts, which
4// the Remotion studio (../../studio) renders and exports; this file turns them
5// into what a surface can draw:
6//
7//   terminal  a Raster's cells: two pixels a cell with the half block (`▀`,
8//             the top pixel the foreground, the bottom the background), or
9//             four with the quadrant blocks; repainted frame by frame with
10//             `$.ui.blit`.
11//   desktop   an SVG drawn in layers, so a 96x96 loop of a dozen frames fits
12//   (and the  the Svg element: the pixels every frame shares are drawn once
13//   editor,   and always shown; the pixels a cluster of like frames shares are
14//   mobile)   drawn once and shown over those frames' spans of the loop; each
15//             frame draws only what is left of it, shown over its own spans.
16//             The spans are discrete SMIL `visibility` animations, so the
17//             surface plays the loop with no redraw at all, and what is
18//             visible to begin with is the first frame, left standing where
19//             animation does not run. A drawing still too large for the
20//             element loses frames (neighbours merged into longer holds), at
21//             worst down to a still of the first.
22
23import type { KessokuMember, KessokuMood, KessokuSlimeState } from '../../types'
24import { SPRITES, SPRITE_COLORS } from './sprites.gen'
25import type { SpriteAnim } from './sprites.gen'
26
27export type { SpriteAnim }
28
29/** A member's figure, played by mood: the whole figure (96x96), the window over the head (32x32), or the hand-drawn head the terminal shows (16x16). */
30export type MemberView = 'full' | 'portrait' | 'mini'
31
32/** A member's slime, one an act on stage, played by state: the slime (36x24), or the slime halved (18x12) for the terminal's quadrants. */
33export type SlimeView = 'slime' | 'slime-mini'
34
35export type SpriteView = MemberView | SlimeView
36
37/**
38 * How a terminal packs pixels into cells, a cell being about twice as tall as
39 * it is wide: `half` puts a column of two pixels in a cell (square pixels,
40 * every color kept); `quad` puts two columns of four (each quadrant a pair of
41 * rows merged, two colors a cell), half the size each way.
42 */
43export type CellMode = 'half' | 'quad'
44
45/**
46 * One sprite placed in a drawing, at a pixel offset, each of its pixels drawn
47 * `zoom` x `zoom` (a whole number; 1 when absent). A mini at zoom 2 at an
48 * even column and a row a multiple of four, in quadrants, is the mini in half
49 * blocks: each cell its pixel on top and the one below at the bottom.
50 */
51export type SpritePart = { id: string; x: number; y: number; zoom?: number; dim?: boolean }
52
53/**
54 * A spotlight behind a drawing: a cone in `color` from the top's middle
55 * sixth to the canvas's full width at the floor (`floor` pixels down; the
56 * canvas's bottom when absent) and its pool on the floor, strong while
57 * `isLit`, faint at rest. Only the SVG draws it; a terminal lights the floor
58 * with its own glyphs.
59 */
60export type StageLayer = { color: string; isLit: boolean; floor?: number }
61
62/** The background a dimmed part's pixels are shaded toward in a terminal (the charcoal). */
63const DIM_TOWARD = '#1b1b1f'
64
65/** A color mixed `amount` of the way toward another: `shade('#ffffff', '#000000', 0.5)` is `#808080`. */
66export function shade(color: string, toward: string, amount: number): string {
67  const c = parseInt(color.slice(1), 16)
68  const t = parseInt(toward.slice(1), 16)
69  if (!Number.isFinite(c) || !Number.isFinite(t)) return color
70  const mix = (shift: number) => {
71    const a = (c >> shift) & 0xff
72    const b = (t >> shift) & 0xff
73    return Math.round(a + (b - a) * amount)
74  }
75  return `#${((mix(16) << 16) | (mix(8) << 8) | mix(0)).toString(16).padStart(6, '0')}`
76}
77
78/** A part's zoom: a whole number, at least 1. */
79function zoomOf(part: SpritePart): number {
80  return Math.max(1, Math.floor(part.zoom ?? 1))
81}
82
83/** Animations by id: the exported ones, or a table of a test's own. */
84export type SpriteTable = Readonly<Record<string, SpriteAnim>>
85
86/** The Blast Radius crash, Bocchi's guitar hitting the stage (96x32). */
87export const CRASH = 'fx-crash'
88
89/** The crash at half the size (48x16), for the terminal's small spots. */
90export const CRASH_MINI = 'fx-crash-mini'
91
92/** The Svg element takes at most this much markup. */
93export const SVG_LIMIT = 131_072
94
95/** A Raster color word for "the terminal's own default". */
96const DEFAULT_COLOR = 0x01000000
97
98/** Each view's size as the studio draws it, for when no frames are there to say. */
99const NOMINAL: Readonly<Record<SpriteView, { w: number; h: number }>> = {
100  full: { w: 96, h: 96 },
101  portrait: { w: 32, h: 32 },
102  mini: { w: 16, h: 16 },
103  slime: { w: 36, h: 24 },
104  'slime-mini': { w: 18, h: 12 },
105}
106
107export function isSlimeView(view: SpriteView): view is SlimeView {
108  return view === 'slime' || view === 'slime-mini'
109}
110
111/** A sprite's id: a member in a mood in a figure's view (`kita-happy-mini`), or her slime in a state (`ryo-slime-active-mini`). */
112export function spriteId(member: KessokuMember, mood: KessokuMood, view?: MemberView): string
113export function spriteId(member: KessokuMember, state: KessokuSlimeState, view: SlimeView): string
114export function spriteId(member: KessokuMember, moodOrState: KessokuMood | KessokuSlimeState, view: SpriteView = 'full'): string {
115  if (isSlimeView(view)) return view === 'slime' ? `${member}-slime-${moodOrState}` : `${member}-slime-${moodOrState}-mini`
116  return view === 'full' ? `${member}-${moodOrState}` : `${member}-${moodOrState}-${view}`
117}
118
119/** A view's size in pixels, read off the exported frames (every member's, mood's and state's is the same), the studio's own when none are there. */
120export function viewSize(view: SpriteView): { w: number; h: number } {
121  const anim = SPRITES[isSlimeView(view) ? spriteId('bocchi', 'active', view) : spriteId('bocchi', 'anxious', view)]
122  return anim === undefined ? NOMINAL[view] : { w: anim.w, h: anim.h }
123}
124
125export function animOf(id: string): SpriteAnim | undefined {
126  return SPRITES[id]
127}
128
129export function hasSprites(): boolean {
130  return Object.keys(SPRITES).length > 0
131}
132
133/** How long one pass of the loop takes, in milliseconds. */
134export function loopMs(anim: SpriteAnim): number {
135  let t = 0
136  for (const [ms] of anim.frames) t += ms
137  return t
138}
139
140/** The frame showing at clock time `now`: every loop runs on the same clock, so two drawings of one sprite agree. */
141export function frameAt(anim: SpriteAnim, now: number): number {
142  const total = loopMs(anim)
143  if (total <= 0 || anim.frames.length <= 1) return 0
144  let t = ((now % total) + total) % total
145  for (let i = 0; i < anim.frames.length; i += 1) {
146    const ms = (anim.frames[i] as readonly [number, string])[0]
147    if (t < ms) return i
148    t -= ms
149  }
150  return anim.frames.length - 1
151}
152
153// ---- Decoding -------------------------------------------------------------------
154//
155// A frame decodes to palette codes, one a pixel: the UTF-16 unit of the
156// pixel's palette character, 0 where it is transparent. Two bytes a pixel, and
157// the color only looked up where a pixel is drawn.
158
159const DOT = 0x2e
160const SAME = 0x3d
161const ZERO = 0x30
162const NINE = 0x39
163
164/** Each palette code's color, by code. */
165const COLOR_OF: (string | undefined)[] = []
166for (const [ch, hex] of Object.entries(SPRITE_COLORS)) COLOR_OF[ch.charCodeAt(0)] = hex
167
168/** A palette code's color: null for transparency (0) or a character the palette does not hold. */
169export function colorOf(code: number): string | null {
170  return code === 0 ? null : (COLOR_OF[code] ?? null)
171}
172
173/**
174 * One frame's runs as palette codes (`size` of them): `A3` is three of `A`,
175 * `.` transparent, and `=` (with a count the same way) pixels copied from
176 * `prev`, the frame before decoded; transparent where there is none, as is a
177 * character the palette does not hold.
178 */
179export function decodeCodes(rle: string, size: number, prev?: Uint16Array): Uint16Array {
180  const out = new Uint16Array(size)
181  let o = 0
182  let i = 0
183  while (i < rle.length && o < size) {
184    const ch = rle.charCodeAt(i)
185    i += 1
186    let n = 0
187    let isCounted = false
188    while (i < rle.length) {
189      const d = rle.charCodeAt(i)
190      if (d < ZERO || d > NINE) break
191      n = n * 10 + (d - ZERO)
192      isCounted = true
193      i += 1
194    }
195    const end = Math.min(size, o + (isCounted ? n : 1))
196    if (ch === SAME) {
197      if (prev !== undefined) out.set(prev.subarray(o, end), o)
198    } else if (ch !== DOT && COLOR_OF[ch] !== undefined) {
199      out.fill(ch, o, end)
200    }
201    o = end
202  }
203  return out
204}
205
206/** Every frame of an animation, decoded in play order (each `=` read against the frame before). */
207export function decodeFrames(anim: SpriteAnim): Uint16Array[] {
208  const size = anim.w * anim.h
209  const out: Uint16Array[] = []
210  let prev: Uint16Array | undefined
211  for (const [, rle] of anim.frames) {
212    prev = decodeCodes(rle, size, prev)
213    out.push(prev)
214  }
215  return out
216}
217
218/** A frame's codes as colors, row by row: a `#rrggbb`, or null where it is transparent. */
219export function pixelsOf(codes: Uint16Array): (string | null)[] {
220  const out: (string | null)[] = new Array(codes.length)
221  for (let i = 0; i < codes.length; i += 1) out[i] = colorOf(codes[i] as number)
222  return out
223}
224
225function sameCodes(a: Uint16Array, b: Uint16Array): boolean {
226  if (a.length !== b.length) return false
227  for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false
228  return true
229}
230
231/** A sprite's frames decoded, and for each the first frame that looks the same (so equal frames repaint nothing). */
232type Decoded = { frames: Uint16Array[]; same: number[] }
233
234const decoded = new Map<string, Decoded>()
235
236/** The terminal's frames, decoded once a sprite and kept (the desktop decodes afresh and keeps only its markup). */
237function decodedOf(id: string): Decoded | undefined {
238  const hit = decoded.get(id)
239  if (hit !== undefined) return hit
240  const anim = SPRITES[id]
241  if (anim === undefined) return undefined
242  const frames = decodeFrames(anim)
243  const firsts: number[] = []
244  const same = frames.map((f, i) => {
245    const j = firsts.find(k => sameCodes(frames[k] as Uint16Array, f))
246    if (j !== undefined) return j
247    firsts.push(i)
248    return i
249  })
250  const entry = { frames, same }
251  decoded.set(id, entry)
252  return entry
253}
254
255/** A frame's pixels, row by row: a `#rrggbb` color, or null where it is transparent. */
256export function framePixels(id: string, index: number): readonly (string | null)[] {
257  const frame = decodedOf(id)?.frames[index]
258  return frame === undefined ? [] : pixelsOf(frame)
259}
260
261const shadeCache = new Map<string, string>()
262
263/** A part's pixel as drawn: its color, or (a `dim` part) that color two thirds of the way to the charcoal. */
264function paintOf(code: number, isDim: boolean): string | null {
265  const color = COLOR_OF[code] ?? null
266  if (color === null || !isDim) return color
267  let hit = shadeCache.get(color)
268  if (hit === undefined) {
269    hit = shade(color, DIM_TOWARD, 2 / 3)
270    shadeCache.set(color, hit)
271  }
272  return hit
273}
274
275/** Several sprites, each at its own frame for `now` and drawn at its zoom (a `dim` one shaded), laid on one canvas of `w` x `h` pixels. */
276export function composite(parts: readonly SpritePart[], w: number, h: number, now: number): (string | null)[] {
277  const px: (string | null)[] = Array.from({ length: w * h }, () => null)
278  for (const part of parts) {
279    const anim = SPRITES[part.id]
280    const frame = decodedOf(part.id)?.frames[anim === undefined ? 0 : frameAt(anim, now)]
281    if (anim === undefined || frame === undefined) continue
282    const z = zoomOf(part)
283    const isDim = part.dim === true
284    for (let y = 0; y < anim.h * z; y += 1) {
285      const ty = part.y + y
286      if (ty < 0 || ty >= h) continue
287      const row = Math.floor(y / z) * anim.w
288      for (let x = 0; x < anim.w * z; x += 1) {
289        const tx = part.x + x
290        if (tx < 0 || tx >= w) continue
291        const code = frame[row + Math.floor(x / z)] as number
292        if (code !== 0) px[ty * w + tx] = paintOf(code, isDim)
293      }
294    }
295  }
296  return px
297}
298
299/** Whether any of the parts ever changes: a still one needs no clock. */
300export function isAnimated(parts: readonly SpritePart[]): boolean {
301  return parts.some(p => (SPRITES[p.id]?.frames.length ?? 0) > 1)
302}
303
304/** Which frame each part shows at `now`: two equal signatures draw the same cells. */
305export function frameSignature(parts: readonly SpritePart[], now: number): string {
306  return parts
307    .map(p => {
308      const anim = SPRITES[p.id]
309      if (anim === undefined) return '-'
310      const i = frameAt(anim, now)
311      return String(decodedOf(p.id)?.same[i] ?? i)
312    })
313    .join(',')
314}
315
316// ---- Terminal: Raster cells --------------------------------------------------------
317
318/** The cells `w` x `h` pixels take. */
319export function cellsFor(w: number, h: number, mode: CellMode): { columns: number; rows: number } {
320  return mode === 'half' ? { columns: w, rows: Math.ceil(h / 2) } : { columns: Math.ceil(w / 2), rows: Math.ceil(h / 4) }
321}
322
323function rgb(hex: string): number {
324  return parseInt(hex.slice(1), 16) & 0xffffff
325}
326
327function luma(c: number): number {
328  return ((c >> 16) & 255) * 0.299 + ((c >> 8) & 255) * 0.587 + (c & 255) * 0.114
329}
330
331/** Two stacked pixels as one quadrant: the darker wins, so eyes, mouths and outlines survive the merge. */
332function mergePair(a: string | null, b: string | null): string | null {
333  if (a === null) return b
334  if (b === null) return a
335  return luma(rgb(a)) <= luma(rgb(b)) ? a : b
336}
337
338function distance(a: number, b: number): number {
339  const dr = ((a >> 16) & 255) - ((b >> 16) & 255)
340  const dg = ((a >> 8) & 255) - ((b >> 8) & 255)
341  const db = (a & 255) - (b & 255)
342  return dr * dr + dg * dg + db * db
343}
344
345// The quadrant blocks by which quarters they fill: upper-left 1, upper-right 2, lower-left 4, lower-right 8.
346const QUADRANTS = [0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588]
347
348/**
349 * A pixel canvas as Raster cells: standard base64 of little-endian u32
350 * triplets `[codePoint, foreground, background]`, row-major. Transparent
351 * pixels take `bg` (a `#rrggbb`, or the terminal's own default when absent;
352 * `isLight` says whether that default is a light one, for the quadrants'
353 * nearest-color choice).
354 */
355export function encodeCells(px: readonly (string | null)[], w: number, h: number, mode: CellMode, bg?: string, isLight = false): { cells: string; columns: number; rows: number } {
356  const back = bg === undefined ? DEFAULT_COLOR : rgb(bg)
357  // The color a transparent quadrant is measured as: the background's own, or, for the terminal's default, white or black.
358  const seen = (c: number) => (c === DEFAULT_COLOR ? (isLight ? 0xffffff : 0x000000) : c)
359  const at = (x: number, y: number): string | null => (x < w && y < h ? (px[y * w + x] ?? null) : null)
360  const { columns, rows } = cellsFor(w, h, mode)
361  const bytes = new Uint8Array(columns * rows * 12)
362  const view = new DataView(bytes.buffer)
363  let o = 0
364  const put = (cp: number, fg: number, bgc: number) => {
365    view.setUint32(o, cp, true)
366    view.setUint32(o + 4, fg, true)
367    view.setUint32(o + 8, bgc, true)
368    o += 12
369  }
370  if (mode === 'half') {
371    for (let r = 0; r < rows; r += 1) {
372      for (let x = 0; x < columns; x += 1) {
373        const top = at(x, r * 2)
374        const bottom = at(x, r * 2 + 1)
375        if (top === null && bottom === null) put(0x20, DEFAULT_COLOR, back)
376        else if (bottom === null) put(0x2580, rgb(top as string), back)
377        else if (top === null) put(0x2584, rgb(bottom), back)
378        else if (top === bottom) put(0x2588, rgb(top), back)
379        else put(0x2580, rgb(top), rgb(bottom))
380      }
381    }
382  } else {
383    // Quadrants: each one pixel wide and two tall (a pair of rows merged), so the
384    // cell keeps the sprite's aspect. A cell shows two colors at most: its two
385    // commonest, every other quadrant taking the nearer of them.
386    for (let r = 0; r < rows; r += 1) {
387      for (let c = 0; c < columns; c += 1) {
388        const x0 = c * 2
389        const y0 = r * 4
390        const quad = [
391          mergePair(at(x0, y0), at(x0, y0 + 1)),
392          mergePair(at(x0 + 1, y0), at(x0 + 1, y0 + 1)),
393          mergePair(at(x0, y0 + 2), at(x0, y0 + 3)),
394          mergePair(at(x0 + 1, y0 + 2), at(x0 + 1, y0 + 3)),
395        ].map(p => (p === null ? back : rgb(p)))
396        const counts = new Map<number, number>()
397        for (const q of quad) counts.set(q, (counts.get(q) ?? 0) + 1)
398        // The commonest first; on a tie a drawn color beats the background (so thin lines
399        // survive) and a darker one a lighter (as mergePair does, so outlines and eyes win).
400        const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || (a[0] === back ? 1 : 0) - (b[0] === back ? 1 : 0) || luma(a[0]) - luma(b[0]))
401        const first = (ranked[0] as [number, number])[0]
402        if (ranked.length === 1) {
403          if (first === back) put(0x20, DEFAULT_COLOR, back)
404          else put(0x2588, first, back)
405          continue
406        }
407        const second = (ranked[1] as [number, number])[0]
408        // The foreground is a drawn color whenever there is one.
409        const fg = first === back ? second : first
410        const other = fg === first ? second : first
411        let mask = 0
412        quad.forEach((q, i) => {
413          // When the other slot is the background it keeps the transparent quadrants alone: every drawn one shows.
414          const isFg = other === back ? q !== back : q === fg || (q !== other && distance(seen(q), fg) <= distance(seen(q), other))
415          if (isFg) mask |= 1 << i
416        })
417        put(QUADRANTS[mask] as number, fg, other)
418      }
419    }
420  }
421  return { cells: base64(bytes), columns, rows }
422}
423
424const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
425
426/** Standard padded base64; the runtime's own `toBase64` where it has one. */
427export function base64(bytes: Uint8Array): string {
428  const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64
429  if (typeof native === 'function') return native.call(bytes)
430  let out = ''
431  for (let i = 0; i < bytes.length; i += 3) {
432    const a = bytes[i] as number
433    const b = i + 1 < bytes.length ? (bytes[i + 1] as number) : 0
434    const c = i + 2 < bytes.length ? (bytes[i + 2] as number) : 0
435    const n = (a << 16) | (b << 8) | c
436    out += B64[(n >> 18) & 63]
437    out += B64[(n >> 12) & 63]
438    out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '='
439    out += i + 2 < bytes.length ? B64[n & 63] : '='
440  }
441  return out
442}
443
444// ---- Desktop: the layers ------------------------------------------------------------
445
446/** A stretch of the loop, in milliseconds from its start: `[from, to)`. */
447export type Span = readonly [from: number, to: number]
448
449/**
450 * One layer of a drawing: its pixels (palette codes, 0 where it draws
451 * nothing), the spans of the loop it shows over (null for always), and the
452 * pixels outside it that every frame it shows over draws in one color
453 * (`free`), which a run of that color may cross, painting them as they are.
454 */
455export type SvgLayer = { px: Uint16Array; spans: readonly Span[] | null; free: Uint16Array | null }
456
457/** A sprite's loop as layers, drawn in order: over any span, exactly the frame then showing. */
458export type SvgLayers = { w: number; h: number; total: number; layers: SvgLayer[] }
459
460/**
461 * What markup costs, in characters, for weighing one layering against
462 * another: a run (`m-7 1h5`), a path's head (`<path stroke="#rrggbb"
463 * d=""/>`), and an animated layer's group with its animation.
464 */
465const RUN_CHARS = 7
466const HEAD_CHARS = 29
467const GROUP_CHARS = 170
468
469/**
470 * About what a layer of `px` (less the pixels `less` draws) costs as markup:
471 * its runs, its paths' heads and its group; nothing when it draws nothing.
472 */
473function layerCost(px: Uint16Array, w: number, less: Uint16Array | null): number {
474  let runs = 0
475  const hues = new Set<number>()
476  let prev = 0
477  for (let i = 0; i < px.length; i += 1) {
478    const c = less !== null && less[i] !== 0 ? 0 : (px[i] as number)
479    if (c !== 0 && (c !== prev || i % w === 0)) {
480      runs += 1
481      hues.add(c)
482    }
483    prev = c
484  }
485  return runs === 0 ? 0 : runs * RUN_CHARS + hues.size * HEAD_CHARS + GROUP_CHARS
486}
487
488function countOf(px: Uint16Array): number {
489  let n = 0
490  for (let i = 0; i < px.length; i += 1) if (px[i] !== 0) n += 1
491  return n
492}
493
494/** Spans in order with touching ones joined; null when they cover the whole loop. */
495function joinSpans(spans: readonly Span[], total: number): Span[] | null {
496  const sorted = [...spans].sort((a, b) => a[0] - b[0])
497  const out: [number, number][] = []
498  for (const [a, b] of sorted) {
499    const last = out[out.length - 1]
500    if (last !== undefined && a <= last[1]) last[1] = Math.max(last[1], b)
501    else out.push([a, b])
502  }
503  const only = out[0]
504  return out.length === 1 && only !== undefined && only[0] <= 0 && only[1] >= total ? null : out
505}
506
507/**
508 * A loop's frames (codes, in play order, each held `durations[i]` ms) as
509 * layers. Frames that look the same are one frame, shown over several spans.
510 *
511 * The first layer, always shown, is the base: every pixel all the frames
512 * draw, in the color most of them give it. Over it each frame has a
513 * residual, its pixels the base leaves out or colors otherwise, and the
514 * residuals are clustered (clusterLayers): a cluster's shared pixels are a
515 * layer shown over all its frames' spans, and what is left of a frame its own
516 * layer, shown over its own.
517 *
518 * Exact: the base covers only pixels every frame draws; a frame's other
519 * layers are disjoint parts of its residual that together make it, drawn
520 * after the base; and a layer is hidden whenever none of its frames shows.
521 * So at every instant the visible layers cover exactly the frame's pixels,
522 * and painted in order they give each its color.
523 */
524export function layerFrames(frames: readonly Uint16Array[], durations: readonly number[], w: number, h: number): SvgLayers {
525  const size = w * h
526  // Distinct frames and the spans each covers; a frame of no duration never shows.
527  const distinct: { px: Uint16Array; spans: [number, number][] }[] = []
528  let total = 0
529  frames.forEach((f, i) => {
530    const ms = durations[i] ?? 0
531    if (ms <= 0) return
532    let d = distinct.find(x => sameCodes(x.px, f))
533    if (d === undefined) {
534      d = { px: f, spans: [] }
535      distinct.push(d)
536    }
537    const last = d.spans[d.spans.length - 1]
538    if (last !== undefined && last[1] === total) last[1] = total + ms
539    else d.spans.push([total, total + ms])
540    total += ms
541  })
542  const first = distinct[0]
543  if (first === undefined) {
544    const still = frames[0]
545    return { w, h, total: 0, layers: still === undefined ? [] : [{ px: still, spans: null, free: null }] }
546  }
547  if (distinct.length === 1) return { w, h, total, layers: [{ px: first.px, spans: null, free: null }] }
548  const pxOf = (f: number) => (distinct[f] as (typeof distinct)[number]).px
549
550  const base = new Uint16Array(size)
551  const tally = new Map<number, number>()
552  for (let i = 0; i < size; i += 1) {
553    if (distinct.some(d => d.px[i] === 0)) continue
554    tally.clear()
555    let best = 0
556    let most = 0
557    for (const d of distinct) {
558      const c = d.px[i] as number
559      const n = (tally.get(c) ?? 0) + 1
560      tally.set(c, n)
561      if (n > most) {
562        most = n
563        best = c
564      }
565    }
566    base[i] = best
567  }
568  const residual = distinct.map(d => {
569    const r = new Uint16Array(size)
570    for (let i = 0; i < size; i += 1) if (base[i] !== d.px[i]) r[i] = d.px[i] as number
571    return r
572  })
573  // A layer shown over some frames: their spans, and the pixels all of them draw in one color.
574  const place = (px: Uint16Array, members: readonly number[]): SvgLayer => {
575    const free = new Uint16Array(size)
576    const lead = pxOf(members[0] as number)
577    for (let i = 0; i < size; i += 1) {
578      const c = lead[i] as number
579      if (c !== 0 && members.every(m => pxOf(m)[i] === c)) free[i] = c
580    }
581    return { px, spans: joinSpans(members.flatMap(m => (distinct[m] as (typeof distinct)[number]).spans), total), free }
582  }
583  const layers: SvgLayer[] = []
584  if (countOf(base) > 0) layers.push({ px: base, spans: null, free: null })
585  layers.push(...clusterLayers(distinct.map((_, f) => f), residual, w, place))
586  return { w, h, total, layers }
587}
588
589/**
590 * Frames' residuals (what each draws over the layers below) as layers,
591 * clustered greedily in play order: a frame joins the cluster it saves the
592 * most markup with (what the cluster's layers cost with it, against what
593 * they cost without it plus its own layer), or starts one of its own when
594 * none saves any. A cluster's shared pixels (those all its frames draw
595 * alike, a subset of each one's residual) are a layer shown over all their
596 * spans; what is left of its frames is clustered again the same way, down
597 * to each frame's own layer.
598 */
599function clusterLayers(members: readonly number[], residual: readonly Uint16Array[], w: number, place: (px: Uint16Array, members: readonly number[]) => SvgLayer): SvgLayer[] {
600  const size = (residual[members[0] as number] as Uint16Array).length
601  type Cluster = { members: number[]; shared: Uint16Array; cost: number }
602  const clusters: Cluster[] = []
603  for (const f of members) {
604    const r = residual[f] as Uint16Array
605    const alone = layerCost(r, w, null)
606    if (alone === 0) continue
607    let best: Cluster | null = null
608    let bestShared: Uint16Array | null = null
609    let bestCost = 0
610    let bestGain = 0
611    for (const c of clusters) {
612      const shared = new Uint16Array(size)
613      let k = 0
614      for (let i = 0; i < size; i += 1) {
615        const v = r[i] as number
616        if (v !== 0 && c.shared[i] === v) {
617          shared[i] = v
618          k += 1
619        }
620      }
621      if (k === 0) continue
622      let cost = layerCost(shared, w, null) + layerCost(r, w, shared)
623      for (const m of c.members) cost += layerCost(residual[m] as Uint16Array, w, shared)
624      const gain = c.cost + alone - cost
625      if (gain > bestGain) {
626        best = c
627        bestShared = shared
628        bestCost = cost
629        bestGain = gain
630      }
631    }
632    if (best === null || bestShared === null) {
633      clusters.push({ members: [f], shared: r, cost: alone })
634      continue
635    }
636    best.members.push(f)
637    best.shared = bestShared
638    best.cost = bestCost
639  }
640  const layers: SvgLayer[] = []
641  for (const c of clusters) {
642    layers.push(place(c.shared, c.members))
643    if (c.members.length === 1) continue
644    const rest = residual.slice()
645    for (const m of c.members) {
646      const r = residual[m] as Uint16Array
647      const own = new Uint16Array(size)
648      for (let i = 0; i < size; i += 1) if (c.shared[i] === 0) own[i] = r[i] as number
649      rest[m] = own
650    }
651    layers.push(...clusterLayers(c.members, rest, w, place))
652  }
653  return layers
654}
655
656/**
657 * A loop with its frames merged down to at most `keep`: the two neighbours
658 * that differ least become one, the earlier held for both their times, until
659 * few enough are left. The first frame always stays first, so at `keep` 1 the
660 * loop is a still of it.
661 */
662export function mergeTimeline(frames: readonly Uint16Array[], durations: readonly number[], keep: number): { frames: Uint16Array[]; durations: number[] } {
663  const fs = [...frames]
664  const ds = fs.map((_, i) => durations[i] ?? 0)
665  const differ = (a: Uint16Array, b: Uint16Array) => {
666    let n = 0
667    for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) n += 1
668    return n
669  }
670  // diffs[i]: how much frame i differs from frame i - 1.
671  const diffs = fs.map((f, i) => (i === 0 ? Infinity : differ(fs[i - 1] as Uint16Array, f)))
672  while (fs.length > Math.max(1, keep)) {
673    let at = 1
674    for (let i = 2; i < fs.length; i += 1) if ((diffs[i] as number) < (diffs[at] as number)) at = i
675    ds[at - 1] = (ds[at - 1] as number) + (ds[at] as number)
676    fs.splice(at, 1)
677    ds.splice(at, 1)
678    diffs.splice(at, 1)
679    if (at < fs.length) diffs[at] = differ(fs[at - 1] as Uint16Array, fs[at] as Uint16Array)
680  }
681  return { frames: fs, durations: ds }
682}
683
684// ---- Desktop: the markup -------------------------------------------------------------
685
686function fmt(n: number): string {
687  return String(Math.round(n * 10000) / 10000)
688}
689
690/** Two path numbers: a space between them, unless the second's minus sign parts them. */
691function pair(a: number, b: number): string {
692  return b < 0 ? `${a}${b}` : `${a} ${b}`
693}
694
695/**
696 * One color's runs in a layer as path data: along the rows (`h` segments) or
697 * down the columns (`v`), each a stroke a pixel wide through the middle of
698 * its pixels, the moves between them relative. A run reaches across pixels
699 * that `crosses` allows (ones painted over later, or ones it paints as they
700 * are), and ends on its own last pixel.
701 */
702function runData(px: Uint16Array, w: number, h: number, c: number, isDown: boolean, crosses: (p: number) => boolean): string {
703  const lines = isDown ? w : h
704  const along = isDown ? h : w
705  const step = isDown ? w : 1
706  const lineStep = isDown ? 1 : w
707  let d = ''
708  let lastLine = 0
709  let lastAt = 0
710  let isStarted = false
711  for (let line = 0; line < lines; line += 1) {
712    const origin = line * lineStep
713    let at = 0
714    while (at < along) {
715      if (px[origin + at * step] !== c) {
716        at += 1
717        continue
718      }
719      let end = at + 1
720      let k = at + 1
721      while (k < along) {
722        const p = origin + k * step
723        if (px[p] === c) {
724          k += 1
725          end = k
726        } else if (crosses(p)) {
727          k += 1
728        } else {
729          break
730        }
731      }
732      const n = end - at
733      if (isDown) d += isStarted ? `m${pair(line - lastLine, at - lastAt)}v${n}` : `M${line}.5 ${at}v${n}`
734      else d += isStarted ? `m${pair(at - lastAt, line - lastLine)}h${n}` : `M${at} ${line}.5h${n}`
735      isStarted = true
736      lastLine = line
737      lastAt = end
738      at = end
739    }
740  }
741  return d
742}
743
744/**
745 * A layer's pixels as SVG paths, a stroke a color. The colors go most-drawn
746 * first, and a run of one may cross pixels of colors drawn after it (they
747 * paint over it) and pixels outside the layer that every frame showing it
748 * draws in that color (the layer's `free`), so the big areas take long runs
749 * and the details sit on top. Each color takes whichever way, rows or
750 * columns, writes shorter.
751 */
752function layerPaths(layer: SvgLayer, w: number, h: number): string {
753  const { px, free } = layer
754  const counts = new Map<number, number>()
755  for (let i = 0; i < px.length; i += 1) {
756    const c = px[i] as number
757    if (c !== 0) counts.set(c, (counts.get(c) ?? 0) + 1)
758  }
759  const order = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0]).map(e => e[0])
760  const rank = new Map<number, number>()
761  order.forEach((c, r) => rank.set(c, r))
762  let out = ''
763  order.forEach((c, r) => {
764    const hex = COLOR_OF[c]
765    if (hex === undefined) return
766    const crosses = (p: number) => {
767      const q = px[p] as number
768      return q !== 0 ? (rank.get(q) as number) > r : free !== null && free[p] === c
769    }
770    const across = runData(px, w, h, c, false, crosses)
771    const down = runData(px, w, h, c, true, crosses)
772    out += `<path stroke="${hex}" d="${down.length < across.length ? down : across}"/>`
773  })
774  return out
775}
776
777/** One layer as markup: its paths, in a group shown over its spans by a discrete visibility animation unless it always shows. */
778function layerMarkup(layer: SvgLayer, w: number, h: number, total: number): string {
779  const paths = layerPaths(layer, w, h)
780  if (paths === '' || layer.spans === null) return paths
781  const isVisibleAtStart = layer.spans.some(([a]) => a <= 0)
782  const times: number[] = [0]
783  const values: string[] = [isVisibleAtStart ? 'visible' : 'hidden']
784  for (const [a, b] of layer.spans) {
785    if (a > 0) {
786      times.push(a / total)
787      values.push('visible')
788    }
789    if (b < total) {
790      times.push(b / total)
791      values.push('hidden')
792    }
793  }
794  const animate = `<animate attributeName="visibility" dur="${total}ms" repeatCount="indefinite" calcMode="discrete" keyTimes="${times.map(fmt).join(';')}" values="${values.join(';')}"/>`
795  return `<g visibility="${isVisibleAtStart ? 'visible' : 'hidden'}">${animate}${paths}</g>`
796}
797
798/** How many times a sprite's loop can be halved before it is a still. */
799function levelsOf(anim: SpriteAnim): number {
800  let n = anim.frames.length
801  let level = 0
802  while (n > 1) {
803    n = Math.ceil(n / 2)
804    level += 1
805  }
806  return level
807}
808
809const markupCaches = new WeakMap<SpriteTable, Map<string, string>>()
810
811/**
812 * A sprite's drawing at `level`: every frame at 0; at each level above, half
813 * as many (neighbours merged by mergeTimeline), down to a still of the first
814 * frame. Kept as markup only.
815 */
816function spriteMarkup(table: SpriteTable, id: string, level: number): string {
817  let cache = markupCaches.get(table)
818  if (cache === undefined) {
819    cache = new Map()
820    markupCaches.set(table, cache)
821  }
822  const key = `${id}@${level}`
823  const hit = cache.get(key)
824  if (hit !== undefined) return hit
825  const anim = table[id]
826  if (anim === undefined) return ''
827  let frames = decodeFrames(anim)
828  let durations = anim.frames.map(([ms]) => ms)
829  if (level > 0) ({ frames, durations } = mergeTimeline(frames, durations, Math.ceil(frames.length / 2 ** level)))
830  const { total, layers } = layerFrames(frames, durations, anim.w, anim.h)
831  const markup = layers.map(l => layerMarkup(l, anim.w, anim.h, total)).join('')
832  if (cache.size > 400) cache.clear()
833  cache.set(key, markup)
834  return markup
835}
836
837/** A drawing as made: its markup, and how far each part gave to fit (0 every frame; above, frames merged; -1 left out). */
838export type SvgDrawing = { svg: string; levels: number[] }
839
840const svgCache = new Map<string, SvgDrawing>()
841
842/**
843 * Sprites laid on one `w` x `h` pixel canvas as one SVG document, each
844 * `scale` CSS pixels a pixel, at most SVG_LIMIT characters: while it is
845 * longer, the largest part that can still lose frames loses half of them,
846 * and past a still of every part the largest part is left out. The ids are
847 * looked up in `table` (the exported sprites, unless a test brings its own).
848 */
849export function drawSvg(parts: readonly SpritePart[], w: number, h: number, scale: number, table: SpriteTable = SPRITES, stage?: StageLayer): SvgDrawing {
850  const stageKey = stage === undefined ? '' : `~${stage.color}${stage.isLit ? '+' : '-'}${stage.floor ?? h}`
851  const key = `${w}x${h}@${scale}${stageKey}:${parts.map(p => `${p.id}+${p.x}+${p.y}*${zoomOf(p)}${p.dim === true ? '~' : ''}`).join('|')}`
852  const hit = table === SPRITES ? svgCache.get(key) : undefined
853  if (hit !== undefined) return hit
854  const present = parts.filter(p => table[p.id] !== undefined)
855  const levels = present.map(() => 0)
856  const most = present.map(p => levelsOf(table[p.id] as SpriteAnim))
857  const defs = present.some(p => p.dim === true) ? `<defs><filter id="${DIM_FILTER}"><feColorMatrix type="saturate" values="0.2"/></filter></defs>` : ''
858  const back = (stage === undefined ? '' : stageMarkup(stage, w, h)) + defs
859  const wrap = (body: string) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w * scale}" height="${h * scale}" shape-rendering="crispEdges" fill="none" stroke-width="1">${back}${body}</svg>`
860  const placed = (p: SpritePart, level: number) => {
861    if (level < 0) return ''
862    const inner = spriteMarkup(table, p.id, level)
863    const z = zoomOf(p)
864    const moved = inner === '' || (p.x === 0 && p.y === 0 && z === 1) ? inner : `<g transform="translate(${p.x} ${p.y})${z === 1 ? '' : ` scale(${z})`}">${inner}</g>`
865    return p.dim === true && moved !== '' ? `<g opacity="0.35" filter="url(#${DIM_FILTER})">${moved}</g>` : moved
866  }
867  for (;;) {
868    const bodies = present.map((p, i) => placed(p, levels[i] as number))
869    const svg = wrap(bodies.join(''))
870    if (svg.length <= SVG_LIMIT) {
871      const drawing = { svg, levels }
872      if (table === SPRITES) {
873        if (svgCache.size > 200) svgCache.clear()
874        svgCache.set(key, drawing)
875      }
876      return drawing
877    }
878    // The largest part that can still give: frames first, the whole part last.
879    const largest = (canGive: (i: number) => boolean) => {
880      let pick = -1
881      bodies.forEach((body, i) => {
882        if (canGive(i) && (pick < 0 || body.length > (bodies[pick] as string).length)) pick = i
883      })
884      return pick
885    }
886    const merge = largest(i => (levels[i] as number) >= 0 && (levels[i] as number) < (most[i] as number))
887    if (merge >= 0) {
888      levels[merge] = (levels[merge] as number) + 1
889      continue
890    }
891    const drop = largest(i => (levels[i] as number) >= 0)
892    if (drop < 0) return { svg: wrap(''), levels }
893    levels[drop] = -1
894  }
895}
896
897/** Sprites laid on one `w` x `h` pixel canvas as one SVG document, each `scale` CSS pixels a pixel, over a spotlight when given (see drawSvg). */
898export function spritesSvg(parts: readonly SpritePart[], w: number, h: number, scale: number, stage?: StageLayer): string {
899  return drawSvg(parts, w, h, scale, SPRITES, stage).svg
900}
901
902/** Sprites fitted into a budget: their markup for a larger document, and how far each part gave (as drawSvg's levels). */
903export type FittedSprites = { markup: string; levels: number[] }
904
905const fitCache = new Map<string, FittedSprites>()
906
907/**
908 * Sprites as markup to place inside a larger SVG document (a scene with its
909 * own chrome), in one group carrying what the sprites' paths need (crisp
910 * edges, strokes, no fill), at most `budget` characters: while longer, the
911 * largest part that can still lose frames loses half of them, and past a
912 * still of every part the largest part is left out, as drawSvg does. A
913 * `dim` part brings its filter's definition with it. The scene places the
914 * group (a translate or a scale); the parts' x and y are pixels inside it.
915 */
916export function fitSprites(parts: readonly SpritePart[], budget: number, table: SpriteTable = SPRITES): FittedSprites {
917  const key = `${budget}:${parts.map(p => `${p.id}+${p.x}+${p.y}*${zoomOf(p)}${p.dim === true ? '~' : ''}`).join('|')}`
918  const hit = table === SPRITES ? fitCache.get(key) : undefined
919  if (hit !== undefined) return hit
920  const present = parts.filter(p => table[p.id] !== undefined)
921  const levels = present.map(() => 0)
922  const most = present.map(p => levelsOf(table[p.id] as SpriteAnim))
923  const defs = present.some(p => p.dim === true) ? `<defs><filter id="${DIM_FILTER}"><feColorMatrix type="saturate" values="0.2"/></filter></defs>` : ''
924  const wrap = (body: string) => `<g shape-rendering="crispEdges" fill="none" stroke-width="1">${defs}${body}</g>`
925  const placed = (p: SpritePart, level: number) => {
926    if (level < 0) return ''
927    const inner = spriteMarkup(table, p.id, level)
928    const z = zoomOf(p)
929    const moved = inner === '' || (p.x === 0 && p.y === 0 && z === 1) ? inner : `<g transform="translate(${p.x} ${p.y})${z === 1 ? '' : ` scale(${z})`}">${inner}</g>`
930    return p.dim === true && moved !== '' ? `<g opacity="0.35" filter="url(#${DIM_FILTER})">${moved}</g>` : moved
931  }
932  for (;;) {
933    const bodies = present.map((p, i) => placed(p, levels[i] as number))
934    const markup = wrap(bodies.join(''))
935    if (markup.length <= budget) {
936      const fitted = { markup, levels }
937      if (table === SPRITES) {
938        if (fitCache.size > 200) fitCache.clear()
939        fitCache.set(key, fitted)
940      }
941      return fitted
942    }
943    const largest = (canGive: (i: number) => boolean) => {
944      let pick = -1
945      bodies.forEach((body, i) => {
946        if (canGive(i) && (pick < 0 || body.length > (bodies[pick] as string).length)) pick = i
947      })
948      return pick
949    }
950    const merge = largest(i => (levels[i] as number) >= 0 && (levels[i] as number) < (most[i] as number))
951    if (merge >= 0) {
952      levels[merge] = (levels[merge] as number) + 1
953      continue
954    }
955    const drop = largest(i => (levels[i] as number) >= 0)
956    if (drop < 0) return { markup: wrap(''), levels }
957    levels[drop] = -1
958  }
959}
960
961const DIM_FILTER = 'kessoku-dim'
962
963/** The gradient's id for a stage layer: its color and whether it is lit, so two slots in one document never share one. */
964export function stageGradientId(stage: StageLayer): string {
965  return `kessoku-stage-${stage.color.replace(/[^0-9a-z]/gi, '')}-${stage.isLit ? 'lit' : 'rest'}`
966}
967
968/**
969 * The spotlight's markup, drawn before the sprites: the cone from the top's
970 * middle sixth to the full width at the floor, filled with a vertical gradient
971 * of the color (0.55 to 0.10 lit, 0.12 to 0.02 at rest), and the pool on the
972 * floor (0.35 lit, 0.08 at rest).
973 */
974export function stageMarkup(stage: StageLayer, w: number, h: number): string {
975  const floor = Math.max(1, Math.min(h, stage.floor ?? h))
976  const id = stageGradientId(stage)
977  const [top, bottom, pool] = stage.isLit ? [0.55, 0.1, 0.35] : [0.12, 0.02, 0.08]
978  const fmt = (n: number) => String(Math.round(n * 100) / 100)
979  const gradient = `<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${stage.color}" stop-opacity="${top}"/><stop offset="1" stop-color="${stage.color}" stop-opacity="${bottom}"/></linearGradient>`
980  const cone = `<polygon points="${fmt((w * 5) / 12)},0 ${fmt((w * 7) / 12)},0 ${w},${floor} 0,${floor}" fill="url(#${id})"/>`
981  const ellipse = `<ellipse cx="${fmt(w / 2)}" cy="${floor}" rx="${fmt(w / 2)}" ry="${fmt(Math.max(2, floor / 16))}" fill="${stage.color}" opacity="${pool}"/>`
982  return `<defs>${gradient}</defs>${cone}${ellipse}`
983}
984
hooks/lib/tree.ts 150 lines
1// Kessoku Ultimate Bocchi Theme: the file tree's shape.
2//
3// Pure: which folders stay out, how paths become rows, and the colour of a
4// light by its age. Paths are compared with forward slashes; a Windows drive
5// letter makes the comparison case-insensitive, as the file system is.
6
7import type { KessokuLight } from '../../types'
8
9/** Folders the tree never shows, even when git tracks files under them. */
10export const ALWAYS_IGNORED = new Set(['.git', 'node_modules', '.claude-plugin'])
11
12/** Folders the fallback walk (no git) stays out of: build output, caches, editors. */
13export const IGNORED_DIRS = new Set([
14  ...ALWAYS_IGNORED, 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.cache', 'coverage',
15  'target', '__pycache__', '.venv', 'venv', '.tox', '.mypy_cache', '.pytest_cache', '.idea', '.vscode',
16  '.gradle', 'Pods', 'DerivedData', '.terraform', 'vendor', 'bin', 'obj', '.svn', '.hg', '.yarn',
17  '.pnpm-store', '.svelte-kit', '.angular', '.parcel-cache', '.dart_tool',
18])
19
20export const MAX_PATHS = 600
21
22/** Forward slashes, `\\?\` prefixes dropped, `.` and `..` folded, no trailing slash. */
23function norm(path: string): string {
24  let p = path.replace(/\\/g, '/').replace(/^\/\/\?\/UNC\//i, '//').replace(/^\/\/\?\//, '')
25  const unc = p.startsWith('//')
26  const drive = /^[A-Za-z]:/.exec(p)?.[0] ?? ''
27  const rooted = p.startsWith('/') || drive !== ''
28  const parts: string[] = []
29  for (const seg of p.slice(drive.length).split('/')) {
30    if (seg === '' || seg === '.') continue
31    if (seg === '..') {
32      if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
33      else if (!rooted) parts.push('..')
34      continue
35    }
36    parts.push(seg)
37  }
38  p = `${drive}${unc ? '//' : rooted ? '/' : ''}${parts.join('/')}`
39  return p === '' ? '.' : p
40}
41
42/** A drive-letter or UNC path: Windows compares them without regard to case. */
43function isWindowsPath(path: string): boolean {
44  return /^[A-Za-z]:\//.test(path) || /^\/\/[^/]/.test(path)
45}
46
47/** A path relative to the project root, forward slashes; the path itself when it is outside. */
48export function relPath(root: string, path: string): string {
49  const p = norm(path)
50  const r = norm(root)
51  if (r === '') return p
52  const ci = isWindowsPath(r) || isWindowsPath(p)
53  const under = ci ? p.toLowerCase().startsWith(r.toLowerCase() + '/') : p.startsWith(r + '/')
54  if (under) return p.slice(r.length + 1)
55  if (ci ? p.toLowerCase() === r.toLowerCase() : p === r) return ''
56  return p
57}
58
59/** Whether `path` (absolute or already relative) lies under the root. */
60export function isInside(root: string, path: string): boolean {
61  const rel = relPath(root, path)
62  if (rel === '' || rel.startsWith('..')) return false
63  return !rel.startsWith('/') && !isWindowsPath(rel) && !/^[A-Za-z]:/.test(rel)
64}
65
66/** The last segment of a root path, for a header. */
67export function baseName(path: string): string {
68  const parts = norm(path).split('/').filter(Boolean)
69  return parts[parts.length - 1] ?? path
70}
71
72export type TreeRow = { key: string; depth: number; name: string; isDir: boolean; path: string }
73
74let rowsCache: { paths: readonly string[]; rows: TreeRow[] } | null = null
75
76/** Rows for a sorted list of file paths: folders first at each level, files under them. Memoised on the list's identity. */
77export function treeRows(paths: readonly string[]): TreeRow[] {
78  if (rowsCache !== null && rowsCache.paths === paths) return rowsCache.rows
79  const sorted = [...paths].sort(comparePaths)
80  const rows: TreeRow[] = []
81  const seen = new Set<string>()
82  for (const path of sorted) {
83    const parts = path.split('/')
84    for (let d = 0; d < parts.length - 1; d += 1) {
85      const dir = parts.slice(0, d + 1).join('/')
86      if (seen.has(dir)) continue
87      seen.add(dir)
88      rows.push({ key: `d:${dir}`, depth: d, name: `${parts[d]}/`, isDir: true, path: dir })
89    }
90    rows.push({ key: `f:${path}`, depth: parts.length - 1, name: parts[parts.length - 1] as string, isDir: false, path })
91  }
92  rowsCache = { paths, rows }
93  return rows
94}
95
96function comparePaths(a: string, b: string): number {
97  const pa = a.split('/')
98  const pb = b.split('/')
99  const n = Math.min(pa.length, pb.length)
100  for (let i = 0; i < n; i += 1) {
101    const aIsLeaf = i === pa.length - 1
102    const bIsLeaf = i === pb.length - 1
103    if (aIsLeaf !== bIsLeaf) return aIsLeaf ? 1 : -1 // folders before files
104    if (pa[i] !== pb[i]) return (pa[i] as string).localeCompare(pb[i] as string)
105  }
106  return pa.length - pb.length
107}
108
109/** How long an `editing` light may wait for the call that finishes it before it fades on its own. */
110export const EDITING_MAX_MS = 100_000
111/** How long an edited file glows pink after the edit. */
112export const EDIT_GLOW_MS = 25_000
113/** How long an edited file stays marked (dimly, once the glow is over) so a later commit can light it green. */
114export const EDITED_KEEP_MS = 6 * 60 * 60_000
115export const READ_GLOW_MS = 8_000
116export const COMMIT_GLOW_MS = 90_000
117
118/** Whether a light still shows at `now`: glowing, or remembered as edited and waiting for a commit. */
119export function isLit(light: KessokuLight, now: number): boolean {
120  const age = now - light.at
121  if (light.kind === 'editing') return age < EDITING_MAX_MS
122  if (light.kind === 'edited') return age < EDITED_KEEP_MS
123  if (light.kind === 'read') return age < READ_GLOW_MS
124  return age < COMMIT_GLOW_MS
125}
126
127/** Whether a light is in its bright phase (the first seconds), as against merely remembered. */
128export function isGlowing(light: KessokuLight, now: number): boolean {
129  const age = now - light.at
130  if (light.kind === 'editing') return age < EDITING_MAX_MS
131  if (light.kind === 'edited') return age < EDIT_GLOW_MS
132  return isLit(light, now)
133}
134
135/** Whether any file is being edited right now, which is the one state that animates. */
136export function isAnyHot(lights: readonly KessokuLight[], now: number): boolean {
137  return lights.some(l => l.kind === 'editing' && isLit(l, now))
138}
139
140/** The rows to show: a window of `rows` around the newest light, or the top. */
141export function windowRows<T extends { path: string }>(rows: readonly T[], focusPath: string | null, count: number): { rows: T[]; above: number; below: number } {
142  if (rows.length <= count) return { rows: [...rows], above: 0, below: 0 }
143  let start = 0
144  if (focusPath !== null) {
145    const at = rows.findIndex(r => r.path === focusPath)
146    if (at >= 0) start = Math.max(0, Math.min(rows.length - count, at - Math.floor(count / 2)))
147  }
148  return { rows: rows.slice(start, start + count), above: start, below: rows.length - start - count }
149}
150