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

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).
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.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.├─) and the output hangs five cells in. A click anywhere on it folds it back.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 <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.selectedBg band, the whole width.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.cat x.py, sed -n 1,80p a.ts, git diff).ctrl+o draws every group open and every call and thinking run opened."tui": "fullscreen"). Clicks need the fullscreen terminal's mouse reporting.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
▶ 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.exited 0, no · 20s).showThinkingSummaries was off stay empty. Claude Code still draws its own Thought for 4s line above text.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./theme.ctrl+o still shows everything).claude plugin validate ./cc-tidy and check claude --debug.hooks/bash-spans.ts).hooks/split-rows.ts), MIT, see licenses/cc-split-diff-view-MIT.hooks/vendor/hljs, BSD-3-Clause, see its LICENSE.MIT, see LICENSE. The vendored and borrowed code keeps its own license, as listed under Credits.
hooks/register.ts 15 lines1import 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}
15hooks/bash-box.ts 29 lines1import 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}
29hooks/collapse.ts 82 lines1import 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}
82hooks/group.ts 354 lines1import 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}
354hooks/timing.ts 75 lines1import 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}
75hooks/callbox.ts 252 lines1import 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}
252hooks/live.ts 70 lines1import { 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}
70hooks/jobs.ts 98 lines1import 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')
98hooks/rows.ts 248 lines1import { 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}
248hooks/thinking.ts 66 lines1import 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
66hooks/palette.ts 19 lines1/**
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
19hooks/textbox.ts 171 lines1import 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