SLOPSHOPPER

show-work

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

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · show-work
│ ┃ Show work ✕ › fix the failing auth test and add an audit log call │ ┃ 1: tab-now 2: tab-notes 3: tab-pictures 4 │ ┃ ⏺ Read(src/auth.ts) │ ┃ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ⎿ Read 6 lines │ ┃ calls · last 2m ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No plan posted yet. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Latest │ ┃ ✓ run Show working tree status ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ $ git status --porcelain │ ┃ ✓ run Clean build output and push ✻ Worked for 42s · done 4:20 PM │ ┃ $ rm -rf build && git push --force… │ ┃ ✓ run Show environment file › /work │ ┃ $ cat .env ⎿ show-work: Show work pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Show work
1: tab-now 2: tab-notes 3: tab-pictures 4: tab-activity ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█ calls · last 2m No plan posted yet. Latest ✓ run Show working tree status $ git status --porcelain ✓ run Clean build output and push $ rm -rf build && git push --force origin main ✓ run Show environment file $ cat .env
README

show-work

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: the goal as Claude understands it and its steps, with the current one marked. Claude posts it with the plan tool; a TodoWrite list shows here too.
  • Notes: each decision (with its reason), assumption, open question, finding and risk, posted with the 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).
  • Agents: each subagent Claude starts gets a lane on the Now tab: its task, its type, a count of its calls and, while it runs, its latest call. A lane turns ✓ when the subagent stops, background ones included.
  • Timeline: a Gantt chart of the last eight turns and the subagents each one started, drawn in cells; running bars grow and pulse four times a second.
  • Context: how full the context window is, with a mark where compaction comes, what fills it by category, an area chart of the window after each turn, the last turn's tokens, the spend so far and the rate limits.
  • Tests: every test command Claude runs (bun test, pytest, cargo test, go test, jest, vitest, claude plugin test) read for its counts and the tests it names: a grid of runs across and tests down, failing tests first, tests that flip between pass and fail flagged as flaky.
  • Files: a treemap of the files Claude read and edited, sized by how often and colored from read (blue) to edited (orange); pressing a file opens its diff.
  • Commits: this session's commits on every branch as a git graph, each with its stat, refreshed after each commit or merge Claude makes (r refreshes it).
  • Explorer (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.
  • Run (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.
  • Git (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.
  • Search (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.
  • Pictures: every image Claude reads or shows (a screenshot, a render, a design) is copied here as it was at that moment; the list under it picks an older one. Under a sheet, a button for each tile picks it: press its letter (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.
  • Activity: every file read, file edited, command run and search, logged by the mod itself, with failures marked and a count of files changed. Repeated reads or edits of one file fold into that file's row, which shows a count (×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:

  • d2 for architecture and systems, containers nested in braces, drawn by D2.
  • mermaid for flowcharts and sequence, state, class, ER and gantt diagrams, drawn by mermaid-cli's mmdc.
  • dot for big graphs such as dependencies or scene trees, drawn by Graphviz.
  • html for a UI mockup (a screen, menu, dialog or HUD), laid out by headless Chrome or Chromium. A wireframe kit gives it classes for layout (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.
  • svg for a freeform drawing, and path for an image that exists.

show shows you images too:

  • path: an image on disk (PNG, JPEG, GIF, WebP, BMP or SVG).
  • url: an image on the web, such as reference art, an asset or a screenshot in some docs. curl fetches it (http or https only, 20 MB in 30 seconds at most). ImageMagick then reads it only with the coder its first bytes name, so a hostile file can't reach ImageMagick's riskier coders. A web page is refused with a hint to give the image's own address.
  • images: two to nine paths or URLs side by side on one sheet, up to four across so it comes out wide, each lettered A, B, C… over its label (its file name if Claude gives none), for you to compare and pick from on the Pictures tab. ImageMagick's montage lays the sheet out, and a sprite under 240 pixels is blown up with whole pixels so it stays crisp.
  • marks: numbered boxes, or rings round a spot, that Claude draws on any picture above in its own pixels, each with a short note and, if Claude likes, a longer detail. A bug in a screenshot or the parts of a mockup get pointed at, and Claude gets the marked picture back to check each mark sits where its note says. Up to twelve per picture.

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.

Use

The pane opens by itself in a wide fullscreen terminal (144 columns or more). Otherwise:

  • /work opens the pane.
  • /work clear empties it.
  • ctrl+x tab focuses it; Tab walks the notes, Enter replies to one.

Install

At the prompt of a terminal session:

/plugin install show-work --marketplace astrosteveo/astro-plugins

Test

claude plugin validate plugins/show-work
claude plugin test plugins/show-work
Source 7 files
hooks/register.tsx 2651 lines
1import { 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 lines
1// 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}
520
hooks/explorer.ts 140 lines
1// 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}
140
hooks/design.ts 422 lines
1// 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}
422
hooks/mermaid.ts 64 lines
1// 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}
64
hooks/views.tsx 715 lines
1// 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}
715
types/index.d.ts 247 lines
1export 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