SLOPSHOPPER

cc-tidy

pi-tidy for Claude Code: a reply's tool calls fold into one line, click to open

newrowsguardprompttimer
v0.2.0MITupdated 2026-10-23Andy8647/cc-tidy
A shopper browsing a rack in a slop shop
README

cc-tidy

pi-tidy's tool-call folding for Claude Code's fullscreen terminal, as a Claude Code mod.

A reply's tool calls fold into one line, and you click it open:

 ▸ 7 calls ·  bash×5   read×2 · 󰧑 3 thoughts · 1 failed · 7.7s

Click the summary and each call becomes a row on a rail, with the model's thinking before it. Click a row to see what the call produced hanging under it, and click that to fold it back.

 ▾ 3 calls ·  bash×3 · 󰧑 1 thought · 1 failed · 1.4s
 │ ✓  bash ls                                               4 lines · 0.0s
 │ 󰧑 thinking  The test probably fails on the missing fixture…
 ├─ ✗  bash pnpm test                                       exit 1 · 1.3s
 │    FAIL src/a.test.ts
 │    …
 │ ✓  bash git log --oneline | head -3                      3 lines · 0.1s

Status: experimental. Claude Code's mod API is early access, so a Claude Code update can break it. Scrolling costs more than without the mod (see Known issues).

Features

  • Grouped tool calls. Every tool call between two pieces of text (a user message, or an assistant message with visible text) folds into one summary line, as pi-tidy's does: a count, each tool with its icon (most frequent first), the thinking runs, failures in red, background jobs still running, and the total time (running bash while a call runs). Claude Code's own folds ("Ran 3 shell commands", "Called Jira") go away; their calls belong to the group.
  • One row per call. An open group is a rail of rows: the status mark, icon, the command or file, and the metrics (12 lines, +4 -1, exit 1, the duration) in columns that end on the same cell. The command gets the row's width; the metrics give way first on a narrow screen. A failure with nothing to read (test -f x, false) keeps its red ✗ and dims.
  • Opened calls hang under their row. No box inside a group: the rail takes a tick (├─) and the output hangs five cells in. A click anywhere on it folds it back.
  • Running calls show up. A call that has run for a second gets its row even in a folded group, its time going up. One that finishes inside the second never appears.
  • Background jobs. A run_in_background command's row tracks the job: a spinner and running 12s, then exited 0 · 20s, exit 1, killed. The summary adds ◐ 1 running. The job's "Background command … completed" notification isn't drawn; the row says so.
  • Thinking rows. Each run of thinking before a call is a row above it, 󰧑 thinking <its first words>. Click it for the whole run. While a run is arriving the summary says ◐ thinking, and an open group shows its newest words under the newest call. This needs "showThinkingSummaries": true in ~/.claude/settings.json: without it Claude Code asks the API for no thinking text, and there are no rows.
  • Hover band. The row under the pointer gets pi's selectedBg band, the whole width.
  • Click anywhere. Every cell of a row is the mod's own button, so a click never reaches Claude Code, which would otherwise expand the message and grey it.
  • Two-pane diffs. Edit, Write, and Bash commands that changed files draw their diff as old │ new: line numbers, a bar on changed lines, a tinted row, and the changed words in a deeper tint (pi-tidy's tint formula). Below 72 columns they fall back to one pane.
  • Syntax highlighting. Read output, new files, and every diff line are highlighted as the file's language with a vendored highlight.js (44 languages). Bash commands in a box use pi-tidy's tokenizer. Bash output is highlighted when the command printed files of one language (cat x.py, sed -n 1,80p a.ts, git diff).
  • pi's colours. Everything is drawn in pi's catppuccin mocha palette, the colours pi-tidy uses.
  • ctrl+o draws every group open and every call and thinking run opened.

Requirements

  • Claude Code with mods (tested on 2.1.309), in fullscreen mode ("tui": "fullscreen"). Clicks need the fullscreen terminal's mouse reporting.
  • A Nerd Font for the icons.
  • Built for Ghostty. Other terminals should work. The icons look larger in Ghostty, because it widens a Nerd Font glyph into the space after it.

Install

claude plugin marketplace add Andy8647/cc-tidy
claude plugin install cc-tidy@andy8647

Or inside Claude Code: /plugin marketplace add Andy8647/cc-tidy, then /plugin install cc-tidy@andy8647. Run /reload-plugins (or start a new session) to load it.

To update: claude plugin marketplace update andy8647, then claude plugin update cc-tidy@andy8647. To turn it off: claude plugin disable cc-tidy@andy8647.

To work on it, clone the repo and load your clone for one session:

git clone https://github.com/Andy8647/cc-tidy.git
claude --plugin-dir ./cc-tidy

Known issues

  • Scrolling costs more than without the mod. In fullscreen, Claude Code re-runs a mod's render hook for each message whose edge a scroll step moves (the viewport position is part of the re-run key, with no opt-out). On a session full of tool calls, measured in a pty, scrolling took about twice the CPU it takes with no mod, a few ms a step. A mod that hooks the same rows and only hands back Claude Code's own drawing, or one line of text, costs the same, so the cost is in hooking those rows at all, not in what cc-tidy draws.
  • No ▶ or › on hover. pi-tidy turns the folded ▸ into ▶ and adds a › to a hovered row. A Button's hover can only restyle text, and a revealed overlay would be a cell that isn't a Button, which a click would pass to Claude Code. So there is the band alone.
  • No durations for old calls. Each call is timed around its execution, because the transcript keeps no timestamps. Calls made before the mod was loaded show no time, and a resumed session's background jobs have no times either (exited 0, no · 20s).
  • Thinking from before the setting has no rows. A session's thinking is stored as the API sent it, so turns taken while showThinkingSummaries was off stay empty. Claude Code still draws its own Thought for 4s line above text.
  • Highlighting needs a known language. Inside a row's output, only highlight.js can colour code. Claude Code's own highlighter only comes as a Code element, and a Code element can't sit inside a Button. Files are recognised by extension or name. Bash output is coloured only when the command clearly printed files of one language.
  • Colours are fixed to catppuccin mocha (pi's palette). They don't follow your Claude Code /theme.
  • Long output is cut at 200 rows in an opened call. A Bash command that changed several files shows them all under one row, and single files can't be folded.
  • Main screen mode is not supported. Outside fullscreen there are no clicks, so groups can't be opened (ctrl+o still shows everything).
  • The mod API can break. Claude Code calls it early access and says it may change between releases. If the transcript suddenly looks like stock Claude Code again, run claude plugin validate ./cc-tidy and check claude --debug.

Credits

  • pi-tidy: the design, colours, tint formula and the bash tokenizer (hooks/bash-spans.ts).
  • cc-split-diff-view: the split-row pairing (hooks/split-rows.ts), MIT, see licenses/cc-split-diff-view-MIT.
  • highlight.js 11.11.1: vendored in hooks/vendor/hljs, BSD-3-Clause, see its LICENSE.

License

MIT, see LICENSE. The vendored and borrowed code keeps its own license, as listed under Credits.

Source 62 files
hooks/register.ts 15 lines
1import type { On } from 'claude-code'
2
3import { registerBashBox } from './bash-box.js'
4import { registerCollapse } from './collapse.js'
5import { registerGroups } from './group.js'
6import { registerTiming } from './timing.js'
7
8/** pi-tidy's look for Claude Code's terminal transcript. */
9export function register(on: On) {
10  registerTiming(on)
11  registerGroups(on)
12  registerBashBox(on)
13  registerCollapse(on)
14}
15
hooks/bash-box.ts 29 lines
1import type { On } from 'claude-code'
2
3import { drawCallBox } from './callbox.js'
4import { watchTick } from './live.js'
5
6/** The calls whose box the person opened, by tool_use_id; a reload folds them again. */
7const open = new Set<string>()
8
9/**
10 * A Bash call outside any group (the one still running, before the transcript
11 * holds it) as the same box an open group draws: one line, a click for all of
12 * its output. Its `ToolResult` draws nothing, as the box holds it.
13 */
14export function registerBashBox(on: On) {
15  on('ui.render', { component: 'ToolUse', props: { tool: 'Bash' } }, async ($, e) => {
16    const { tool_use_id: id } = e.props
17    // Its time goes up while it runs.
18    if (e.props.isRunning) await watchTick($)
19    const width = (e.viewport?.columns ?? 100) - 1
20    return drawCallBox($.ui.resolve(e), e.props, width, e.props.isExpanded === true || open.has(id), () => {
21      if (open.has(id)) open.delete(id)
22      else open.add(id)
23      $.ui.invalidate('ui.render')
24    })
25  })
26
27  on('ui.render', { component: 'ToolResult', props: { tool: 'Bash' } }, async ($, e) => $.ui.resolve(e).Box({}))
28}
29
hooks/collapse.ts 82 lines
1import type { On } from 'claude-code'
2
3/**
4 * The results the person opened, by tool_use_id. A module variable rather than
5 * `$.state`: a reload folds everything again, which is fine for a view toggle.
6 */
7const open = new Set<string>()
8
9const isRecord = (value: unknown): value is Record<string, unknown> =>
10  typeof value === 'object' && value !== null
11
12const MARK = '  ⎿  '
13
14/**
15 * Folds the diff of an Edit or a Write to one summary line. A click on the
16 * summary shows that one result's diff as Claude Code draws it, and a click on
17 * `▾ hide diff` folds it again. The ctrl+o transcript always shows everything.
18 */
19export function registerCollapse(on: On) {
20  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
21    const { tool, output, isErrored, isExpanded, tool_use_id: id } = e.props
22    if (isErrored || isExpanded === true || !isRecord(output)) return next(e)
23    if (tool !== 'Edit' && tool !== 'Write') return next(e)
24    const lines = summary(tool, output)
25    if (lines === null) return next(e)
26
27    const { Box, Text, Button } = $.ui.resolve(e)
28    const toggle = () => {
29      if (open.has(id)) open.delete(id)
30      else open.add(id)
31      $.ui.invalidate('ui.render')
32    }
33
34    if (open.has(id)) {
35      // A tall diff pushes the top one off screen, so it folds from its foot too.
36      const hide = (key: string) => Button({ key, plain: true, dimColor: true, onPress: toggle, children: [MARK + '▾ hide diff'] })
37      return Box({ flexDirection: 'column', children: [hide(id), await next(e), hide(id + ':foot')] })
38    }
39
40    const fold = Button({
41      key: id,
42      plain: true,
43      dimColor: true,
44      onPress: toggle,
45      children: [Text({ children: [lines.map((line, i) => MARK + line + (i === lines.length - 1 ? ' ▸' : '')).join('\n')] })],
46    })
47    return Box({ flexDirection: 'column', children: [fold] })
48  })
49}
50
51type Hunk = { lines: string[] }
52
53const hunksOf = (patch: unknown): Hunk[] =>
54  (Array.isArray(patch) ? patch : []).flatMap((hunk: unknown) =>
55    isRecord(hunk) && Array.isArray(hunk.lines)
56      ? [{ lines: hunk.lines.filter((line): line is string => typeof line === 'string') }]
57      : [],
58  )
59
60const counts = (hunks: Hunk[]) => {
61  const lines = hunks.flatMap(hunk => hunk.lines)
62  return { added: lines.filter(l => l.startsWith('+')).length, removed: lines.filter(l => l.startsWith('-')).length }
63}
64
65const plural = (n: number, noun: string) => n + ' ' + noun + (n === 1 ? '' : 's')
66
67/**
68 * The folded lines for a result, or null to leave Claude Code's drawing alone:
69 * an edit held for review, or a write that changed nothing.
70 */
71function summary(tool: string, output: Record<string, unknown>): string[] | null {
72  if (output.staged === true) return null
73  if (tool === 'Write' && output.type === 'create' && typeof output.content === 'string') {
74    const n = output.content.split('\n').length - (output.content.endsWith('\n') ? 1 : 0)
75    return ['Wrote ' + plural(n, 'line')]
76  }
77  const hunks = hunksOf(output.structuredPatch)
78  if (hunks.length === 0) return null
79  const { added, removed } = counts(hunks)
80  return ['Added ' + plural(added, 'line') + ', removed ' + plural(removed, 'line')]
81}
82
hooks/group.ts 354 lines
1import type { EngineInterface, On, RenderElement, SessionMessage } from 'claude-code'
2
3import { isNotification, jobOf, noteEnded, parseNotification, readJobs } from './jobs.js'
4import { spinnerFrame, startThinkTicker, watchThinking, watchTick } from './live.js'
5import { bodyLines, callRowSpans, durationText, jobRowSpans, metrics, summarySpans, thoughtBodyLines, thoughtRowSpans } from './rows.js'
6import { endRun, isThinkingLive, liveText, noteThinking, noteTurn, thoughtsOf } from './thinking.js'
7import { PI } from './palette.js'
8import { durationOf, isTimedRunning } from './timing.js'
9import { drawLine, fit, textWidth } from './textbox.js'
10import type { Call } from './callbox.js'
11import type { Columns, Tally } from './rows.js'
12import type { Kit } from './textbox.js'
13
14/** A call as the transcript holds it. */
15type Member = { id: string; tool: string; input: unknown; output: unknown; isError: boolean }
16
17/**
18 * The tool calls between two pieces of text, as pi-tidy groups them: a user
19 * message or an assistant message with visible text starts a group, and steps
20 * that only call tools don't split one. `first` draws the summary line.
21 */
22type Group = { first: string; calls: Member[] }
23
24/** Groups the person opened, by their first call's id; a reload folds them again. */
25const open = new Set<string>()
26
27/** Calls opened to show what they produced, under their row. */
28const opened = new Set<string>()
29
30/** Thinking runs opened, by the call they came before. */
31const openedThoughts = new Set<string>()
32
33/** The thinking before each call, from the transcript as last read. */
34let thoughts = new Map<string, string>()
35
36/** The newest call the model made: a run arriving now joins its group, unless text comes first. */
37let lastCallId: string | undefined
38
39/** tool_use_id → its group, from the transcript as last read. */
40let byCall = new Map<string, Group>()
41let readAt = 0
42let reading: Promise<void> | undefined
43
44/**
45 * Set when the transcript changed (a call started or ended, a prompt or a
46 * turn went by): the next draw reads it again. Otherwise draws reuse the
47 * grouping, which matters because every scroll step redraws.
48 */
49let stale = true
50
51/** How often, at most, a draw that finds a call outside every group reads the transcript again. */
52const RETRY = 500
53
54export function markStale() {
55  stale = true
56}
57
58function groupsOf(messages: SessionMessage[]) {
59  const map = new Map<string, Group>()
60  let current: Group | undefined
61  for (const message of messages) {
62    // A background job's notification is its row's news, not a piece of text.
63    if (message.text.trim() !== '' && !(message.role === 'user' && isNotification(message.text))) current = undefined
64    for (const use of message.role === 'assistant' ? message.toolUses : []) {
65      if (current === undefined) {
66        current = { first: use.tool_use_id, calls: [] }
67      }
68      const output = use.result ?? use.text
69      current.calls.push({ id: use.tool_use_id, tool: use.tool, input: use.input, output, isError: use.isError === true })
70      map.set(use.tool_use_id, current)
71    }
72  }
73  return map
74}
75
76async function groupOf($: EngineInterface, id: string) {
77  if (stale || (!byCall.has(id) && Date.now() - readAt > RETRY)) {
78    stale = false
79    reading ??= Promise.all([$.session.messages(), $.session.messages({ as: 'api' })]).then(([messages, api]) => {
80      byCall = groupsOf(messages)
81      readJobs(messages)
82      thoughts = thoughtsOf(api)
83      readAt = Date.now()
84      reading = undefined
85    })
86    await reading
87  }
88  return byCall.get(id)
89}
90
91/** A transcript call as a row draws it; whether it runs is timed here. */
92const callOf = (member: Member): Call => ({
93  tool_use_id: member.id,
94  tool: member.tool,
95  input: member.input,
96  output: member.output,
97  isRunning: isTimedRunning(member.id),
98  isErrored: member.isError,
99  isInterrupted: false,
100})
101
102/** The widest note of each group, kept while its transcript read stands: notes walk whole outputs. */
103const noteWidths = new WeakMap<Group, number>()
104
105/**
106 * The group's metrics columns, so every row ends on the same cell. `own` are
107 * the calls this site draws, from its props, which can be newer than the
108 * transcript as last read.
109 */
110function columnsOf(group: Group, own: Call[]): Columns {
111  let note = noteWidths.get(group)
112  if (note === undefined) {
113    note = Math.max(0, ...group.calls.map(member => textWidth(metrics(callOf(member), undefined).note)))
114    noteWidths.set(group, note)
115  }
116  const ids = [...group.calls.map(member => member.id), ...own.map(call => call.tool_use_id ?? '')]
117  return {
118    note: Math.max(note, ...own.map(call => textWidth(metrics(call, undefined).note))),
119    duration: Math.max(0, ...ids.map(id => textWidth(durationText(durationOf(id))))),
120  }
121}
122
123type RenderEvent = Parameters<EngineInterface['ui']['resolve']>[0]
124
125/**
126 * The group's summary line, one button the width of the row: it opens the
127 * group, or folds it and every call opened in it.
128 */
129function header($: EngineInterface, kit: Kit, group: Group, isOpen: boolean, width: number) {
130  const running = group.calls.map(member => jobOf(member.id)).filter(job => job?.status === 'running')
131  const jobs = { running: running.length, spinner: spinnerFrame(running[0]?.startedAt) }
132  const thinking = {
133    count: group.calls.filter(member => thoughts.has(member.id)).length,
134    live: isCurrent(group) && isThinkingLive(),
135    spinner: spinnerFrame(undefined),
136  }
137  const tallies: Tally[] = group.calls.map(member => ({
138    tool: member.tool,
139    isError: member.isError,
140    isRunning: isTimedRunning(member.id),
141    ms: durationOf(member.id),
142  }))
143  const toggle = () => {
144    if (open.has(group.first)) {
145      open.delete(group.first)
146      for (const member of group.calls) {
147        opened.delete(member.id)
148        openedThoughts.delete(member.id)
149      }
150    } else open.add(group.first)
151    $.ui.invalidate('ui.render')
152  }
153  const line = drawLine(kit, 'head:' + group.first, fit(summarySpans(tallies, isOpen, jobs, thinking), width), toggle, PI.selectedBg)
154  return kit.Box({ key: 'head', children: [line] })
155}
156
157/**
158 * One call of an open group: its row, and once opened (a click, or ctrl+o:
159 * `full`), what it produced hanging below. A click on the row opens or folds
160 * it; a click on the body folds it.
161 */
162function callLines($: EngineInterface, kit: Kit, group: Group, call: Call, columns: Columns, width: number, full: boolean, isOpen: boolean): RenderElement[] {
163  const id = call.tool_use_id ?? ''
164  const isOpened = full || opened.has(id)
165  const toggle = () => {
166    open.add(group.first)
167    if (opened.has(id)) opened.delete(id)
168    else opened.add(id)
169    $.ui.invalidate('ui.render')
170  }
171  const fold = () => {
172    opened.delete(id)
173    $.ui.invalidate('ui.render')
174  }
175  const job = jobOf(id)
176  const spans = job === undefined ? callRowSpans(call, durationOf(id), width, columns, isOpened) : jobRowSpans(call, job, width, isOpened)
177  const row = drawLine(kit, 'row:' + id, spans, toggle, PI.selectedBg)
178  // Folded, a running call's row shows alone: its thinking waits for the group to open.
179  const lines = [...(isOpen ? thoughtLines($, kit, group, id, width, full) : []), kit.Box({ key: 'row:' + id, children: [row] })]
180  if (isOpened) {
181    const body = bodyLines(call, width).map((spans, i) => drawLine(kit, 'body:' + id + ':' + i, spans, fold))
182    lines.push(kit.Box({ key: 'body:' + id, flexDirection: 'column', children: body }))
183  }
184  // The run arriving now, under the newest call: a window over its newest words.
185  if (isOpen && id === lastCallId && isCurrent(group) && isThinkingLive()) {
186    const spans = thoughtRowSpans(liveText(), width, false, true)
187    lines.push(kit.Box({ key: 'live', children: [drawLine(kit, 'live:' + id, spans, () => {}, PI.selectedBg)] }))
188  }
189  return lines
190}
191
192/** Whether a run arriving now belongs to this group: the one the newest call is in. */
193const isCurrent = (group: Group) => lastCallId !== undefined && byCall.get(lastCallId) === group
194
195/**
196 * The thinking run before a call, as a row above it; a click opens the run
197 * under it, and a click there folds it.
198 */
199function thoughtLines($: EngineInterface, kit: Kit, group: Group, id: string, width: number, full: boolean): RenderElement[] {
200  const text = thoughts.get(id)
201  if (text === undefined) return []
202  const isOpened = full || openedThoughts.has(id)
203  const toggle = () => {
204    open.add(group.first)
205    if (openedThoughts.has(id)) openedThoughts.delete(id)
206    else openedThoughts.add(id)
207    $.ui.invalidate('ui.render')
208  }
209  const fold = () => {
210    openedThoughts.delete(id)
211    $.ui.invalidate('ui.render')
212  }
213  const row = drawLine(kit, 'thought:' + id, thoughtRowSpans(text, width, isOpened), toggle, PI.selectedBg)
214  const lines = [kit.Box({ key: 'thought:' + id, children: [row] })]
215  if (isOpened) {
216    const body = thoughtBodyLines(text, width).map((spans, i) => drawLine(kit, 'thought-body:' + id + ':' + i, spans, fold))
217    lines.push(kit.Box({ key: 'thought-body:' + id, flexDirection: 'column', children: body }))
218  }
219  return lines
220}
221
222/** A running call or job shows its row under a folded group once it has run this long. */
223const REVEAL_MS = 1000
224
225/**
226 * Whether a call of a folded group shows its row: one running for a second or
227 * more, so a 30-second command isn't invisible, and a background job's while
228 * it runs. A call done within the second never shows (no flicker); a failure
229 * doesn't either, the red count is its sign.
230 */
231function isRevealed(call: Call, now: number) {
232  const id = call.tool_use_id ?? ''
233  const job = jobOf(id)
234  if (job?.status === 'running') return job.startedAt === undefined || now - job.startedAt >= REVEAL_MS
235  if (!call.isRunning) return false
236  const ms = durationOf(id)
237  return ms === undefined || ms >= REVEAL_MS
238}
239
240/** Whether a site draws something that changes by the second: it then watches the tick. */
241function isLiveSite(group: Group, calls: Call[], hasHeader: boolean) {
242  const runs = (id: string) => jobOf(id)?.status === 'running'
243  return calls.some(call => call.isRunning || runs(call.tool_use_id ?? '')) || (hasHeader && group.calls.some(member => runs(member.id)))
244}
245
246/**
247 * What one site draws of a group: the summary line where the group's first
248 * call is, and a row for each of its own calls while the group is open (or,
249 * folded, for a call still running). `full` is the message expanded (ctrl+o):
250 * the group open and every call too.
251 */
252async function drawSite($: EngineInterface, e: RenderEvent, columnsOnScreen: number | undefined, group: Group, calls: Call[], full: boolean): Promise<RenderElement> {
253  const kit = $.ui.resolve(e)
254  // One cell short of the edge: a row exactly as wide as the screen wraps.
255  const width = (columnsOnScreen ?? 100) - 1
256  const isOpen = full || open.has(group.first)
257  const hasHeader = calls.some(call => call.tool_use_id === group.first)
258  if (isLiveSite(group, calls, hasHeader)) await watchTick($)
259  // The current group's summary and newest row move with a thinking run.
260  if (isCurrent(group) && (hasHeader || calls.some(call => call.tool_use_id === lastCallId))) await watchThinking($)
261  const now = Date.now()
262  const shown = isOpen ? calls : calls.filter(call => isRevealed(call, now))
263  const columns = shown.length > 0 ? columnsOf(group, calls) : { note: 0, duration: 0 }
264  const lines = calls.flatMap(call => [
265    ...(call.tool_use_id === group.first ? [header($, kit, group, isOpen, width)] : []),
266    ...(shown.includes(call) ? callLines($, kit, group, call, columns, width, full, isOpen) : []),
267  ])
268  return lines.length === 0 ? kit.Box({}) : kit.Box({ flexDirection: 'column', children: lines })
269}
270
271/**
272 * Folds each group of tool calls to one line, `▸ 5 calls · bash×3  read×2`.
273 * A click opens it as pi-tidy's rows, one a call; a click on a row shows what
274 * the call produced under it, and a click there folds it again. Every cell is
275 * a button of ours, so Claude Code never takes a click to expand the message
276 * (which greys it).
277 */
278export function registerGroups(on: On) {
279  // The transcript changed: the next draw reads it again.
280  on('prompt.submit', async ($, e, next) => {
281    markStale()
282    // A background job ended: its row says so from now.
283    const note = e.origin.kind === 'task-notification' ? parseNotification(e.text) : undefined
284    if (note !== undefined && noteEnded(note.toolUseId, note.status, note.exitCode)) $.ui.invalidate('ui.render')
285    return next(e)
286  })
287  on('turn.start', async ($, e, next) => {
288    markStale()
289    if (e.agentId === undefined) noteTurn(true)
290    return next(e)
291  })
292  on('turn.complete', async ($, e, next) => {
293    markStale()
294    if (e.agentId === undefined) noteTurn(false)
295    return next(e)
296  })
297
298  // Watches the main thread's response go by: thinking arriving, then the call
299  // or text that ends the run. Every chunk is passed on as it came.
300  on('turn.step', async function* ($, e, next) {
301    if (e.agentId !== undefined) return yield* next(e)
302    const stream = next(e)
303    let inRun = false
304    for await (const chunk of stream) {
305      if (chunk.kind === 'thinking') {
306        const wasLive = isThinkingLive()
307        noteThinking(chunk.text, !inRun)
308        inRun = true
309        // The summary has nothing else to draw it again: say the run started.
310        if (!wasLive && isThinkingLive()) {
311          startThinkTicker($)
312          $.ui.invalidate('ui.render')
313        }
314      } else if (chunk.kind === 'tool' || (chunk.kind === 'text' && chunk.text.trim() !== '')) {
315        if (chunk.kind === 'tool') lastCallId = chunk.id
316        inRun = false
317        endRun()
318      }
319      yield chunk
320    }
321    endRun()
322    return await stream.result
323  })
324
325  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
326    const group = await groupOf($, e.props.tool_use_id)
327    if (group === undefined) return next(e)
328    return drawSite($, e, e.viewport?.columns, group, [e.props], e.props.isExpanded === true)
329  })
330
331  // A job's notification ("Background command … completed"): its row says that already.
332  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, async ($, e, next) => {
333    const id = e.props.task?.toolUseId
334    if (id === undefined || e.props.isExpanded || jobOf(id) === undefined || (await groupOf($, id)) === undefined) return next(e)
335    return $.ui.resolve(e).Box({})
336  })
337
338  // The call's row holds its result.
339  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
340    const group = await groupOf($, e.props.tool_use_id)
341    if (group === undefined) return next(e)
342    return $.ui.resolve(e).Box({})
343  })
344
345  // Claude Code's own fold ("Ran 3 shell commands"): its calls are a group's.
346  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
347    const id = e.props.calls[0]?.tool_use_id
348    if (id === undefined) return next(e)
349    const group = await groupOf($, id)
350    if (group === undefined) return next(e)
351    return drawSite($, e, e.viewport?.columns, group, [...e.props.calls], e.props.isExpanded)
352  })
353}
354
hooks/timing.ts 75 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { markStale } from './group.js'
4import { noteStarted, taskIdOf } from './jobs.js'
5import { startTicker } from './live.js'
6
7/** How long each call ran, in ms, by tool_use_id. */
8const durations = new Map<string, number>()
9
10/** When each running call started. */
11const started = new Map<string, number>()
12
13let storeKey: string | undefined
14
15/** How long a call ran (or has run so far), or undefined when nobody timed it. */
16export function durationOf(id: string): number | undefined {
17  const done = durations.get(id)
18  if (done !== undefined) return done
19  const start = started.get(id)
20  return start === undefined ? undefined : Date.now() - start
21}
22
23/** Whether the call is running now, as timed here. */
24export const isTimedRunning = (id: string) => started.has(id)
25
26export const anyTimedRunning = () => started.size > 0
27
28/** `0.4s`, `12.3s`, `3m05s`, as pi-tidy says it. */
29export function formatDuration(ms: number) {
30  if (!Number.isFinite(ms) || ms < 0) return ''
31  if (ms < 60_000) return (Math.round(ms / 100) / 10).toFixed(1) + 's'
32  const s = Math.round(ms / 1000)
33  return Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's'
34}
35
36async function load($: EngineInterface) {
37  storeKey = 'durations:' + (await $.session.id())
38  const saved = await $.store.get(storeKey)
39  if (saved !== null && typeof saved === 'object') {
40    for (const [id, ms] of Object.entries(saved)) if (typeof ms === 'number' && !durations.has(id)) durations.set(id, ms)
41  }
42}
43
44/**
45 * Times every tool call: the transcript keeps no timestamps, so a call's
46 * duration is measured here, around the call, and kept per session in the
47 * plugin's store so a resumed session still has it. Calls made before the
48 * mod was loaded have none.
49 */
50export function registerTiming(on: On) {
51  on('session.start', async ($, e, next) => {
52    await load($)
53    return next(e)
54  })
55
56  on('tool.call', async ($, e, next) => {
57    started.set(e.tool_use_id, Date.now())
58    markStale()
59    startTicker($)
60    try {
61      const answer = await next(e)
62      // A call that started a background job ends at once; the job runs on.
63      const taskId = taskIdOf(answer.result)
64      if (taskId !== undefined) noteStarted(e.tool_use_id, taskId)
65      return answer
66    } finally {
67      durations.set(e.tool_use_id, Date.now() - (started.get(e.tool_use_id) ?? Date.now()))
68      started.delete(e.tool_use_id)
69      markStale()
70      if (storeKey !== undefined) void $.store.set(storeKey, Object.fromEntries(durations))
71      $.ui.invalidate('ui.render')
72    }
73  })
74}
75
hooks/callbox.ts 252 lines
1import type { RenderElement } from 'claude-code'
2
3import { bashSpans } from './bash-spans.js'
4import { PI } from './palette.js'
5import { highlightLines, languageOf, languageOfCommand } from './highlight.js'
6import { splitDiffRows } from './split.js'
7import { durationOf, formatDuration } from './timing.js'
8import { clean, drawTextBox } from './textbox.js'
9import type { Kit, Row, Span } from './textbox.js'
10
11/** What a box is drawn from: a `ToolUse` row's props, or a call of a `ToolGroup`. */
12export type Call = {
13  tool_use_id?: string
14  tool: string
15  input: unknown
16  output?: unknown
17  isRunning: boolean
18  isErrored: boolean
19  isInterrupted: boolean
20}
21
22/** The most output lines an open box draws. */
23const OPEN_LINES = 200
24
25const isRecord = (value: unknown): value is Record<string, unknown> =>
26  typeof value === 'object' && value !== null
27
28const pick = (input: unknown, key: string) =>
29  isRecord(input) && typeof input[key] === 'string' ? (input[key] as string) : undefined
30
31/** Nerd Font icons, as pi-tidy's (fa-terminal, fa-eye, fa-pencil, …). */
32const ICONS: Record<string, string> = {
33  Bash: '\u{f120}',
34  Read: '\u{f06e}',
35  Edit: '\u{f044}',
36  Write: '\u{f0c7}',
37  Grep: '\u{f002}',
38  Glob: '\u{f07c}',
39  mcp: '\u{f12e}',
40}
41
42/** `Bash` → `bash`, `mcp__jira__jira_get_issue` → `jira`. */
43export const shortName = (tool: string) => (tool.startsWith('mcp__') ? (tool.split('__')[1] ?? 'mcp') : tool.toLowerCase())
44
45export const iconOf = (tool: string) => ICONS[tool.startsWith('mcp__') ? 'mcp' : tool] ?? '\u{f0ad}'
46
47/**
48 * The call's own words, pi-tidy's: a shell command's first line and how many
49 * lines follow it; else the file, the pattern, the MCP tool, or the first
50 * string argument.
51 */
52export function argParts(call: Call): { text: string; more: number } {
53  const command = pick(call.input, 'command')
54  if (command !== undefined) {
55    const [first = '', ...rest] = command.split('\n')
56    return { text: clean(first.trim()), more: rest.length }
57  }
58  if (call.tool.startsWith('mcp__')) return { text: call.tool.split('__').slice(2).join('__'), more: 0 }
59  for (const key of ['file_path', 'path', 'pattern', 'url', 'query', 'description']) {
60    const value = pick(call.input, key)
61    if (value !== undefined && value !== '') return { text: clean(tildePath(value)), more: 0 }
62  }
63  const first = isRecord(call.input) ? Object.values(call.input).find(value => typeof value === 'string' && value !== '') : undefined
64  return { text: typeof first === 'string' ? clean(first.split('\n')[0] ?? '') : '', more: 0 }
65}
66
67/** The call's own words, the command highlighted: the head of a box. */
68function subject(call: Call): { spans: Span[]; more: number } {
69  const { text, more } = argParts(call)
70  return { spans: call.tool === 'Bash' ? bashSpans(text) : [{ text, color: PI.toolOutput }], more }
71}
72
73/** A patch's lines, one pane: the `+`/`-` mark in green or red, the code highlighted. */
74function unifiedRows(patch: unknown[], language: string | undefined): Span[][] {
75  return patch.flatMap((hunk: unknown) =>
76    isRecord(hunk) && Array.isArray(hunk.lines)
77      ? hunk.lines
78          .filter((line): line is string => typeof line === 'string')
79          .map(line => {
80            const mark = line.charAt(0)
81            const code = highlightLines(clean(line.slice(1)), language, mark === ' ' ? PI.diffContext : PI.toolOutput)[0] ?? []
82            return [{ text: mark, color: mark === '+' ? PI.success : mark === '-' ? PI.error : PI.diffContext }, ...code]
83          })
84      : [],
85  )
86}
87
88/** A patch as two panes when `inner` has room for them, else as one. */
89const diffRows = (patch: unknown[], inner: number, language: string | undefined) =>
90  splitDiffRows(patch, inner, language) ?? unifiedRows(patch, language)
91
92/** `/Users/andy/x` → `~/x`. */
93export const tildePath = (path: string) => path.replace(/^\/Users\/[^/]+(?=\/|$)/, '~')
94
95/** The files a shell command changed (`bashEditDiff`): a header each, then its diff. */
96function bashEditRows(diff: unknown, inner: number): Span[][] {
97  if (!isRecord(diff) || diff.skipped === true || diff.unavailable === true || !Array.isArray(diff.files)) return []
98  const rows = diff.files.flatMap((file: unknown): Span[][] => {
99    if (!isRecord(file) || typeof file.filePath !== 'string' || !Array.isArray(file.hunks)) return []
100    const all = file.hunks.flatMap((h: unknown) => (isRecord(h) && Array.isArray(h.lines) ? h.lines : []))
101    const added = all.filter(l => typeof l === 'string' && l.startsWith('+')).length
102    const removed = all.filter(l => typeof l === 'string' && l.startsWith('-')).length
103    const verb = file.created === true ? 'Created ' : file.deleted === true ? 'Deleted ' : 'Updated '
104    const header: Span[] = [
105      { text: verb, color: PI.muted },
106      { text: tildePath(file.filePath), color: PI.toolTitle },
107      { text: ' (+' + added + ' -' + removed + ')', color: PI.muted },
108    ]
109    // A new or removed file has one empty side: one pane says the same.
110    const language = languageOf(file.filePath)
111    const body = file.created === true || file.deleted === true ? unifiedRows(file.hunks, language) : diffRows(file.hunks, inner, language)
112    return [[{ text: '' }], header, ...body]
113  })
114  const more = typeof diff.moreFiles === 'number' ? diff.moreFiles : 0
115  return more > 0 ? [...rows, [{ text: '… ' + more + ' more files changed', color: PI.muted }]] : rows
116}
117
118/** What the call printed or returned, line by line; a diff as two panes. */
119export function outputRows(call: Call, inner: number): Span[][] {
120  const out = call.output
121  if (typeof out === 'string') return lines(out).map(plain)
122  if (!isRecord(out)) return []
123  const path = pick(call.input, 'file_path') ?? (typeof out.filePath === 'string' ? out.filePath : undefined)
124  const language = languageOf(path)
125  // A new file: its content, highlighted (its patch is the whole file as +).
126  if (call.tool === 'Write' && out.type === 'create' && typeof out.content === 'string') return highlighted(out.content, language)
127  if (Array.isArray(out.structuredPatch) && out.structuredPatch.length > 0) return diffRows(out.structuredPatch, inner, language)
128  if (typeof out.stdout === 'string' || typeof out.stderr === 'string') {
129    const text = [out.stdout, out.stderr].filter((part): part is string => typeof part === 'string' && part.trim() !== '').join('\n')
130    // The output's language, when the command printed files of one. No guessing from the
131    // text: highlight.js scores a short `ls -la` as CSS as high as real Python.
132    const printed = languageOfCommand(pick(call.input, 'command') ?? '')
133    const rows = printed === undefined ? lines(text).map(plain) : highlighted(text, printed)
134    return [...rows, ...bashEditRows(out.bashEditDiff, inner)]
135  }
136  if (isRecord(out.file) && typeof out.file.content === 'string') return highlighted(out.file.content, language)
137  if (typeof out.content === 'string') return highlighted(out.content, language)
138  return lines(JSON.stringify(out, null, 2) ?? '').map(plain)
139}
140
141/** An output line in the text colour; a `$ command` line highlighted as bash. */
142const plain = (text: string): Span[] =>
143  /^\$ \S/.test(text) ? [{ text: '$ ', color: PI.muted }, ...bashSpans(text.slice(2))] : [{ text, color: PI.toolOutput }]
144
145/** A file's text, highlighted as its language, trailing blank lines dropped. */
146const highlighted = (text: string, language: string | undefined) =>
147  highlightLines(text.replace(/\s+$/, '').replace(/\t/g, '    '), language, PI.toolOutput).map(row => row.map(span => ({ ...span, text: clean(span.text) })))
148
149function lines(text: string) {
150  const trimmed = text.replace(/\s+$/, '')
151  return trimmed === '' ? [] : trimmed.split('\n').map(clean)
152}
153
154const plural = (n: number, one: string, many = one + 's') => n + ' ' + (n === 1 ? one : many)
155
156/**
157 * A failed Bash call's first line: `Exit code N` as the model reads it,
158 * `Error: Exit code N` as the transcript stores it.
159 */
160const EXIT = /^(?:Error: )?Exit code (\d+)\n?/
161
162/**
163 * A failure with nothing to read: the call exited non-zero and said nothing
164 * (`test`, a probe, `sed` on a path that moved). pi-tidy keeps its red `✗`
165 * and dims the rest, so it doesn't compete with the reply.
166 */
167export function isSilentFailure(call: Call) {
168  if (!call.isErrored || call.isRunning || call.isInterrupted) return false
169  return typeof call.output !== 'string' || call.output.replace(EXIT, '').trim() === ''
170}
171
172/**
173 * What the call came to, said short, as pi-tidy says it: `12 lines`, `+4 -1`,
174 * `3 lines · 2 files`, `exit 1`. Built-in tools only: anything else gets
175 * none rather than a wrong one, and so does a call still running.
176 */
177export function note(call: Call) {
178  if (call.isRunning) return ''
179  if (call.isInterrupted) return 'interrupted'
180  const out = call.output
181  if (call.isErrored) {
182    const code = typeof out === 'string' ? EXIT.exec(out)?.[1] : undefined
183    return code === undefined ? '' : 'exit ' + code
184  }
185  if (!isRecord(out)) return ''
186  if (Array.isArray(out.structuredPatch) && out.structuredPatch.length > 0) {
187    const all = out.structuredPatch.flatMap((h: unknown) => (isRecord(h) && Array.isArray(h.lines) ? h.lines : []))
188    return '+' + all.filter(l => typeof l === 'string' && l.startsWith('+')).length + ' -' + all.filter(l => typeof l === 'string' && l.startsWith('-')).length
189  }
190  if (call.tool === 'Write' && typeof out.content === 'string') return plural(lines(out.content).length, 'line')
191  if (typeof out.stdout === 'string') {
192    const n = lines([out.stdout, typeof out.stderr === 'string' ? out.stderr : ''].join('\n')).length
193    const diff = out.bashEditDiff
194    const files = isRecord(diff) && Array.isArray(diff.files) ? diff.files.length + (typeof diff.moreFiles === 'number' ? diff.moreFiles : 0) : 0
195    const printed = n > 0 ? plural(n, 'line') : ''
196    return files > 0 ? [printed, plural(files, 'file')].filter(Boolean).join(' · ') : printed
197  }
198  if (call.tool === 'Read' && isRecord(out.file)) {
199    const n = typeof out.file.numLines === 'number' ? out.file.numLines : typeof out.file.content === 'string' ? lines(out.file.content).length : undefined
200    return n === undefined ? '' : plural(n, 'line')
201  }
202  if (call.tool === 'Glob' && typeof out.numFiles === 'number') return out.numFiles === 0 ? 'no files' : plural(out.numFiles, 'file')
203  if (call.tool === 'Grep') {
204    const n = out.mode === 'content' ? out.numLines : out.numFiles
205    if (typeof n === 'number') return n === 0 ? 'no matches' : out.mode === 'content' ? plural(n, 'line') : plural(n, 'file')
206  }
207  return ''
208}
209
210/** The call's state: its border, and the mark before it (pi-tidy's `⋯ ✗ ✓`). */
211export function statusOf(call: Call) {
212  if (call.isRunning) return { color: PI.running, mark: '⋯' }
213  if (call.isErrored || call.isInterrupted) return { color: PI.error, mark: '✗' }
214  return { color: PI.success, mark: '✓' }
215}
216
217/**
218 * A call as one rounded box: folded, the one line `✓ >_bash git status
219 * 4 lines · 0.1s`; full, that line and everything the call produced. A
220 * click anywhere on the box runs `toggle`.
221 */
222export function drawCallBox(kit: Kit, call: Call, width: number, isFull: boolean, toggle: () => void): RenderElement {
223  const status = statusOf(call)
224  const { spans, more } = subject(call)
225  // Folded, nothing below the header is drawn: don't build (or highlight) it.
226  const body = isFull ? outputRows(call, width - 4) : []
227  const ms = durationOf(call.tool_use_id ?? '')
228  const tail = [note(call), ms === undefined ? '' : formatDuration(ms)].filter(Boolean).join(' · ')
229  const right: Span[] = [
230    ...(more > 0 ? [{ text: '  (+' + more + (more === 1 ? ' line)' : ' lines)'), color: PI.muted }] : []),
231    ...(tail === '' ? [] : [{ text: '  ' + tail, color: call.isErrored || call.isInterrupted ? PI.error : PI.muted }]),
232    { text: isFull ? '  ▾' : '  ▸', color: PI.muted },
233  ]
234  const header: Row = {
235    left: [
236      { text: status.mark + ' ', color: status.color },
237      // The space lets Ghostty draw the icon two cells wide, as pi-tidy's are.
238      { text: iconOf(call.tool) + ' ' + shortName(call.tool) + ' ', color: PI.toolTitle },
239      ...spans,
240    ],
241    right,
242  }
243  const shown = isFull ? body.slice(0, OPEN_LINES) : []
244  const hidden = isFull ? body.length - shown.length : 0
245  const rows: Row[] = [
246    header,
247    ...shown.map(left => ({ left })),
248    ...(hidden > 0 ? [{ left: [{ text: '… ' + hidden + ' more lines', color: PI.muted }] }] : []),
249  ]
250  return drawTextBox(kit, 'box:' + (call.tool_use_id ?? ''), width, status.color, rows, toggle)
251}
252
hooks/live.ts 70 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Timer } from 'claude-code'
3
4import { anyJobRunning } from './jobs.js'
5import { isThinkingLive } from './thinking.js'
6import { anyTimedRunning } from './timing.js'
7
8/**
9 * A count that goes up once a second while anything is live (a call or a
10 * background job running). A drawing that shows something live reads it, and
11 * `$.state` redraws exactly those readers on each tick: not every site, as
12 * `$.ui.invalidate` would.
13 */
14const tick = atom({ plugin: 'cc-tidy', key: 'tick' } as const, 0)
15
16let ticker: Timer | undefined
17
18const isLive = () => anyTimedRunning() || anyJobRunning()
19
20/** Starts the tick if it isn't going; it stops itself once nothing is live. */
21export function startTicker($: EngineInterface) {
22  if (ticker !== undefined) return
23  ticker = $.clock.every(1000, () => {
24    if (!isLive()) {
25      ticker?.cancel()
26      ticker = undefined
27      return
28    }
29    void update($, tick, n => (n ?? 0) + 1)
30  })
31}
32
33/** Subscribes the drawing to the tick, starting it where it isn't going. */
34export async function watchTick($: EngineInterface) {
35  startTicker($)
36  await read($, tick)
37}
38
39/**
40 * A faster count, every 300 ms while a thinking run is arriving: the live
41 * preview moves at most this often (per frame, it would slide a cell a token
42 * and read as flicker). Its last step comes once the run goes quiet, so the
43 * marker leaves on time even with nothing else drawing.
44 */
45const thinkTick = atom({ plugin: 'cc-tidy', key: 'thinkTick' } as const, 0)
46
47let thinkTicker: Timer | undefined
48
49export function startThinkTicker($: EngineInterface) {
50  if (thinkTicker !== undefined) return
51  thinkTicker = $.clock.every(300, () => {
52    if (!isThinkingLive()) {
53      thinkTicker?.cancel()
54      thinkTicker = undefined
55    }
56    void update($, thinkTick, n => (n ?? 0) + 1)
57  })
58}
59
60/** Subscribes the drawing to the thinking tick. */
61export async function watchThinking($: EngineInterface) {
62  await read($, thinkTick)
63}
64
65/** pi-tidy's spinner, `◐ ◓ ◑ ◒` a step a second, still (`●`) after five minutes. */
66export function spinnerFrame(startedAt: number | undefined, now = Date.now()) {
67  if (startedAt !== undefined && now - startedAt > 5 * 60_000) return '●'
68  return '◐◓◑◒'.charAt(Math.floor(now / 1000) % 4)
69}
70
hooks/jobs.ts 98 lines
1import type { SessionMessage } from 'claude-code'
2
3/**
4 * What a call that started a background job (`run_in_background`, or ctrl+b)
5 * shows on its row instead of a result: the job's state, as pi-tidy draws a
6 * pi-processes job.
7 *
8 * - `running` till its notification comes
9 * - `done`, `failed` (a non-zero exit), `killed` as the notification says
10 * - `gone`: nothing says how it ended (a resumed session's job, whose process
11 *   went with the old one)
12 */
13export type Job = {
14  taskId: string
15  status: 'running' | 'done' | 'failed' | 'killed' | 'gone'
16  exitCode?: number
17  /** When it started and ended, as seen here: a resumed session has neither. */
18  startedAt?: number
19  endedAt?: number
20}
21
22/** Jobs by the tool_use_id of the call that started them. */
23const jobs = new Map<string, Job>()
24
25const isRecord = (value: unknown): value is Record<string, unknown> =>
26  typeof value === 'object' && value !== null
27
28export const jobOf = (toolUseId: string) => jobs.get(toolUseId)
29
30/** The background task id a call's result names, if it started one. */
31export function taskIdOf(result: unknown) {
32  return isRecord(result) && typeof result.backgroundTaskId === 'string' ? result.backgroundTaskId : undefined
33}
34
35/** A call just started a job: it runs from now. */
36export function noteStarted(toolUseId: string, taskId: string) {
37  jobs.set(toolUseId, { taskId, status: 'running', startedAt: Date.now() })
38}
39
40/** A `<task-notification>`'s fields: which call, and how the job ended. */
41export function parseNotification(text: string) {
42  if (!text.includes('<task-notification>')) return undefined
43  const field = (name: string) => new RegExp('<' + name + '>([\\s\\S]*?)</' + name + '>').exec(text)?.[1]?.trim()
44  const toolUseId = field('tool-use-id')
45  if (toolUseId === undefined) return undefined
46  const code = /exit code (\d+)/.exec(field('summary') ?? '')?.[1]
47  return { toolUseId, status: field('status') ?? '', exitCode: code === undefined ? undefined : Number(code) }
48}
49
50export const isNotification = (text: string) => text.trimStart().startsWith('<task-notification>')
51
52/** How a notification's status reads as a job's end. */
53function ended(status: string, exitCode: number | undefined): Job['status'] {
54  if (status === 'completed') return exitCode !== undefined && exitCode !== 0 ? 'failed' : 'done'
55  if (status === 'failed') return 'failed'
56  return 'killed'
57}
58
59/**
60 * A notification says how a job ended. Returns whether that changed what its
61 * row shows.
62 */
63export function noteEnded(toolUseId: string, status: string, exitCode: number | undefined) {
64  const job = jobs.get(toolUseId)
65  if (job === undefined || job.status !== 'running') return false
66  jobs.set(toolUseId, { ...job, status: ended(status, exitCode), exitCode, endedAt: Date.now() })
67  return true
68}
69
70/**
71 * The jobs the transcript tells of: every call whose result names a task, and
72 * how each ended where its notification is there. A job started in this
73 * process keeps its times; one nothing explains is `gone`.
74 */
75export function readJobs(messages: SessionMessage[]) {
76  const ends = new Map<string, { status: string; exitCode: number | undefined }>()
77  for (const message of messages) {
78    const note = message.role === 'user' ? parseNotification(message.text) : undefined
79    if (note !== undefined) ends.set(note.toolUseId, note)
80  }
81  for (const message of messages) {
82    for (const use of message.role === 'assistant' ? message.toolUses : []) {
83      const taskId = taskIdOf(use.result)
84      if (taskId === undefined) continue
85      const known = jobs.get(use.tool_use_id)
86      const end = ends.get(use.tool_use_id)
87      if (end !== undefined && (known === undefined || known.status === 'running')) {
88        // Seen running here, it ends now; read from a resumed transcript, it has no times.
89        const times = known?.startedAt === undefined ? {} : { startedAt: known.startedAt, endedAt: Date.now() }
90        jobs.set(use.tool_use_id, { taskId, status: ended(end.status, end.exitCode), exitCode: end.exitCode, ...times })
91      } else if (end === undefined && known === undefined) jobs.set(use.tool_use_id, { taskId, status: 'gone' })
92    }
93  }
94}
95
96/** Whether any job started here still runs: the clock keeps ticking for it. */
97export const anyJobRunning = () => [...jobs.values()].some(job => job.status === 'running')
98
hooks/rows.ts 248 lines
1import { argParts, iconOf, isSilentFailure, note, outputRows, shortName, statusOf } from './callbox.js'
2import { bashSpans } from './bash-spans.js'
3import { PI } from './palette.js'
4import { formatDuration } from './timing.js'
5import { clean, fit, spansWidth, textWidth, truncate, wrapText } from './textbox.js'
6import { spinnerFrame } from './live.js'
7import type { Call } from './callbox.js'
8import type { Job } from './jobs.js'
9import type { Span } from './textbox.js'
10
11/**
12 * pi-tidy's open group, as text: the rail ` │` down the left, one row per call,
13 * and an opened call's body hanging under its row, which takes a tick (` ├─`).
14 *
15 *    ▾ 3 calls ·  bash×2  read×1 · 1.4s
16 *    │ ✓  bash git status --short        4 lines · 0.1s
17 *    ├─ ✓  read ~/x/notes.md            12 lines · 0.0s
18 *    │    <what it produced>
19 */
20
21/** What a summary line counts: one entry a call. */
22export type Tally = { tool: string; isError: boolean; isRunning: boolean; ms: number | undefined }
23
24/** Cells a row keeps for the command before the metrics give way (pi-tidy's). */
25const MIN_ARG_CELLS = 12
26
27/** Cells an opened call's body is pushed in by: the rail and three. */
28export const BODY_INDENT = 5
29
30const rail = (glyph: string): Span[] => [{ text: ' ' }, { text: glyph, color: PI.borderMuted }]
31
32const SEP: Span = { text: ' · ', color: PI.muted }
33
34const plural = (n: number, one: string) => n + ' ' + (n === 1 ? one : one + 's')
35
36/**
37 * `nf-md-brain`. Not the emoji 🧠: no monospace Nerd Font has it, and its
38 * two-cell colour fallback breaks the row's alignment.
39 */
40export const THOUGHT_ICON = '\u{f09d1}'
41
42/** A settled run's preview is a glance, not a paragraph (pi-tidy's 56 cells). */
43const THOUGHT_PREVIEW_CELLS = 56
44
45/**
46 * A run squeezed onto one row, in cells. Settled, its head, capped at 56
47 * cells; live (`tail`), a window over its newest words that takes the whole
48 * row, cut on a word break so the line doesn't shave half a word off.
49 */
50export function previewText(text: string, maxWidth: number, tail = false) {
51  const flat = text.replace(/\s+/g, ' ').trim()
52  const budget = tail ? maxWidth : Math.min(maxWidth, THOUGHT_PREVIEW_CELLS)
53  if (budget <= 1) return ''
54  if (!tail) return truncate(flat, budget)
55  let out = flat
56  while (textWidth(out) > budget - 1) out = out.slice(Math.max(1, Math.ceil(out.length / 8)))
57  if (out.length >= flat.length) return flat
58  const breakAt = out.indexOf(' ')
59  if (breakAt > 0 && breakAt < out.length - 1) out = out.slice(breakAt + 1)
60  return '…' + truncate(out, budget - 1)
61}
62
63/**
64 * A thinking run's row: `󰧑 thinking  <its text>`, the label in the thinking
65 * colour and the text in pi's `thinkingText`, all italic. Opened, the rail
66 * takes the tick and the run hangs below.
67 */
68export function thoughtRowSpans(text: string, width: number, isOpened: boolean, isLive = false): Span[] {
69  const head: Span[] = [...leadOf(isOpened), { text: THOUGHT_ICON + ' thinking  ', color: PI.warning, italic: true }]
70  const room = width - spansWidth(head)
71  return fit([...head, { text: previewText(text, room, isLive), color: PI.muted, italic: true }], width)
72}
73
74/** An opened run: its text wrapped under the row, five cells in. */
75export function thoughtBodyLines(text: string, width: number): Span[][] {
76  const inner = Math.max(4, width - BODY_INDENT)
77  const lead = [...rail('│'), { text: '   ' }]
78  return wrapText(text.trim(), inner).map(line => [...lead, ...fit([{ text: line, color: PI.muted, italic: true }], inner)])
79}
80
81/**
82 * The summary line, pi-tidy's: `▸ 7 calls ·  bash×5  read×2 · 1 failed ·
83 * 7.7s`, most frequent tool first. While a call runs, `running <tool>` takes
84 * the duration's place. Folded, the failures are the only sign of them and
85 * go red; open, the rows carry their own `✗` and the count goes quiet.
86 */
87export function summarySpans(
88  calls: Tally[],
89  isOpen: boolean,
90  jobs: { running: number; spinner: string } = { running: 0, spinner: '◐' },
91  thoughts: { count: number; live: boolean; spinner: string } = { count: 0, live: false, spinner: '◐' },
92): Span[] {
93  const counts = new Map<string, number>()
94  for (const call of calls) counts.set(call.tool, (counts.get(call.tool) ?? 0) + 1)
95  const kinds = [...counts]
96    .sort(([a, m], [b, n]) => n - m || shortName(a).localeCompare(shortName(b)))
97    .map(([tool, n]) => iconOf(tool) + ' ' + shortName(tool) + '×' + n)
98    .join('  ')
99  const failed = calls.filter(call => call.isError).length
100  const running = calls.find(call => call.isRunning)
101  const ms = calls.reduce((sum, call) => sum + (call.isRunning ? 0 : (call.ms ?? 0)), 0)
102  const n = calls.length
103  const parts: Span[][] = [
104    [
105      { text: isOpen ? '▾ ' : '▸ ', color: PI.accent },
106      { text: n + (n === 1 ? ' call' : ' calls'), color: PI.toolTitle },
107    ],
108    [{ text: kinds, color: PI.muted }],
109    // Folded, the only sign the reply thought at all. While a run is arriving
110    // it says so, the spinner in place of the brain, and the count comes back
111    // once it stops.
112    ...(thoughts.live
113      ? [[{ text: thoughts.spinner + ' thinking', color: PI.warning }]]
114      : thoughts.count > 0
115        ? [[{ text: THOUGHT_ICON + ' ' + plural(thoughts.count, 'thought'), color: PI.warning }]]
116        : []),
117    ...(failed > 0 ? [[{ text: failed + ' failed', color: isOpen ? PI.muted : PI.error }]] : []),
118    ...(jobs.running > 0 ? [[{ text: jobs.spinner + ' ' + jobs.running + ' running', color: PI.accent }]] : []),
119    ...(running !== undefined
120      ? [[{ text: 'running ' + shortName(running.tool), color: PI.warning }]]
121      : ms > 0
122        ? [[{ text: formatDuration(ms), color: PI.muted }]]
123        : []),
124  ]
125  return [{ text: ' ' }, ...parts.flatMap((part, i) => (i === 0 ? part : [SEP, ...part]))]
126}
127
128/** A group's metrics columns: the widest note and the widest duration. */
129export type Columns = { note: number; duration: number }
130
131/** A call's time as its column says it; none when nobody timed it. */
132export const durationText = (ms: number | undefined) => (ms === undefined || ms <= 0 ? '' : formatDuration(ms))
133
134/** What a call's metrics say: its note, and its time (live while it runs). */
135export function metrics(call: Call, ms: number | undefined) {
136  return { note: note(call), duration: durationText(ms) }
137}
138
139/** The tail of a row: the note and the duration, each padded to its column. */
140function tailText(m: { note: string; duration: string }, columns: Columns) {
141  const noteCol = columns.note > 0 ? m.note.padStart(columns.note) : ''
142  const durCol = columns.duration > 0 ? m.duration.padStart(columns.duration) : ''
143  const sep = noteCol !== '' && durCol !== '' ? (m.note !== '' && m.duration !== '' ? ' · ' : '   ') : ''
144  const text = noteCol + sep + durCol
145  return text.trim() === '' ? '' : '  ' + text
146}
147
148/**
149 * A row's layout, exactly `width` cells: `head` (the rail, the mark, the
150 * tool), the call's words, and `tail` at the right edge. The words get what
151 * the rest leaves; when that would be under 12 cells the tail goes first,
152 * then the `(+N lines)` marker.
153 */
154function layoutRow(head: Span[], call: Call, tail: Span | undefined, width: number, dim: string | undefined): Span[] {
155  const { text, more } = argParts(call)
156  const suffix = more > 0 ? ' (+' + more + (more === 1 ? ' line)' : ' lines)') : ''
157  // One cell for the space before the words.
158  const fixed = spansWidth(head) + 1
159  const tailWidth = tail === undefined ? 0 : textWidth(tail.text)
160  const keepTail = tail !== undefined && width - fixed - tailWidth >= MIN_ARG_CELLS
161  const budget = width - fixed - (keepTail ? tailWidth : 0)
162  // `(+12 lines)` is a nicety; the command is not.
163  const shownSuffix = budget - textWidth(suffix) < MIN_ARG_CELLS ? '' : suffix
164  const shown = truncate(text, budget - textWidth(shownSuffix))
165  const left: Span[] = [
166    ...head,
167    ...(shown === '' ? [] : [{ text: ' ' + shown, color: dim ?? PI.toolOutput }]),
168    ...(shownSuffix === '' ? [] : [{ text: shownSuffix, color: dim ?? PI.muted }]),
169  ]
170  return keepTail && tail !== undefined ? [...fit(left, width - tailWidth), tail] : fit(left, width)
171}
172
173const leadOf = (isOpened: boolean): Span[] => [...rail(isOpened ? '├─' : '│'), { text: ' ' }]
174
175// The space after the icon lets Ghostty draw it two cells wide, as pi-tidy's are.
176const toolName = (call: Call, color: string): Span => ({ text: iconOf(call.tool) + ' ' + shortName(call.tool), color })
177
178/**
179 * A call's row: the rail (or the tick, once opened), `✓  bash <command>`, and
180 * the metrics in the group's columns at the right edge. A silent failure
181 * keeps its red `✗` and dims the rest.
182 */
183export function callRowSpans(call: Call, ms: number | undefined, width: number, columns: Columns, isOpened: boolean): Span[] {
184  const status = statusOf(call)
185  const dim = isSilentFailure(call) ? PI.dim : undefined
186  const head: Span[] = [...leadOf(isOpened), { text: status.mark + ' ', color: status.color }, toolName(call, dim ?? PI.toolTitle)]
187  const tail = tailText(metrics(call, ms), columns)
188  return layoutRow(head, call, tail === '' ? undefined : { text: tail, color: dim ?? PI.muted }, width, dim)
189}
190
191/** Whole seconds, then minutes: `12s`, `3m05s`, `1h02m` (pi-tidy's). */
192export function formatElapsed(ms: number) {
193  const s = Math.round(ms / 1000)
194  if (s < 60) return s + 's'
195  if (s < 3600) return Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's'
196  return Math.floor(s / 3600) + 'h' + String(Math.floor((s % 3600) / 60)).padStart(2, '0') + 'm'
197}
198
199/** `running 12s`, `exited 0 · 20s`, `exit 1 · 3s`, `killed · 3s`, `not running`. */
200export function describeJob(job: Job, now = Date.now()) {
201  if (job.status === 'gone') return 'not running'
202  const end = job.status === 'running' ? now : job.endedAt
203  const took = job.startedAt !== undefined && end !== undefined ? formatElapsed(end - job.startedAt) : ''
204  if (job.status === 'running') return took === '' ? 'running' : 'running ' + took
205  const said = job.status === 'done' ? 'exited ' + (job.exitCode ?? 0) : job.status === 'failed' ? (job.exitCode === undefined ? 'failed' : 'exit ' + job.exitCode) : 'killed'
206  return took === '' ? said : said + ' · ' + took
207}
208
209const JOB_MARK: Record<Job['status'], { mark?: string; color: string }> = {
210  running: { color: PI.accent },
211  done: { mark: '✓', color: PI.success },
212  failed: { mark: '✗', color: PI.error },
213  killed: { mark: '⊘', color: PI.muted },
214  gone: { mark: '⊘', color: PI.muted },
215}
216
217/**
218 * A call that started a background job: the row tracks the job, not the call.
219 * `◐ bash <command>  running 12s`, the spinner turning a step a second.
220 */
221export function jobRowSpans(call: Call, job: Job, width: number, isOpened: boolean, now = Date.now()): Span[] {
222  const look = JOB_MARK[job.status]
223  const mark = look.mark ?? spinnerFrame(job.startedAt, now)
224  const head: Span[] = [...leadOf(isOpened), { text: mark + ' ', color: look.color }, toolName(call, PI.toolTitle)]
225  const color = job.status === 'failed' ? PI.error : job.status === 'running' ? PI.warning : PI.muted
226  return layoutRow(head, call, { text: '  ' + describeJob(job, now), color }, width, undefined)
227}
228
229/** The most lines an opened call draws. */
230const OPEN_LINES = 200
231
232/**
233 * An opened call's body, each line exactly `width` cells: the rail and three
234 * cells, then what the call produced. The row above already has the command's
235 * first line, so only the rest of a multi-line command comes first.
236 */
237export function bodyLines(call: Call, width: number): Span[][] {
238  const inner = Math.max(4, width - BODY_INDENT)
239  const command = call.tool === 'Bash' && call.input !== null && typeof call.input === 'object' ? (call.input as Record<string, unknown>).command : undefined
240  const rest = typeof command === 'string' ? command.split('\n').slice(1).map(line => bashSpans(clean(line))) : []
241  const all = [...rest, ...outputRows(call, inner)]
242  const shown = all.slice(0, OPEN_LINES)
243  const hidden = all.length - shown.length
244  const rows = hidden > 0 ? [...shown, [{ text: '… ' + hidden + ' more lines', color: PI.muted }]] : shown
245  const lead = [...rail('│'), { text: '   ' }]
246  return (rows.length > 0 ? rows : [[{ text: 'no output', color: PI.muted }]]).map(row => [...lead, ...fit(row, inner)])
247}
248
hooks/thinking.ts 66 lines
1import type { ApiMessage } from 'claude-code'
2
3/**
4 * The model's thinking, as pi-tidy shows it inside a group: one row per run
5 * of thinking that came before a call with no text between them. A run
6 * before text (a boundary's) is Claude Code's to draw ("Thought for 4s"), and
7 * isn't counted.
8 *
9 * Claude Code sends thinking back as the API gave it: with the
10 * `showThinkingSummaries` setting on, a summary of it; off, nothing (the
11 * blocks are empty, signed), and then there are no rows.
12 */
13
14/** The thinking before each call, by tool_use_id: consecutive blocks joined. */
15export function thoughtsOf(messages: ApiMessage[]) {
16  const map = new Map<string, string>()
17  for (const message of messages) {
18    if (message.role !== 'assistant') continue
19    let run: string[] = []
20    for (const block of message.content) {
21      if (block.type === 'thinking' && typeof block.thinking === 'string' && block.thinking.trim() !== '') run.push(block.thinking.trim())
22      else if (block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') run = []
23      else if (block.type === 'tool_use' && typeof block.id === 'string') {
24        if (run.length > 0) map.set(block.id, run.join('\n\n'))
25        run = []
26      }
27    }
28  }
29  return map
30}
31
32/**
33 * A run is live while it is actually arriving, which takes three things: a
34 * main-thread turn open, its step still streaming thinking, and the text grown
35 * within the last 1.5 s. Growth is the signal that can't lie: when tokens stop,
36 * the row goes quiet on its own, and a restored session never looks live.
37 */
38export const LIVE_WINDOW_MS = 1500
39
40const live = { isTurnOpen: false, text: '', grewAt: 0 }
41
42export function noteTurn(isOpen: boolean) {
43  live.isTurnOpen = isOpen
44  if (!isOpen) endRun()
45}
46
47/** A piece of thinking arrived; `fresh` starts a new run (a new step or block). */
48export function noteThinking(text: string, fresh: boolean) {
49  if (fresh) live.text = ''
50  live.text += text
51  if (text !== '') live.grewAt = Date.now()
52}
53
54/** The run ended: a call or text came after it, or its step did. */
55export function endRun() {
56  live.grewAt = 0
57  live.text = ''
58}
59
60export const isThinkingLive = (now = Date.now()) => live.isTurnOpen && live.grewAt > 0 && now - live.grewAt < LIVE_WINDOW_MS
61
62/** When the live window lapses, if it is open: a redraw then takes the marker off. */
63export const liveUntil = () => (isThinkingLive() ? live.grewAt + LIVE_WINDOW_MS : undefined)
64
65export const liveText = () => live.text
66
hooks/palette.ts 19 lines
1/**
2 * pi's catppuccin mocha theme, the colours pi-tidy draws with, by pi's names.
3 * Claude Code's theme has no tool or syntax colours, so these are hex.
4 */
5export const PI = {
6  toolTitle: '#89b4fa', // blue: a call's icon and name, a group's count
7  toolOutput: '#a6adc8', // subtext0: a call's words and output
8  muted: '#7f849c', // overlay1: notes, counts, separators
9  accent: '#89dceb', // sky: a group's ▸/▾
10  diffContext: '#7f849c', // overlay1
11  success: '#a6e3a1',
12  error: '#f38ba8',
13  warning: '#f9e2af', // yellow: `running <tool>` in a summary
14  running: '#6c7086', // overlay0
15  borderMuted: '#45475a', // surface1: an open group's rail
16  selectedBg: '#313244', // surface0: the band under the pointer
17  dim: '#6c7086', // overlay0: a silent failure's row
18} as const
19
hooks/textbox.ts 171 lines
1import type { ElementTable, RenderElement } from 'claude-code'
2
3export type Kit = Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Button'>
4
5/** A run of text in one colour (a theme key or a hex), or plain. */
6export type Span = { text: string; color?: string; bg?: string; dim?: boolean; bold?: boolean; italic?: boolean }
7
8/** A line of a box: `left` is cut to the room `right` (kept whole) leaves it. */
9export type Row = { left: Span[]; right?: Span[] }
10
11/**
12 * A character's width in terminal cells: two for East Asian wide and emoji,
13 * none for combining marks, one otherwise (Nerd Font glyphs included).
14 */
15function charWidth(code: number) {
16  if (code >= 0x300 && code <= 0x36f) return 0
17  if (code === 0x200d || (code >= 0xfe00 && code <= 0xfe0f)) return 0
18  if (
19    (code >= 0x1100 && code <= 0x115f) ||
20    (code >= 0x2e80 && code <= 0xa4cf) ||
21    (code >= 0xac00 && code <= 0xd7a3) ||
22    (code >= 0xf900 && code <= 0xfaff) ||
23    (code >= 0xfe30 && code <= 0xfe4f) ||
24    (code >= 0xff00 && code <= 0xff60) ||
25    (code >= 0xffe0 && code <= 0xffe6) ||
26    (code >= 0x1f300 && code <= 0x1faff) ||
27    (code >= 0x20000 && code <= 0x3fffd)
28  )
29    return 2
30  return 1
31}
32
33export const textWidth = (text: string) => {
34  let width = 0
35  for (const char of text) width += charWidth(char.codePointAt(0) ?? 0)
36  return width
37}
38
39/** Tabs as spaces, every other control character dropped: one line, drawable. */
40export const clean = (text: string) => text.replace(/\t/g, '    ').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '')
41
42/**
43 * Text wrapped to `width` cells a line: at a space where the line has one,
44 * anywhere in a run without (CJK has none). Blank lines are kept.
45 */
46export function wrapText(text: string, width: number): string[] {
47  const out: string[] = []
48  for (const paragraph of text.split('\n')) {
49    let line = ''
50    let lineWidth = 0
51    for (const char of clean(paragraph)) {
52      const w = charWidth(char.codePointAt(0) ?? 0)
53      if (lineWidth + w > width) {
54        const space = line.lastIndexOf(' ')
55        if (space > 0 && char !== ' ') {
56          out.push(line.slice(0, space))
57          line = line.slice(space + 1)
58        } else {
59          out.push(line.replace(/ +$/, ''))
60          line = ''
61        }
62        lineWidth = textWidth(line)
63        if (char === ' ' && line === '') continue
64      }
65      line += char
66      lineWidth += w
67    }
68    out.push(line)
69  }
70  return out
71}
72
73/** Text cut to `width` cells, an ellipsis where it was cut; never padded. */
74export function truncate(text: string, width: number) {
75  if (textWidth(text) <= width) return text
76  let out = ''
77  let left = width - 1
78  for (const char of text) {
79    const w = charWidth(char.codePointAt(0) ?? 0)
80    if (w > left) break
81    out += char
82    left -= w
83  }
84  return width > 0 ? out + '…' : ''
85}
86
87/**
88 * Spans cut to `width` cells, an ellipsis where they were cut, padded to
89 * exactly `width`; the padding takes `pad`'s background (a tinted diff row).
90 */
91export function fit(spans: Span[], width: number, pad: Omit<Span, 'text'> = {}): Span[] {
92  const total = spans.reduce((n, span) => n + textWidth(span.text), 0)
93  if (total <= width) return [...spans, { ...pad, text: ' '.repeat(width - total) }]
94  const out: Span[] = []
95  let left = width - 1
96  for (const span of spans) {
97    let text = ''
98    for (const char of span.text) {
99      const w = charWidth(char.codePointAt(0) ?? 0)
100      if (w > left) break
101      text += char
102      left -= w
103    }
104    out.push({ ...span, text })
105    if (text.length < span.text.length) break
106  }
107  return [...out, { ...pad, text: '…', dim: true }, { ...pad, text: ' '.repeat(left) }]
108}
109
110export const spansWidth = (spans: Span[]) => spans.reduce((n, span) => n + textWidth(span.text), 0)
111
112const toText = (kit: Kit, span: Span) =>
113  kit.Text({
114    ...(span.color === undefined ? {} : { color: span.color }),
115    ...(span.bg === undefined ? {} : { backgroundColor: span.bg }),
116    ...(span.dim === true ? { dimColor: true } : {}),
117    ...(span.bold === true ? { bold: true } : {}),
118    ...(span.italic === true ? { italic: true } : {}),
119    children: [span.text],
120  })
121
122/**
123 * One line of ours, a single button: a click anywhere on it runs `onPress`,
124 * and Claude Code never gets the click (which it would take to expand the
125 * message and grey it). The spans should fill the row, so every cell is the
126 * button's.
127 *
128 * A plain Button inverts under the pointer; its hover overrides come after
129 * that, so `inverse: false` keeps the line still, and `band` lays pi's
130 * `selectedBg` under the whole line instead. A hover needs a keyed Box above
131 * it in the same answer: the caller's, which `key` must be unique within.
132 */
133export function drawLine(kit: Kit, key: string, spans: Span[], onPress: () => void, band?: string): RenderElement {
134  return kit.Button({
135    key,
136    plain: true,
137    onPress,
138    hover: band === undefined ? { inverse: false } : { inverse: false, backgroundColor: band },
139    children: spans.map(span => toText(kit, span)),
140  })
141}
142
143/**
144 * A rounded box drawn as text, one button per row, border included: a click
145 * anywhere on the box runs `onPress`. Nothing lights under the pointer.
146 *
147 * @param key unique per box; it keys the Box the rows' hover needs, and each row's button
148 * @param width the box's outer width in cells
149 * @param border the border's colour
150 * @param rows the lines inside, each cut or padded to fit
151 */
152export function drawTextBox(kit: Kit, key: string, width: number, border: string, rows: Row[], onPress: () => void): RenderElement {
153  const inner = Math.max(4, width - 4)
154  const edge = (id: string, left: string, right: string) =>
155    drawLine(kit, key + ':' + id, [{ text: left + '─'.repeat(inner + 2) + right, color: border }], onPress)
156  return kit.Box({
157    key,
158    flexDirection: 'column',
159    children: [
160      edge('top', '╭', '╮'),
161      ...rows.map((row, i) => {
162        const right = row.right ?? []
163        const room = Math.max(1, inner - spansWidth(right))
164        const spans = [{ text: '│ ', color: border }, ...fit(row.left, room), ...right, { text: ' │', color: border }]
165        return drawLine(kit, key + ':' + i, spans, onPress)
166      }),
167      edge('bottom', '╰', '╯'),
168    ],
169  })
170}
171