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…

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 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.
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:
/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./bocchi theme on. The mod also offers it once, on first run.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.
set 3).✦ encore! on the desktop, a bold line in her colour in a terminal.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
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:
How to put it on and take it off:
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./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.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:
♪ on folded tool groups (accent while active, rose at rest)♪ /<command> <args> (red on an error)Nijika asks the band, with her head, over the question dialoglight keeps to the small lines:
♪ while a tool runs, ♫ while Claude respondsTuning 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 modesEncored 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 turnslet Ryo take it backstage in place of Claude Code's run in background hint♪ before info noticesnone leaves all of these alone.
With Tool row cards on all, every tool row gets a track header:
♪ 03 · Ryo Read · hooks/register.tsx 0:01
playing while it runs, cut off when interrupted, wrong note in red on an error, else the call's length.♫: 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.♫ setlist · <file> card with Kita's verdict: Kita approves · +12 -3, or Kita missed a chord.♪ 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:
| Member | Tools |
|---|---|
| Nijika | Agent, Workflow, the Task tools, TodoWrite, AskUserQuestion, Skill, plan mode, ScheduleWakeup, SendMessage, ListAgents, the memo tool |
| Ryo | Grep, Glob, Bash, PowerShell, Read, WebSearch, WebFetch, ToolSearch, Monitor, LSP, MCP resources, worktrees, every mcp__ tool |
| Kita | Write, Edit, NotebookEdit, the Artifact tools, SendUserFile, DesignSync, ClaudeDesign |
| Bocchi | everything else |
<face> bocchi · <what she is doing>. See Moods.claude, follows the active theme.| Member | Plays | On the panel | Colour |
|---|---|---|---|
| Bocchi (Hitori Gotoh) | lead guitar | main agent · heavy tasks | pink |
| Nijika (Nijika Ijichi) | drums | coordinator · scheduling | yellow |
| Ryo (Ryo Yamada) | bass | background · refactors · db | blue |
| Kita (Ikuyo Kita) | vocals & rhythm | ui · frontend · docs | coral |
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:
kaomoji turns every picture off: text faces everywhere, no 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.
| State | When | Looks |
|---|---|---|
| active | the act runs | bounces (Bocchi's wide-eyed) |
| idle | pending or waiting | breathes |
| asleep | completed, failed or stopped | breathes 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).
Bocchi's mood shows on the status line, on stage and across the panel.
| Mood | Face | Her loop | When |
|---|---|---|---|
| anxious | ` (;´Д) `` | a breathing idle with nervous glances | idle |
| focused | ( •_•)♪ | strumming, notes rising | a turn is running |
| happy | (≧▽≦) | a jump with sparkles | 6 s after a turn ends with an answer: nailed it! 2:14 · 3 edits on the setlist (/replay) |
| panic | (゚Д゚;) | shaking, sweat flying | 8 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 melt | context at 85 % or more; while the conversation is compacted, by /compact or automatically |
| hiding | [(._.)] | peeking out of a cardboard box | 8 s after you interrupt a turn; 6 s after a call is refused; while the cache sits cold between turns |
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.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./bocchi mood <mood> tries a face on the status line for 15 s.The other three take their mood from their acts:
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 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.
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.
Kessoku Band sign with its star, and a LIVE/OFF 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.+n badge. A failure from this set stays on stage under a red ✗ bubble; an encore wears a sparkle.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:
♪ playing, ⧗ waiting, ✓ done (with a sparkle through its encore), ✗ failed, — stopped0:48 while it plays, ran 2:40 once it endednow Grep or last Readmain loop; before the first set it reads the main conversationThe 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.
Top to bottom:
♪ 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.▔ 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.+n. Under it, each member's initial with a numbered button per act (1, 2, …) that solos her with that act first.b n r k), and her state: playing, head down, 2 playing, set done or her mood linectx ▰▰▱▱▱▱ 34% with its tokens in and out♪ 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! · 2:40 · ≈$0.11 at the right. A failed one reads wrong note · 2:40 in red.solo b n r k, a mix button (m) on a solo, and Hide (h) above the prompt.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.
One member alone, every act in full.
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.m (Back to the mix, mix), /bocchi solo off, or /clear.Ryo’s page with bass · background · refactors · db, a summary (3 acts · $0.31 · 1 playing · 2 done) and her portrait as a 2x polaroidalso tonight at the foot, with how the others are doingencore! 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>r or m: back to the mix (her own letter) to say the way outThe 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.
| Type | For | Tools | Gives back |
|---|---|---|---|
kessoku-ultimate-bocchi:nijika | plans, reviews, triage, summaries | Read, Grep, Glob, WebSearch, WebFetch | a numbered plan or a verdict with path:line; edits nothing, runs nothing |
kessoku-ultimate-bocchi:ryo | searches, shells, tests, lint, type checks, "find where X happens" | Read, Grep, Glob, Bash, PowerShell, WebSearch, WebFetch | short lines with path:line |
| kessoku-ultimate-bocchi:kita | edits to named files: docs, README sections, UI copy, comments | Read, Edit, Write | every file
hooks/register.tsx 3810 lines1// 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 lines1// 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}
408hooks/lib/band.ts 342 lines1// 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}
342hooks/lib/blast.ts 754 lines1// 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}
754hooks/lib/diff.ts 206 lines1// 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}
206hooks/lib/engine.ts 59 lines1// 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}
59hooks/lib/reflect.ts 180 lines1// 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}
180hooks/lib/typeahead.ts 101 lines1// 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}
101hooks/lib/scene.ts 2887 lines1// 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, '&')
454 .replace(/</g, '<')
455 .replace(/>/g, '>')
456 .replace(/"/g, '"')
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 lines1// 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}
331hooks/lib/sprite.ts 984 lines1// 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}
984hooks/lib/tree.ts 150 lines1// 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