A file tree of the working directory in a pane beside the transcript, with folders you open and close and the files Claude just changed marked

Part of my portfolio.
Mods for Claude Code that change what it does and what it shows. Each one is a hooks module: it runs inside Claude Code, hooks prompts, tool calls and turns, and draws a line above the prompt or a pane beside the transcript. The five here are deviation-kernel, cache-meter, goal-board, file-tree and idea-agents. All five ship with tests that run without a live session.
| Mod | What it does | Costs anything? |
|---|---|---|
| deviation-kernel | Keeps the assumptions your project refuses in Claude's system prompt on every request, and catches an edit that brings one back at the step where it lands. | One small Haiku call after each long answer (can be switched off) |
| cache-meter | Shows how long the prompt cache stays warm, what a miss would cost to rewrite, how full the context is, and the session's cost at API rates. | No |
| goal-board | Shows each session's goal, task progress and ETA, and /goals lists every running session on the machine. | No |
| file-tree | Shows the working directory as a file tree in a pane on the right, with folders you open and close and the files Claude just changed marked. | No |
| idea-agents | Gives a project six exploration agents (three that explore ideas, a lazy "trogg" for each) and one ideas/ folder per run, and tells Claude how to run a round. | Only when you run a round: the agents' own model use (the troggs run on Haiku) |
Install any one of them on its own.
Paste this into Claude Code, in a terminal session:
Set up mods from the GitHub repo Spendry/claude-code-mods for me. Work through these steps and stop where a step says to ask me.
1. Run `claude --version`. The mods need 2.1.287 or later; if it's older, tell me how to update and stop.
2. Show me the table of mods from the repo README (https://github.com/Spendry/claude-code-mods) and ask which ones I want. Don't pick for me.
3. Clone https://github.com/Spendry/claude-code-mods into a temporary folder. For each mod I chose, run `claude plugin validate <that folder>/<mod>`, read its `hooks/register.ts`, and tell me in plain words what it can touch (the `calls:` line lists it). Ask me whether to go ahead.
4. Run `claude plugin marketplace add Spendry/claude-code-mods`, then install each mod I approved with `claude plugin install <mod>@claude-code-mods --scope user`. A note that options are not yet set is expected; the defaults apply until I change them. Delete the temporary clone.
5. Run `claude plugin list` and confirm each one shows as enabled.
6. If I chose deviation-kernel: ask which project it's for, and interview me there for the assumptions that project refuses on purpose. Do not suggest refusals of your own; the point of the kernel is that they come from me. For each one I give you, help me word it, propose tells (words or /regex/ that would show up when it's broken) and an "instead:", then show me the KERNEL.md and write it to the project root only when I say so. Then ask which mode I want (ask, warn or block), explain the difference in one line each, and tell me to type `/kernel mode <mode>` in that project's session to set it.
7. If I chose cache-meter: ask whether I pay API rates or use a subscription, and explain that on a subscription its dollar figures are the API-equivalent, not my bill.
8. Finish with a short list of what I still have to do myself (from the "What only you can do" section of the README).
Claude runs the install. It can't decide your project's refusals or see the bands for you; that part stays yours.
KERNEL.md. Claude can help you word a refusal and suggest tells, but which assumptions to refuse is your design decision. Start with two or three. You can add more later with /kernel add.## Exempt in KERNEL.md (for example - ideas/**) so the guard skips them; code everywhere else stays checked./kernel log shows what tripped and what you decided./goals and /cache answer in text instead.cache-meter/hooks/register.ts with the date they were checked. A model not in the table is priced at the first row's rates and shown with ≈.At the prompt of a Claude Code terminal session, one line per mod:
/plugin install deviation-kernel --marketplace Spendry/claude-code-mods
/plugin install cache-meter --marketplace Spendry/claude-code-mods
/plugin install goal-board --marketplace Spendry/claude-code-mods
/plugin install file-tree --marketplace Spendry/claude-code-mods
/plugin install idea-agents --marketplace Spendry/claude-code-mods
Answer y to add the marketplace, then pick the user scope so the mod loads in every session. A mod with options shows a screen to set them. /plugin lists what's active.
To turn every mod off for one session: claude --safe-mode. To update: claude plugin update <mod>@claude-code-mods, then /reload-plugins.
Built and tested against Claude Code 2.1.296; needs 2.1.287 or later.
What it guarantees: the project's refused assumptions are in Claude's system prompt on every request, with nothing to paste and nothing lost to compaction. An edit that brings one back is caught at that step.
The problem it answers: a design that refuses a standard assumption tends to lose that refusal while the ordinary parts get filled in. Claude reaches for the conventional component in each slot, and the conventional component carries the assumption the design rejected.
Setup. Put a KERNEL.md at the project root (or .claude/KERNEL.md). /kernel init writes a template; KERNEL.example.md shows a filled-in one. Each refusal is one line:
- R1: one global clock paces every stage | tells: global clock, clock tree, /clk_?en/ | instead: each stage hands off by local request/acknowledge
In a folder with no kernel, the first prompt you type there asks whether Claude should help you write one: now, after that message, not this session, or never in that folder. Mid-project, Claude reads the project's own record first so its questions point at what you set out to do, then asks you for the refusals (it proposes none of its own) and writes KERNEL.md only when you say so. Your home folder is never asked. Turn the offer off with the offerKernel option.
What happens.
ask (default): holds the edit and asks you. Refusing sends Claude the refusal and its instead:.warn: lets it through and flags it to Claude with your next prompt.block: refuses the edit outright.drop in the band if a flag is wrong. Turn this off with the semanticCheck option if you don't want the extra calls.Commands. /kernel opens the kernel and recent trips · /kernel init · /kernel add <text> | tells: a, b | instead: ... · /kernel mode ask|warn|block · /kernel check (a full audit of the conversation so far, served mostly from cache) · /kernel log · /kernel reload.
Limits. Tells are matched as whole words or regexes, so a tell also fires on a line that rejects the assumption ("no central scheduler"). The semantic check tells the two apart; the keyword check does not. The kernel file is never edited by Claude unless you ask.
What it guarantees: you can see how long the cached prefix has left, what a miss would cost to rewrite, context fill and session cost, without asking.
cache ▰▰▰▰▱▱▱▱ 2:41 · miss $0.50 · ctx 84k/200k 42% · session $4.10 API
/cache gives the full breakdown, including the hit and miss price of the next request.cacheTtl option defaults to auto. It assumes the 5-minute cache until a request reads from cache after more than five idle minutes, which only the 1-hour cache can serve. It then switches to 1h, toasts once, and remembers that for later sessions. The rewrite price changes with it (1.25x input for 5m, 2x for 1h). Set 5m or 1h to fix it.hooks/register.ts. On a subscription the dollar figures are what the same work would cost at API rates, not what you're billed.What it guarantees: each session shows its goal, task progress and ETA, and one command shows every running session on this machine.
/goal <text>. /goal clear resets it./goals opens the board of all sessions: working, idle or done, the current task, and the ETA.What it guarantees: you can see the project's files while Claude works on them, and which ones it has changed this session.
╭──────────────────────────────────────────╮
│ work ‹ up home refresh collapse │
│ ──────────────────────────────────────── │
│ ├─ ▸ notes │
│ └─ ▾ relay · here │
│ ├─ ▾ src │
│ │ ├─ ● main.ts │
│ │ └─ util.ts │
│ └─ README.md │
│ │
│ 1 changed · 1 up from relay │
╰──────────────────────────────────────────╯
/files opens it (above the prompt on the main screen). /files again closes it.r refreshes and c collapses every folder.‹ up (or u) moves the tree's root up one folder per press, like cd .., until it reaches the drive or filesystem root. The working folder stays open under its parent and is marked · here. home (or h) puts the root back on the working folder. Only the view moves; Claude's working directory stays where it was.background is the hex color painted behind the tree; the default #0f0f0f is half of Windows Terminal's Dark+ background. Set it to half of your own terminal's background, or leave it empty to keep the terminal's. coverFrame (on by default) draws the orange border over Claude Code's own square pane frame, so the side facing the conversation is orange; Claude Code still shows its thin grey line along the top. Turn it off to draw the border just inside the frame instead.…: a file or folder name keeps its start, the path in the footer keeps its end.●. A folder holding one has its name in green, so a change inside a closed folder still shows.duel option..git, node_modules, __pycache__, .venv, venv and .next are left out. A tree is cut at 1,500 rows; close a folder to see the rest.What it guarantees: a project can get a second set of eyes on a stuck problem, from agents that propose and size ideas but never touch its code, with every run kept in its own folder you can read later.
premise-breaker lists the assumptions under a problem and flips each one. field-crosser brings mechanisms from other fields, at least half outside computing. idea-sizer checks the ideas against numbers the project already measured and gives each a kill test. Each has a lazy helper on Haiku, a "trogg": Skip asks "why do it at all?", Borrow "what already does this?", Poke "what's the dumbest quick try?". The upper agents read their trogg's note and say in one line what they took..claude/agents/. It never overwrites an agent the project already has. Then it asks whether Claude should tailor them to the project (its building blocks, gates, results files); Claude shows each change and writes it only when you say so. Home folders and folders you said never for are skipped./ideas new <topic> makes ideas/<date>-<nn>-<topic>/ (or under research/ideas/ if the project keeps research apart, or .claude/agents/ideas/ if that folder exists, to keep runs beside the agents) with a run card (README.md: question, read order, outcome, decisions) and 0-data.md to fill. The agents write 1-premises.md, 2-fields.md, 3-sizing.md and their lazy notes next to it; the main session writes 4-result.md./ideas shows what's installed and the latest run. /ideas install adds missing agents. /ideas tailor starts the tailoring.Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.ts (or .tsx for a mod that draws a pane) and tests/. The marketplace file at .claude-plugin/marketplace.json lists them.
claude plugin validate <mod folder> reports what the mod hooks and calls, and anything the engine would refuse.claude plugin test <mod folder> runs its tests without a session.claude plugin marketplace add <path to clone>) and install from it. A folder marketplace is read from the folder itself, so after an edit /reload-plugins picks up the change with no reinstall.A new mod is a new folder plus one entry in marketplace.json.
MIT. See LICENSE.
hooks/register.tsx 325 lines1import { atom, read, update } from 'claude-code'
2import type { Engine, FsEntry, Register } from 'claude-code'
3
4import type { Changed } from '../types'
5import { advance, beatCells, createPlayer, DUEL_ROWS, parseColor } from './duel'
6
7const PANE = 'file-tree'
8const SKIP = new Set(['.git', 'node_modules', '__pycache__', '.venv', 'venv', '.next', '.DS_Store', 'Thumbs.db'])
9// Tools that can change what is on disk; a read-only call leaves the tree alone.
10const WRITERS = new Set(['Write', 'Edit', 'NotebookEdit', 'Bash', 'PowerShell'])
11// Rows past this are not listed, so a huge opened folder cannot stall a draw.
12const MAX_ROWS = 1500
13const KEEP_CHANGED = 50
14// The duel's frame time: slow enough to stay in the corner of the eye.
15const DUEL_MS = 250
16
17// Every path the mod keeps is absolute with '/' separators, so the tree's root can move above the working folder.
18const expanded = atom({ plugin: 'file-tree', key: 'expanded' } as const, [])
19const changed = atom({ plugin: 'file-tree', key: 'changed' } as const, [])
20const rev = atom({ plugin: 'file-tree', key: 'rev' } as const, 0)
21const up = atom({ plugin: 'file-tree', key: 'up' } as const, 0)
22
23/** `guide` is the tree's line art before the name: '│ ' per open ancestor, then '├─ ' or '└─ '. */
24type Row = { path: string; name: string; isDir: boolean; guide: string }
25
26export function slashes(path: string): string {
27 const flat = path.replace(/\\/g, '/')
28 return flat === '/' ? flat : flat.replace(/\/+$/, '')
29}
30
31/** The path made absolute against cwd, with '/' separators. */
32export function absolute(cwd: string, path: string): string {
33 const flat = slashes(path)
34 if (/^([A-Za-z]:)?\//.test(flat)) return flat
35 return `${slashes(cwd)}/${flat.replace(/^\.\//, '')}`
36}
37
38/** Windows paths compare without case; elsewhere this costs nothing in practice. */
39function same(a: string, b: string): boolean {
40 return a.toLowerCase() === b.toLowerCase()
41}
42
43/** The folder `levels` above cwd, stopping at the drive or filesystem root. */
44export function rootAbove(cwd: string, levels: number): string {
45 const parts = slashes(cwd).split('/')
46 const keep = Math.max(1, parts.length - levels)
47 const root = parts.slice(0, keep).join('/')
48 // 'C:' alone means the drive's current folder; '' is the POSIX root.
49 return root === '' || /^[A-Za-z]:$/.test(root) ? `${root}/` : root
50}
51
52/** How many levels cwd can go up before it reaches the root. */
53export function maxUp(cwd: string): number {
54 return slashes(cwd).split('/').filter(Boolean).length - 1
55}
56
57/** Cuts text to `width` cells: a name keeps its head, a path keeps its tail, '…' marking the cut. */
58export function fit(text: string, width: number, keep: 'head' | 'tail' = 'head'): string {
59 if (width <= 0) return ''
60 if (text.length <= width) return text
61 if (width === 1) return '…'
62 return keep === 'head' ? `${text.slice(0, width - 1)}…` : `…${text.slice(text.length - width + 1)}`
63}
64
65function join(dir: string, name: string): string {
66 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
67}
68
69/** Each folder below `root` down to `path`, `path` first. */
70function chain(root: string, path: string): string[] {
71 const out: string[] = []
72 let at = path
73 while (at.length > root.replace(/\/$/, '').length && at.startsWith(root.replace(/\/$/, ''))) {
74 out.push(at)
75 const cut = at.lastIndexOf('/')
76 if (cut <= 0) break
77 at = at.slice(0, cut)
78 }
79 return out
80}
81
82export function sortEntries(entries: readonly FsEntry[]): FsEntry[] {
83 return entries
84 .filter(entry => !SKIP.has(entry.name))
85 .sort((a, b) => {
86 const isDirA = a.kind === 'dir'
87 const isDirB = b.kind === 'dir'
88 if (isDirA !== isDirB) return isDirA ? -1 : 1
89 return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
90 })
91}
92
93async function walk($: Engine, root: string, isOpen: (path: string) => boolean): Promise<Row[]> {
94 const rows: Row[] = []
95 const visit = async (dir: string, lead: string) => {
96 const entries = sortEntries(await $.fs.list(dir).catch(() => [] as FsEntry[]))
97 for (const [i, entry] of entries.entries()) {
98 if (rows.length >= MAX_ROWS) return
99 const isLast = i === entries.length - 1
100 const path = join(dir, entry.name)
101 const isDir = entry.kind === 'dir'
102 rows.push({ path, name: entry.name, isDir, guide: lead + (isLast ? '└─ ' : '├─ ') })
103 if (isDir && isOpen(path)) await visit(path, lead + (isLast ? ' ' : '│ '))
104 }
105 }
106 await visit(root, '')
107 return rows
108}
109
110/** Moves the tree's root to `levels` above the working folder, opening the way down to it. */
111async function goUp($: Engine, levels: number) {
112 const cwd = slashes(await $.session.cwd())
113 const next = Math.max(0, Math.min(levels, maxUp(cwd)))
114 const way = chain(rootAbove(cwd, next), cwd)
115 await update($, expanded, list => {
116 const now = list ?? []
117 return [...now, ...way.filter(one => !now.some(have => same(have, one)))]
118 })
119 await update($, up, () => next)
120}
121
122async function toggle($: Engine) {
123 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
124 if (isUp) {
125 await $.ui.close({ id: PANE })
126 return false
127 }
128 await $.ui.open({ id: PANE, title: 'Files' })
129 return true
130}
131
132export const register: Register = (on, options) => {
133 const configured = (options as { background?: string; coverFrame?: boolean; duel?: boolean }) ?? {}
134 const background = configured.background?.trim() || undefined
135 const coverFrame = configured.coverFrame !== false
136 const isDuelOn = configured.duel !== false
137 const duelBackground = parseColor(background)
138 // Animation bookkeeping only; a reload starting these over just restarts the fight.
139 const player = createPlayer()
140 let isWorking = false
141 let duelColumns = 0
142
143 on('session.start', async ($, e, next) => {
144 await $.command.register({
145 name: 'files',
146 description: 'Show or hide the file tree pane',
147 })
148 void $.ui.open({ id: PANE, title: 'Files' })
149 if (isDuelOn) {
150 // Repaints only the duel's cells; the tree is not drawn again. A blit with nothing mounted is refused, harmlessly.
151 $.clock.every(DUEL_MS, () => {
152 const beat = advance(player, isWorking ? 'fight' : 'calm')
153 if (duelColumns > 0)
154 void $.ui.blit({ requestId: PANE, key: 'duel', cells: beatCells(beat, duelColumns, duelBackground) }).catch(() => {})
155 })
156 }
157
158 return next(e)
159 })
160
161 // The duel fences while Claude works and meditates between turns.
162 on('turn.start', async ($, e, next) => {
163 isWorking = true
164 return next(e)
165 })
166
167 on('turn.complete', async ($, e, next) => {
168 isWorking = false
169 return next(e)
170 })
171
172 on('turn.abort', async ($, e, next) => {
173 isWorking = false
174 return next(e)
175 })
176
177 on('command.run', { command: 'files' }, async $ => {
178 const isOpen = await toggle($)
179
180 return { text: isOpen ? 'File tree opened.' : 'File tree closed.' }
181 })
182
183 on('tool.call', async ($, e, next) => {
184 const ran = await next(e)
185 if (!WRITERS.has(e.tool)) return ran
186
187 // Bookkeeping for the drawing only: a failure here must never fail the tool call.
188 try {
189 const target =
190 e.tool === 'Write' || e.tool === 'Edit' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : undefined
191 if (target !== undefined) {
192 const path = absolute(await $.session.cwd(), target)
193 const at = Date.now()
194 await update($, changed, list =>
195 [...(list ?? []).filter(one => !same(one.path, path)), { path, at } satisfies Changed].slice(-KEEP_CHANGED),
196 )
197 }
198 await update($, rev, n => (n ?? 0) + 1)
199 } catch {}
200
201 return ran
202 })
203
204 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
205 const { Box, Text, Button } = $.ui.resolve(e)
206 await read($, rev)
207 const cwd = slashes(await $.session.cwd())
208 const levels = Math.min(await read($, up), maxUp(cwd))
209 const root = rootAbove(cwd, levels)
210 const open = new Set((await read($, expanded)).map(one => one.toLowerCase()))
211 const touched = new Set((await read($, changed)).map(one => one.path.toLowerCase()))
212 // A folder holding a changed file is marked too, so a change inside a closed folder still shows.
213 const touchedDirs = new Set<string>()
214 for (const path of touched) for (const dir of chain(root.toLowerCase(), path).slice(1)) touchedDirs.add(dir)
215
216 const rows = await walk($, root, path => open.has(path.toLowerCase()))
217 const rootName = root.replace(/\/$/, '').split('/').pop() || root
218 const here = cwd.split('/').pop() || cwd
219
220 const flip = (path: string) =>
221 update($, expanded, list => {
222 const now = list ?? []
223 const key = path.toLowerCase()
224 const isOpen = now.some(one => one.toLowerCase() === key)
225 return isOpen
226 ? now.filter(one => !(one.toLowerCase() === key || one.toLowerCase().startsWith(key + '/')))
227 : [...now, path]
228 })
229
230 // Kept dark so it sits behind the transcript: a muted orange frame on a darker ground, every
231 // name dimmed, and only a changed file in color. The engine's own square frame is outside this one.
232 const columns = e.props.bodyColumns ?? 40
233 const tall = Math.max(6, e.props.scroll?.bodyRows ?? 0)
234 // Over the frame, the border sits one cell outside the body and the text gets those cells back.
235 const inner = Math.max(10, columns - (coverFrame ? 2 : 4))
236 const count = touched.size > 0 ? `${touched.size} changed · ` : ''
237 const where = levels > 0 ? `${levels} up from ${here}` : root
238 return (
239 <Box
240 flexDirection="column"
241 borderStyle={coverFrame ? undefined : 'round'}
242 borderColor={coverFrame ? undefined : 'claude'}
243 borderDimColor={!coverFrame}
244 backgroundColor={background}
245 paddingX={1}
246 minHeight={tall}
247 >
248 {coverFrame && (
249 <Box
250 position="absolute"
251 top={-1}
252 left={-1}
253 width={columns + 2}
254 height={tall + 2}
255 borderStyle="round"
256 borderColor="claude"
257 borderDimColor
258 />
259 )}
260 <Box flexDirection="row" justifyContent="space-between">
261 <Text color="claude" dimColor>
262 {fit(rootName, Math.max(4, inner - 34))}
263 </Text>
264 <Box flexDirection="row" gap={1}>
265 <Button key="up" plain hotkey="u" onPress={() => goUp($, levels + 1)}>
266 <Text color={levels >= maxUp(cwd) ? 'inactive' : 'subtle'} dimColor>‹ up</Text>
267 </Button>
268 <Button key="home" plain hotkey="h" onPress={() => update($, up, () => 0)}>
269 <Text color={levels === 0 ? 'inactive' : 'subtle'} dimColor>home</Text>
270 </Button>
271 <Button key="refresh" plain hotkey="r" onPress={() => update($, rev, n => (n ?? 0) + 1)}>
272 <Text color="subtle" dimColor>refresh</Text>
273 </Button>
274 <Button key="collapse" plain hotkey="c" onPress={() => update($, expanded, () => [])}>
275 <Text color="subtle" dimColor>collapse</Text>
276 </Button>
277 </Box>
278 </Box>
279 <Text color="claude" dimColor>{'─'.repeat(inner)}</Text>
280 {rows.length === 0 && <Text color="subtle" dimColor italic>empty folder</Text>}
281 {rows.map(row => {
282 const key = row.path.toLowerCase()
283 const room = inner - row.guide.length - 2
284 if (row.isDir) {
285 const isOpen = open.has(key)
286 const isHere = levels > 0 && key === cwd.toLowerCase()
287 const mark = isHere ? ' · here' : ''
288 return (
289 <Button key={`d:${row.path}`} plain onPress={() => flip(row.path)}>
290 <Text color="subtle" dimColor>{row.guide}</Text>
291 <Text color="subtle" dimColor>{isOpen ? '▾ ' : '▸ '}</Text>
292 <Text color={isHere ? 'claude' : touchedDirs.has(key) ? 'success' : 'subtle'} dimColor={isHere || touchedDirs.has(key)}>
293 {fit(row.name, room - mark.length)}
294 </Text>
295 <Text color="subtle" dimColor italic>{mark}</Text>
296 </Button>
297 )
298 }
299 return (
300 <Text>
301 <Text color="subtle" dimColor>{row.guide}</Text>
302 {touched.has(key) ? (
303 <Text color="success" dimColor>● {fit(row.name, room)}</Text>
304 ) : (
305 <Text color="subtle" dimColor> {fit(row.name, room)}</Text>
306 )}
307 </Text>
308 )
309 })}
310 {rows.length >= MAX_ROWS && <Text color="subtle" dimColor italic>more not listed; close a folder</Text>}
311 <Box flexGrow={1} />
312 {isDuelOn && e.surface === 'terminal' && inner >= 20 && (() => {
313 const { Raster } = $.ui.resolve(e)
314 duelColumns = inner
315 return <Raster key="duel" columns={inner} rows={DUEL_ROWS} cells={beatCells(player.beat, inner, duelBackground)} />
316 })()}
317 <Text color="subtle" dimColor italic>
318 {fit(count, inner)}
319 {fit(where, inner - count.length, 'tail')}
320 </Text>
321 </Box>
322 )
323 })
324}
325hooks/duel.ts 410 lines1// Two stick figures that fence while Claude works and meditate while it waits, drawn as Raster
2// cells: [codePoint, foreground, background] per cell. Pure: the module times it and blits it.
3//
4// The animation is a graph of clips. Each clip runs from one stance to another and ends on that
5// stance's own frame, so any clip that starts there joins on without a jump. At each clip's end
6// the player picks the next one at random from those leaving the stance it is in, preferring the
7// mode it is asked for and taking a bridge clip (rise, bow and kneel, disengage) to get there.
8
9export const DUEL_ROWS = 4
10const SPRITE = 8
11const DEFAULT = 0x01000000
12
13const BODY = 0x5c5c5c
14const BLADE_LEFT = 0x9a5a28
15const BLADE_RIGHT = 0x3f6f8f
16const SPARK = 0xb0903a
17const AURA = 0x5a4a7a
18
19/** One pose, facing right, 8 columns by 4 rows: body and blade as separate layers, spaces transparent. */
20type Pose = { body: string[]; blade: string[] }
21
22const POSES = {
23 guard: {
24 body: [' o ', ' /|- ', ' | ', ' / \\ '],
25 blade: [' ╱ ', ' ╱ ', ' ', ' '],
26 },
27 step: {
28 body: [' o ', ' /|- ', ' | ', ' /| '],
29 blade: [' ╱ ', ' ╱ ', ' ', ' '],
30 },
31 thrust: {
32 body: [' o ', ' /|-- ', ' | ', ' / \\ '],
33 blade: [' ', ' ───', ' ', ' '],
34 },
35 parry: {
36 body: [' o ', ' /|- ', ' | ', ' / \\ '],
37 blade: [' │ ', ' │ ', ' ', ' '],
38 },
39 high: {
40 body: [' o_ ', ' /| ', ' | ', ' / \\ '],
41 blade: [' ─── ', ' ', ' ', ' '],
42 },
43 low: {
44 body: [' o ', ' /| ', ' |\\ ', ' / \\ '],
45 blade: [' ', ' ', ' ─── ', ' '],
46 },
47 recoil: {
48 body: ['\\o ', ' |\\ ', ' | ', '/ > '],
49 blade: [' ', ' ', ' ', ' ── '],
50 },
51 bow: {
52 body: [' ', ' _o ', ' |\\ ', ' / \\ '],
53 blade: [' ', ' ', ' │ ', ' │ '],
54 },
55 kneel: {
56 body: [' ', ' o ', ' /|- ', ' |_\\ '],
57 blade: [' ', ' │ ', ' │ ', ' ┴ '],
58 },
59 sit: {
60 body: [' ', ' o ', ' /|\\ ', ' /_\\ '],
61 blade: [' ', ' ', ' ', ' ──'],
62 },
63 breathe: {
64 body: [' ', ' \\o/ ', ' | ', ' /_\\ '],
65 blade: [' ', ' ', ' ', ' ──'],
66 },
67} satisfies Record<string, Pose>
68
69type PoseName = keyof typeof POSES
70
71/** gap: columns between the two 8-wide sprite boxes, negative where they overlap. */
72export type Beat = {
73 left: PoseName
74 right: PoseName
75 gap: number
76 /** The blades meet: a spark where they touch. */
77 clash?: boolean
78 /** Dots above the seated heads. */
79 aura?: 1 | 2
80 /** How many ticks this beat holds; 1 when absent. */
81 hold?: number
82}
83
84export type Stance = 'apart' | 'close' | 'seated'
85export type Mode = 'fight' | 'calm'
86
87/** The frame each stance rests on: every clip ending at a stance ends on exactly this beat. */
88export const STANCE: Record<Stance, Beat> = {
89 apart: { left: 'guard', right: 'guard', gap: -1 },
90 close: { left: 'guard', right: 'guard', gap: -5 },
91 seated: { left: 'sit', right: 'sit', gap: 1, hold: 2 },
92}
93
94export type Clip = {
95 name: string
96 from: Stance
97 to: Stance
98 /** fight and calm clips play in their mode; a bridge plays to reach the other mode's stances. */
99 mode: Mode | 'bridge'
100 /** For a bridge: the mode it leads toward. */
101 toward?: Mode
102 beats: Beat[]
103}
104
105const S = STANCE
106
107export const CLIPS: Clip[] = [
108 // Fighting, while Claude works.
109 {
110 name: 'approach',
111 from: 'apart',
112 to: 'close',
113 mode: 'fight',
114 beats: [
115 { left: 'step', right: 'guard', gap: -2 },
116 { left: 'guard', right: 'step', gap: -3 },
117 { left: 'step', right: 'guard', gap: -4 },
118 S.close,
119 ],
120 },
121 {
122 name: 'lunge',
123 from: 'apart',
124 to: 'close',
125 mode: 'fight',
126 beats: [
127 { left: 'step', right: 'guard', gap: -2 },
128 { left: 'step', right: 'parry', gap: -4 },
129 { left: 'thrust', right: 'parry', gap: -5, clash: true },
130 S.close,
131 ],
132 },
133 {
134 name: 'circle',
135 from: 'apart',
136 to: 'apart',
137 mode: 'fight',
138 beats: [
139 { left: 'step', right: 'step', gap: -1 },
140 { left: 'parry', right: 'guard', gap: -1 },
141 { left: 'guard', right: 'parry', gap: -1 },
142 S.apart,
143 ],
144 },
145 {
146 name: 'exchange',
147 from: 'close',
148 to: 'close',
149 mode: 'fight',
150 beats: [
151 { left: 'thrust', right: 'parry', gap: -5, clash: true },
152 { left: 'guard', right: 'guard', gap: -5 },
153 { left: 'parry', right: 'thrust', gap: -5, clash: true },
154 S.close,
155 ],
156 },
157 {
158 name: 'high-low',
159 from: 'close',
160 to: 'close',
161 mode: 'fight',
162 beats: [
163 { left: 'high', right: 'high', gap: -5, clash: true },
164 { left: 'low', right: 'low', gap: -5, clash: true },
165 { left: 'high', right: 'parry', gap: -5 },
166 S.close,
167 ],
168 },
169 {
170 name: 'feint',
171 from: 'close',
172 to: 'close',
173 mode: 'fight',
174 beats: [
175 { left: 'step', right: 'guard', gap: -4 },
176 { left: 'low', right: 'parry', gap: -5 },
177 { left: 'thrust', right: 'parry', gap: -5, clash: true },
178 S.close,
179 ],
180 },
181 {
182 name: 'left-wins',
183 from: 'close',
184 to: 'apart',
185 mode: 'fight',
186 beats: [
187 { left: 'thrust', right: 'recoil', gap: -5 },
188 { left: 'guard', right: 'recoil', gap: -4, hold: 2 },
189 { left: 'guard', right: 'step', gap: -3 },
190 { left: 'guard', right: 'guard', gap: -2 },
191 S.apart,
192 ],
193 },
194 {
195 name: 'right-wins',
196 from: 'close',
197 to: 'apart',
198 mode: 'fight',
199 beats: [
200 { left: 'recoil', right: 'thrust', gap: -5 },
201 { left: 'recoil', right: 'guard', gap: -4, hold: 2 },
202 { left: 'step', right: 'guard', gap: -3 },
203 { left: 'guard', right: 'guard', gap: -2 },
204 S.apart,
205 ],
206 },
207
208 // Meditating, while Claude waits.
209 {
210 name: 'breathe',
211 from: 'seated',
212 to: 'seated',
213 mode: 'calm',
214 beats: [
215 { left: 'sit', right: 'sit', gap: 1, hold: 3 },
216 { left: 'breathe', right: 'breathe', gap: 1, hold: 4 },
217 S.seated,
218 ],
219 },
220 {
221 name: 'aura',
222 from: 'seated',
223 to: 'seated',
224 mode: 'calm',
225 beats: [
226 { left: 'sit', right: 'sit', gap: 1, aura: 1, hold: 3 },
227 { left: 'sit', right: 'sit', gap: 1, aura: 2, hold: 3 },
228 { left: 'sit', right: 'sit', gap: 1, aura: 1, hold: 3 },
229 S.seated,
230 ],
231 },
232 {
233 name: 'stillness',
234 from: 'seated',
235 to: 'seated',
236 mode: 'calm',
237 beats: [{ left: 'sit', right: 'sit', gap: 1, hold: 6 }, S.seated],
238 },
239
240 // Bridges between the two.
241 {
242 name: 'disengage',
243 from: 'close',
244 to: 'apart',
245 mode: 'bridge',
246 toward: 'calm',
247 beats: [
248 { left: 'parry', right: 'parry', gap: -4 },
249 { left: 'step', right: 'step', gap: -3 },
250 { left: 'guard', right: 'guard', gap: -2 },
251 S.apart,
252 ],
253 },
254 {
255 name: 'bow-and-kneel',
256 from: 'apart',
257 to: 'seated',
258 mode: 'bridge',
259 toward: 'calm',
260 beats: [
261 { left: 'bow', right: 'bow', gap: 0, hold: 2 },
262 { left: 'kneel', right: 'kneel', gap: 1, hold: 2 },
263 S.seated,
264 ],
265 },
266 {
267 name: 'rise',
268 from: 'seated',
269 to: 'apart',
270 mode: 'bridge',
271 toward: 'fight',
272 beats: [
273 { left: 'kneel', right: 'kneel', gap: 1, hold: 2 },
274 { left: 'bow', right: 'bow', gap: 0, hold: 2 },
275 S.apart,
276 ],
277 },
278]
279
280export type Player = { stance: Stance; clip: Clip | undefined; index: number; held: number; beat: Beat }
281
282/** A player resting seated, as a session starts before Claude has worked. */
283export function createPlayer(): Player {
284 return { stance: 'seated', clip: undefined, index: 0, held: 0, beat: STANCE.seated }
285}
286
287/** The next clip from where the player stands: one of the wanted mode, else a bridge toward it. */
288export function nextClip(stance: Stance, want: Mode, random: () => number = Math.random): Clip {
289 const leaving = CLIPS.filter(clip => clip.from === stance)
290 const own = leaving.filter(clip => clip.mode === want)
291 const bridge = leaving.filter(clip => clip.mode === 'bridge' && clip.toward === want)
292 const pool = own.length > 0 ? own : bridge.length > 0 ? bridge : leaving
293 return pool[Math.floor(random() * pool.length) % pool.length]
294}
295
296/** Advances one tick and returns the beat to draw. Clips always finish; the mode only steers the next pick. */
297export function advance(player: Player, want: Mode, random: () => number = Math.random): Beat {
298 if (player.held + 1 < (player.beat.hold ?? 1)) {
299 player.held++
300 return player.beat
301 }
302 player.held = 0
303 if (player.clip === undefined || player.index >= player.clip.beats.length) {
304 player.clip = nextClip(player.stance, want, random)
305 player.index = 0
306 }
307 player.beat = player.clip.beats[player.index++]
308 if (player.index >= player.clip.beats.length) player.stance = player.clip.to
309 return player.beat
310}
311
312const SWAP: Record<string, string> = { '/': '\\', '\\': '/', '╱': '╲', '╲': '╱', '<': '>', '>': '<', '(': ')', ')': '(' }
313
314function mirror(rows: string[]): string[] {
315 return rows.map(row => [...row].reverse().map(ch => SWAP[ch] ?? ch).join(''))
316}
317
318/** '#0f0f0f' to 0x0f0f0f; anything else is the terminal's own background. */
319export function parseColor(hex: string | undefined): number {
320 const m = /^#?([0-9a-f]{6})$/i.exec(hex?.trim() ?? '')
321 return m ? parseInt(m[1], 16) : DEFAULT
322}
323
324const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
325
326function base64(bytes: Uint8Array): string {
327 let out = ''
328 for (let i = 0; i < bytes.length; i += 3) {
329 const a = bytes[i]
330 const b = i + 1 < bytes.length ? bytes[i + 1] : 0
331 const c = i + 2 < bytes.length ? bytes[i + 2] : 0
332 const n = (a << 16) | (b << 8) | c
333 out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63]
334 out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '='
335 out += i + 2 < bytes.length ? B64[n & 63] : '='
336 }
337 return out
338}
339
340/** The cells of one beat on a strip `columns` wide, packed for a Raster; blank when too narrow. */
341export function beatCells(beat: Beat, columns: number, background = DEFAULT): string {
342 const width = Math.max(1, columns)
343 const glyph = new Array<number>(width * DUEL_ROWS).fill(32)
344 const color = new Array<number>(width * DUEL_ROWS).fill(DEFAULT)
345 const at = (row: number, col: number) => row * width + col
346 const inside = (row: number, col: number) => col >= 0 && col < width && row >= 0 && row < DUEL_ROWS
347 const put = (row: number, col: number, ch: string, fg: number) => {
348 if (ch === ' ' || !inside(row, col)) return
349 glyph[at(row, col)] = ch.codePointAt(0) ?? 32
350 color[at(row, col)] = fg
351 }
352 /** Draws a layer and returns the cells it covered, for finding where blades meet. */
353 const draw = (rows: string[], x: number, fg: number) => {
354 const covered: [number, number][] = []
355 rows.forEach((line, row) =>
356 [...line].forEach((ch, i) => {
357 if (ch === ' ') return
358 put(row, x + i, ch, fg)
359 covered.push([row, x + i])
360 }),
361 )
362 return covered
363 }
364
365 if (width >= SPRITE * 2 + 2) {
366 const total = SPRITE * 2 + beat.gap
367 const leftX = Math.floor((width - total) / 2)
368 const rightX = leftX + SPRITE + beat.gap
369 const left = POSES[beat.left]
370 const right = POSES[beat.right]
371
372 draw(left.body, leftX, BODY)
373 draw(mirror(right.body), rightX, BODY)
374 const leftBlade = draw(left.blade, leftX, BLADE_LEFT)
375 const leftGlyphs = new Map(leftBlade.map(([r, c]) => [at(r, c), glyph[at(r, c)]]))
376 const rightBlade = draw(mirror(right.blade), rightX, BLADE_RIGHT)
377 // Two diagonals in one cell are crossed blades.
378 const diagonals = new Set(['╱', '╲'].map(ch => ch.codePointAt(0)))
379 for (const [r, c] of rightBlade) {
380 const under = leftGlyphs.get(at(r, c))
381 if (under !== undefined && under !== glyph[at(r, c)] && diagonals.has(under) && diagonals.has(glyph[at(r, c)]))
382 put(r, c, '╳', BODY)
383 }
384
385 if (beat.clash) {
386 // The first cell where the blades touch: shared, or side by side on one row.
387 const near = leftBlade.find(([r, c]) => rightBlade.some(([r2, c2]) => r2 === r && Math.abs(c2 - c) <= 1))
388 if (near) put(near[0], near[1], '*', SPARK)
389 }
390 if (beat.aura) {
391 const heads = [leftX + 3, rightX + SPRITE - 1 - 3]
392 for (const head of heads) {
393 put(0, head, '·', AURA)
394 if (beat.aura === 2) {
395 put(0, head - 1, '·', AURA)
396 put(0, head + 1, '·', AURA)
397 }
398 }
399 }
400 }
401
402 const words = new Uint32Array(width * DUEL_ROWS * 3)
403 for (let i = 0; i < width * DUEL_ROWS; i++) {
404 words[i * 3] = glyph[i]
405 words[i * 3 + 1] = color[i]
406 words[i * 3 + 2] = background
407 }
408 return base64(new Uint8Array(words.buffer))
409}
410types/index.d.ts 18 lines1/** A file Claude wrote or edited this session: an absolute path with '/' separators. */
2export type Changed = { path: string; at: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'file-tree': {
7 /** Folders the person has opened, absolute paths with '/' separators. */
8 expanded: string[]
9 /** Files touched by Write, Edit or NotebookEdit, newest last. */
10 changed: Changed[]
11 /** How many folders above the working folder the tree's root sits; 0 is the working folder. */
12 up: number
13 /** Bumped after anything that may have changed the disk; the drawing reads it to redraw. */
14 rev: number
15 }
16 }
17}
18