A pane where Claude shows its plan, decisions, assumptions, questions and the files and commands it touches.

A pane beside the chat where Claude shows its work as it goes, so you can follow and steer it without reading every tool call.
The pane has thirteen tabs (hotkeys 1 to 9, 0, u, g and f while it has focus). Now, the default, holds a pulse of tool calls over the last two minutes, then the plan, the questions waiting on you, the subagents and the last three actions; the other tabs hold the rest. A plan with a new goal starts a new task, and the notes of earlier tasks hide behind a button on the Notes tab.
plan tool; a TodoWrite list shows here too.note tool the moment it comes up. Each opens with a tag in Claude's voice ("I chose", "I assume", "I ask", "I found", "Risk") and ends with what to do about it ("veto?", "correct me?", "answer me", "FYI", "heads up"). Press a question to quote it into your prompt and answer it; it turns replied once you send text after the quote. The tab counts only the notes still waiting on you. Press any other note to open it: it shows Claude's reason and detail and two buttons, one that starts a reply (Veto, Correct it, Tell me more, What would you do?) and one that marks it read (OK, Right, Got it).r refreshes it).0): the project's files as git sees them. Walk the tree, or type a few letters of a name to find it (Enter opens the best match). A file opens in a syntax-highlighted preview with buttons that drop @path into your prompt (m), open its diff (d) and close it (x). Files git calls changed, and files Claude read or edited, are marked. Switching to this tab puts the typing in the filter; Enter gives the keys back to the tab hotkeys.u): type a command and press Enter; it runs in a shell in the working directory and its output streams in, errors red. Stop it (k), run it again (e), or hand its output to Claude in your prompt (a). Recent commands rerun with a press. A reload of the plugin stops running commands.g): the branch and how far it is ahead of or behind its upstream, then staged, changed and new files. Press a file to stage or unstage it, ± for its diff; stage all (a), unstage all (z), type a message to commit the staged files, push (p) when ahead. What git said shows under the branch.f): ripgrep over the project (git grep where there is no rg), smart case, hits grouped by file; a hit opens its file at that line in the Explorer's preview, ready to mention.a, b, …) or click it, and your prompt starts with "I pick B (Bug page) from …". On a sheet of six or more, f and g pick tiles F and G while this tab is open.×3) and moves to the bottom. Above the log, each changed file is a button: press it to see its diff against git HEAD (a new file against nothing), added lines green and removed ones red; the diff refreshes when the file is edited again, and x closes it.Outside the pane: while Claude works, a band above the prompt shows the pulse, the step it is on, its running agents, the last test run and what waits on you. The context and spend stay in your status line. In the chat, each Edit and Write row gets a bar of the lines it added and removed, and each test command's row a bar of its passes and failures. Diffs are drawn by Claude Code's own diff highlighter.
Claude can also draw a design in the chat itself with the show tool, so you can veto it before it is built. Claude describes the design's structure and a tool lays it out:
mmdc.row, col, grid, grow), parts (box, window, btn, input, slider, tabs, list, img placeholders), annotations (note, numbered mark) and game screens (stage or screen, children pinned by anchor: tl … br). The page fits its content, or takes the width and height Claude gives.show shows you images too:
montage lays the sheet out, and a sprite under 240 pixels is blown up with whole pixels so it stays crisp.Under a sheet or a marked picture in the chat sits a small card: a button per tile (B Bug page) and one per mark (2 Score jumps here ↩). Click one and your prompt starts with the pick, or with "About mark 2 ("Score jumps here") on …: " for you to finish, with no trip to the pane. Point at a mark on the picture and its row in the card lights and its detail shows in lines the card keeps for it under the rows; a row lights the same way under the pointer, here or in the Pictures tab. The card never changes size, so the chat never moves under the pointer. Where two marks overlap, pointing lands on the smaller one, and Claude is told the two overlap so it can tighten them. Nothing is drawn over the picture on hover: kitty leaves a revealed picture on screen once the chat scrolls. Pictures are sized to the terminal's real cell shape, read once from its tty at the session's start (Linux; 1:2 elsewhere), so a picture fills its box and each mark's spot sits on it.
Each picture follows Claude Code's light or dark theme. A design Claude wrote, an image from the web and a sheet each come back to Claude in the tool's result as the picture you see, so it can fix overlaps or cut text, or catch the wrong picture, before you look; a source that won't parse comes back with the renderer's words. The picture appears under the tool's row, as wide as the transcript and at most a screen tall, in a terminal that draws images (kitty, Ghostty, iTerm2); other surfaces show its caption and path. Pictures are kept as PNGs in ~/.cache/show-work (the newest 30), converted with rsvg-convert and ImageMagick's magick.
A ``mermaid block in Claude's reply draws as the diagram itself, in place, in such a terminal: its code shows while it renders and again, under mermaid's error, if it will not. Diagrams render once with mermaid-cli's mmdc (npm i -g @mermaid-js/mermaid-cli) in Claude Code's light or dark theme and are cached in ~/.cache/show-work` (the newest 40). Other surfaces draw the block as Claude Code does.
A short section in the system prompt tells Claude when to post a plan and when to note something.
The pane opens by itself in a wide fullscreen terminal (144 columns or more). Otherwise:
/work opens the pane./work clear empties it.At the prompt of a terminal session:
/plugin install show-work --marketplace astrosteveo/astro-plugins
claude plugin validate plugins/show-work
claude plugin test plugins/show-workhooks/register.tsx 2651 lines1import { atom, read, update } from 'claude-code'
2import type {
3 Color,
4 EngineInterface,
5 HookStream,
6 ProcessSpawnChunk,
7 ProcessSpawnResult,
8 Register,
9 Timer,
10} from 'claude-code'
11
12import type {
13 Act,
14 ActKind,
15 Diagram,
16 CommitRow,
17 GitState,
18 Job,
19 Meter,
20 Preview,
21 Search,
22 TestRun,
23 Touch,
24 ActStatus,
25 Diff,
26 Lane,
27 Note,
28 NoteKind,
29 Picture,
30 Plan,
31 Step,
32 StepStatus,
33 Tab,
34 Turn,
35} from '../types'
36import { GREEN, RED, gantt, legend, parseLog, parseTests, patchCounts, pulse, shareBar } from './charts'
37import type { Bar } from './charts'
38import { parseGit, parseHits, parseStatus, plain } from './explorer'
39import {
40 FIND_BROWSER,
41 LONGEST,
42 SHEET_MOST,
43 TILE,
44 blowUp,
45 browserArgs,
46 curlArgs,
47 d2Args,
48 dotArgs,
49 errorLines,
50 frame,
51 fromHex,
52 markArgs,
53 markCells,
54 measured,
55 overlaps,
56 wrap,
57 mermaidArgs,
58 nameOf,
59 page,
60 sheetArgs,
61 sniff,
62 svgSize,
63} from './design'
64import type { Drawn, Mark } from './design'
65import { diagramColumns, hashOf, splitMermaid } from './mermaid'
66import { contextView, commitsView, explorerView, filesView, gitView, runView, searchView, testsView } from './views'
67
68const PANE = 'show-work'
69const TITLE = 'Show work'
70const OWN = 'mcp__show-work__'
71
72// Change a tag when that value's fields change: it declines what an earlier
73// version stored, which outlives a reload.
74const plan = atom({ plugin: 'show-work', key: 'plan' } as const, null, {
75 shape: 'plan-1',
76})
77const notes = atom({ plugin: 'show-work', key: 'notes' } as const, [], {
78 shape: 'notes-1',
79})
80const activity = atom({ plugin: 'show-work', key: 'activity' } as const, [], {
81 shape: 'activity-2',
82})
83const changed = atom({ plugin: 'show-work', key: 'changed' } as const, [], {
84 shape: 'changed-1',
85})
86const pictures = atom({ plugin: 'show-work', key: 'pictures' } as const, [], {
87 shape: 'pictures-1',
88})
89const diagrams = atom({ plugin: 'show-work', key: 'diagrams' } as const, [], {
90 shape: 'diagrams-1',
91})
92// The diff the Activity tab shows; null shows the log.
93const diff = atom({ plugin: 'show-work', key: 'diff' } as const, null, {
94 shape: 'diff-1',
95})
96const lanes = atom({ plugin: 'show-work', key: 'lanes' } as const, [], {
97 shape: 'lanes-1',
98})
99const turns = atom({ plugin: 'show-work', key: 'turns' } as const, [], {
100 shape: 'turns-1',
101})
102const meter = atom({ plugin: 'show-work', key: 'meter' } as const, null, {
103 shape: 'meter-1',
104})
105const tests = atom({ plugin: 'show-work', key: 'tests' } as const, [], {
106 shape: 'tests-1',
107})
108const touches = atom({ plugin: 'show-work', key: 'touches' } as const, [], {
109 shape: 'touches-1',
110})
111const commits = atom({ plugin: 'show-work', key: 'commits' } as const, [], {
112 shape: 'commits-1',
113})
114const tree = atom({ plugin: 'show-work', key: 'tree' } as const, [], { shape: 'tree-1' })
115const gitStatus = atom({ plugin: 'show-work', key: 'status' } as const, {}, { shape: 'status-1' })
116const openDirs = atom({ plugin: 'show-work', key: 'openDirs' } as const, [], { shape: 'openDirs-1' })
117const filter = atom({ plugin: 'show-work', key: 'filter' } as const, '')
118const preview = atom({ plugin: 'show-work', key: 'preview' } as const, null, { shape: 'preview-1' })
119const jobs = atom({ plugin: 'show-work', key: 'jobs' } as const, [], { shape: 'jobs-1' })
120const pickedJob = atom({ plugin: 'show-work', key: 'pickedJob' } as const, null)
121const git = atom({ plugin: 'show-work', key: 'git' } as const, null, { shape: 'git-1' })
122const search = atom({ plugin: 'show-work', key: 'search' } as const, null, { shape: 'search-1' })
123// How many times taller than wide the terminal's cells are, as read from its
124// tty; 2 where the terminal will not say, 0 until read.
125const cellTall = atom({ plugin: 'show-work', key: 'cellTall' } as const, 0)
126// The picture the pane shows; null shows the newest.
127const picked = atom({ plugin: 'show-work', key: 'picked' } as const, null)
128const tabAtom = atom({ plugin: 'show-work', key: 'tab' } as const, 'now')
129const showEarlier = atom({ plugin: 'show-work', key: 'showEarlier' } as const, false)
130// The note opened to show its why, detail and actions; null when none is.
131const openNote = atom({ plugin: 'show-work', key: 'openNote' } as const, null)
132
133const KEPT_PICTURES = 30
134const KEPT_DIAGRAMS = 40
135// mmdc draws at this many pixels per CSS pixel, so a diagram stays sharp.
136const DIAGRAM_SCALE = 2
137// The longest side of a picture handed to the model, as the API scales it.
138const MODEL_SIDE = 1568
139// The page width of an HTML mockup when Claude gives none.
140const PAGE_WIDTH = 1280
141// The most an Image takes as bytes: 2 MiB, as base64.
142const PNG_BYTES = Math.floor((2 * 1024 * 1024 * 4) / 3)
143const KEPT_LANES = 20
144const KEPT_TURNS = 50
145const KEPT_RUNS = 40
146const TREE_FILES = 20_000
147const KEPT_JOBS = 12
148const JOB_LINES = 500
149// How often a running job's output is written to the pane, in ms.
150const JOB_FLUSH = 200
151const HITS = 200
152const PREVIEW_LINES = 400
153const KEPT_TOUCHES = 200
154// A test command, by the runner or the word.
155const TEST_COMMAND = /\b(tests?|spec|pytest|jest|vitest|mocha|rspec|ctest|gut|gdunit\w*)\b/i
156// A git command that moves branches.
157const GIT_MOVE = /\bgit\b.*\b(commit|merge|rebase|cherry-pick|reset|checkout|switch|pull|am|revert)\b/
158// The pulse: a column per two seconds.
159const BEAT = 2000
160// How many turns the timeline draws, newest last.
161const GANTT_TURNS = 8
162const COLORS = {
163 turn: 0xd97757,
164 live: 0xf0a070,
165 lane: 0x5fa8d3,
166 failed: 0xe06c75,
167}
168const DIFF_LINES = 300
169const IMAGE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i
170// The most marks one picture takes.
171const MARKS_MOST = 12
172
173const STEP_STATUSES: readonly StepStatus[] = ['todo', 'doing', 'done', 'dropped']
174const NOTE_KINDS: readonly NoteKind[] = [
175 'decision',
176 'assumption',
177 'question',
178 'finding',
179 'risk',
180]
181
182// Tools whose calls are bookkeeping, not work worth showing.
183const QUIET = new Set([
184 'ToolSearch',
185 'TodoWrite',
186 'TaskCreate',
187 'TaskUpdate',
188 'TaskGet',
189 'TaskList',
190 'AskUserQuestion',
191])
192
193const GUIDE = `# Showing your work
194
195The person watches a "Show work" pane beside the chat. Keep it current so they can follow and steer your work without reading every tool call. It already logs your reads, edits and commands by itself.
196
197- For any task of more than a couple of steps, call mcp__show-work__plan first with the goal as you understand it and your steps. Call it again whenever a step starts, finishes or the plan changes; mark exactly one step "doing" while you work.
198- Call mcp__show-work__note the moment you:
199 - make a choice the person might have made differently ("decision", with the reason in "why");
200 - assume something you have not confirmed ("assumption");
201 - hit a question only the person can answer ("question"); if you can go on under a stated assumption, note both and keep working rather than stopping;
202 - learn something that changes the picture ("finding"), or see a hazard ("risk").
203- Give each Bash call a description that says why you run it, not only what it does: the pane shows it as the reason above the command.
204- Notes are how you keep the person in the loop without stopping. Once the goal is clear, keep working until it is met; stop only for a question that blocks the work or a snag that needs their direction.
205- To show the person a design, call mcp__show-work__show and describe its structure; the tool lays it out. Use d2 for architecture, mermaid for flows, sequences and states, and html with the wireframe classes for a screen, menu or HUD; svg only for a freeform drawing. Show a design before you build it, so they can veto it. The result hands you the picture: fix overlaps and cut or crowded text, and show it again before you go on. Images you Read land in the pane by themselves.
206- To show the person an image, call mcp__show-work__show with a path, a url for one on the web (reference art, an asset, a screenshot in docs), or images to set several side by side for them to compare and pick from. Add marks to point at what matters in it: a bug in a screenshot, what changed in a mockup.
207- One short sentence per note, written to stand alone. The person presses a question to answer it, and opens any other note to read its "why" and "detail", then vetoes, corrects or asks more, or presses OK. So put in "detail" what they need to judge the note: what you saw and where, what it means, what you would do.
208- Notes do not replace your reply: still say what matters in chat, briefly.`
209
210const PLAN_TOOL = {
211 name: 'plan',
212 description:
213 'Show the person your goal and plan in the Show work pane. Replaces the plan shown. Call it at the start of a multi-step task and whenever a step changes status.',
214 isDeferred: false,
215 inputSchema: {
216 type: 'object',
217 properties: {
218 goal: {
219 type: 'string',
220 description: 'The task as you understand it, in one sentence.',
221 },
222 steps: {
223 type: 'array',
224 items: {
225 type: 'object',
226 properties: {
227 text: { type: 'string', description: 'The step, a few words.' },
228 status: { type: 'string', enum: STEP_STATUSES },
229 },
230 required: ['text', 'status'],
231 },
232 },
233 },
234 required: ['goal', 'steps'],
235 },
236} as const
237
238const NOTE_TOOL = {
239 name: 'note',
240 description:
241 'Post a decision, assumption, open question, finding or risk to the Show work pane, where the person can answer a question, or open another note to read more and veto, correct or OK it.',
242 isDeferred: false,
243 inputSchema: {
244 type: 'object',
245 properties: {
246 kind: { type: 'string', enum: NOTE_KINDS },
247 text: { type: 'string', description: 'One short sentence.' },
248 why: {
249 type: 'string',
250 description: 'The reason, for a decision or a risk. Optional.',
251 },
252 detail: {
253 type: 'string',
254 description:
255 'Shown when the person opens the note: what you saw and where, what it means, what you would do. Two or three sentences. Optional.',
256 },
257 },
258 required: ['kind', 'text'],
259 },
260} as const
261
262const SHOW_TOOL = {
263 name: 'show',
264 description: `Draw a design in the chat for the person; the result hands you the picture to check. Give one source, and the tool lays it out:
265- d2: architecture and systems. Containers nest in braces: \`server: Server { api: API; db: Postgres {shape: cylinder} }\`, \`client -> server.api: HTTPS\`.
266- mermaid: flowcharts, sequence, state, class, ER and gantt diagrams.
267- dot: Graphviz, for big graphs such as dependencies or scene trees.
268- html: a UI mockup (screen, menu, dialog, HUD) laid out by a browser. Set width and height for a fixed screen; leave height out to fit the content. A wireframe kit is loaded, so classes do the styling:
269 layout: row, col, grid (style="--cols:4"), grow, wrap, between, center, middle, end, tight, loose, pad;
270 parts: box, window (data-title="…"), btn (+ primary or ghost), input, check (+ on), slider and bar (style="--value:60%"), tabs and list (child .on is selected), img (picture placeholder), icon, avatar, lines (text filler, style="--n:3"), tag, hr;
271 text: h1-h3, muted, small, bold; annotations: note (callout), mark (numbered pin);
272 game screens: stage (a 16:9 view, --aspect to change) or screen (fills the picture), each child placed by an anchor class: tl tc tr ml mc mr bl bc br;
273 colors: var(--paper --panel --ink --muted --line --fill --accent --good --bad).
274- svg: a freeform drawing; give it a background rect and a width of 800 or more.
275- path: an image file that already exists.
276- url: an image on the web: the image's own address, not the page it sits on. The result hands you what came back, so check it is the picture you meant.
277- images: several images (paths or URLs) side by side on one sheet, lettered A, B, C… and labeled, to compare options. The person can press a tile's letter in the pane to pick it, which starts their prompt with "I pick B".
278Add marks to any of these to point things out: numbered boxes or rings in the picture's own pixels. Each note is a button in a card under the picture that starts the person's reply about it; a detail shows there while they hover over the mark.`,
279 isDeferred: false,
280 inputSchema: {
281 type: 'object',
282 properties: {
283 caption: { type: 'string', description: 'What the picture shows, a few words.' },
284 d2: { type: 'string', description: 'D2 source: an architecture or system diagram.' },
285 mermaid: { type: 'string', description: 'Mermaid source, without the fence.' },
286 dot: { type: 'string', description: 'Graphviz DOT source.' },
287 html: { type: 'string', description: 'HTML for a mockup, using the wireframe classes; a <style> of your own is welcome.' },
288 width: { type: 'number', description: 'html: the page width in CSS pixels. Default 1280.' },
289 height: { type: 'number', description: 'html: the page height; leave it out to fit the content.' },
290 svg: { type: 'string', description: 'SVG source.' },
291 path: { type: 'string', description: 'An image file (PNG, JPEG, GIF, WebP, BMP or SVG), absolute or relative to the working directory.' },
292 url: { type: 'string', description: 'An image on the web (http or https): PNG, JPEG, GIF, WebP, BMP or SVG.' },
293 marks: {
294 type: 'array',
295 description: 'Numbered marks drawn on the picture, in its own pixels, each note listed under it by number.',
296 maxItems: MARKS_MOST,
297 items: {
298 type: 'object',
299 properties: {
300 x: { type: 'number', description: 'Left of the box, or the spot a ring circles.' },
301 y: { type: 'number', description: 'Top of the box, or the spot a ring circles.' },
302 w: { type: 'number', description: 'Box width; leave out for a ring.' },
303 h: { type: 'number', description: 'Box height; leave out for a ring.' },
304 note: { type: 'string', description: 'What the mark points out, in a few words.' },
305 detail: { type: 'string', description: 'More on it, a sentence or two, shown while the person hovers over the mark.' },
306 },
307 required: ['x', 'y', 'note'],
308 },
309 },
310 images: {
311 type: 'array',
312 description: `2 to ${SHEET_MOST} images to set side by side.`,
313 maxItems: SHEET_MOST,
314 items: {
315 type: 'object',
316 properties: {
317 src: { type: 'string', description: 'A path or an http(s) URL.' },
318 label: { type: 'string', description: 'A few words under it; its file name if left out.' },
319 },
320 required: ['src'],
321 },
322 },
323 },
324 required: ['caption'],
325 },
326} as const
327
328// The tag says who wrote the note; the ask says what the person does with it.
329const TAGS: Record<NoteKind, { tag: string; ask: string; color: Color }> = {
330 decision: { tag: 'I chose', ask: 'veto?', color: 'claude' },
331 assumption: { tag: 'I assume', ask: 'correct me?', color: 'warning' },
332 question: { tag: 'I ask', ask: 'answer me', color: 'suggestion' },
333 finding: { tag: 'I found', ask: 'FYI', color: 'success' },
334 risk: { tag: 'Risk', ask: 'heads up', color: 'error' },
335}
336
337// What an opened note offers: a reply started in the prompt, then an OK. A
338// question has none: pressing it starts the answer at once.
339const ACTIONS: Record<Exclude<NoteKind, 'question'>, { reply: string; fill: string; ok: string }> = {
340 decision: { reply: 'Veto', fill: "I'd rather ", ok: 'OK' },
341 assumption: { reply: 'Correct it', fill: 'Not quite: ', ok: 'Right' },
342 finding: { reply: 'Tell me more', fill: 'tell me more.', ok: 'Got it' },
343 risk: {
344 reply: 'What would you do?',
345 fill: 'how likely is it, and what would you do about it?',
346 ok: 'Got it',
347 },
348}
349
350// The field each tool's tab puts the typing in.
351const FIELDS: Partial<Record<Tab, string>> = { explorer: 'ex-filter', run: 'run-cmd', search: 'search-q' }
352
353const STEP_MARK: Record<StepStatus, { mark: string; color?: Color }> = {
354 todo: { mark: '·' },
355 doing: { mark: '▸', color: 'claude' },
356 done: { mark: '✓', color: 'success' },
357 dropped: { mark: '✗', color: 'inactive' },
358}
359
360const ACT_MARK: Record<ActStatus, { mark: string; color: Color }> = {
361 runs: { mark: '…', color: 'warning' },
362 ok: { mark: '✓', color: 'success' },
363 failed: { mark: '✗', color: 'error' },
364}
365
366export function replyPrefix(note: Note) {
367 const quote = note.text.length > 80 ? note.text.slice(0, 77) + '...' : note.text
368
369 return `About your ${note.kind} "${quote}": `
370}
371
372// A sheet's tiles go by letters, A first: the digits switch the pane's tabs.
373export const letterOf = (n: number) => String.fromCharCode(65 + n)
374
375// What pressing a sheet's tile starts the prompt with.
376export function pickText(picture: Pick<Picture, 'caption' | 'tiles'>, n: number) {
377 return `I pick ${letterOf(n)} (${picture.tiles?.[n] ?? ''}) from "${picture.caption}". `
378}
379
380// What pressing a mark's button starts the prompt with.
381export function markText(picture: Pick<Picture, 'caption' | 'marks'>, n: number) {
382 const note = picture.marks?.[n]?.note ?? ''
383 const quote = note.length > 60 ? `${note.slice(0, 57)}...` : note
384
385 return `About mark ${n + 1} ("${quote}") on "${picture.caption}": `
386}
387
388// How wide a picture's card is drawn: as its longest line, a detail
389// included, so revealing one never widens it; at most `room`.
390export function cardWidth(picture: Pick<Picture, 'tiles' | 'marks'>, room: number) {
391 const tiles = picture.tiles ?? []
392 const pick = tiles.length === 0 ? 0 : `Pick: ${tiles.map((label, n) => `[ ${letterOf(n)} ${label} ]`).join(' ')}`.length
393 const marks = picture.marks ?? []
394 const lines = marks.flatMap((mark, n) => [`${n + 1} ${mark.note} ↩`.length, mark.detail?.length ?? 0])
395 const hint = marks.some(mark => mark.detail !== undefined) ? 'Point at a mark for its detail.'.length : 0
396
397 // The border and padding take two cells a side.
398 return Math.min(room, Math.max(pick, hint, ...lines) + 4)
399}
400
401// The hover group of a picture's mark: its spot on the picture and its row
402// in the card light together.
403export const markScope = (picture: Pick<Picture, 'id'>, n: number) => `mark-${picture.id.slice(-40)}-${n + 1}`
404
405// A prompt replies to a note when it quotes it and says something after.
406export function isReplyTo(note: Note, text: string) {
407 const prefix = replyPrefix(note)
408 const at = text.indexOf(prefix)
409
410 return at >= 0 && text.slice(at + prefix.length).trim() !== ''
411}
412
413const firstLine = (text: string) => (text.split('\n')[0] ?? '').trim()
414
415const asText = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
416
417export function readSteps(value: unknown): Step[] {
418 if (!Array.isArray(value)) {
419 return []
420 }
421
422 return value.flatMap(item => {
423 const text = asText(item?.text)
424 const status = STEP_STATUSES.includes(item?.status) ? item.status : 'todo'
425
426 return text === '' ? [] : [{ text, status }]
427 })
428}
429
430// The marks a show call asks for, each with a spot and a note; a box needs
431// both its width and height.
432export function readMarks(value: unknown): Mark[] {
433 if (!Array.isArray(value)) {
434 return []
435 }
436
437 return value.flatMap(item => {
438 const note = asText(item?.note)
439 const detail = asText(item?.detail)
440 const [x, y, w, h] = [item?.x, item?.y, item?.w, item?.h].map(Number)
441 if (note === '' || !Number.isFinite(x) || !Number.isFinite(y)) {
442 return []
443 }
444 const isBox = Number.isFinite(w) && Number.isFinite(h) && (w ?? 0) > 0 && (h ?? 0) > 0
445
446 return [{ x: x ?? 0, y: y ?? 0, ...(isBox ? { w, h } : {}), note, ...(detail === '' ? {} : { detail }) }]
447 })
448}
449
450// Whether a note belongs to the task the plan opened; with no plan, all do.
451export function isCurrent(note: Note, since: number | undefined) {
452 return since === undefined || (note.at ?? -1) >= since
453}
454
455export function absolute(path: string, cwd: string) {
456 return path.startsWith('/') || cwd === '' ? path : `${cwd}/${path}`
457}
458
459// A show call's input as its row prints it: the caption, then each source by
460// its size, where the engine would print the source itself. A path or URL
461// is short, so it stays; a sheet lists its labels.
462export function briefShow(input: unknown) {
463 const given = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
464 const brief: Record<string, unknown> = {}
465 for (const [key, value] of Object.entries(given)) {
466 if (['d2', 'mermaid', 'dot', 'html', 'svg'].includes(key) && typeof value === 'string') {
467 const lines = value.trim().split('\n').length
468 brief[key] = lines > 1 ? `${lines} lines` : `${value.length} characters`
469 } else if (key === 'marks' && Array.isArray(value)) {
470 brief[key] = `${value.length} ${value.length === 1 ? 'mark' : 'marks'}`
471 } else if (key === 'images' && Array.isArray(value)) {
472 brief[key] = value.map(item => asText(item?.label) || nameOf(asText(item?.src))).join(', ')
473 } else {
474 brief[key] = value
475 }
476 }
477
478 return brief
479}
480
481// Rows a picture takes at a width, a terminal cell being `tall` times as
482// tall as it is wide: about twice, or as the terminal says (cellShape).
483export function rowsFor(picture: Pick<Picture, 'width' | 'height'>, columns: number, most: number, tall = 2) {
484 const rows = Math.round((columns * picture.height) / picture.width / tall)
485
486 return Math.max(1, Math.min(most, rows))
487}
488
489// The box that shows a whole picture in at most `columns` by `rows`: as wide
490// as allowed, narrower when a tall picture would take more rows. Sized to
491// the cells' real shape, the picture fills it, so a mark's cells sit on it.
492export function fit(picture: Pick<Picture, 'width' | 'height'>, columns: number, rows: number, tall = 2) {
493 const down = rowsFor(picture, columns, Number.POSITIVE_INFINITY, tall)
494 if (down <= rows) {
495 return { columns, rows: down }
496 }
497
498 return { columns: Math.max(1, Math.min(columns, Math.round((rows * tall * picture.width) / picture.height))), rows }
499}
500
501export function relative(path: string, cwd: string) {
502 return cwd !== '' && path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
503}
504
505// What the activity log says for one call, or nothing for a quiet one.
506// A read or edit of a file already listed folds into its row, which moves to
507// the end and takes the new call's id, so the call's status lands on it.
508export function addAct(list: Act[], act: Act): Act[] {
509 const same =
510 act.kind === 'read' || act.kind === 'edit'
511 ? list.find(
512 one =>
513 one.kind === act.kind &&
514 one.label === act.label &&
515 one.isSubagent === act.isSubagent,
516 )
517 : undefined
518 if (same === undefined) {
519 return [...list, act].slice(-200)
520 }
521
522 return [...list.filter(one => one !== same), { ...act, count: (same.count ?? 1) + 1 }]
523}
524
525export function describe(
526 e: { tool: string; [argument: string]: unknown },
527 cwd: string,
528):
529 | { kind: ActKind; label: string; detail?: string; path?: string; image?: string }
530 | undefined {
531 const tool = String(e.tool)
532 if (QUIET.has(tool) || tool.startsWith(OWN)) {
533 return undefined
534 }
535
536 const path = asText(e.file_path) || asText(e.notebook_path)
537 switch (tool) {
538 case 'Read':
539 return IMAGE.test(path)
540 ? { kind: 'read', label: relative(path, cwd), image: path }
541 : { kind: 'read', label: relative(path, cwd) }
542 case 'Edit':
543 case 'Write':
544 case 'NotebookEdit':
545 return { kind: 'edit', label: relative(path, cwd), path }
546 case 'Bash': {
547 const command = firstLine(asText(e.command))
548 const why = asText(e.description)
549
550 return why === ''
551 ? { kind: 'run', label: command }
552 : { kind: 'run', label: why, detail: command }
553 }
554 case 'Grep':
555 case 'Glob':
556 return { kind: 'search', label: asText(e.pattern) }
557 case 'WebFetch':
558 return { kind: 'web', label: asText(e.url) }
559 case 'WebSearch':
560 return { kind: 'web', label: asText(e.query) }
561 case 'Agent':
562 return { kind: 'agent', label: asText(e.description) || 'subagent' }
563 default:
564 return { kind: 'tool', label: tool.replace(/^mcp__/, '') }
565 }
566}
567
568export function duration(ms: number) {
569 const s = Math.max(0, Math.round(ms / 1000))
570
571 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
572}
573
574// The line between two turns in the activity log.
575export function divider(turn: Turn | undefined, n: number, now: number) {
576 if (turn === undefined) {
577 return `── turn ${n} ──`
578 }
579 const took = turn.end === undefined ? `${duration(now - turn.start)}…` : duration(turn.end - turn.start)
580 const calls = `${turn.calls} ${turn.calls === 1 ? 'call' : 'calls'}`
581 const failed = turn.failed > 0 ? ` · ${turn.failed} failed` : ''
582
583 return `── turn ${n} · ${took} · ${calls}${failed} ──`
584}
585
586// How often a running chart repaints, in ms.
587const FRAME = 250
588// The color of a diff line by its first character.
589export function diffColor(line: string): Color | undefined {
590 if (line.startsWith('+++') || line.startsWith('---')) {
591 return undefined
592 }
593 if (line.startsWith('+')) {
594 return 'success'
595 }
596 if (line.startsWith('-')) {
597 return 'error'
598 }
599
600 return line.startsWith('@@') ? 'suggestion' : undefined
601}
602
603// Asks git what changed in a file: against HEAD when it is tracked, against
604// nothing when it is new.
605async function loadDiff($: EngineInterface, path: string, label: string): Promise<Diff> {
606 const dir = path.slice(0, path.lastIndexOf('/')) || '/'
607 let out = (await $.process.run(['git', '-C', dir, 'diff', '--no-color', 'HEAD', '--', path])).stdout
608 if (out.trim() === '') {
609 out = (
610 await $.process.run(['git', '-C', dir, 'diff', '--no-color', '--no-index', '/dev/null', path])
611 ).stdout
612 }
613 // The header lines before the first hunk say nothing the title doesn't.
614 const all = out.split('\n')
615 const hunk = all.findIndex(line => line.startsWith('@@'))
616 const lines = (hunk < 0 ? all : all.slice(hunk)).filter((line, i, list) => i < list.length - 1 || line !== '')
617
618 return {
619 file: label,
620 lines: lines.length === 0 ? [] : lines.slice(0, DIFF_LINES),
621 isCut: lines.length > DIFF_LINES,
622 }
623}
624
625// An image at a path or a URL; several of them on one sheet; or a design.
626type Source = { src: string } | { sheet: { src: string; label?: string }[] } | Drawn
627
628const WEB = /^https?:\/\//i
629
630const cacheDir = async ($: EngineInterface) =>
631 `${(await $.env.get('XDG_CACHE_HOME')) || `${await $.env.get('HOME')}/.cache`}/show-work`
632
633// Reads the terminal's size in pixels from the tty of the nearest process
634// above this one that has one, and answers its cells' height over width;
635// undefined where there is no /proc or the terminal gives no pixels.
636const CELL_SHAPE = `
637import fcntl, os, struct, termios
638pid = os.getppid()
639for _ in range(16):
640 tty = next((t for t in (os.readlink(f'/proc/{pid}/fd/{n}') for n in (0, 1, 2) if os.path.exists(f'/proc/{pid}/fd/{n}')) if t.startswith(('/dev/pts/', '/dev/tty'))), None)
641 if tty:
642 with open(tty, 'rb') as f:
643 rows, cols, x, y = struct.unpack('HHHH', fcntl.ioctl(f.fileno(), termios.TIOCGWINSZ, bytes(8)))
644 if rows and cols and x and y:
645 print((y / rows) / (x / cols))
646 break
647 pid = int(open(f'/proc/{pid}/stat').read().rsplit(')', 1)[1].split()[1])
648 if pid <= 1:
649 break
650`
651
652async function cellShape($: EngineInterface) {
653 const ran = await $.process.run(['python3', '-I', '-c', CELL_SHAPE], { timeoutMs: 5000 }).catch(() => undefined)
654 const tall = Number(firstLine(ran?.stdout ?? ''))
655
656 return ran?.exitCode === 0 && tall >= 1 && tall <= 4 ? tall : undefined
657}
658
659// The cells' shape for sizing a picture. Unread, as after a reload of the
660// plugin mid-session, it is read now and the picture drawn again; 2 till then.
661let isReadingCell = false
662async function shapeOf($: EngineInterface) {
663 const tall = await read($, cellTall)
664 if (tall === 0 && !isReadingCell) {
665 isReadingCell = true
666 void cellShape($)
667 .then(found => update($, cellTall, () => found ?? 2))
668 .finally(() => {
669 isReadingCell = false
670 })
671 }
672
673 return tall === 0 ? 2 : tall
674}
675
676const isLightTheme = async ($: EngineInterface) =>
677 /light/i.test(String((await $.config.list()).find(row => row.key === 'theme')?.value ?? ''))
678
679// Runs a renderer; answers what went wrong, or nothing when it drew.
680async function draw($: EngineInterface, argv: string[]) {
681 const ran = await $.process.run(argv, { timeoutMs: 30_000 }).catch((error: unknown) => String(error))
682 if (typeof ran === 'string') {
683 return /time|still running/i.test(ran) ? `${argv[0]} took too long.` : `${argv[0]} would not start: is it installed?`
684 }
685
686 return ran.exitCode === 0 ? undefined : errorLines(ran.stderr.split('\n').map(plain).join('\n')) || `${argv[0]} failed.`
687}
688
689// The headless browser, looked for each time so one installed later is found.
690async function findBrowser($: EngineInterface) {
691 const found = await $.process.run(['sh', '-c', FIND_BROWSER]).catch(() => undefined)
692
693 return found?.exitCode === 0 ? firstLine(found.stdout) || undefined : undefined
694}
695
696// Screenshots a page with a headless browser, `height` tall or as tall as
697// its content.
698async function shoot(
699 $: EngineInterface,
700 found: string,
701 from: string,
702 file: string,
703 width: number,
704 height?: number,
705) {
706 const profile = `${file.replace(/\.png$/, '')}-chrome`
707 const argv = browserArgs(found, profile)
708 const url = `file://${encodeURI(from)}`
709 try {
710 let tall = height
711 if (tall === undefined) {
712 const dom = await $.process.run([...argv, `--window-size=${width},100`, '--dump-dom', url], {
713 timeoutMs: 30_000,
714 })
715 tall = measured(dom.stdout) ?? 800
716 }
717
718 return await draw($, [...argv, `--window-size=${width},${tall}`, `--screenshot=${file}`, url])
719 } finally {
720 await $.process.run(['rm', '-rf', profile])
721 }
722}
723
724// Lays a design out from its source into `file`, with the tool that reads
725// it. Answers why when it can't.
726async function render($: EngineInterface, source: Drawn, dir: string, id: string, file: string) {
727 const isLight = await isLightTheme($)
728 const at = `${dir}/${id}`
729 if ('svg' in source) {
730 await $.fs.write(`${at}.svg`, source.svg)
731
732 return draw($, ['rsvg-convert', '-o', file, `${at}.svg`])
733 }
734 if ('mermaid' in source) {
735 await $.fs.write(`${at}.mmd`, source.mermaid)
736
737 return draw($, mermaidArgs(isLight, `${at}.mmd`, file, DIAGRAM_SCALE))
738 }
739 if ('dot' in source) {
740 await $.fs.write(`${at}.dot`, source.dot)
741
742 return draw($, dotArgs(isLight, `${at}.dot`, file))
743 }
744 if ('d2' in source) {
745 // D2 writes an SVG that only a browser draws whole (rsvg drops its
746 // label masks); rsvg stands in where there is none.
747 await $.fs.write(`${at}.d2`, source.d2)
748 const why = await draw($, d2Args(isLight, `${at}.d2`, `${at}.svg`))
749 if (why !== undefined) {
750 return why
751 }
752 const size = svgSize(await $.fs.read(`${at}.svg`))
753 const found = await findBrowser($)
754 if (size === undefined || found === undefined) {
755 return draw($, ['rsvg-convert', '-o', file, `${at}.svg`])
756 }
757 await $.fs.write(`${at}.html`, frame(`${id}.svg`))
758
759 return shoot($, found, `${at}.html`, file, Math.min(LONGEST, size.width), Math.min(LONGEST, size.height))
760 }
761 const found = await findBrowser($)
762 if (found === undefined) {
763 return 'No Chrome or Chromium to lay out the page: install one.'
764 }
765 await $.fs.write(`${at}.html`, page(source.html, isLight))
766
767 return shoot($, found, `${at}.html`, file, source.width, source.height)
768}
769
770// Fetches an image from the web into `${at}.web` and names its format from
771// its first bytes. Answers why when it can't.
772async function fetchImage($: EngineInterface, url: string, at: string) {
773 const file = `${at}.web`
774 const ran = await $.process.run(curlArgs(url, file), { timeoutMs: 40_000 }).catch((error: unknown) => String(error))
775 if (typeof ran === 'string') {
776 return /time|still running/i.test(ran) ? `Fetching ${url} took too long.` : 'curl would not start: is it installed?'
777 }
778 if (ran.exitCode !== 0) {
779 const said = firstLine(plain(ran.stderr)).replace(/^curl: \(\d+\)\s*/, '')
780
781 return `Could not fetch ${url}: ${said || `curl exited ${ran.exitCode}`}.`
782 }
783 const head = await $.process.run(['od', '-An', '-tx1', '-N512', file])
784 const bytes = fromHex(head.stdout)
785 const format = sniff(bytes)
786 if (format === undefined) {
787 await $.process.run(['rm', '-f', file])
788
789 return /<(!doctype )?html/i.test(bytes)
790 ? `${url} is a web page: give the address of the image itself.`
791 : `${url} is not a PNG, JPEG, GIF, WebP, BMP or SVG image.`
792 }
793
794 return { file, format }
795}
796
797// Converts the image at a path or URL into a PNG at `to`. Answers why when
798// it can't.
799async function bring($: EngineInterface, src: string, at: string, to: string) {
800 let argv: string[]
801 let fetched: string | undefined
802 if (WEB.test(src)) {
803 const got = await fetchImage($, src, at)
804 if (typeof got === 'string') {
805 return got
806 }
807 fetched = got.file
808 argv = got.format === 'svg' ? ['rsvg-convert', '-o', to, got.file] : ['magick', `${got.format}:${got.file}[0]`, to]
809 } else {
810 if (!(await $.fs.exists(src))) {
811 return `No image at ${src}.`
812 }
813 argv = /\.svg$/i.test(src) ? ['rsvg-convert', '-o', to, src] : /\.png$/i.test(src) ? ['cp', src, to] : ['magick', `${src}[0]`, to]
814 }
815 const made = await $.process.run(argv)
816 if (fetched !== undefined) {
817 await $.process.run(['rm', '-f', fetched])
818 }
819
820 return made.exitCode === 0 ? undefined : `Could not read the image: ${firstLine(made.stderr) || argv[0]}`
821}
822
823// The font a sheet's labels are set in: the system's sans; none leaves
824// ImageMagick's own.
825async function findFont($: EngineInterface) {
826 const found = await $.process.run(['fc-match', '-f', '%{file}', 'sans-serif:weight=medium']).catch(() => undefined)
827
828 return found?.exitCode === 0 ? firstLine(found.stdout) || undefined : undefined
829}
830
831// Sets several images side by side on one sheet at `to`, each numbered over
832// its label; a small one, such as a sprite, is blown up with whole pixels.
833// Answers why when it can't.
834async function sheet($: EngineInterface, items: { src: string; label?: string }[], at: string, to: string) {
835 const tiles: { file: string; label: string }[] = []
836 try {
837 for (const [n, item] of items.entries()) {
838 const file = `${at}-${n + 1}.png`
839 tiles.push({ file, label: `${letterOf(n)} ${item.label || nameOf(item.src)}` })
840 const why = await bring($, item.src, `${at}-${n + 1}`, file)
841 if (why !== undefined) {
842 return `Picture ${n + 1}: ${why}`
843 }
844 // A sprite is blown up with whole pixels; anything bigger than a tile
845 // shrinks to fit one, so the sheet's cells take the pictures' shape.
846 const size = await sizeOf($, file)
847 const factor = blowUp(size?.width ?? 0, size?.height ?? 0)
848 const resize = factor > 1 ? ['-filter', 'point', '-resize', `${factor * 100}%`] : ['-resize', `${TILE}x${TILE}>`]
849 await $.process.run(['magick', file, ...resize, file])
850 }
851
852 return await draw($, sheetArgs(await isLightTheme($), tiles, to, await findFont($)))
853 } finally {
854 await $.process.run(['rm', '-f', ...tiles.map(tile => tile.file)])
855 }
856}
857
858// Copies or converts an image into the cache as a PNG, so a later write to
859// the same file leaves this picture as it was; a design is laid out into
860// one. Answers why when it can't.
861async function freeze(
862 $: EngineInterface,
863 source: Source,
864 id: string,
865 caption: string,
866 origin: Picture['origin'],
867 marks: Mark[] = [],
868): Promise<Picture | string> {
869 const dir = await cacheDir($)
870 const file = `${dir}/${id}.png`
871 await $.process.run(['mkdir', '-p', dir])
872
873 if ('src' in source) {
874 // Reading a picture from the cache adds no copy; showing one again, as
875 // to mark it, does.
876 if (origin === 'read' && source.src.startsWith(`${dir}/`)) {
877 return 'That picture is already shown.'
878 }
879 const why = await bring($, source.src, `${dir}/${id}`, file)
880 if (why !== undefined) {
881 return why
882 }
883 } else if ('sheet' in source) {
884 const why = await sheet($, source.sheet, `${dir}/${id}`, file)
885 if (why !== undefined) {
886 return why
887 }
888 } else {
889 const why = await render($, source, dir, id, file)
890 if (why !== undefined) {
891 return why
892 }
893 if (!(await $.fs.exists(file))) {
894 return 'The design drew nothing.'
895 }
896 }
897
898 const size = await sizeOf($, file)
899 if (marks.length > 0 && size !== undefined) {
900 const why = await draw($, markArgs(file, file, marks, size, await isLightTheme($), await findFont($)))
901 if (why !== undefined) {
902 return `Could not draw the marks: ${why}`
903 }
904 }
905 const tiles = 'sheet' in source ? { tiles: source.sheet.map(item => item.label || nameOf(item.src)) } : {}
906
907 return { id, file, ...(size ?? { width: 16, height: 9 }), caption, origin, ...tiles, ...(marks.length > 0 ? { marks } : {}) }
908}
909
910// A picture's size in pixels, as ImageMagick reads it.
911async function sizeOf($: EngineInterface, file: string) {
912 const size = await $.process.run(['magick', 'identify', '-format', '%w %h', file])
913 const [width = 0, height = 0] = size.stdout.split(' ').map(Number)
914
915 return width > 0 && height > 0 ? { width, height } : undefined
916}
917
918// The picture as the model reads it: scaled down past the side the API
919// would scale it to anyway, so less crosses the wire.
920async function lookAt($: EngineInterface, picture: Picture) {
921 let file = picture.file
922 if (Math.max(picture.width, picture.height) > MODEL_SIDE) {
923 file = picture.file.replace(/\.png$/, '-look.png')
924 const made = await $.process.run(['magick', picture.file, '-resize', `${MODEL_SIDE}x${MODEL_SIDE}>`, file])
925 file = made.exitCode === 0 ? file : picture.file
926 }
927 const png = await $.fs.read(file, { as: 'bytes' })
928
929 return { type: 'image', source: { type: 'base64', media_type: 'image/png', data: png.base64 } } as const
930}
931
932// Keeps the newest pictures and deletes the files of the rest, with the
933// sources and pages they were drawn from.
934async function keep($: EngineInterface, picture: Picture) {
935 let dropped: Picture[] = []
936 await update($, pictures, list => {
937 const all = [...list.filter(one => one.id !== picture.id), picture]
938 dropped = all.slice(0, -KEPT_PICTURES)
939
940 return all.slice(-KEPT_PICTURES)
941 })
942 await update($, picked, () => null)
943 if (dropped.length > 0) {
944 const files = dropped.flatMap(one => [
945 ...['.png', '-look.png', '.svg', '.d2', '.mmd', '.dot', '.html'].map(end => one.file.replace(/\.png$/, end)),
946 ])
947 await $.process.run(['rm', '-f', ...files])
948 }
949}
950
951// Renders a mermaid block to a PNG in the cache with mmdc, in the person's
952// theme, and keeps the newest. A block that will not render keeps why.
953async function drawDiagram($: EngineInterface, source: string, id: string) {
954 const dir = await cacheDir($)
955 const file = `${dir}/mermaid-${id}.png`
956 const made = await $.process
957 .run(['mkdir', '-p', dir])
958 .then(async () => {
959 if (await $.fs.exists(file)) {
960 return undefined
961 }
962 const isLight = await isLightTheme($)
963 await $.fs.write(`${dir}/mermaid-${id}.mmd`, source)
964 const argv = ['mmdc', '-q', '-i', `${dir}/mermaid-${id}.mmd`, '-o', file]
965 const style = ['-t', isLight ? 'default' : 'dark', '-b', 'transparent', '-s', String(DIAGRAM_SCALE)]
966 const ran = await $.process.run([...argv, ...style], { timeoutMs: 30_000 })
967
968 return ran.exitCode === 0 ? undefined : firstLine(plain(ran.stderr)) || 'mmdc failed'
969 })
970 .catch((error: unknown) => (/ENOENT|not found/i.test(String(error)) ? 'mmdc is not installed' : String(error)))
971 const size = made === undefined ? await $.process.run(['magick', 'identify', '-format', '%w %h', file]) : undefined
972 const [width = 0, height = 0] = (size?.stdout ?? '').split(' ').map(Number)
973 const drawn: Diagram =
974 made === undefined && width > 0 && height > 0
975 ? { id, file, width, height }
976 : { id, file, width: 0, height: 0, error: made ?? 'mmdc drew nothing' }
977 await update($, diagrams, list => [...list.filter(one => one.id !== id), drawn].slice(-KEPT_DIAGRAMS))
978}
979
980// The Rasters the pane drew that move on their own: the timer repaints each
981// while it is live, drops one whose paint is refused, and stops with none.
982type Paint = {
983 /** The site the Raster is drawn in: the pane, or the band's request id. */
984 site: string
985 key: string
986 draw: (now: number, frame: number) => { columns: number; rows: number; cells: string }
987 isLive: (now: number) => boolean
988}
989const ticker: { paints: Map<string, Paint>; timer?: Timer; frame: number } = {
990 paints: new Map(),
991 frame: 0,
992}
993
994async function tick($: EngineInterface) {
995 const now = await $.clock.now()
996 const live = [...ticker.paints].filter(([, paint]) => paint.isLive(now))
997 if (live.length === 0) {
998 ticker.timer?.cancel()
999 ticker.timer = undefined
1000
1001 return
1002 }
1003 ticker.frame += 1
1004 for (const [id, paint] of live) {
1005 const cells = paint.draw(now, ticker.frame)
1006 // A refused or failed paint means the Raster is gone.
1007 const painted = await $.ui
1008 .blit({ requestId: paint.site, key: paint.key, ...cells })
1009 .catch(() => ({ deny: 'failed' }))
1010 if (painted.deny !== undefined) {
1011 ticker.paints.delete(id)
1012 }
1013 }
1014}
1015
1016function paint(one: Paint) {
1017 ticker.paints.set(`${one.site}:${one.key}`, one)
1018}
1019
1020function forget(site: string) {
1021 for (const [id, one] of ticker.paints) {
1022 if (one.site === site) {
1023 ticker.paints.delete(id)
1024 }
1025 }
1026}
1027
1028function startTicking($: EngineInterface, now: number) {
1029 if (ticker.timer === undefined && [...ticker.paints.values()].some(one => one.isLive(now))) {
1030 ticker.timer = $.clock.every(FRAME, () => void tick($))
1031 }
1032}
1033
1034// The window, spend and rate limits as the session reports them now.
1035async function loadMeter($: EngineInterface): Promise<Meter | null> {
1036 const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
1037 if (usage === undefined) {
1038 return null
1039 }
1040 const categories = usage.context.breakdown?.categories ?? []
1041
1042 return {
1043 ...(usage.context.tokens === undefined ? {} : { tokens: usage.context.tokens }),
1044 window: usage.context.window,
1045 ...(usage.context.percent === undefined ? {} : { percent: usage.context.percent }),
1046 ...(usage.cost === undefined ? {} : { usd: usage.cost.usd }),
1047 ...(() => {
1048 const buffer = categories.find(one => one.kind === 'buffer')?.tokens
1049
1050 return buffer === undefined ? {} : { buffer }
1051 })(),
1052 categories: categories
1053 .filter(one => one.kind === 'used' && one.tokens > 0)
1054 .map(one => ({ name: one.name, tokens: one.tokens })),
1055 limits: usage.rateLimits.map(one => ({ kind: one.kind, percentUsed: one.percentUsed })),
1056 at: await $.clock.now(),
1057 }
1058}
1059
1060// The project's files as git sees them, ignored ones left out; where git
1061// is not, whatever find turns up a few levels down.
1062async function loadTree($: EngineInterface, cwd: string): Promise<string[]> {
1063 if (cwd === '') {
1064 return []
1065 }
1066 const listed = await $.process
1067 .run(['git', '-C', cwd, 'ls-files', '--cached', '--others', '--exclude-standard'])
1068 .catch(() => undefined)
1069 if (listed !== undefined && listed.exitCode === 0) {
1070 return [...new Set(listed.stdout.split('\n').filter(line => line !== ''))].slice(0, TREE_FILES)
1071 }
1072 const found = await $.process
1073 .run(['find', cwd, '-maxdepth', '6', '-type', 'f', '-not', '-path', '*/.git/*', '-not', '-path', '*/node_modules/*'])
1074 .catch(() => undefined)
1075
1076 return (found?.stdout ?? '')
1077 .split('\n')
1078 .filter(line => line.startsWith(cwd + '/'))
1079 .map(line => line.slice(cwd.length + 1))
1080 .slice(0, TREE_FILES)
1081}
1082
1083async function loadStatus($: EngineInterface, cwd: string): Promise<Record<string, string>> {
1084 const ran = await $.process.run(['git', '-C', cwd, 'status', '--porcelain']).catch(() => undefined)
1085
1086 return ran !== undefined && ran.exitCode === 0 ? parseStatus(ran.stdout) : {}
1087}
1088
1089// A file's text for the preview: the head of a long one, or the stretch
1090// around a search hit; none of a binary one.
1091async function loadPreview($: EngineInterface, cwd: string, file: string, hit?: number): Promise<Preview> {
1092 const text = await $.fs.read(absolute(file, cwd)).catch(() => '')
1093 const source = typeof text === 'string' ? text : ''
1094 if (source.includes('\u0000')) {
1095 return { file, source: '', lines: 0, isCut: false, isBinary: true }
1096 }
1097 const lines = source.split('\n')
1098 const start = hit === undefined ? 1 : Math.max(1, hit - 10)
1099
1100 return {
1101 file,
1102 source: lines.slice(start - 1, start - 1 + PREVIEW_LINES).join('\n'),
1103 lines: lines.length,
1104 isCut: start > 1 || lines.length > start - 1 + PREVIEW_LINES,
1105 isBinary: false,
1106 ...(hit === undefined ? {} : { start, hit }),
1107 }
1108}
1109
1110async function loadGit($: EngineInterface, cwd: string, note?: { text: string; isError: boolean }): Promise<GitState | null> {
1111 const ran = await $.process.run(['git', '-C', cwd, 'status', '--porcelain=v1', '--branch']).catch(() => undefined)
1112 if (ran === undefined || ran.exitCode !== 0) {
1113 return null
1114 }
1115
1116 return {
1117 ...parseGit(ran.stdout),
1118 ...(note === undefined ? {} : { note: note.text, isNoteError: note.isError }),
1119 }
1120}
1121
1122// A git command from the Git tab, then the tab, the Explorer's marks and
1123// the commit graph read again.
1124async function gitDo($: EngineInterface, cwd: string, argv: string[], timeoutMs = 30_000) {
1125 const ran = await $.process.run(['git', '-C', cwd, ...argv], { timeoutMs }).catch((error: unknown) => ({
1126 exitCode: 1,
1127 stdout: '',
1128 stderr: String(error),
1129 }))
1130 const said = (ran.exitCode === 0 ? ran.stdout || ran.stderr : ran.stderr || ran.stdout).trim().split('\n')
1131 const note = { text: `git ${argv[0]}: ${said.at(-1) || (ran.exitCode === 0 ? 'done' : 'failed')}`, isError: ran.exitCode !== 0 }
1132 const [state, codes, rows] = await Promise.all([loadGit($, cwd, note), loadStatus($, cwd), loadCommits($, cwd)])
1133 await update($, git, () => state)
1134 await update($, gitStatus, () => codes)
1135 await update($, commits, () => rows)
1136}
1137
1138// ripgrep where it is, git grep where it is not: smart case, a few hits a file.
1139async function runSearch($: EngineInterface, cwd: string, query: string): Promise<Search> {
1140 if (query.trim() === '') {
1141 return { query, hits: [], isCut: false }
1142 }
1143 const rg = await $.process
1144 .run(['rg', '--line-number', '--no-heading', '--color', 'never', '--smart-case', '--max-count', '20', '--max-columns', '300', '--', query], { cwd })
1145 .catch(() => undefined)
1146 const ran =
1147 rg !== undefined && rg.exitCode !== 2
1148 ? rg
1149 : await $.process.run(['git', '-C', cwd, 'grep', '-n', '-I', '-i', '--', query]).catch(() => undefined)
1150 if (ran === undefined || ran.exitCode > 1) {
1151 return { query, hits: [], isCut: false, error: (ran?.stderr ?? 'no rg or git').trim().split('\n')[0] ?? '' }
1152 }
1153
1154 return { query, ...parseHits(ran.stdout, HITS) }
1155}
1156
1157// Children of the Run tab, by job: ending the stream kills the child.
1158const children = new Map<string, HookStream<ProcessSpawnChunk, ProcessSpawnResult>>()
1159
1160// Runs a command in a shell and keeps the tail of its output in the job,
1161// flushed a few times a second; the job ends with the child.
1162async function runJob($: EngineInterface, cwd: string, command: string) {
1163 const id = crypto.randomUUID()
1164 const job: Job = { id, command, status: 'running', start: await $.clock.now(), output: [], total: 0 }
1165 await update($, jobs, list => [...list, job].slice(-KEPT_JOBS))
1166 await update($, pickedJob, () => id)
1167 const child = $.process.spawn({ argv: ['bash', '-lc', command], ...(cwd === '' ? {} : { cwd }) })
1168 children.set(id, child)
1169 let carry = ''
1170 let lines: string[] = []
1171 let flushedAt = 0
1172 const flush = async () => {
1173 const fresh = lines
1174 lines = []
1175 flushedAt = await $.clock.now()
1176 await update($, jobs, list =>
1177 list.map(one =>
1178 one.id === id
1179 ? { ...one, output: [...one.output, ...fresh].slice(-JOB_LINES), total: one.total + fresh.length }
1180 : one,
1181 ),
1182 )
1183 }
1184 let result: { code: number | null; signal: string | null } | undefined
1185 try {
1186 let step = await child.next()
1187 while (step.done !== true) {
1188 const parts = (carry + step.value.text).split('\n')
1189 carry = parts.pop() ?? ''
1190 lines.push(...parts.map(plain))
1191 if ((await $.clock.now()) - flushedAt >= JOB_FLUSH) {
1192 await flush()
1193 }
1194 step = await child.next()
1195 }
1196 result = step.value
1197 } catch (error) {
1198 lines.push(`(could not run: ${String(error)})`)
1199 }
1200 if (carry !== '') {hooks/charts.ts 520 lines1// What the pane draws in Raster cells, and the parsing behind it: pure
2// functions of plain data, so the tests can read the cells back.
3
4import type { CommitRow, TestRun } from '../types'
5
6export const DEFAULT = 0x01000000
7const GRID = 0x3a3a3a
8const DIM = 0x8a8a8a
9const BRIGHT = 0xd0d0d0
10const LABEL = 14
11const PULSE = 0xffe0b0
12// Half-cell glyphs: a bar's ends land on half a column.
13const LEFT = 0x258c
14const RIGHT = 0x2590
15const FULL = 0x2588
16const SHADE = 0x2591
17const HATCH = 0x2592
18// Eighth blocks, from one eighth up to seven: a column's top lands on an eighth.
19const EIGHTHS = [0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587]
20
21export const GREEN = 0x6cc070
22export const ORANGE = 0xe0a040
23export const RED = 0xe06c75
24export const BLUE = 0x5fa8d3
25// The breakdown's categories take these in turn.
26export const PALETTE = [0x7aa2f7, 0xbb9af7, 0x9ece6a, 0xe0af68, 0xf7768e, 0x2ac3de, 0x73daca, 0xff9e64]
27
28const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
29
30// Padded base64; the module has neither btoa nor Buffer.
31export function base64(bytes: Uint8Array) {
32 let out = ''
33 for (let i = 0; i < bytes.length; i += 3) {
34 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
35 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
36 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
37 out += i + 2 < bytes.length ? B64[n & 63]! : '='
38 }
39
40 return out
41}
42
43// The few marks beyond ASCII the charts write, each one cell wide.
44const MARKS = new Set([...'↳−✓✗·…'].map(ch => ch.codePointAt(0)))
45
46// A Raster takes one printable, single-width BMP character per cell: plain
47// ASCII and the marks pass, anything else (emoji, CJK, controls) draws as ?.
48export function glyphs(text: string) {
49 return [...text].map(ch => {
50 const code = ch.codePointAt(0) ?? 0x3f
51
52 return (code >= 0x20 && code < 0x7f) || MARKS.has(code) ? code : 0x3f
53 })
54}
55
56// A blank grid of cells to draw into; writes past its edges are dropped.
57export function grid(columns: number, rows: number) {
58 const words = new Uint32Array(columns * rows * 3)
59 for (let at = 0; at < words.length; at += 3) {
60 words[at] = 0x20
61 words[at + 1] = DEFAULT
62 words[at + 2] = DEFAULT
63 }
64 const put = (row: number, col: number, glyph: number, fg: number, bg = DEFAULT) => {
65 if (row < 0 || row >= rows || col < 0 || col >= columns) {
66 return
67 }
68 const at = (row * columns + col) * 3
69 words[at] = glyph
70 words[at + 1] = fg
71 words[at + 2] = bg
72 }
73 const text = (row: number, col: number, value: string, fg: number, bg = DEFAULT) =>
74 glyphs(value).forEach((code, i) => put(row, col + i, code, fg, bg))
75
76 return {
77 put,
78 text,
79 done: () => ({ columns, rows, cells: base64(new Uint8Array(words.buffer)) }),
80 }
81}
82
83export function tokens(n: number) {
84 if (n >= 1_000_000) {
85 return `${(n / 1_000_000).toFixed(1)}M`
86 }
87
88 return n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : String(n)
89}
90
91export type Bar = {
92 label: string
93 start: number
94 end?: number
95 color: number
96 isSub: boolean
97}
98
99// A Gantt chart: one row per bar, labels on the left, time running left to
100// right from the earliest start to now, with a faint grid. A running bar's
101// leading cell pulses brighter on odd frames.
102export function gantt(bars: Bar[], now: number, wanted: number, frame = 0) {
103 const columns = Math.max(LABEL + 5, wanted)
104 const width = columns - LABEL - 1
105 const from = Math.min(now - 1000, ...bars.map(bar => bar.start))
106 const span = Math.max(1, now - from)
107 const g = grid(columns, bars.length)
108
109 bars.forEach((bar, row) => {
110 g.text(row, 0, (bar.isSub ? '↳ ' + bar.label : bar.label).slice(0, LABEL), bar.isSub ? DIM : BRIGHT)
111 for (let col = 0; col < width; col += 6) {
112 g.put(row, LABEL + 1 + col, 0x00b7, GRID)
113 }
114
115 // Half columns: a bar covers [a, b) and never less than one half.
116 const halves = width * 2
117 const a = Math.floor(((bar.start - from) / span) * halves)
118 const b = Math.max(a + 1, Math.ceil((((bar.end ?? now) - from) / span) * halves))
119 const head = Math.floor((Math.min(halves, b) - 1) / 2)
120 for (let half = Math.max(0, a); half < Math.min(halves, b); half += 1) {
121 const col = Math.floor(half / 2)
122 const hasLeft = col * 2 >= a && col * 2 < b
123 const hasRight = col * 2 + 1 >= a && col * 2 + 1 < b
124 const isPulse = bar.end === undefined && col === head && frame % 2 === 1
125 g.put(
126 row,
127 LABEL + 1 + col,
128 hasLeft && hasRight ? FULL : hasLeft ? LEFT : RIGHT,
129 isPulse ? PULSE : bar.color,
130 )
131 }
132 })
133
134 return { ...g.done(), from }
135}
136
137// One row of swatches and names, in the very colors the chart draws.
138export function legend(entries: [number, string][]) {
139 const columns = entries.reduce((sum, [, name], i) => sum + (i === 0 ? 0 : 2) + 2 + [...name].length, 0)
140 const g = grid(Math.max(1, columns), 1)
141 let col = 0
142 entries.forEach(([color, name], i) => {
143 col += i === 0 ? 0 : 2
144 g.put(0, col, FULL, color)
145 g.text(0, col + 1, ` ${name}`, DIM)
146 col += 2 + [...name].length
147 })
148
149 return g.done()
150}
151
152export type Beat = { at: number; kind: string; status: string }
153
154// Tool calls over the last stretch of time, one column per bucket, newest on
155// the right: the height is how many calls, the color what they were.
156export function pulse(beats: Beat[], now: number, columns: number, bucket: number) {
157 const g = grid(columns, 1)
158 const counts = Array.from({ length: columns }, () => ({ n: 0, edits: 0, failed: 0 }))
159 for (const beat of beats) {
160 const col = columns - 1 - Math.floor((now - beat.at) / bucket)
161 const slot = counts[col]
162 if (slot !== undefined) {
163 slot.n += 1
164 slot.edits += beat.kind === 'edit' ? 1 : 0
165 slot.failed += beat.status === 'failed' ? 1 : 0
166 }
167 }
168 const most = Math.max(4, ...counts.map(slot => slot.n))
169 counts.forEach((slot, col) => {
170 if (slot.n === 0) {
171 g.put(0, col, EIGHTHS[0]!, GRID)
172
173 return
174 }
175 const level = Math.max(1, Math.round((slot.n / most) * 8))
176 const color = slot.failed > 0 ? RED : slot.edits > 0 ? ORANGE : BLUE
177 g.put(0, col, level >= 8 ? FULL : EIGHTHS[level - 1]!, color)
178 })
179
180 return g.done()
181}
182
183// A meter of how full the window is, with a mark where compaction comes.
184export function gauge(fraction: number, columns: number, mark?: number) {
185 const g = grid(columns, 1)
186 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * columns)
187 const color = fraction < 0.5 ? GREEN : fraction < 0.8 ? ORANGE : RED
188 for (let col = 0; col < columns; col++) {
189 g.put(0, col, col < filled ? FULL : SHADE, col < filled ? color : GRID)
190 }
191 if (mark !== undefined && mark > 0 && mark < 1) {
192 g.put(0, Math.min(columns - 1, Math.round(mark * columns)), 0x2503, 0xf0f0f0)
193 }
194
195 return g.done()
196}
197
198// The window split by what fills it, each part in its palette color, the
199// free space shaded and the compaction buffer hatched at the end.
200export function stacked(parts: { tokens: number; color: number }[], window: number, buffer: number, columns: number) {
201 const g = grid(columns, 1)
202 const scale = columns / Math.max(1, window)
203 let col = 0
204 for (const part of parts) {
205 const width = Math.max(1, Math.round(part.tokens * scale))
206 for (let i = 0; i < width && col < columns; i++, col++) {
207 g.put(0, col, FULL, part.color)
208 }
209 }
210 const bufferFrom = columns - Math.round(buffer * scale)
211 for (; col < columns; col++) {
212 g.put(0, col, col >= bufferFrom ? HATCH : SHADE, col >= bufferFrom ? RED : GRID)
213 }
214
215 return g.done()
216}
217
218// An area chart of fractions of the window, one group of columns per value,
219// with a faint line at the compaction mark.
220export function area(values: number[], columns: number, rows: number, mark?: number) {
221 const g = grid(columns, rows)
222 const each = Math.max(1, Math.min(4, Math.floor(columns / Math.max(1, values.length))))
223 const shown = values.slice(-Math.floor(columns / each))
224 const markRow = mark === undefined ? undefined : rows - 1 - Math.floor(mark * rows)
225 if (markRow !== undefined && markRow >= 0) {
226 for (let col = 0; col < columns; col++) {
227 g.put(markRow, col, 0x2500, 0x5a3030)
228 }
229 }
230 shown.forEach((value, i) => {
231 const eighths = Math.round(Math.min(1, Math.max(0, value)) * rows * 8)
232 const color = value < 0.5 ? GREEN : value < 0.8 ? ORANGE : RED
233 for (let col = i * each; col < (i + 1) * each - (each > 1 ? 1 : 0); col++) {
234 for (let row = rows - 1, left = eighths; row >= 0 && left > 0; row--, left -= 8) {
235 g.put(row, col, left >= 8 ? FULL : EIGHTHS[left - 1]!, color)
236 }
237 }
238 })
239
240 return g.done()
241}
242
243export type Tile = { label: string; weight: number; color: number }
244
245type Rect = { x: number; y: number; w: number; h: number }
246
247// The squarified treemap: rows of tiles laid along the short side, each row
248// closed when one more tile would make its tiles less square.
249function squarify(weights: number[], box: Rect): Rect[] {
250 const total = weights.reduce((sum, w) => sum + w, 0) || 1
251 const areas = weights.map(w => (w / total) * box.w * box.h)
252 const rects: Rect[] = []
253 let free = { ...box }
254 let row: number[] = []
255 const worst = (list: number[], side: number) => {
256 const sum = list.reduce((a, b) => a + b, 0)
257
258 return Math.max(...list.map(a => Math.max((side * side * a) / (sum * sum), (sum * sum) / (side * side * a))))
259 }
260 const place = (list: number[]) => {
261 const sum = list.reduce((a, b) => a + b, 0)
262 if (free.w >= free.h) {
263 const w = sum / free.h
264 let y = free.y
265 for (const a of list) {
266 rects.push({ x: free.x, y, w, h: a / w })
267 y += a / w
268 }
269 free = { x: free.x + w, y: free.y, w: free.w - w, h: free.h }
270 } else {
271 const h = sum / free.w
272 let x = free.x
273 for (const a of list) {
274 rects.push({ x, y: free.y, w: a / h, h })
275 x += a / h
276 }
277 free = { x: free.x, y: free.y + h, w: free.w, h: free.h - h }
278 }
279 }
280 for (const a of areas) {
281 const side = Math.min(free.w, free.h)
282 if (row.length === 0 || worst([...row, a], side) <= worst(row, side)) {
283 row.push(a)
284 } else {
285 place(row)
286 row = [a]
287 }
288 }
289 if (row.length > 0) {
290 place(row)
291 }
292
293 return rects
294}
295
296// Tiles sized by weight, each a block of its color with its label inside
297// where it fits. A cell is about twice as tall as wide, so the layout runs
298// in half rows to keep tiles square on screen.
299export function treemap(tiles: Tile[], columns: number, rows: number) {
300 const g = grid(columns, rows)
301 const sorted = [...tiles].sort((a, b) => b.weight - a.weight)
302 const rects = squarify(sorted.map(tile => tile.weight), { x: 0, y: 0, w: columns, h: rows * 2 })
303 const placed: { tile: Tile; col: number; row: number; width: number; height: number }[] = []
304 rects.forEach((rect, i) => {
305 const tile = sorted[i]!
306 const col = Math.round(rect.x)
307 const row = Math.round(rect.y / 2)
308 const width = Math.round(rect.x + rect.w) - col
309 const height = Math.round((rect.y + rect.h) / 2) - row
310 if (width <= 0 || height <= 0) {
311 return
312 }
313 // A gap on the right and the bottom keeps neighbours apart.
314 const fillW = width > 2 ? width - 1 : width
315 const fillH = height > 1 ? height - 1 : height
316 for (let r = row; r < row + fillH; r++) {
317 for (let c = col; c < col + fillW; c++) {
318 g.put(r, c, 0x20, DEFAULT, tile.color)
319 }
320 }
321 if (fillW >= 3) {
322 g.text(row, col, tile.label.slice(0, fillW), 0xf4f4f4, tile.color)
323 }
324 placed.push({ tile, col, row, width, height })
325 })
326
327 return { ...g.done(), placed }
328}
329
330// A color between reads (blue) and edits (orange) by how much was edited.
331export function touchColor(reads: number, edits: number) {
332 const t = edits / Math.max(1, reads + edits)
333 const mix = (from: number, to: number, shift: number) =>
334 Math.round(((from >> shift) & 0xff) * (1 - t) + ((to >> shift) & 0xff) * t) << shift
335
336 return mix(0x2f5f80, 0xb4532f, 16) | mix(0x2f5f80, 0xb4532f, 8) | mix(0x2f5f80, 0xb4532f, 0)
337}
338
339const NAMED = [
340 // bun, claude plugin test: (pass) name [12ms]
341 { re: /^\s*\((pass|fail)\)\s+(.+?)(?:\s+\[[^\]]*\])?\s*$/, ok: (m: RegExpMatchArray) => m[1] === 'pass', name: 2 },
342 // jest, vitest, mocha: ✓ name (5 ms)
343 { re: /^\s*([✓✔√✕✗✘×])\s+(.+?)(?:\s+\(\d+(?:\.\d+)?\s*m?s\))?\s*$/, ok: (m: RegExpMatchArray) => '✓✔√'.includes(m[1]!), name: 2 },
344 // pytest -v: tests/x.py::test_a PASSED
345 { re: /^(\S+::\S+)\s+(PASSED|FAILED|ERROR)\b/, ok: (m: RegExpMatchArray) => m[2] === 'PASSED', name: 1 },
346 // cargo: test a::b ... ok
347 { re: /^test\s+(\S+)\s+\.\.\.\s+(ok|FAILED)/, ok: (m: RegExpMatchArray) => m[2] === 'ok', name: 1 },
348 // go test -v: --- PASS: TestX
349 { re: /^\s*--- (PASS|FAIL): (\S+)/, ok: (m: RegExpMatchArray) => m[1] === 'PASS', name: 2 },
350]
351
352const lastCount = (text: string, re: RegExp) => {
353 const all = [...text.matchAll(re)]
354
355 return all.length === 0 ? undefined : Number(all.at(-1)![1])
356}
357
358// Pass and fail counts and the tests named, from a test runner's output;
359// nothing when the output looks like no test run.
360export function parseTests(text: string) {
361 const names: { name: string; ok: boolean }[] = []
362 for (const line of text.split('\n')) {
363 for (const { re, ok, name } of NAMED) {
364 const m = line.match(re)
365 if (m !== null) {
366 names.push({ name: m[name]!.trim(), ok: ok(m) })
367 break
368 }
369 }
370 }
371 const passed = lastCount(text, /(\d+)\s+pass(?:ed|ing|es)?\b/gi)
372 const failed = lastCount(text, /(\d+)\s+fail(?:ed|ing|ures?|s)?\b/gi)
373 if (passed === undefined && failed === undefined && names.length === 0) {
374 return undefined
375 }
376
377 return {
378 passed: passed ?? names.filter(one => one.ok).length,
379 failed: failed ?? names.filter(one => !one.ok).length,
380 names: names.slice(0, 200),
381 }
382}
383
384// Tests that both passed and failed across the runs, flipping at least twice.
385export function flaky(runs: TestRun[]) {
386 const seen = new Map<string, boolean[]>()
387 for (const run of runs) {
388 for (const { name, ok } of run.names) {
389 seen.set(name, [...(seen.get(name) ?? []), ok])
390 }
391 }
392
393 return new Set(
394 [...seen].filter(([, oks]) => oks.slice(1).filter((ok, i) => ok !== oks[i]).length >= 2).map(([name]) => name),
395 )
396}
397
398// Runs across, tests down: the top row says how each run went, each row
399// below how one test did in each run, a dot where the run did not name it.
400export function scoreboard(runs: TestRun[], wanted: number, most: number) {
401 const width = Math.max(1, Math.floor((wanted - LABEL - 1) / 2))
402 const shown = runs.slice(-width)
403 const last = shown.at(-1)
404 const order = new Map<string, number>()
405 shown.forEach((run, i) => run.names.forEach(({ name }) => order.set(name, i)))
406 const failingNow = new Set(last?.names.filter(one => !one.ok).map(one => one.name) ?? [])
407 const shaky = flaky(shown)
408 const names = [...order]
409 .sort(([a, i], [b, j]) => Number(failingNow.has(b)) - Number(failingNow.has(a)) || j - i)
410 .map(([name]) => name)
411 .slice(0, most)
412 const columns = LABEL + 1 + shown.length * 2
413 const g = grid(Math.max(LABEL + 2, columns), names.length + 1)
414
415 g.text(0, 0, 'runs', BRIGHT)
416 shown.forEach((run, i) => {
417 g.put(0, LABEL + 1 + i * 2, FULL, run.failed > 0 || run.isError ? RED : GREEN)
418 })
419 names.forEach((name, row) => {
420 g.text(row + 1, 0, name.slice(0, LABEL), shaky.has(name) ? ORANGE : DIM)
421 shown.forEach((run, i) => {
422 const result = run.names.find(one => one.name === name)
423 g.put(
424 row + 1,
425 LABEL + 1 + i * 2,
426 result === undefined ? 0x00b7 : 0x2586,
427 result === undefined ? GRID : result.ok ? GREEN : RED,
428 )
429 })
430 })
431
432 return { ...g.done(), names, shaky }
433}
434
435// `git log --graph` written with %x1e before a commit's fields and %x1f
436// between them, and --shortstat: commits with their stats, and the graph's
437// joins and forks between them.
438export function parseLog(stdout: string): CommitRow[] {
439 const rows: CommitRow[] = []
440 for (const line of stdout.split('\n')) {
441 if (line.includes('\x1e')) {
442 const [graph = '', fields = ''] = line.split('\x1e')
443 const [sha, subject, when, refs] = fields.split('\x1f')
444 rows.push({
445 graph: graph.trimEnd(),
446 ...(sha === undefined ? {} : { sha }),
447 ...(subject === undefined ? {} : { subject }),
448 ...(when === undefined ? {} : { when }),
449 ...(refs === undefined || refs === '' ? {} : { refs }),
450 })
451 continue
452 }
453 const stat = line.match(/\d+ files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/)
454 const commit = rows.findLast(row => row.sha !== undefined)
455 if (stat !== null && commit !== undefined) {
456 commit.added = Number(stat[1] ?? 0)
457 commit.removed = Number(stat[2] ?? 0)
458 continue
459 }
460 // The vertical runs between commits say nothing; forks and joins do.
461 const graph = line.trimEnd()
462 if (/[/\\]/.test(graph)) {
463 rows.push({ graph })
464 }
465 }
466
467 return rows
468}
469
470// A share bar under a transcript row: a count in words, then a bar whose
471// parts are in proportion, at most `most` cells long.
472export function shareBar(parts: { n: number; color: number; word: string }[], most: number) {
473 const words = parts.map(part => `${part.word}`).join(' ')
474 const total = parts.reduce((sum, part) => sum + part.n, 0)
475 const width = Math.min(most, Math.max(1, total))
476 const cells = parts.map(part => (total === 0 ? 0 : Math.round((part.n / total) * width)))
477 // Every part that has any shows at least one cell, taken from the largest.
478 parts.forEach((part, i) => {
479 if (part.n > 0 && cells[i] === 0) {
480 cells[i] = 1
481 }
482 })
483 while (cells.reduce((a, b) => a + b, 0) > width) {
484 const most = cells.indexOf(Math.max(...cells))
485 cells[most] = cells[most]! - 1
486 }
487 const g = grid([...words].length + 1 + cells.reduce((a, b) => a + b, 0), 1)
488 let col = 0
489 parts.forEach((part, i) => {
490 g.text(0, col, part.word, part.color)
491 col += [...part.word].length + (i < parts.length - 1 ? 1 : 0)
492 })
493 col += 1
494 parts.forEach((part, i) => {
495 for (let n = 0; n < cells[i]!; n++, col++) {
496 g.put(0, col, FULL, part.color)
497 }
498 })
499
500 return g.done()
501}
502
503// Lines added and removed by an Edit or Write, from the tool's own record.
504export function patchCounts(output: unknown) {
505 const record = (output ?? {}) as {
506 type?: string
507 content?: string
508 structuredPatch?: { lines?: string[] }[]
509 }
510 const lines = (record.structuredPatch ?? []).flatMap(hunk => hunk.lines ?? [])
511 if (lines.length === 0 && record.type === 'create' && typeof record.content === 'string') {
512 return { added: record.content.split('\n').length, removed: 0 }
513 }
514
515 return {
516 added: lines.filter(line => line.startsWith('+')).length,
517 removed: lines.filter(line => line.startsWith('-')).length,
518 }
519}
520hooks/explorer.ts 140 lines1// The Explorer tab's tree, built from a flat list of the project's files.
2
3export type Row = {
4 path: string
5 name: string
6 depth: number
7 isDir: boolean
8 isOpen: boolean
9}
10
11type Node = { dirs: Map<string, Node>; files: string[] }
12
13function build(paths: readonly string[]) {
14 const root: Node = { dirs: new Map(), files: [] }
15 for (const path of paths) {
16 const parts = path.split('/')
17 let node = root
18 for (const part of parts.slice(0, -1)) {
19 let next = node.dirs.get(part)
20 if (next === undefined) {
21 next = { dirs: new Map(), files: [] }
22 node.dirs.set(part, next)
23 }
24 node = next
25 }
26 node.files.push(parts.at(-1)!)
27 }
28
29 return root
30}
31
32// The rows the tree shows: folders first, then files, each sorted, a
33// folder's contents only while it is open.
34export function treeRows(paths: readonly string[], open: ReadonlySet<string>): Row[] {
35 const rows: Row[] = []
36 const walk = (node: Node, prefix: string, depth: number) => {
37 for (const name of [...node.dirs.keys()].sort()) {
38 const path = prefix + name
39 const isOpen = open.has(path)
40 rows.push({ path, name, depth, isDir: true, isOpen })
41 if (isOpen) {
42 walk(node.dirs.get(name)!, `${path}/`, depth + 1)
43 }
44 }
45 for (const name of [...node.files].sort()) {
46 rows.push({ path: prefix + name, name, depth, isDir: false, isOpen: false })
47 }
48 }
49 walk(build(paths), '', 0)
50
51 return rows
52}
53
54// How well a path matches a filter typed in pieces: every character in
55// order, scored higher for a run of them and for a hit in the file's name.
56export function score(path: string, filter: string) {
57 const hay = path.toLowerCase()
58 const needle = filter.toLowerCase().replace(/\s+/g, '')
59 if (needle === '') {
60 return 0
61 }
62 const nameAt = hay.lastIndexOf('/') + 1
63 let at = -1
64 let total = 0
65 let run = 0
66 for (const ch of needle) {
67 const found = hay.indexOf(ch, at + 1)
68 if (found < 0) {
69 return undefined
70 }
71 run = found === at + 1 ? run + 1 : 0
72 total += 1 + run * 2 + (found >= nameAt ? 2 : 0)
73 at = found
74 }
75
76 return total - path.length / 100
77}
78
79// The files a filter finds, best first.
80export function matches(paths: readonly string[], filter: string, most: number) {
81 return paths
82 .flatMap(path => {
83 const found = score(path, filter)
84
85 return found === undefined ? [] : [{ path, found }]
86 })
87 .sort((a, b) => b.found - a.found)
88 .slice(0, most)
89 .map(one => one.path)
90}
91
92// `git status --porcelain` read into each path's two-letter code.
93export function parseStatus(stdout: string) {
94 const codes: Record<string, string> = {}
95 for (const line of stdout.split('\n')) {
96 if (line.length > 3) {
97 const path = line.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, '')
98 codes[path] = line.slice(0, 2).trim() || line.slice(0, 2)
99 }
100 }
101
102 return codes
103}
104
105// `git status --porcelain=v1 --branch`: the branch, how far it is ahead of
106// and behind its upstream, and each file's two columns.
107export function parseGit(stdout: string) {
108 const lines = stdout.split('\n').filter(line => line !== '')
109 const head = lines[0]?.startsWith('## ') ? lines.shift()!.slice(3) : ''
110 // `work...origin/work [ahead 1]`, or `work [ahead 1]` with no upstream.
111 const branch = head.replace(/^No commits yet on /, '').split(/\.\.\.| /)[0] ?? ''
112 const ahead = Number(head.match(/ahead (\d+)/)?.[1] ?? 0)
113 const behind = Number(head.match(/behind (\d+)/)?.[1] ?? 0)
114 const files = lines.map(line => ({
115 path: line.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''),
116 index: line[0] ?? ' ',
117 worktree: line[1] ?? ' ',
118 }))
119
120 return { branch, ahead, behind, files }
121}
122
123// `rg --line-number --no-heading` (or `git grep -n`) output read into hits.
124export function parseHits(stdout: string, most: number) {
125 const hits: { file: string; line: number; text: string }[] = []
126 for (const row of stdout.split('\n')) {
127 const m = row.match(/^(.+?):(\d+):(.*)$/)
128 if (m !== null) {
129 hits.push({ file: m[1]!, line: Number(m[2]), text: m[3]!.trim().slice(0, 300) })
130 }
131 }
132
133 return { hits: hits.slice(0, most), isCut: hits.length > most }
134}
135
136// Terminal colour codes and carriage-return rewrites out of a line of output.
137export function plain(line: string) {
138 return (line.split('\r').at(-1) ?? '').replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b[()][0-9A-Za-z]/g, '')
139}
140hooks/design.ts 422 lines1// Designs Claude describes by their structure, laid out by the tool that
2// reads each source: D2, mermaid or Graphviz for a diagram, a browser for an
3// HTML mockup dressed by the wireframe kit below.
4
5export type Drawn =
6 | { svg: string }
7 | { d2: string }
8 | { mermaid: string }
9 | { dot: string }
10 | { html: string; width: number; height?: number }
11
12// The page each design sits on, in Claude Code's light or dark theme.
13export const PAPER = { light: '#f6f5f1', dark: '#1d1e21' }
14
15// The wireframe kit: layout and parts as classes, colors as variables, so a
16// mockup says what goes where and the browser places it.
17export const KIT = `
18:root{--paper:${PAPER.light};--panel:#fff;--ink:#1f2328;--muted:#6e7781;--line:#c4c8cc;--fill:#e9eaec;--accent:#d97757;--accent-ink:#fff;--good:#2e9e5b;--bad:#d1453b;--gap:12px;--radius:8px;color-scheme:light}
19:root[data-theme=dark]{--paper:${PAPER.dark};--panel:#26282c;--ink:#e8e8e6;--muted:#9ba1a8;--line:#464a51;--fill:#33363b;--accent:#e08a6a;--accent-ink:#1d1e21;--good:#4cc27a;--bad:#ef6b60;color-scheme:dark}
20*{box-sizing:border-box}
21html,body{margin:0}
22body{background:var(--paper);color:var(--ink);font:15px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px}
23h1,h2,h3,p{margin:0}h1{font-size:26px}h2{font-size:20px}h3{font-size:16px}
24.row,.col{display:flex;gap:var(--gap)}.col{flex-direction:column}
25.grid{display:grid;gap:var(--gap);grid-template-columns:repeat(var(--cols,3),minmax(0,1fr))}
26.grow{flex:1 1 0;min-width:0;min-height:0}.wrap{flex-wrap:wrap}
27.between{justify-content:space-between}.center{justify-content:center;align-items:center}.middle{align-items:center}.end{justify-content:flex-end}
28.tight{--gap:6px}.loose{--gap:24px}.pad{padding:14px}
29.box{background:var(--panel);border:1.5px solid var(--line);border-radius:var(--radius);padding:14px}
30.window{background:var(--panel);border:1.5px solid var(--line);border-radius:10px;overflow:hidden}
31.window::before{content:attr(data-title);display:block;padding:7px 12px;background:var(--fill);border-bottom:1.5px solid var(--line);color:var(--muted);font-size:13px;line-height:19px;height:34px;box-sizing:border-box}
32:is(.pad,.box).window::before{margin:-14px -14px 14px}
33.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:7px 16px;border:1.5px solid var(--line);border-radius:var(--radius);background:var(--panel);font-weight:500;white-space:nowrap}
34.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}.btn.ghost{background:none;border-color:transparent}
35.input{display:flex;align-items:center;min-height:36px;padding:7px 12px;border:1.5px solid var(--line);border-radius:var(--radius);background:var(--paper);color:var(--muted)}
36.check::before{content:"";display:inline-block;width:15px;height:15px;margin-right:8px;vertical-align:-2px;border:1.5px solid var(--line);border-radius:4px}
37.check.on::before{background:var(--accent);border-color:var(--accent)}
38.slider,.bar{position:relative;height:8px;border-radius:4px;background:linear-gradient(to right,var(--color,var(--accent)) var(--value,50%),var(--fill) var(--value,50%))}
39.bar{height:12px;border-radius:6px}
40.slider::after{content:"";position:absolute;top:50%;left:var(--value,50%);width:18px;height:18px;transform:translate(-50%,-50%);border-radius:50%;background:var(--panel);border:2px solid var(--color,var(--accent))}
41.tabs{display:flex;gap:4px;border-bottom:1.5px solid var(--line)}.tabs>*{padding:8px 14px;color:var(--muted)}
42.tabs>.on{color:var(--ink);border-bottom:2.5px solid var(--accent);margin-bottom:-1.5px}
43.list{display:flex;flex-direction:column}.list>*{padding:10px 12px;border-bottom:1px solid var(--line)}.list>:last-child{border-bottom:0}.list>.on{background:var(--fill)}
44.img{display:flex;align-items:center;justify-content:center;min-height:80px;color:var(--muted);border:1.5px solid var(--line);border-radius:var(--radius);background:linear-gradient(to top right,transparent calc(50% - 1px),var(--line) 50%,transparent calc(50% + 1px)),linear-gradient(to bottom right,transparent calc(50% - 1px),var(--line) 50%,transparent calc(50% + 1px)),var(--fill)}
45.icon{flex:none;width:20px;height:20px;border-radius:5px;background:var(--fill);border:1.5px solid var(--line)}
46.avatar{flex:none;width:36px;height:36px;border-radius:50%;background:var(--fill);border:1.5px solid var(--line)}
47.lines{height:calc(var(--n,3) * 18px - 8px);background:repeating-linear-gradient(var(--fill) 0 10px,transparent 10px 18px);border-radius:3px}
48.tag{display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:var(--fill);color:var(--muted);font-size:12px}
49hr,.hr{align-self:stretch;margin:0;border:0;border-top:1.5px solid var(--line)}
50.muted{color:var(--muted)}.small{font-size:13px}.bold{font-weight:600}
51.note{padding:6px 10px;border:1.5px dashed var(--accent);border-radius:6px;color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,transparent);font-size:13px}
52.mark{display:inline-flex;flex:none;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;background:var(--accent);color:var(--accent-ink);font-size:12px;font-weight:700}
53.stage,.screen{position:relative;overflow:hidden;background:linear-gradient(transparent 49px,color-mix(in srgb,var(--line) 35%,transparent) 50px) 0 0/50px 50px,linear-gradient(90deg,transparent 49px,color-mix(in srgb,var(--line) 35%,transparent) 50px) 0 0/50px 50px,linear-gradient(color-mix(in srgb,var(--accent) 10%,var(--paper)),var(--paper))}
54.stage{aspect-ratio:var(--aspect,16/9);border:1.5px solid var(--line);border-radius:var(--radius)}
55.screen{position:fixed;inset:0}
56:is(.stage,.screen)>:is(.tl,.tc,.tr,.ml,.mc,.mr,.bl,.bc,.br){position:absolute;margin:16px}
57:is(.stage,.screen)>:is(.tl,.tc,.tr){top:0}:is(.stage,.screen)>:is(.bl,.bc,.br){bottom:0}
58:is(.stage,.screen)>:is(.tl,.ml,.bl){left:0}:is(.stage,.screen)>:is(.tr,.mr,.br){right:0}
59:is(.stage,.screen)>:is(.tc,.bc,.mc){left:50%;translate:-50% 0}:is(.stage,.screen)>:is(.ml,.mr,.mc){top:50%;translate:0 -50%}
60:is(.stage,.screen)>.mc{translate:-50% -50%}
61`
62
63// Set once the page has loaded and its fonts are in: the height its content
64// takes, read back from the dumped DOM.
65const MEASURE =
66 '<script>addEventListener("load",()=>document.fonts.ready.then(()=>{document.documentElement.dataset.height=String(Math.ceil(document.documentElement.scrollHeight))}))</script>'
67
68// The page an HTML mockup is drawn from: the kit, then Claude's markup. A
69// whole document works too; the parser folds its html, head and body in.
70export function page(html: string, isLight: boolean) {
71 const theme = isLight ? 'light' : 'dark'
72
73 return `<!doctype html><html data-theme="${theme}"><head><meta charset="utf-8"><style>${KIT}</style></head><body>${html}${MEASURE}</body></html>`
74}
75
76// The page that frames an SVG at its own size, for the browser to draw.
77export function frame(svg: string) {
78 return `<!doctype html><style>html,body{margin:0}img{display:block}</style><img src="${svg}">`
79}
80
81// The content height MEASURE left in a dumped DOM, within what a picture
82// may take.
83export function measured(dom: string) {
84 const found = /data-height="(\d+)"/.exec(dom)
85
86 return found === null ? undefined : Math.max(1, Math.min(LONGEST, Number(found[1])))
87}
88
89// The size an SVG declares on its root element, as d2 writes it.
90export function svgSize(svg: string) {
91 const root = /<svg\b[^>]*>/.exec(svg)?.[0] ?? ''
92 const width = Number(/\swidth="([\d.]+)"/.exec(root)?.[1] ?? 0)
93 const height = Number(/\sheight="([\d.]+)"/.exec(root)?.[1] ?? 0)
94
95 return width > 0 && height > 0 ? { width: Math.ceil(width), height: Math.ceil(height) } : undefined
96}
97
98// The longest side a picture is drawn at; the API takes up to 8000.
99export const LONGEST = 6000
100
101// Where a browser that draws headless may be: on PATH, in macOS's
102// Applications, or the one puppeteer fetched for mermaid-cli.
103export const FIND_BROWSER = [
104 'for b in chromium chromium-browser google-chrome-stable google-chrome chrome microsoft-edge brave-browser; do command -v "$b" && exit 0; done',
105 'for b in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "/Applications/Chromium.app/Contents/MacOS/Chromium" "$HOME"/.cache/puppeteer/chrome-headless-shell/*/*/chrome-headless-shell; do [ -x "$b" ] && echo "$b" && exit 0; done',
106 'exit 1',
107].join('; ')
108
109// A headless browser on its own throwaway profile, so it never joins a
110// browser the person has open.
111export function browserArgs(browser: string, profile: string) {
112 return [
113 browser,
114 '--headless',
115 '--disable-gpu',
116 '--hide-scrollbars',
117 '--no-first-run',
118 '--no-default-browser-check',
119 '--force-device-scale-factor=1',
120 '--virtual-time-budget=2000',
121 `--user-data-dir=${profile}`,
122 ]
123}
124
125// D2's own themes: Neutral default, and Dark Mauve.
126export function d2Args(isLight: boolean, from: string, to: string) {
127 return ['d2', `--theme=${isLight ? 0 : 200}`, '--pad=40', '--scale=1', from, to]
128}
129
130// Graphviz in the theme's colors, nodes as rounded boxes; what the source
131// sets wins over these defaults.
132export function dotArgs(isLight: boolean, from: string, to: string) {
133 const ink = isLight ? '#1f2328' : '#e8e8e6'
134 const line = isLight ? '#57606a' : '#9ba1a8'
135 const paper = isLight ? PAPER.light : PAPER.dark
136 const font = 'sans-serif'
137
138 return [
139 'dot',
140 '-Tpng',
141 '-Gdpi=144',
142 `-Gbgcolor=${paper}`,
143 `-Gcolor=${line}`,
144 `-Gfontcolor=${ink}`,
145 `-Gfontname=${font}`,
146 '-Gpad=0.3',
147 '-Nshape=box',
148 '-Nstyle=rounded',
149 `-Ncolor=${line}`,
150 `-Nfontcolor=${ink}`,
151 `-Nfontname=${font}`,
152 `-Ecolor=${line}`,
153 `-Efontcolor=${ink}`,
154 `-Efontname=${font}`,
155 '-Efontsize=11',
156 '-o',
157 to,
158 from,
159 ]
160}
161
162// mermaid-cli on the theme's paper, drawn at `scale` so it stays sharp.
163export function mermaidArgs(isLight: boolean, from: string, to: string, scale: number) {
164 const paper = isLight ? '#ffffff' : PAPER.dark
165
166 return ['mmdc', '-q', '-i', from, '-o', to, '-t', isLight ? 'default' : 'dark', '-b', paper, '-s', String(scale)]
167}
168
169// What a renderer said went wrong, short enough to read: its first lines,
170// past the noise of stack frames and browser warnings.
171export function errorLines(stderr: string) {
172 return stderr
173 .split('\n')
174 .map(line => line.trimEnd())
175 .filter(line => line.trim() !== '' && !/^\s+at\s|^\[\d+\/\d+\.\d+:|^\s*\^?\s*$/.test(line))
176 .slice(0, 6)
177 .map(line => (line.length > 200 ? `${line.slice(0, 199)}…` : line))
178 .join('\n')
179}
180
181// A picture fetched from the web: http or https only, redirects followed,
182// at most 20 MB in 30 seconds, under a name that says who asks.
183export function curlArgs(url: string, to: string) {
184 return [
185 'curl',
186 '-fsSL',
187 '--proto',
188 '=http,https',
189 '--max-filesize',
190 String(20 * 1024 * 1024),
191 '--max-time',
192 '30',
193 '-A',
194 'show-work (Claude Code plugin)',
195 '-o',
196 to,
197 url,
198 ]
199}
200
201export type Format = 'png' | 'jpeg' | 'gif' | 'webp' | 'bmp' | 'svg'
202
203// The format a file's first bytes say it is, so ImageMagick reads a fetched
204// file with that one coder and no other; undefined for anything else.
205export function sniff(head: string): Format | undefined {
206 if (head.startsWith('\x89PNG')) {
207 return 'png'
208 }
209 if (head.startsWith('\xff\xd8\xff')) {
210 return 'jpeg'
211 }
212 if (head.startsWith('GIF8')) {
213 return 'gif'
214 }
215 if (head.startsWith('RIFF') && head.slice(8, 12) === 'WEBP') {
216 return 'webp'
217 }
218 if (head.startsWith('BM')) {
219 return 'bmp'
220 }
221 const text = head.replace(/^\xef\xbb\xbf/, '').trimStart()
222
223 return text.startsWith('<') && /<svg\b/i.test(text) ? 'svg' : undefined
224}
225
226// `od -An -tx1` output back to the bytes it shows, one char per byte.
227export function fromHex(dump: string) {
228 return (dump.match(/[0-9a-f]{2}/gi) ?? []).map(pair => String.fromCharCode(parseInt(pair, 16))).join('')
229}
230
231// The name a picture goes by: its file's name, or a URL's last part.
232export function nameOf(src: string) {
233 if (/^https?:\/\//i.test(src)) {
234 try {
235 const url = new URL(src)
236 const last = url.pathname.slice(url.pathname.lastIndexOf('/') + 1)
237
238 return decodeURIComponent(last) || url.host
239 } catch {
240 return src
241 }
242 }
243
244 return src.slice(src.lastIndexOf('/') + 1)
245}
246
247// The most pictures a sheet holds, and the longest side of each on it.
248export const SHEET_MOST = 9
249export const TILE = 480
250
251// How many pictures a sheet sets across: one row up to four, so a sheet is
252// wide and fills the chat's width; then rows of three or four.
253export function across(count: number) {
254 return count <= 4 ? count : count <= 6 || count === 9 ? 3 : 4
255}
256
257// The factor that brings a small picture, such as a sprite, up to half a
258// tile with whole pixels; 1 for a picture already that big.
259export function blowUp(width: number, height: number) {
260 const longest = Math.max(width, height)
261
262 return longest > 0 && longest < TILE / 2 ? Math.floor(TILE / 2 / longest) : 1
263}
264
265// A label as ImageMagick takes it literally: % starts an escape, and a
266// leading @ names a file to read.
267export function literal(label: string) {
268 return label.replaceAll('%', '%%').replace(/^@/, '\\@')
269}
270
271// Pictures side by side on the theme's paper, each over its label.
272export function sheetArgs(isLight: boolean, tiles: { file: string; label: string }[], to: string, font?: string) {
273 return [
274 'magick',
275 'montage',
276 ...tiles.flatMap(tile => ['-label', literal(tile.label), tile.file]),
277 '-tile',
278 `${across(tiles.length)}x`,
279 // Each picture already fits a tile; a cell takes the biggest's size.
280 '-geometry',
281 '+16+16',
282 '-background',
283 isLight ? PAPER.light : PAPER.dark,
284 '-fill',
285 isLight ? '#1f2328' : '#e8e8e6',
286 ...(font === undefined ? [] : ['-font', font]),
287 '-pointsize',
288 '20',
289 '-depth',
290 '8',
291 to,
292 ]
293}
294
295// A mark on a picture, in its own pixels: a box when it has a width and
296// height, a ring round a spot when it has neither. Its note names it in the
297// card under the picture; its detail shows there while it is hovered.
298export type Mark = { x: number; y: number; w?: number; h?: number; note: string; detail?: string }
299
300// The accent marks are drawn in, as the wireframe kit's.
301const ACCENT = { light: '#d97757', dark: '#e08a6a' }
302
303// An offset from the picture's center, as -annotate takes it under
304// -gravity Center.
305const offset = (dx: number, dy: number) =>
306 `${dx < 0 ? '-' : '+'}${Math.abs(Math.round(dx))}${dy < 0 ? '-' : '+'}${Math.abs(Math.round(dy))}`
307
308// The sizes marks are drawn at on a picture: the line, the badge's radius,
309// and a ring's.
310function markSizes(size: { width: number; height: number }) {
311 const longest = Math.max(size.width, size.height)
312 const r = Math.max(12, Math.round(longest / 60))
313
314 return { stroke: Math.max(3, Math.round(longest / 300)), r, ring: Math.round(r * 1.3) }
315}
316
317// Marks drawn on a picture, each numbered on a badge kept inside the
318// edges. Their notes go in the card under it, not on the picture.
319export function markArgs(
320 from: string,
321 to: string,
322 marks: Mark[],
323 size: { width: number; height: number },
324 isLight: boolean,
325 font?: string,
326) {
327 const { width, height } = size
328 const accent = isLight ? ACCENT.light : ACCENT.dark
329 const paper = isLight ? PAPER.light : PAPER.dark
330 const { stroke, r, ring } = markSizes(size)
331 const inside = (value: number, most: number) => Math.round(Math.min(Math.max(value, r), most - r))
332 const typeface = font === undefined ? [] : ['-font', font]
333 const drawn = marks.flatMap((mark, n) => {
334 const isBox = (mark.w ?? 0) > 0 && (mark.h ?? 0) > 0
335 const shape = isBox
336 ? `roundrectangle ${Math.round(mark.x)},${Math.round(mark.y)} ${Math.round(mark.x + (mark.w ?? 0))},${Math.round(mark.y + (mark.h ?? 0))} 8,8`
337 : `circle ${Math.round(mark.x)},${Math.round(mark.y)} ${Math.round(mark.x + ring)},${Math.round(mark.y)}`
338 const cx = inside(isBox ? mark.x : mark.x + ring * 0.7, width)
339 const cy = inside(isBox ? mark.y : mark.y - ring * 0.7, height)
340
341 return [
342 '-fill', 'none', '-stroke', accent, '-strokewidth', String(stroke), '-draw', shape,
343 '-fill', accent, '-stroke', paper, '-strokewidth', '2', '-draw', `circle ${cx},${cy} ${cx + r},${cy}`,
344 '-fill', 'white', '-stroke', 'none', ...typeface, '-pointsize', String(Math.round(r * 1.2)),
345 '-gravity', 'Center', '-annotate', offset(cx - width / 2, cy - height / 2), String(n + 1),
346 ]
347 })
348
349 return ['magick', from, ...drawn, to]
350}
351
352// The pixels a mark's shape takes: its box, or its ring's square.
353export function shapeBounds(mark: Mark, size: { width: number; height: number }) {
354 const { ring } = markSizes(size)
355 const isBox = (mark.w ?? 0) > 0 && (mark.h ?? 0) > 0
356
357 return isBox
358 ? { x0: mark.x, y0: mark.y, x1: mark.x + (mark.w ?? 0), y1: mark.y + (mark.h ?? 0) }
359 : { x0: mark.x - ring, y0: mark.y - ring, x1: mark.x + ring, y1: mark.y + ring }
360}
361
362// The pairs of marks whose shapes overlap, numbered from 1.
363export function overlaps(marks: Mark[], size: { width: number; height: number }) {
364 const bounds = marks.map(mark => shapeBounds(mark, size))
365
366 return bounds.flatMap((a, i) =>
367 bounds
368 .slice(i + 1)
369 .flatMap((b, k) => (a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 ? [[i + 1, i + k + 2] as const] : [])),
370 )
371}
372
373// The terminal cells a mark covers when its picture is drawn `columns` by
374// `rows`: its shape with its badge; at least one cell.
375export function markCells(
376 mark: Mark,
377 size: { width: number; height: number },
378 box: { columns: number; rows: number },
379) {
380 const { r } = markSizes(size)
381 const isBox = (mark.w ?? 0) > 0 && (mark.h ?? 0) > 0
382 const shape = shapeBounds(mark, size)
383 // A box's badge sits on its corner; a ring's up and to the right.
384 const [x0, y0, x1, y1] = isBox
385 ? [shape.x0 - r, shape.y0 - r, shape.x1, shape.y1]
386 : [shape.x0, shape.y0 - r, shape.x1 + r, shape.y1]
387 const across = (x: number) => Math.min(box.columns, Math.max(0, (x / size.width) * box.columns))
388 const down = (y: number) => Math.min(box.rows, Math.max(0, (y / size.height) * box.rows))
389 const left = Math.min(box.columns - 1, Math.floor(across(x0)))
390 const top = Math.min(box.rows - 1, Math.floor(down(y0)))
391
392 return {
393 left,
394 top,
395 width: Math.max(1, Math.ceil(across(x1)) - left),
396 height: Math.max(1, Math.ceil(down(y1)) - top),
397 }
398}
399
400// Text broken into lines of at most `width` cells, at spaces where it can.
401export function wrap(text: string, width: number) {
402 const lines: string[] = []
403 let line = ''
404 for (const word of text.split(/\s+/).filter(Boolean)) {
405 for (let rest = word; rest !== ''; ) {
406 const room = width - (line === '' ? 0 : line.length + 1)
407 if (rest.length <= room) {
408 line = line === '' ? rest : `${line} ${rest}`
409 rest = ''
410 } else if (line !== '') {
411 lines.push(line)
412 line = ''
413 } else {
414 lines.push(rest.slice(0, width))
415 rest = rest.slice(width)
416 }
417 }
418 }
419
420 return line === '' ? lines : [...lines, line]
421}
422hooks/mermaid.ts 64 lines1// Mermaid blocks in a reply: finding them in its markdown, and sizing the
2// picture each one renders to.
3
4export type Part = { kind: 'markdown'; text: string } | { kind: 'mermaid'; source: string; fence: string }
5
6const OPEN = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/
7
8// Splits markdown into its text and its closed ```mermaid blocks, in order.
9// A block still open (a reply streaming in) and a fence inside another fence
10// stay text.
11export function splitMermaid(text: string): Part[] {
12 const lines = text.split('\n')
13 const parts: Part[] = []
14 let prose: string[] = []
15 let i = 0
16 while (i < lines.length) {
17 const open = OPEN.exec(lines[i] ?? '')
18 if (open === null) {
19 prose.push(lines[i] ?? '')
20 i += 1
21 continue
22 }
23 const marks = open[1] ?? '```'
24 const close = new RegExp(`^ {0,3}${marks[0] === '`' ? '`' : '~'}{${marks.length},}\\s*$`)
25 let end = i + 1
26 while (end < lines.length && !close.test(lines[end] ?? '')) {
27 end += 1
28 }
29 const block = lines.slice(i, end + 1)
30 if (end < lines.length && open[2]?.toLowerCase() === 'mermaid') {
31 if (prose.length > 0) {
32 parts.push({ kind: 'markdown', text: prose.join('\n') })
33 }
34 prose = []
35 parts.push({ kind: 'mermaid', source: block.slice(1, -1).join('\n'), fence: block.join('\n') })
36 } else {
37 prose.push(...block)
38 }
39 i = end + 1
40 }
41 if (prose.join('').trim() !== '') {
42 parts.push({ kind: 'markdown', text: prose.join('\n') })
43 }
44
45 return parts
46}
47
48// A short stable name for a diagram's source: FNV-1a, in hex.
49export function hashOf(text: string) {
50 let hash = 0x811c9dc5
51 for (let i = 0; i < text.length; i += 1) {
52 hash ^= text.charCodeAt(i)
53 hash = Math.imul(hash, 0x01000193)
54 }
55
56 return (hash >>> 0).toString(16).padStart(8, '0')
57}
58
59// Columns a diagram takes: its natural size at about 8 pixels a cell, the
60// renderer drawing at `scale`, but never wider than the room.
61export function diagramColumns(width: number, scale: number, room: number) {
62 return Math.max(1, Math.min(room, 255, Math.ceil(width / scale / 8)))
63}
64hooks/views.tsx 715 lines1// The Context, Tests, Files and Commits tabs. Each takes the elements of the
2// surface it draws on, with a Raster where the surface has one, and the data.
3
4import type { Color, Elements, RenderElement } from 'claude-code'
5
6import type { CommitRow, GitFile, GitState, Job, Meter, Preview, Search, TestRun, Touch, Turn } from '../types'
7import { matches, treeRows } from './explorer'
8import {
9 PALETTE,
10 area,
11 gauge,
12 legend,
13 scoreboard,
14 stacked,
15 tokens,
16 touchColor,
17 treemap,
18} from './charts'
19
20export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'> & {
21 Raster?: Elements['terminal']['Raster']
22}
23
24export function ago(ms: number) {
25 const s = Math.max(0, Math.round(ms / 1000))
26 if (s < 60) {
27 return `${s}s ago`
28 }
29
30 return s < 3600 ? `${Math.floor(s / 60)}m ago` : `${Math.floor(s / 3600)}h ago`
31}
32
33const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1)
34
35export function contextView(
36 { Box, Text, Raster }: Kit,
37 { shown, turns, columns }: { shown: Meter | null; turns: Turn[]; columns: number },
38): RenderElement {
39 if (shown === null) {
40 return <Text dimColor>No reading yet. The meter fills after Claude's next answer.</Text>
41 }
42 const used = shown.tokens ?? 0
43 const fraction = used / Math.max(1, shown.window)
44 const mark = shown.buffer === undefined ? undefined : (shown.window - shown.buffer) / shown.window
45 const charted = turns.filter(turn => turn.context !== undefined)
46 const last = turns.findLast(turn => turn.usage !== undefined)
47 const color: Color = fraction < 0.5 ? 'success' : fraction < 0.8 ? 'warning' : 'error'
48
49 return (
50 <Box flexDirection="column" gap={1}>
51 <Box flexDirection="column">
52 <Text>
53 <Text weight="bold">Context</Text>{' '}
54 <Text color={color} weight="bold">
55 {shown.percent ?? Math.round(fraction * 100)}%
56 </Text>{' '}
57 {tokens(used)} / {tokens(shown.window)}
58 {mark === undefined ? '' : <Text dimColor> · compacts at {tokens(shown.window - (shown.buffer ?? 0))}</Text>}
59 {shown.usd === undefined ? '' : <Text dimColor> · ${shown.usd.toFixed(2)} so far</Text>}
60 </Text>
61 {Raster !== undefined && (
62 <Raster key="ctx-gauge" {...gauge(fraction, columns, mark)} />
63 )}
64 </Box>
65 {shown.categories.length > 0 && (
66 <Box flexDirection="column">
67 <Text weight="bold">What fills it</Text>
68 {Raster !== undefined && (
69 <Raster
70 key="ctx-stack"
71 {...stacked(
72 shown.categories.map((one, i) => ({ tokens: one.tokens, color: PALETTE[i % PALETTE.length]! })),
73 shown.window,
74 shown.buffer ?? 0,
75 columns,
76 )}
77 />
78 )}
79 {shown.categories.map((one, i) =>
80 Raster !== undefined ? (
81 <Raster
82 key={`ctx-cat-${i}`}
83 {...legend([[PALETTE[i % PALETTE.length]!, `${one.name} ${tokens(one.tokens)}`]])}
84 />
85 ) : (
86 <Text key={`ctx-cat-${i}`} dimColor>
87 {one.name} {tokens(one.tokens)}
88 </Text>
89 ),
90 )}
91 </Box>
92 )}
93 {charted.length > 0 && (
94 <Box flexDirection="column">
95 <Text weight="bold">
96 After each turn <Text dimColor>{charted.length} turns</Text>
97 </Text>
98 {Raster !== undefined ? (
99 <Raster
100 key="ctx-area"
101 {...area(
102 charted.map(turn => (turn.context ?? 0) / Math.max(1, shown.window)),
103 columns,
104 8,
105 mark,
106 )}
107 />
108 ) : (
109 <Text dimColor>{charted.map(turn => tokens(turn.context ?? 0)).join(' → ')}</Text>
110 )}
111 </Box>
112 )}
113 {last?.usage !== undefined && (
114 <Text dimColor>
115 turn {last.n}: {tokens(last.usage.input)} in · {tokens(last.usage.output)} out ·{' '}
116 {tokens(last.usage.cacheRead)} cache read · {tokens(last.usage.cacheWrite)} cache write
117 </Text>
118 )}
119 {shown.limits.length > 0 && (
120 <Text dimColor>
121 limits: {shown.limits.map(one => `${one.kind.replace('_', ' ')} ${Math.round(one.percentUsed)}%`).join(' · ')}
122 </Text>
123 )}
124 </Box>
125 )
126}
127
128export function testsView(
129 { Box, Text, Raster }: Kit,
130 { runs, columns, now }: { runs: TestRun[]; columns: number; now: number },
131): RenderElement {
132 const last = runs.at(-1)
133 if (last === undefined) {
134 return (
135 <Text dimColor>
136 No test runs yet. The test commands Claude runs (bun test, pytest, cargo test, claude plugin test and
137 the like) land here.
138 </Text>
139 )
140 }
141 const isGreen = last.failed === 0 && !last.isError
142 const board = scoreboard(runs, columns, 20)
143
144 return (
145 <Box flexDirection="column" gap={1}>
146 <Text wrap="truncate-end">
147 <Text color={isGreen ? 'success' : 'error'} weight="bold">
148 {isGreen ? '✓' : '✗'} {last.passed} pass · {last.failed} fail
149 </Text>{' '}
150 <Text dimColor>
151 {last.command} · {ago(now - last.at)}
152 </Text>
153 </Text>
154 {Raster !== undefined && <Raster key="tests-board" {...board} />}
155 {board.shaky.size > 0 && (
156 <Text color="warning" wrap="truncate-end">
157 flaky: {[...board.shaky].join(', ')}
158 </Text>
159 )}
160 <Box flexDirection="column">
161 {runs
162 .slice(-6)
163 .reverse()
164 .map(run => (
165 <Text key={`run-${run.id}`} wrap="truncate-end" dimColor={run !== last}>
166 <Text color={run.failed === 0 && !run.isError ? 'success' : 'error'}>
167 {run.failed === 0 && !run.isError ? '✓' : '✗'}
168 </Text>{' '}
169 {String(run.passed).padStart(4)} pass {String(run.failed).padStart(3)} fail · {run.command}
170 </Text>
171 ))}
172 </Box>
173 </Box>
174 )
175}
176
177export function filesView(
178 { Box, Text, Button, Raster }: Kit,
179 { list, columns, onOpen }: { list: Touch[]; columns: number; onOpen: (file: string) => () => void },
180): RenderElement {
181 if (list.length === 0) {
182 return <Text dimColor>No files touched yet. Each file Claude reads or edits gets a tile here.</Text>
183 }
184 const ranked = [...list].sort((a, b) => b.reads + b.edits * 2 - (a.reads + a.edits * 2))
185
186 return (
187 <Box flexDirection="column" gap={1}>
188 {Raster !== undefined && (
189 <Box flexDirection="column">
190 <Raster
191 key="files-map"
192 {...treemap(
193 ranked.slice(0, 40).map(touch => ({
194 label: basename(touch.file),
195 weight: touch.reads + touch.edits * 2,
196 color: touchColor(touch.reads, touch.edits),
197 })),
198 columns,
199 12,
200 )}
201 />
202 <Raster
203 key="files-key"
204 {...legend([
205 [touchColor(1, 0), 'read'],
206 [touchColor(1, 1), 'both'],
207 [touchColor(0, 1), 'edited · size is how often'],
208 ])}
209 />
210 </Box>
211 )}
212 <Box flexDirection="column">
213 {ranked.slice(0, 10).map(touch => (
214 <Button key={`touch-${touch.file}`} plain dimColor={touch.edits === 0} onPress={onOpen(touch.file)}>
215 <Text color={touch.edits > 0 ? 'warning' : 'suggestion'}>{touch.edits > 0 ? '±' : '·'}</Text>{' '}
216 {touch.file}{' '}
217 <Text dimColor>
218 {touch.reads} read{touch.reads === 1 ? '' : 's'} · {touch.edits} edit{touch.edits === 1 ? '' : 's'}
219 </Text>
220 </Button>
221 ))}
222 </Box>
223 </Box>
224 )
225}
226
227// Each column of the graph keeps one color, as git colors its lanes.
228const LANES: Color[] = ['claude', 'suggestion', 'success', 'warning', 'error']
229const GRAPH: Record<string, string> = { '*': '●', '|': '│', '/': '╱', '\\': '╲' }
230
231export function commitsView(
232 { Box, Text, Button }: Kit,
233 { rows, onRefresh }: { rows: CommitRow[]; onRefresh: () => void },
234): RenderElement {
235 const count = rows.filter(row => row.sha !== undefined).length
236
237 return (
238 <Box flexDirection="column" gap={1}>
239 <Box flexDirection="row" gap={2}>
240 <Text weight="bold">
241 This session's commits <Text dimColor>{count}</Text>
242 </Text>
243 <Button key="commits-refresh" plain dimColor hotkey="r" onPress={onRefresh}>
244 ↻ refresh
245 </Button>
246 </Box>
247 {count === 0 ? (
248 <Text dimColor>No commits since the session began.</Text>
249 ) : (
250 <Box flexDirection="column">
251 {rows.map((row, i) => (
252 <Text key={`commit-${row.sha ?? `g${i}`}`} wrap="truncate-end">
253 {[...row.graph].map((ch, col) =>
254 ch === ' ' ? (
255 ' '
256 ) : (
257 <Text color={LANES[Math.floor(col / 2) % LANES.length]}>{GRAPH[ch] ?? ch}</Text>
258 ),
259 )}
260 {row.sha !== undefined && (
261 <Text>
262 {' '}
263 <Text color="warning">{row.sha}</Text> {row.subject}
264 {row.refs !== undefined && <Text color="success"> ({row.refs})</Text>}
265 {row.added !== undefined && (
266 <Text>
267 {' '}
268 <Text color="success">+{row.added}</Text> <Text color="error">−{row.removed ?? 0}</Text>
269 </Text>
270 )}
271 <Text dimColor> {row.when}</Text>
272 </Text>
273 )}
274 </Text>
275 ))}
276 </Box>
277 )}
278 </Box>
279 )
280}
281
282// A git status code's color: changed, added or new, deleted.
283const STATUS_COLOR: Record<string, Color> = { M: 'warning', A: 'success', '??': 'success', D: 'error', R: 'suggestion' }
284const statusColor = (code: string) => STATUS_COLOR[code] ?? STATUS_COLOR[code[0] ?? ''] ?? 'warning'
285
286export type ExplorerKit = Kit & {
287 Input?: Elements['terminal']['Input']
288 Code: Elements['terminal']['Code']
289}
290
291// The project's files: a tree to walk, a filter that finds a file by a few
292// of its letters, and a preview with a button that mentions it in the prompt.
293export function explorerView(
294 { Box, Text, Button, Input, Code }: ExplorerKit,
295 data: {
296 files: string[]
297 codes: Record<string, string>
298 touched: Touch[]
299 open: string[]
300 filter: string
301 preview: Preview | null
302 onFilter: (text: string) => void
303 onToggle: (dir: string) => () => void
304 onOpen: (file: string) => () => void
305 onClose: () => void
306 onMention: (file: string) => () => void
307 onDiff: (file: string) => () => void
308 onRefresh: () => void
309 },
310): RenderElement {
311 const changed = Object.keys(data.codes).length
312 const touch = new Map(data.touched.map(one => [one.file, one]))
313 const marks = (path: string) => {
314 const code = data.codes[path]
315 const mine = touch.get(path)
316
317 return (
318 <Text>
319 {code !== undefined && <Text color={statusColor(code)}> {code}</Text>}
320 {mine !== undefined && (
321 <Text color={mine.edits > 0 ? 'claude' : 'suggestion'}> {mine.edits > 0 ? '± edited' : '· read'}</Text>
322 )}
323 </Text>
324 )
325 }
326
327 if (data.preview !== null) {
328 const shown = data.preview
329 const code = data.codes[shown.file]
330
331 return (
332 <Box flexDirection="column" gap={1}>
333 <Box flexDirection="column">
334 <Text wrap="truncate-end">
335 <Text weight="bold">{shown.file}</Text>
336 <Text dimColor>
337 {' '}
338 {shown.hit === undefined ? `${shown.lines} lines` : `line ${shown.hit} of ${shown.lines}`}
339 </Text>
340 {marks(shown.file)}
341 </Text>
342 <Box flexDirection="row" gap={2}>
343 <Button key="ex-mention" variant="primary" hotkey="m" onPress={data.onMention(shown.file)}>
344 @ mention
345 </Button>
346 {code !== undefined && code !== '??' && (
347 <Button key="ex-diff" variant="secondary" hotkey="d" onPress={data.onDiff(shown.file)}>
348 diff
349 </Button>
350 )}
351 <Button key="ex-close" plain dimColor hotkey="x" onPress={data.onClose}>
352 ✗ close
353 </Button>
354 </Box>
355 </Box>
356 {shown.isBinary ? (
357 <Text dimColor>A binary file: nothing to preview.</Text>
358 ) : (
359 <Code source={shown.source === '' ? ' ' : shown.source} path={shown.file} startLine={shown.start ?? 1} />
360 )}
361 {shown.isCut && (
362 <Text dimColor>
363 … lines {shown.start ?? 1} to {(shown.start ?? 1) + shown.source.split('\n').length - 1}
364 </Text>
365 )}
366 </Box>
367 )
368 }
369
370 const found = data.filter.trim() === '' ? undefined : matches(data.files, data.filter, 40)
371 const rows = found === undefined ? treeRows(data.files, new Set(data.open)).slice(0, 120) : []
372
373 return (
374 <Box flexDirection="column" gap={1}>
375 <Box flexDirection="column">
376 {Input !== undefined && (
377 <Input
378 key="ex-filter"
379 placeholder="find a file by a few letters…"
380 value={data.filter}
381 onInput={value => data.onFilter(value)}
382 onSubmit={value => {
383 const first = matches(data.files, value, 1)[0]
384 if (first !== undefined) {
385 data.onOpen(first)()
386 }
387 }}
388 />
389 )}
390 <Box flexDirection="row" gap={2}>
391 <Text dimColor>
392 {data.files.length} files{changed > 0 ? ` · ${changed} changed` : ''}
393 </Text>
394 <Button key="ex-refresh" plain dimColor hotkey="r" onPress={data.onRefresh}>
395 ↻ refresh
396 </Button>
397 </Box>
398 </Box>
399 {data.files.length === 0 ? (
400 <Text dimColor>No files found here.</Text>
401 ) : found !== undefined ? (
402 <Box flexDirection="column">
403 {found.length === 0 && <Text dimColor>No file matches.</Text>}
404 {found.map(path => (
405 <Button key={`ex-${path}`} plain onPress={data.onOpen(path)}>
406 <Text dimColor>{path.slice(0, path.lastIndexOf('/') + 1)}</Text>
407 <Text weight="bold">{basename(path)}</Text>
408 {marks(path)}
409 </Button>
410 ))}
411 </Box>
412 ) : (
413 <Box flexDirection="column">
414 {rows.map(row => (
415 <Button
416 key={`ex-${row.path}`}
417 plain
418 dimColor={!row.isDir && data.codes[row.path] === undefined && !touch.has(row.path)}
419 onPress={row.isDir ? data.onToggle(row.path) : data.onOpen(row.path)}
420 >
421 {' '.repeat(row.depth)}
422 {row.isDir ? <Text color="suggestion">{row.isOpen ? '▾ ' : '▸ '}</Text> : ' '}
423 {row.isDir ? <Text weight="bold">{row.name}/</Text> : row.name}
424 {!row.isDir && marks(row.path)}
425 </Button>
426 ))}
427 </Box>
428 )}
429 </Box>
430 )
431}
432
433const since = (ms: number) => {
434 const sec = Math.max(0, Math.round(ms / 1000))
435
436 return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m${String(sec % 60).padStart(2, '0')}s`
437}
438
439const JOB_MARK: Record<Job['status'], { mark: string; color: Color }> = {
440 running: { mark: '…', color: 'warning' },
441 ok: { mark: '✓', color: 'success' },
442 failed: { mark: '✗', color: 'error' },
443 stopped: { mark: '■', color: 'inactive' },
444}
445
446// A line of output's color: errors red, warnings yellow, the rest as is.
447const lineColor = (line: string): Color | undefined =>
448 /\b(error|fail(ed|ure)?|panic|exception)\b/i.test(line)
449 ? 'error'
450 : /\bwarn(ing)?\b/i.test(line)
451 ? 'warning'
452 : undefined
453
454// Commands you start from the pane: a field to type one, the recent ones to
455// run again, and the chosen one's output as it streams, to stop or hand to
456// Claude.
457export function runView(
458 { Box, Text, Button, Input }: Kit & { Input?: Elements['terminal']['Input'] },
459 data: {
460 jobs: Job[]
461 picked: string | null
462 now: number
463 onRun: (command: string) => () => void
464 onPick: (id: string) => () => void
465 onStop: (id: string) => () => void
466 onAsk: (job: Job) => () => void
467 },
468): RenderElement {
469 const job = data.jobs.find(one => one.id === data.picked) ?? data.jobs.at(-1)
470 const recent = [...new Set(data.jobs.map(one => one.command).reverse())].slice(0, 5)
471
472 return (
473 <Box flexDirection="column" gap={1}>
474 <Box flexDirection="column">
475 {Input !== undefined && (
476 <Input
477 key="run-cmd"
478 placeholder="run a command: bun test, godot --headless --export-release …"
479 submitLabel="run"
480 onSubmit={value => {
481 if (value.trim() !== '') {
482 data.onRun(value.trim())()
483 }
484 }}
485 />
486 )}
487 {recent.length > 0 && (
488 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
489 <Text dimColor>again:</Text>
490 {recent.map((command, i) => (
491 <Button key={`run-again-${i}`} plain dimColor onPress={data.onRun(command)}>
492 {command.length > 40 ? command.slice(0, 39) + '…' : command}
493 </Button>
494 ))}
495 </Box>
496 )}
497 </Box>
498 {job === undefined ? (
499 <Text dimColor>Nothing run yet. Type a command above and press Enter; its output streams here.</Text>
500 ) : (
501 <Box flexDirection="column">
502 <Box flexDirection="column">
503 {data.jobs
504 .slice(-5)
505 .reverse()
506 .map(one => (
507 <Button key={`job-${one.id}`} plain dimColor={one.id !== job.id} onPress={data.onPick(one.id)}>
508 <Text color={JOB_MARK[one.status].color}>{JOB_MARK[one.status].mark}</Text> {one.command}
509 <Text dimColor>
510 {' '}
511 {since((one.end ?? data.now) - one.start)}
512 {one.code === undefined || one.code === null || one.code === 0 ? '' : ` · exit ${one.code}`}
513 </Text>
514 </Button>
515 ))}
516 </Box>
517 <Box flexDirection="row" gap={2}>
518 {job.status === 'running' && (
519 <Button key="run-stop" variant="secondary" hotkey="k" onPress={data.onStop(job.id)}>
520 ■ stop
521 </Button>
522 )}
523 <Button key="run-rerun" plain dimColor hotkey="e" onPress={data.onRun(job.command)}>
524 ↻ again
525 </Button>
526 <Button key="run-ask" plain dimColor hotkey="a" onPress={data.onAsk(job)}>
527 → ask Claude
528 </Button>
529 <Text dimColor>
530 {job.total} lines{job.total > job.output.length ? `, last ${job.output.length} kept` : ''}
531 </Text>
532 </Box>
533 <Box flexDirection="column">
534 {job.output.slice(-40).map((line, i) => (
535 <Text key={`out-${i}`} wrap="truncate-end" color={lineColor(line)}>
536 {line === '' ? ' ' : line}
537 </Text>
538 ))}
539 </Box>
540 </Box>
541 )}
542 </Box>
543 )
544}
545
546// What git says of a file in a word, by its columns.
547const gitWord = (file: GitFile) => {
548 const code = file.index === '?' ? '?' : file.index !== ' ' ? file.index : file.worktree
549
550 return { M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied', U: 'conflict', '?': 'new' }[code] ?? code
551}
552
553// The working tree as git sees it: staged above, unstaged and new below;
554// press a file to stage or unstage it, ± for its diff, then commit and push.
555export function gitView(
556 { Box, Text, Button, Input }: Kit & { Input?: Elements['terminal']['Input'] },
557 data: {
558 state: GitState | null
559 onToggle: (file: GitFile) => () => void
560 onDiff: (path: string) => () => void
561 onStageAll: () => void
562 onUnstageAll: () => void
563 onCommit: (message: string) => () => void
564 onPush: () => void
565 onRefresh: () => void
566 },
567): RenderElement {
568 const state = data.state
569 if (state === null) {
570 return <Text dimColor>Not a git repository.</Text>
571 }
572 const staged = state.files.filter(file => file.index !== ' ' && file.index !== '?')
573 const unstaged = state.files.filter(file => file.worktree !== ' ' && file.index !== '?')
574 const fresh = state.files.filter(file => file.index === '?')
575 const row = (file: GitFile, isStaged: boolean) => (
576 <Box key={`git-${isStaged ? 's' : 'w'}-${file.path}`} flexDirection="row" gap={1}>
577 <Button key={`git-${isStaged ? 's' : 'w'}-${file.path}`} plain onPress={data.onToggle(file)}>
578 <Text color={isStaged ? 'success' : file.index === '?' ? 'suggestion' : 'warning'}>
579 {isStaged ? '●' : '○'}
580 </Text>{' '}
581 {file.path} <Text dimColor>{gitWord(file)}</Text>
582 </Button>
583 {file.index !== '?' && (
584 <Button key={`git-diff-${isStaged ? 's' : 'w'}-${file.path}`} plain dimColor onPress={data.onDiff(file.path)}>
585 ±
586 </Button>
587 )}
588 </Box>
589 )
590 const section = (title: string, files: GitFile[], isStaged: boolean) =>
591 files.length > 0 && (
592 <Box flexDirection="column">
593 <Text weight="bold">
594 {title} <Text dimColor>{files.length}</Text>
595 </Text>
596 {files.slice(0, 40).map(file => row(file, isStaged))}
597 </Box>
598 )
599
600 return (
601 <Box flexDirection="column" gap={1}>
602 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
603 <Text>
604 <Text color="claude" weight="bold">
605 ⎇ {state.branch || 'detached'}
606 </Text>
607 {state.ahead > 0 && <Text color="success"> ↑{state.ahead}</Text>}
608 {state.behind > 0 && <Text color="warning"> ↓{state.behind}</Text>}
609 </Text>
610 <Button key="git-all" plain dimColor hotkey="a" onPress={data.onStageAll}>
611 stage all
612 </Button>
613 <Button key="git-none" plain dimColor hotkey="z" onPress={data.onUnstageAll}>
614 unstage all
615 </Button>
616 {state.ahead > 0 && (
617 <Button key="git-push" plain dimColor hotkey="p" onPress={data.onPush}>
618 push
619 </Button>
620 )}
621 <Button key="git-refresh" plain dimColor hotkey="r" onPress={data.onRefresh}>
622 ↻
623 </Button>
624 </Box>
625 {state.note !== undefined && (
626 <Text color={state.isNoteError === true ? 'error' : 'success'} wrap="truncate-end">
627 {state.note}
628 </Text>
629 )}
630 {state.files.length === 0 ? (
631 <Text dimColor>Nothing to commit: the working tree is clean.</Text>
632 ) : (
633 <Box flexDirection="column" gap={1}>
634 {section('Staged', staged, true)}
635 {section('Changed', unstaged, false)}
636 {section('New', fresh, false)}
637 </Box>
638 )}
639 {staged.length > 0 && Input !== undefined && (
640 <Input
641 key="git-msg"
642 placeholder={`commit ${staged.length} staged file${staged.length === 1 ? '' : 's'}: message…`}
643 submitLabel="commit"
644 onSubmit={value => {
645 if (value.trim() !== '') {
646 data.onCommit(value.trim())()
647 }
648 }}
649 />
650 )}
651 </Box>
652 )
653}
654
655// A search of the project's text: the hits by file, each opening the file
656// at that line in the Explorer's preview.
657export function searchView(
658 { Box, Text, Button, Input }: Kit & { Input?: Elements['terminal']['Input'] },
659 data: {
660 search: Search | null
661 onSearch: (query: string) => () => void
662 onOpen: (file: string, line?: number) => () => void
663 },
664): RenderElement {
665 const found = data.search
666 const byFile = new Map<string, { line: number; text: string }[]>()
667 for (const hit of found?.hits ?? []) {
668 byFile.set(hit.file, [...(byFile.get(hit.file) ?? []), hit])
669 }
670
671 return (
672 <Box flexDirection="column" gap={1}>
673 <Box flexDirection="column">
674 {Input !== undefined && (
675 <Input
676 key="search-q"
677 placeholder="search the project's text (regex, smart case)…"
678 value={found?.query ?? ''}
679 submitLabel="search"
680 onSubmit={value => data.onSearch(value)()}
681 />
682 )}
683 {found !== null && found.query !== '' && (
684 <Text dimColor>
685 {found.error !== undefined
686 ? found.error
687 : `${found.hits.length}${found.isCut ? '+' : ''} hits in ${byFile.size} file${byFile.size === 1 ? '' : 's'}`}
688 </Text>
689 )}
690 </Box>
691 {found === null || found.query === '' ? (
692 <Text dimColor>Type a word or a pattern and press Enter. A hit opens its file at that line.</Text>
693 ) : (
694 <Box flexDirection="column">
695 {[...byFile].slice(0, 40).map(([file, hits]) => (
696 <Box key={`hits-${file}`} flexDirection="column">
697 <Button key={`hit-file-${file}`} plain onPress={data.onOpen(file)}>
698 <Text color="suggestion" weight="bold">
699 {file}
700 </Text>{' '}
701 <Text dimColor>{hits.length}</Text>
702 </Button>
703 {hits.slice(0, 8).map(hit => (
704 <Button key={`hit-${file}:${hit.line}`} plain dimColor onPress={data.onOpen(file, hit.line)}>
705 <Text color="warning">{String(hit.line).padStart(5)}</Text> {hit.text}
706 </Button>
707 ))}
708 </Box>
709 ))}
710 </Box>
711 )}
712 </Box>
713 )
714}
715types/index.d.ts 247 lines1export type StepStatus = 'todo' | 'doing' | 'done' | 'dropped'
2export type Step = { text: string; status: StepStatus }
3/** `startedAt`, when this goal was first posted, opens the current task. */
4export type Plan = { goal: string; steps: Step[]; startedAt?: number }
5
6export type NoteKind = 'decision' | 'assumption' | 'question' | 'finding' | 'risk'
7export type Note = {
8 id: string
9 kind: NoteKind
10 text: string
11 why?: string
12 /** More for whoever opens the note: what Claude saw, where, what it means. */
13 detail?: string
14 /** When it was posted; a note from before the current task has none or less. */
15 at?: number
16 isReplied: boolean
17 /** The person read it and pressed OK: no reply needed. */
18 isOk?: boolean
19}
20
21export type Tab =
22 | 'now'
23 | 'notes'
24 | 'pictures'
25 | 'activity'
26 | 'timeline'
27 | 'context'
28 | 'tests'
29 | 'files'
30 | 'commits'
31 | 'explorer'
32 | 'run'
33 | 'git'
34 | 'search'
35
36export type ActKind = 'read' | 'edit' | 'run' | 'search' | 'web' | 'agent' | 'tool'
37export type ActStatus = 'runs' | 'ok' | 'failed'
38export type Act = {
39 id: string
40 kind: ActKind
41 label: string
42 /** The command itself, shown under a Bash call's description. */
43 detail?: string
44 status: ActStatus
45 isSubagent: boolean
46 /** Calls folded into this row: repeated reads or edits of one file. */
47 count?: number
48 /** The main-loop turn it ran in, by number. */
49 turn?: number
50 /** When it started. */
51 at?: number
52}
53
54/** A turn of the main loop: from the prompt to the answer. */
55export type Turn = {
56 id: string
57 /** Its number in the session, from 1. */
58 n: number
59 start: number
60 end?: number
61 reason?: 'answer' | 'aborted' | 'refusal' | 'error'
62 calls: number
63 failed: number
64 /** Tokens in the context window when it ended. */
65 context?: number
66 usage?: { input: number; output: number; cacheRead: number; cacheWrite: number }
67}
68
69/** The context window and spend as the session last reported them. */
70export type Meter = {
71 tokens?: number
72 window: number
73 percent?: number
74 usd?: number
75 /** The autocompact buffer: compaction comes at window minus this. */
76 buffer?: number
77 categories: { name: string; tokens: number }[]
78 limits: { kind: string; percentUsed: number }[]
79 at: number
80}
81
82/** One test run read from a test command's output. */
83export type TestRun = {
84 id: string
85 at: number
86 command: string
87 passed: number
88 failed: number
89 /** The command itself failed. */
90 isError: boolean
91 /** Each test the output named, where it names them. */
92 names: { name: string; ok: boolean }[]
93}
94
95/** How often a file was read and edited. */
96export type Touch = { file: string; reads: number; edits: number; at: number }
97
98/** A file open in the Explorer's preview. */
99export type Preview = {
100 file: string
101 source: string
102 lines: number
103 isCut: boolean
104 isBinary: boolean
105 /** The file's line the preview starts at, when it opened at a search hit. */
106 start?: number
107 /** The line a search hit landed on. */
108 hit?: number
109}
110
111/** A command run from the Run tab, with the tail of its output. */
112export type Job = {
113 id: string
114 command: string
115 status: 'running' | 'ok' | 'failed' | 'stopped'
116 code?: number | null
117 start: number
118 end?: number
119 output: string[]
120 /** Lines written in all, kept or not. */
121 total: number
122}
123
124/** A file `git status` names: its index and worktree columns. */
125export type GitFile = { path: string; index: string; worktree: string }
126
127export type GitState = {
128 branch: string
129 ahead: number
130 behind: number
131 files: GitFile[]
132 /** What the last action from the tab came to. */
133 note?: string
134 isNoteError?: boolean
135}
136
137export type Hit = { file: string; line: number; text: string }
138
139export type Search = { query: string; hits: Hit[]; isCut: boolean; error?: string }
140
141/** A row of `git log --graph`: a commit, or a line of the graph alone. */
142export type CommitRow = {
143 graph: string
144 sha?: string
145 subject?: string
146 when?: string
147 refs?: string
148 added?: number
149 removed?: number
150}
151
152/** A changed file's diff, as git gave it when the person pressed the file. */
153export type Diff = { file: string; lines: string[]; isCut: boolean }
154
155/** A subagent's lane: its task and what it is doing now. */
156export type Lane = {
157 /** The subagent's id, which its own tool calls carry as `agentId`. */
158 id: string
159 /** The Agent call that spawned it. */
160 toolUseId: string
161 label: string
162 type: string
163 status: ActStatus
164 calls: number
165 /** Its latest call, as the activity log words it. */
166 last?: string
167 at: number
168 /** When it stopped. */
169 end?: number
170}
171
172/** An image frozen as a PNG in the cache, as it was when shown or read. */
173export type Picture = {
174 id: string
175 file: string
176 width: number
177 height: number
178 caption: string
179 origin: 'shown' | 'read'
180 /** A sheet's labels, one per tile in order, for the person to pick from. */
181 tiles?: string[]
182 /** The marks drawn on it, in its own pixels, in their numbered order. */
183 marks?: { x: number; y: number; w?: number; h?: number; note: string; detail?: string }[]
184}
185
186/** A ```mermaid block of a reply, rendered once; `error` when it would not. */
187export type Diagram = {
188 id: string
189 file: string
190 width: number
191 height: number
192 error?: string
193}
194
195declare module 'claude-code' {
196 interface McpToolInputs {
197 'mcp__show-work__plan': { goal: string; steps: Step[] }
198 'mcp__show-work__note': { kind: NoteKind; text: string; why?: string; detail?: string }
199 'mcp__show-work__show': {
200 caption: string
201 d2?: string
202 mermaid?: string
203 dot?: string
204 html?: string
205 width?: number
206 height?: number
207 svg?: string
208 path?: string
209 url?: string
210 images?: { src: string; label?: string }[]
211 marks?: { x: number; y: number; w?: number; h?: number; note: string; detail?: string }[]
212 }
213 }
214 interface PluginState {
215 'show-work': {
216 plan: Shaped<Plan | null>
217 notes: Shaped<Note[]>
218 activity: Shaped<Act[]>
219 changed: Shaped<string[]>
220 pictures: Shaped<Picture[]>
221 diagrams: Shaped<Diagram[]>
222 diff: Shaped<Diff | null>
223 lanes: Shaped<Lane[]>
224 turns: Shaped<Turn[]>
225 meter: Shaped<Meter | null>
226 tests: Shaped<TestRun[]>
227 touches: Shaped<Touch[]>
228 commits: Shaped<CommitRow[]>
229 tree: Shaped<string[]>
230 status: Shaped<Record<string, string>>
231 openDirs: Shaped<string[]>
232 filter: string
233 /** How many times taller than wide the terminal's cells are. */
234 cellTall: number
235 preview: Shaped<Preview | null>
236 jobs: Shaped<Job[]>
237 pickedJob: string | null
238 git: Shaped<GitState | null>
239 search: Shaped<Search | null>
240 picked: string | null
241 tab: Tab
242 showEarlier: boolean
243 openNote: string | null
244 }
245 }
246}
247