One short row for each Bash, Edit, Write, Read, Grep and Glob call, with buttons to open and copy its output, /simple to switch it and /simple show to print a…

simple-view draws one short row for each Bash, Edit, Write, Read, Grep and Glob call in the transcript. A Bash row with output has a more button that opens the output under the row, and a copy button that copies it. /simple switches the view off and on for the session. /simple show N prints one call in full. The model still reads the full tool result. Only the screen changes.
● List the mods ls exit 0 7 lines 1.1s [ more ] [ copy ]
● List a folder that does not exist ls exit 2 ls: cannot access '/n… [ more ] [ copy ]
● Append a probe line printf exit 0 0 lines 2.8s
Updated README.md (+1 -0)
● Edit src/app.ts +2 -1
● Read mods/handily/.claude-plugin/plugin.json 18 lines
● Read docs/design.md lines 160-239 of 674
The approved mocks are in docs/mocks.md, sections 6 and 7.2. The design is in docs/design.md, section 4.8.
handily-simple-view depends on handily-quiet-items, and handily-quiet-items depends on handily-workitems. Claude Code installs both with it:
/plugin install handily-simple-view --marketplace niksavis/handily
| Call | Row | Result block under it |
|---|---|---|
Bash, Read, Grep, Glob, before the run | description or tool, the parts of the input it can read, dim … | as the engine draws it |
Bash, running | description, program, dim running, time since the call started | as the engine draws it |
Bash, no error | description, program, exit 0, stdout line count, time | one Updated line per changed file |
Bash, Exit code N | description, program, exit N, the first non-empty error line, time | empty |
Edit, Write | tool, file path, added and removed line totals | empty |
Read | tool, file path, line count, or the range of lines that the call read | empty |
Grep | tool, pattern in quotes, in and the folder, the count of the output mode | empty |
Glob | tool, pattern, in and the folder, the count of matched files | empty |
Read, Grep, Glob, running | tool, the path or the pattern from the input, dim … | as the engine draws it |
description that the model gave. Without one, the row shows the first command segment. An ellipsis (…) marks a cut or a segment that follows.cd, pushd or popd. A VAR=value word before it is skipped.No matches found for grep, the row shows that meaning in place of exit 0.output saved to a file in place of the line count, and /simple show N prints the path of that file.Updated, Created or Deleted, with (+added -removed). Files that the engine counts but does not list show as and N more changed files. When the engine reports that it could not track the changes, the block says File changes were not tracked for this call, and /simple show N prints File diff: not tracked by the engine.Updated line proves that the call changed no file only for a call without an error, and only when Bash edit tracking was on for that call. With tracking off, the engine sends no diff, also for a call that wrote a file. The block is then empty, and /simple show N prints File diff: not reported by the engine.exit N, the engine sends no diff, but the command can still have changed a file. /simple show N then prints File diff: not reported by the engine./simple show N prints File diff: none. for a Bash call only when the engine tracked the call and listed no changed file.bashEditDiffEnabled setting turns Bash edit tracking on or off. The CLAUDE_CODE_BASH_EDIT_DIFF environment variable wins over the setting. Without either, the engine decides, and the mod cannot read that decision... Another path shows in full.Bash call, so the mod clock measures the time around the call. It includes the time that a permission question waited for an answer.Read of a whole file shows its line count, such as 18 lines. A Read of a part shows the range, such as lines 160-239 of 674.Grep row counts what its output mode returned: 12 lines for content, 3 files for files_with_matches and 7 matches for count. When the engine cut the output at head_limit, the row shows both counts, such as 10 of 12 lines.Glob row shows the total of matched files. When the engine knows the total only as a lower bound, the row adds a +, such as 100+ files.Grep or Glob tool in a live check, also not to an Explore agent. The mod reads their outputs in the shape of the 2.1.295 output schema. The transcripts of 2.1.294 show the same shape.Write of a new file counts the lines of its content, blank lines at the end included, as its diff does. When the engine gives no diff for an update (for example, the old content was too large to diff), the row shows the path only, with no totals.Claude Code sends the tool name of a call first, and its input when the model has written it. Before the call runs, a Bash, Read, Grep or Glob row shows what is readable, and a dim … in place of the result:
● Bash …
● Count to five slowly sleep …
● Read …
Bash row shows the tool name until the description or the command arrives. Then it shows the same label and program as the finished row.Read row shows the path, and a Grep or Glob row shows the pattern and the folder, as soon as they arrive. A running Read, Grep or Glob call shows the same row.{} until the model had written all of it. The row showed ● Bash … first, then the description and the program with … until the call started.A finished Bash row with output ends with two buttons. They stand at the right edge of the row, dim until the pointer is on them.
● Run the unit tests npm exit 0 140 lines 3.0s [ more ] [ copy ]
more opens the output under the row, at most 20 lines. Then all 140 lines opens every line, and less on the row folds the output again:
● Run the unit tests npm exit 0 140 lines 3.0s [ less ] [ copy ]
line 1
…
line 20
…
[ all 140 lines ]
copy copies the full output of the call: stdout, then stderr, exactly as the engine gave them. Trailing spaces and blank lines stay. After exit N it copies the error output under the Error: Exit code N line. A toast says how many lines it copied, or why the copy failed.copy reads the saved file and copies all of it.copy keeps the whole line. Colour codes are dropped, a line that a carriage return overwrote shows its last part, a tab becomes spaces up to the next 8-cell stop, and other hidden characters show as escapes such as \u200b. A wide character or an emoji takes 2 cells, and a combining mark takes none.all N lines draws at most 60000 characters. Then a dim line says how many lines it did not draw, and copy still takes all of them.more only. It opens the full command under the row, one dim line for each command line. When the call ends, an opened row shows the output in place of the command.Edit or Write row have no buttons. An output of only spaces, tabs and blank lines counts as no output, so its row shows 0 lines and has no buttons.simple-view draws the engine row unchanged in each of these cases:
/simple turned the view off for this session.quiet-items stored a row for the call, and the quiet-items mode is on. The tracker row of quiet-items wins. While /quiet-items has turned its mode off, simple-view draws its own row for the call.Error: Exit code N text, for example a timeout or a refusal at the permission question.Edit or Write that did not change the file. A Read of an image, a PDF, a notebook or an unchanged file is not a shape that the mod reads.Read, Grep or Glob call that errored.command that is not text.Bash, Edit, Write, Read, Grep and Glob.In the normal view the engine folds runs of read-only calls into one group line, such as Read 2 files, listed 1 directory, ran 1 shell command. While its mode is on, simple-view unfolds a group when each call of the group draws as one row: a Bash call in the foreground, a Read, a Grep or a Glob. The calls of the group then draw as their rows in its place:
● Read README.md 123 lines
● Read package.json 30 lines
● List the mods ls exit 0 9 lines [ more ] [ copy ]
The group also unfolds while the input of its calls streams, as soon as the tool names are known. So the engine line Running 1 shell command… does not show first.
A group that holds another tool keeps the line of the engine, except in the two cases below. The ctrl+o transcript unfolds every group, and each call there shows its simple-view row.
When a folded group holds a call that failed, simple-view unfolds the group while its mode is on. Each call of the group then draws as its own row, so the failure is visible. When quiet-items also unfolds the group, the result is the same.
While the live group holds a Bash call that runs in the foreground, simple-view also unfolds that group. The engine would draw the full command of the running call there, over many lines. The running call draws as one row instead, and its more button opens the command. When no call of the group runs, the group folds again to the line of the engine, unless a call of the group failed.
| Command | Effect |
|---|---|
/simple | Turns the view off or on for this session, and replies with the new mode. It starts on |
/simple show N | Prints the input, the output and the file diff of the N-th last tool call. 1 is the last |
/simple show N refuses a number outside the kept calls, and gives the valid range.more on the row, or /simple show N./simple show keeps the last 50 tool calls of the main session in $.state. It cuts each part of a call (the input, the output and the file diff) at 8000 characters, and says how many characters it cut. Calls of subagents are not kept.
When a session ends for any reason (/exit, /clear, a resume or a finished run), the mod resets the count of kept calls. The calls and the times of the ended session are no longer reachable: /simple show refuses them, and an old row draws without its time. $.state has no delete, so the mod writes each new call over a slot of the ended session. The 50 slots stay the upper limit. One small time record (three numbers) stays for each Bash call of the process.
A press on more, all N lines or less keeps one small value for that call in $.state, so the row stays open or folded when it draws again.
/simple does not change the mode of quiet-items. Claude Code refuses a write to the state of another plugin: handily-quiet-items owns that value and only its owner writes it. Use /quiet-items to switch the tracker rows.hooks/register.ts 496 lines1import type {
2 EngineInterface,
3 Register,
4 RenderElement,
5 RenderPropsOf,
6 RenderSurface,
7 Timer,
8 ToolGroupCall,
9} from 'claude-code'
10import type { SimpleViewFold, SimpleViewMode } from '../types'
11import { commandLabel, programOf } from './command'
12import { KEPT_CALLS, callRecord, showText, slotOf, type CallFacts } from './detail'
13import {
14 callOutput,
15 copyRefusalText,
16 foldButtons,
17 outputBlock,
18 plural,
19 type CallOutput,
20 type FoldActions,
21} from './fold'
22import { bashChanges, bashEnd, fileEdit, isRecord, lineCount, shownPath } from './output'
23import { READING_TOOLS, readingCount, readingTarget, streamingTarget } from './reading'
24import { bashRow, changesBlock, fileRow, readingRow, type BashState, type RowLook } from './row'
25
26const COMMAND = 'simple'
27const TICK_MS = 1000
28const COUNT_TRIES = 5
29const DEFAULT_MODE: SimpleViewMode = 'on'
30const MODE = { plugin: 'handily-simple-view', key: 'mode' } as const
31const COUNT = { plugin: 'handily-simple-view', key: 'count' } as const
32const GENERATION = { plugin: 'handily-simple-view', key: 'generation' } as const
33const QUIET_ITEMS_MODE = { plugin: 'handily-quiet-items', key: 'mode' } as const
34const RESERVED_KEYS: ReadonlySet<string> = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
35const FILE_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write'])
36
37const ON_TEXT =
38 'on for this session. Bash, Edit, Write, Read, Grep and Glob calls draw as one row. /simple show N prints call N in full, where 1 is the last call.'
39const OFF_TEXT = 'off for this session. Tool calls draw as Claude Code draws them.'
40const USAGE_TEXT =
41 '/simple takes no argument, or show N. /simple toggles this session; /simple show N prints the N-th last tool call in full.'
42const NO_CALL_TEXT = '/simple show has no tool call to print yet in this session.'
43
44type Answer = Awaited<ReturnType<EngineInterface['tool']['call']>>
45type StreamingBashInput = { command?: string; description?: string; run_in_background?: boolean }
46type BashInput = StreamingBashInput & { command: string }
47type CallProgress = Pick<ToolGroupCall, 'isRunning' | 'isErrored' | 'isInterrupted' | 'output'>
48
49function isStreamingBashInput(input: unknown): input is StreamingBashInput {
50 return (
51 isRecord(input) &&
52 (input.command === undefined || typeof input.command === 'string') &&
53 (input.description === undefined || typeof input.description === 'string')
54 )
55}
56
57function isBashInput(input: unknown): input is BashInput {
58 return isStreamingBashInput(input) && typeof input.command === 'string'
59}
60
61function isPending(call: CallProgress): boolean {
62 return !call.isRunning && !call.isErrored && !call.isInterrupted && call.output === undefined
63}
64
65function bashLabel(input: StreamingBashInput): string {
66 const description = input.description?.trim() ?? ''
67 return description === '' ? commandLabel(input.command ?? '') : description
68}
69
70function rangeText(kept: number): string {
71 return `/simple show takes a call number from 1 to ${String(kept)}, where 1 is the last tool call.`
72}
73
74function logFailure($: EngineInterface, what: string, error: unknown): void {
75 $.ui.log(`simple-view: ${what}: ${String(error)}`)
76}
77
78async function toggle($: EngineInterface): Promise<string> {
79 const { value: current = DEFAULT_MODE } = await $.state.get(MODE)
80 const mode: SimpleViewMode = current === 'on' ? 'off' : 'on'
81 await $.state.set(MODE, mode)
82 return mode === 'on' ? ON_TEXT : OFF_TEXT
83}
84
85async function generationOf($: EngineInterface): Promise<number> {
86 const { value = 0 } = await $.state.get(GENERATION)
87 return value
88}
89
90function timingKey(generation: number, toolUseId: string): string {
91 return `${String(generation)}:${toolUseId}`
92}
93
94async function forgetSession($: EngineInterface): Promise<void> {
95 try {
96 await $.state.set(GENERATION, (await generationOf($)) + 1)
97 await $.state.set(COUNT, 0)
98 } catch (error) {
99 logFailure($, 'the calls of the ended session stay reachable', error)
100 }
101}
102
103async function show($: EngineInterface, raw: string | undefined): Promise<string> {
104 const generation = await generationOf($)
105 const { value: count = 0 } = await $.state.get(COUNT)
106 const kept = Math.min(count, KEPT_CALLS)
107 if (kept === 0) return NO_CALL_TEXT
108 if (raw === undefined || !/^\d+$/.test(raw)) return rangeText(kept)
109 const back = Number(raw)
110 if (back < 1 || back > kept) return `No call ${raw} is kept. ${rangeText(kept)}`
111 const seq = count - back
112 const { value: call } = await $.state.get({
113 plugin: 'handily-simple-view',
114 key: 'calls',
115 id: slotOf(seq),
116 })
117 if (call?.generation !== generation || call.seq !== seq) {
118 return `No call ${raw} is kept. ${rangeText(kept)}`
119 }
120 return showText(call, back, kept)
121}
122
123async function tick($: EngineInterface, id: string): Promise<void> {
124 try {
125 const ref = { plugin: 'handily-simple-view', key: 'timing', id } as const
126 const { value, version } = await $.state.get(ref)
127 if (value?.isRunning !== true) return
128 const now = await $.clock.now()
129 await $.state.set(ref, { ...value, elapsedMs: now - value.startedAt }, { ifVersion: version })
130 } catch (error) {
131 logFailure($, `no running time for ${id}`, error)
132 }
133}
134
135async function startTicker($: EngineInterface, id: string, startedAt: number): Promise<Timer> {
136 await $.state.set(
137 { plugin: 'handily-simple-view', key: 'timing', id },
138 { startedAt, elapsedMs: 0, isRunning: true },
139 )
140 return $.clock.every(TICK_MS, () => {
141 void tick($, id)
142 })
143}
144
145async function settleTiming(
146 $: EngineInterface,
147 id: string,
148 startedAt: number,
149 elapsedMs: number,
150): Promise<void> {
151 try {
152 await $.state.set(
153 { plugin: 'handily-simple-view', key: 'timing', id },
154 { startedAt, elapsedMs, isRunning: false },
155 )
156 } catch (error) {
157 logFailure($, `no time for ${id}`, error)
158 }
159}
160
161async function remember(
162 $: EngineInterface,
163 facts: Omit<CallFacts, 'seq' | 'generation'>,
164): Promise<void> {
165 try {
166 for (let attempt = 0; attempt < COUNT_TRIES; attempt += 1) {
167 const generation = await generationOf($)
168 const { value: count = 0, version } = await $.state.get(COUNT)
169 const { isSet } = await $.state.set(COUNT, count + 1, { ifVersion: version })
170 if (!isSet) continue
171 await $.state.set(
172 { plugin: 'handily-simple-view', key: 'calls', id: slotOf(count) },
173 callRecord({ ...facts, generation, seq: count }),
174 )
175 return
176 }
177 $.ui.log(`simple-view: /simple show misses ${facts.tool_use_id}; other calls kept the count`)
178 } catch (error) {
179 logFailure($, `/simple show misses ${facts.tool_use_id}`, error)
180 }
181}
182
183type Ended = {
184 facts: Omit<CallFacts, 'seq' | 'generation' | 'elapsedMs'>
185 startedAt: number
186 timing: string | null
187}
188
189async function recordCall($: EngineInterface, { facts, startedAt, timing }: Ended): Promise<void> {
190 let elapsedMs: number
191 try {
192 elapsedMs = (await $.clock.now()) - startedAt
193 } catch (error) {
194 logFailure($, `no time for ${facts.tool_use_id}`, error)
195 return
196 }
197 if (timing !== null) await settleTiming($, timing, startedAt, elapsedMs)
198 await remember($, { ...facts, elapsedMs })
199}
200
201function inputOf(e: object): Record<string, unknown> {
202 return Object.fromEntries(Object.entries(e).filter(([key]) => !RESERVED_KEYS.has(key)))
203}
204
205async function timingOf($: EngineInterface, toolUseId: string) {
206 const id = timingKey(await generationOf($), toolUseId)
207 const { value } = await $.state.get({ plugin: 'handily-simple-view', key: 'timing', id })
208 return value
209}
210
211async function copyOutput(
212 $: EngineInterface,
213 output: CallOutput,
214 surface: RenderSurface,
215): Promise<void> {
216 try {
217 const text = output.savedTo === null ? output.text : await $.fs.read(output.savedTo)
218 const result = await $.ui.copy({ text, surface })
219 $.ui.toast(
220 result.isCopied
221 ? `Copied ${plural(lineCount(text), 'line')} of output.`
222 : copyRefusalText(result.reason),
223 )
224 } catch (error) {
225 logFailure($, 'the output was not copied', error)
226 $.ui.toast('Not copied. The debug log says why.')
227 }
228}
229
230function foldActions($: EngineInterface, id: string, output: CallOutput | null): FoldActions {
231 return {
232 fold: (next) => {
233 void $.state
234 .set({ plugin: 'handily-simple-view', key: 'folds', id }, next)
235 .catch((error: unknown) => {
236 logFailure($, `the output of ${id} did not ${next === 'folded' ? 'fold' : 'open'}`, error)
237 })
238 },
239 copy:
240 output === null
241 ? undefined
242 : (surface) => {
243 void copyOutput($, output, surface)
244 },
245 }
246}
247
248function commandOutput(command: string): CallOutput {
249 return { text: command, lines: command.split(/\r?\n/), savedTo: null }
250}
251
252async function foldOf($: EngineInterface, id: string): Promise<SimpleViewFold> {
253 const { value = 'folded' } = await $.state.get({
254 plugin: 'handily-simple-view',
255 key: 'folds',
256 id,
257 })
258 return value
259}
260
261function pendingBashRow(look: RowLook, tool: string, input: unknown): RenderElement | null {
262 if (!isStreamingBashInput(input) || input.run_in_background === true) return null
263 const label = bashLabel(input)
264 return bashRow(look, {
265 label: label === '' ? tool : label,
266 program: programOf(input.command ?? ''),
267 state: { kind: 'pending' },
268 elapsedMs: null,
269 })
270}
271
272async function bashUse(
273 $: EngineInterface,
274 props: RenderPropsOf['ToolUse'],
275 look: RowLook,
276): Promise<RenderElement | null> {
277 if (isPending(props)) return pendingBashRow(look, props.tool, props.input)
278 if (!isBashInput(props.input) || props.input.run_in_background === true) return null
279 const state: BashState | null = props.isRunning
280 ? { kind: 'running' }
281 : bashEnd(props.output, props.isErrored)
282 if (state === null) return null
283 const timing = await timingOf($, props.tool_use_id)
284 const view = {
285 label: bashLabel(props.input),
286 program: programOf(props.input.command),
287 state,
288 elapsedMs: timing?.elapsedMs ?? null,
289 }
290 const isRunning = state.kind === 'running'
291 const output = isRunning ? null : callOutput(props.output, props.isErrored)
292 if (!isRunning && output === null) return bashRow(look, view)
293 const fold = await foldOf($, props.tool_use_id)
294 const actions = foldActions($, props.tool_use_id, output)
295 const row = bashRow(look, view, foldButtons(look, fold, actions))
296 if (fold === 'folded') return row
297 const opened =
298 output === null
299 ? outputBlock(look, commandOutput(props.input.command), 'all', actions)
300 : outputBlock(look, output, fold, actions)
301 const { Box } = look.elements
302 return Box({ flexDirection: 'column', children: [row, opened] })
303}
304
305async function fileUse(
306 $: EngineInterface,
307 props: RenderPropsOf['ToolUse'],
308 look: RowLook,
309): Promise<RenderElement | null> {
310 if (props.isRunning || props.isErrored) return null
311 const edit = fileEdit(props.tool, props.output)
312 if (edit === null) return null
313 return fileRow(look, props.tool, shownPath(edit.path, await $.session.root()), edit.totals)
314}
315
316async function readingUse(
317 $: EngineInterface,
318 props: RenderPropsOf['ToolUse'],
319 look: RowLook,
320): Promise<RenderElement | null> {
321 if (props.isErrored) return null
322 if (props.isRunning || isPending(props)) {
323 const target = streamingTarget(props.tool, props.input, await $.session.root())
324 return target === null ? null : readingRow(look, props.tool, target, null)
325 }
326 const count = readingCount(props.tool, props.output)
327 if (count === null) return null
328 const target = readingTarget(props.tool, props.input, props.output, await $.session.root())
329 if (target === null) return null
330 return readingRow(look, props.tool, target, count)
331}
332
333async function useDrawing(
334 $: EngineInterface,
335 props: RenderPropsOf['ToolUse'],
336 look: RowLook,
337): Promise<RenderElement | null> {
338 if (props.isInterrupted) return null
339 if (props.tool === 'Bash') return bashUse($, props, look)
340 if (FILE_TOOLS.has(props.tool)) return fileUse($, props, look)
341 if (READING_TOOLS.has(props.tool)) return readingUse($, props, look)
342 return null
343}
344
345async function bashResult(
346 $: EngineInterface,
347 props: RenderPropsOf['ToolResult'],
348 look: RowLook,
349): Promise<RenderElement | null> {
350 const end = bashEnd(props.output, props.isErrored)
351 if (end === null) return null
352 if (end.kind === 'exit') return look.elements.Box({})
353 const changes = bashChanges(props.output)
354 if (changes === null) return null
355 if (changes.kind === 'unreported') return look.elements.Box({})
356 if (changes.kind === 'untracked') return changesBlock(look, null)
357 const root = changes.files.length === 0 ? '' : await $.session.root()
358 const files = changes.files.map((file) => ({ ...file, path: shownPath(file.path, root) }))
359 return changesBlock(look, { files, moreFiles: changes.moreFiles })
360}
361
362async function resultDrawing(
363 $: EngineInterface,
364 props: RenderPropsOf['ToolResult'],
365 look: RowLook,
366): Promise<RenderElement | null> {
367 if (props.tool === 'Bash') return bashResult($, props, look)
368 if (props.isErrored) return null
369 if (READING_TOOLS.has(props.tool)) {
370 return readingCount(props.tool, props.output) === null ? null : look.elements.Box({})
371 }
372 if (!FILE_TOOLS.has(props.tool)) return null
373 return fileEdit(props.tool, props.output) === null ? null : look.elements.Box({})
374}
375
376async function isSimple($: EngineInterface, id: string): Promise<boolean> {
377 const { value: mode = DEFAULT_MODE } = await $.state.get(MODE)
378 if (mode === 'off') return false
379 const { value: rows } = await $.state.get({ plugin: 'handily-quiet-items', key: 'rows', id })
380 if (rows === undefined || rows.length === 0) return true
381 const { value: quietMode = 'on' } = await $.state.get(QUIET_ITEMS_MODE)
382 return quietMode === 'off'
383}
384
385function isFailed(call: ToolGroupCall): boolean {
386 return call.isErrored && !call.isRunning
387}
388
389function isRunningForeground(call: ToolGroupCall): boolean {
390 return (
391 call.tool === 'Bash' &&
392 call.isRunning &&
393 !(isBashInput(call.input) && call.input.run_in_background === true)
394 )
395}
396
397function isDrawnAsRow(call: ToolGroupCall): boolean {
398 if (call.tool !== 'Bash') return READING_TOOLS.has(call.tool)
399 const { input } = call
400 if (!isStreamingBashInput(input) || input.run_in_background === true) return false
401 return isPending(call) || typeof input.command === 'string'
402}
403
404function shouldUnfold(props: RenderPropsOf['ToolGroup']): boolean {
405 if (props.isExpanded) return false
406 return (
407 (props.calls.length > 0 && props.calls.every(isDrawnAsRow)) ||
408 props.calls.some(isFailed) ||
409 (props.isActive && props.calls.some(isRunningForeground))
410 )
411}
412
413export const register: Register = (on) => {
414 on('session.start', async ($, e, next) => {
415 await $.command.register({
416 name: COMMAND,
417 description: 'handily · Toggle the one-row view of tool calls, or print a call in full',
418 argumentHint: '[show N]',
419 })
420 await $.state.set({ plugin: 'handily-simple-view', key: 'ready' }, { root: $.plugin.root })
421 return next(e)
422 })
423
424 on('command.run', { command: COMMAND }, async ($, e) => {
425 const [verb, number, ...rest] = e.args
426 .trim()
427 .split(/\s+/)
428 .filter((word) => word !== '')
429 if (verb === undefined) return { text: await toggle($) }
430 if (verb === 'show' && rest.length === 0) return { text: await show($, number) }
431 return { text: USAGE_TEXT }
432 })
433
434 on('session.end', async ($, e, next) => {
435 await forgetSession($)
436 return next(e)
437 })
438
439 on('tool.call', async ($, e, next) => {
440 const id = e.tool_use_id
441 if (e.agentId !== undefined) return next(e)
442 const isTimed = e.tool === 'Bash' && e.run_in_background !== true
443 let startedAt: number
444 let timing: string | null
445 let ticker: Timer | null
446 try {
447 startedAt = await $.clock.now()
448 timing = isTimed ? timingKey(await generationOf($), id) : null
449 ticker = timing === null ? null : await startTicker($, timing, startedAt)
450 } catch (error) {
451 logFailure($, `no time for ${id}`, error)
452 return next(e)
453 }
454 let answer: Answer
455 try {
456 answer = await next(e)
457 } finally {
458 ticker?.cancel()
459 }
460 await recordCall($, {
461 facts: { tool_use_id: id, tool: e.tool, input: inputOf(e), answer },
462 startedAt,
463 timing,
464 })
465 return answer
466 })
467
468 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
469 if (!shouldUnfold(e.props)) return next(e)
470 let isOn = false
471 try {
472 const { value: mode = DEFAULT_MODE } = await $.state.get(MODE)
473 isOn = mode === 'on'
474 } catch (error) {
475 logFailure($, 'the engine folds the group', error)
476 }
477 return next(isOn ? { ...e, props: { ...e.props, isExpanded: true } } : e)
478 })
479
480 on('ui.render', { component: ['ToolUse', 'ToolResult'] }, async ($, e, next) => {
481 let drawn: RenderElement | null = null
482 try {
483 if (await isSimple($, e.props.tool_use_id)) {
484 const look = { elements: $.ui.resolve(e), hasToolMarker: e.surface === 'terminal' }
485 drawn =
486 e.component === 'ToolUse'
487 ? await useDrawing($, e.props, look)
488 : await resultDrawing($, e.props, look)
489 }
490 } catch (error) {
491 logFailure($, `the engine draws ${e.props.tool_use_id}`, error)
492 }
493 return drawn ?? next(e)
494 })
495}
496hooks/command.ts 100 lines1const LABEL_LENGTH = 60
2const DIRECTORY_CHANGES = new Set(['cd', 'pushd', 'popd'])
3const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
4const TWO_CHARACTER_SEPARATORS = new Set(['&&', '||'])
5const ONE_CHARACTER_SEPARATORS = new Set([';', '|', '\n'])
6
7type Quote = '"' | "'" | '`'
8type Scan = { quote: Quote | null; depth: number; isEscaped: boolean }
9
10const QUOTES: ReadonlySet<string> = new Set(['"', "'", '`'])
11
12function isQuote(character: string): character is Quote {
13 return QUOTES.has(character)
14}
15
16function isQuiet(scan: Scan): boolean {
17 return scan.quote === null && scan.depth === 0 && !scan.isEscaped
18}
19
20function step(scan: Scan, character: string, following: string): void {
21 if (scan.isEscaped) {
22 scan.isEscaped = false
23 } else if (character === '\\' && scan.quote !== "'") {
24 scan.isEscaped = true
25 } else if (scan.quote !== null) {
26 if (character === scan.quote) scan.quote = null
27 } else if (isQuote(character)) {
28 scan.quote = character
29 } else if (character === '$' && following === '(') {
30 scan.depth += 1
31 } else if (character === ')' && scan.depth > 0) {
32 scan.depth -= 1
33 }
34}
35
36export function segments(command: string): string[] {
37 const found: string[] = []
38 const scan: Scan = { quote: null, depth: 0, isEscaped: false }
39 let start = 0
40 let index = 0
41 while (index < command.length) {
42 const character = command.charAt(index)
43 const pair = command.slice(index, index + 2)
44 if (isQuiet(scan) && TWO_CHARACTER_SEPARATORS.has(pair)) {
45 found.push(command.slice(start, index))
46 index += 2
47 start = index
48 continue
49 }
50 if (isQuiet(scan) && ONE_CHARACTER_SEPARATORS.has(character)) {
51 found.push(command.slice(start, index))
52 index += 1
53 start = index
54 continue
55 }
56 step(scan, character, command.charAt(index + 1))
57 index += 1
58 }
59 found.push(command.slice(start))
60 return found.map((segment) => segment.replace(/\s+/g, ' ').trim()).filter((s) => s !== '')
61}
62
63function words(segment: string): string[] {
64 return segment.split(' ').filter((word) => !ASSIGNMENT.test(word))
65}
66
67function unquoted(word: string): string {
68 return word.replace(/^(['"])(.*)\1$/, '$2')
69}
70
71function baseName(word: string): string {
72 return (
73 word
74 .split('/')
75 .filter((part) => part !== '')
76 .at(-1) ?? word
77 )
78}
79
80export function programOf(command: string): string {
81 const firstWords = segments(command)
82 .map((segment) => words(segment)[0])
83 .filter((word): word is string => word !== undefined)
84 .map((word) => baseName(unquoted(word)))
85 return firstWords.find((word) => !DIRECTORY_CHANGES.has(word)) ?? firstWords[0] ?? ''
86}
87
88export function cutLabel(text: string, length = LABEL_LENGTH): string {
89 const graphemes = Array.from(new Intl.Segmenter().segment(text), (part) => part.segment)
90 if (graphemes.length <= length) return text
91 return `${graphemes.slice(0, length).join('').trimEnd()}…`
92}
93
94export function commandLabel(command: string): string {
95 const [first = '', ...rest] = segments(command)
96 const label = cutLabel(first)
97 if (rest.length === 0 || label.endsWith('…')) return label
98 return `${label} …`
99}
100hooks/detail.ts 136 lines1import type { SimpleViewCall, SimpleViewDiffGap } from '../types'
2import { contentLines, diffGap, hunksOf, isRecord } from './output'
3
4export const KEPT_CALLS = 50
5export const PART_LIMIT = 8000
6
7type Answer = { result?: unknown; text?: string; deny?: string; isError?: true }
8
9export function capped(text: string, limit = PART_LIMIT): string {
10 if (text.length <= limit) return text
11 const left = text.length - limit
12 return `${text.slice(0, limit)}\n[simple-view kept the first ${String(limit)} characters; ${String(left)} more were cut]`
13}
14
15function hunkText(hunks: ReturnType<typeof hunksOf>): string[] {
16 return (hunks ?? []).flatMap((hunk) => [
17 `@@ -${String(hunk.oldStart)},${String(hunk.oldLines)} +${String(hunk.newStart)},${String(hunk.newLines)} @@`,
18 ...hunk.lines,
19 ])
20}
21
22function fileDiff(path: string, hunks: ReturnType<typeof hunksOf>): string[] {
23 const body = hunkText(hunks)
24 return body.length === 0 ? [] : [`--- ${path}`, `+++ ${path}`, ...body]
25}
26
27function createdDiff(path: string, content: string): string[] {
28 const lines = contentLines(content)
29 return [
30 '--- /dev/null',
31 `+++ ${path}`,
32 `@@ -0,0 +1,${String(lines.length)} @@`,
33 ...lines.map((line) => `+${line}`),
34 ]
35}
36
37export function diffText(result: unknown): string {
38 if (!isRecord(result)) return ''
39 if (typeof result.filePath === 'string') {
40 const hunks = hunksOf(result.structuredPatch)
41 if (hunks?.length === 0 && result.type === 'create' && typeof result.content === 'string') {
42 return createdDiff(result.filePath, result.content).join('\n')
43 }
44 return fileDiff(result.filePath, hunks).join('\n')
45 }
46 const diff = result.bashEditDiff
47 if (!isRecord(diff) || !Array.isArray(diff.files)) return ''
48 const lines = diff.files.flatMap((file: unknown) =>
49 isRecord(file) && typeof file.filePath === 'string'
50 ? fileDiff(file.filePath, hunksOf(file.hunks))
51 : [],
52 )
53 if (typeof diff.moreFiles === 'number' && diff.moreFiles > 0) {
54 lines.push(`(${String(diff.moreFiles)} more changed files have no diff here)`)
55 }
56 return lines.join('\n')
57}
58
59function savedNote(result: unknown): string {
60 if (!isRecord(result) || typeof result.persistedOutputPath !== 'string') return ''
61 return `Full output saved to ${result.persistedOutputPath}\n\n`
62}
63
64function outputText(answer: Answer): string {
65 if (answer.deny !== undefined) return `Refused: ${answer.deny}`
66 if (answer.text !== undefined) return `${savedNote(answer.result)}${answer.text}`
67 if (typeof answer.result === 'string') return answer.result
68 return JSON.stringify(answer.result ?? null, null, 2)
69}
70
71export type CallFacts = {
72 generation: number
73 seq: number
74 tool_use_id: string
75 tool: string
76 input: Record<string, unknown>
77 answer: Answer
78 elapsedMs: number
79}
80
81export function callRecord(facts: CallFacts): SimpleViewCall {
82 const gap = diffGap(facts.tool, facts.answer.result)
83 return {
84 generation: facts.generation,
85 seq: facts.seq,
86 tool_use_id: facts.tool_use_id,
87 tool: facts.tool,
88 input: capped(JSON.stringify(facts.input, null, 2)),
89 output: capped(outputText(facts.answer)),
90 diff: gap === null ? capped(diffText(facts.answer.result)) : '',
91 diffGap: gap,
92 isErrored: facts.answer.deny !== undefined || facts.answer.isError === true,
93 elapsedMs: facts.elapsedMs,
94 }
95}
96
97export function slotOf(seq: number): string {
98 return String(seq % KEPT_CALLS)
99}
100
101function fenced(language: string, text: string): string {
102 const longest = Math.max(2, ...Array.from(text.matchAll(/`+/g), (run) => run[0].length))
103 const fence = '`'.repeat(longest + 1)
104 return `${fence}${language}\n${text}\n${fence}`
105}
106
107export function elapsedText(ms: number): string {
108 if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`
109 const seconds = Math.round(ms / 1000)
110 if (seconds < 60) return `${String(seconds)}s`
111 return `${String(Math.floor(seconds / 60))}m ${String(seconds % 60).padStart(2, '0')}s`
112}
113
114const GAP_HEADINGS: Readonly<Record<SimpleViewDiffGap, string>> = {
115 unreported: 'File diff: not reported by the engine.',
116 untracked: 'File diff: not tracked by the engine.',
117}
118
119function diffHeading(call: SimpleViewCall): string {
120 if (call.diffGap !== null) return GAP_HEADINGS[call.diffGap]
121 return call.diff === '' ? 'File diff: none.' : 'File diff:'
122}
123
124export function showText(call: SimpleViewCall, back: number, kept: number): string {
125 const ending = call.isErrored ? 'errored' : 'answered'
126 return [
127 `Call ${String(back)} of the last ${String(kept)} (1 is the last): ${call.tool}, ${ending}, ${elapsedText(call.elapsedMs)}`,
128 'Input:',
129 fenced('json', call.input),
130 'Output:',
131 fenced('text', call.output),
132 diffHeading(call),
133 ...(call.diff === '' ? [] : [fenced('diff', call.diff)]),
134 ].join('\n\n')
135}
136hooks/fold.ts 195 lines1import type { RenderElement, RenderSurface } from 'claude-code'
2import type { SimpleViewFold } from '../types'
3import { EXIT_PREFIX, isRecord } from './output'
4import type { RowLook } from './row'
5import { graphemesOf, isEmoji } from './width'
6
7export const OPEN_LINES = 20
8export const DRAWN_CHARACTERS_AT_MOST = 60_000
9const TAB_STOP = 8
10const RESULT_INDENT = 2
11// eslint-disable-next-line no-control-regex -- an ANSI colour code starts with the escape character
12const COLOR_CODE = /\u001b\[[0-9;:]*m/g
13const HIDDEN_CHARACTER = /(?!\t)[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu
14const EMOJI_JOINER = /^[\u200D\u{E0020}-\u{E007F}]$/u
15const LEADING_BLANK_LINES = /^(?:[ \t]*\r?\n)+/
16
17const COPY_REFUSALS: Readonly<Record<'no-surface' | 'no-clipboard' | 'refused', string>> = {
18 'no-surface': 'no screen is attached to this session',
19 'no-clipboard': 'the clipboard took nothing. The text may be too long for this terminal',
20 refused: 'another plugin refused the copy',
21}
22
23export function copyRefusalText(reason: keyof typeof COPY_REFUSALS): string {
24 return `Not copied: ${COPY_REFUSALS[reason]}.`
25}
26
27export type CallOutput = { text: string; lines: string[]; savedTo: string | null }
28
29export type FoldActions = {
30 fold: (next: SimpleViewFold) => void
31 copy?: (surface: RenderSurface) => void
32}
33
34function escapedUnits(text: string): string {
35 return Array.from(
36 { length: text.length },
37 (_, index) => `\\u${text.charCodeAt(index).toString(16).padStart(4, '0')}`,
38 ).join('')
39}
40
41function shownGrapheme(grapheme: string): string {
42 const keepsJoiners = isEmoji(grapheme)
43 return grapheme.replace(HIDDEN_CHARACTER, (found) =>
44 keepsJoiners && EMOJI_JOINER.test(found) ? found : escapedUnits(found),
45 )
46}
47
48function shownWithTabs(line: string): string {
49 let shown = ''
50 let column = 0
51 for (const { grapheme, cells } of graphemesOf(line)) {
52 if (grapheme === '\t') {
53 const tabCells = TAB_STOP - (column % TAB_STOP)
54 shown += ' '.repeat(tabCells)
55 column += tabCells
56 continue
57 }
58 const drawn = shownGrapheme(grapheme)
59 shown += drawn
60 column +=
61 drawn === grapheme ? cells : graphemesOf(drawn).reduce((sum, part) => sum + part.cells, 0)
62 }
63 return shown
64}
65
66export function shownLine(line: string): string {
67 const overwritten = line.replace(COLOR_CODE, '').split('\r').at(-1) ?? ''
68 return shownWithTabs(overwritten)
69}
70
71function outputOf(copied: string, drawn: string, savedTo: string | null): CallOutput | null {
72 const kept = drawn.trimEnd()
73 if (kept === '' && savedTo === null) return null
74 return { text: copied, lines: kept === '' ? [] : kept.split(/\r?\n/), savedTo }
75}
76
77function stdoutThenStderr(stdout: string, stderr: string): string {
78 const isBreakNeeded = stdout !== '' && stderr !== '' && !stdout.endsWith('\n')
79 return `${stdout}${isBreakNeeded ? '\n' : ''}${stderr}`
80}
81
82export function callOutput(output: unknown, isErrored: boolean): CallOutput | null {
83 if (isErrored) {
84 if (typeof output !== 'string') return null
85 const found = EXIT_PREFIX.exec(output)
86 if (found === null) return null
87 const errorOutput = output.slice(found[0].length).replace(LEADING_BLANK_LINES, '')
88 return outputOf(errorOutput, errorOutput, null)
89 }
90 if (!isRecord(output) || typeof output.stdout !== 'string') return null
91 const stderr = typeof output.stderr === 'string' ? output.stderr : ''
92 const drawn = [output.stdout.trimEnd(), stderr.trimEnd()].filter((part) => part !== '').join('\n')
93 const savedTo = typeof output.persistedOutputPath === 'string' ? output.persistedOutputPath : null
94 return outputOf(stdoutThenStderr(output.stdout, stderr), drawn, savedTo)
95}
96
97export function plural(count: number, word: string): string {
98 return `${String(count)} ${word}${count === 1 ? '' : 's'}`
99}
100
101export function foldButtons(
102 look: RowLook,
103 fold: SimpleViewFold,
104 actions: FoldActions,
105): RenderElement {
106 const { Box, Button } = look.elements
107 return Box({
108 flexDirection: 'row',
109 gap: 1,
110 children: [
111 Button({
112 key: 'fold',
113 label: fold === 'folded' ? 'more' : 'less',
114 dimColor: true,
115 onPress: () => {
116 actions.fold(fold === 'folded' ? 'open' : 'folded')
117 },
118 }),
119 actions.copy === undefined
120 ? null
121 : Button({
122 key: 'copy',
123 label: 'copy',
124 dimColor: true,
125 onPress: (press) => {
126 actions.copy?.(press.surface)
127 },
128 }),
129 ],
130 })
131}
132
133function drawnLines(lines: readonly string[], fold: 'open' | 'all'): string[] {
134 if (fold === 'open') return lines.slice(0, OPEN_LINES).map(shownLine)
135 const drawn: string[] = []
136 let characters = 0
137 for (const line of lines) {
138 const shown = shownLine(line)
139 characters += shown.length + 1
140 if (characters > DRAWN_CHARACTERS_AT_MOST) break
141 drawn.push(shown)
142 }
143 return drawn
144}
145
146function noteLines(output: CallOutput, fold: 'open' | 'all', drawn: number): string[] {
147 const left = output.lines.length - drawn
148 const notes: string[] = []
149 if (fold === 'open' && left > 0) notes.push('…')
150 if (fold === 'all' && left > 0) {
151 notes.push(`… ${plural(left, 'more line')} not drawn. Copy takes all of them.`)
152 }
153 if (output.savedTo !== null) {
154 notes.push(`The full output is in ${shownLine(output.savedTo)}. Copy takes all of it.`)
155 }
156 return notes
157}
158
159export function outputBlock(
160 look: RowLook,
161 output: CallOutput,
162 fold: 'open' | 'all',
163 actions: FoldActions,
164): RenderElement {
165 const { Box, Text, Button } = look.elements
166 const drawn = drawnLines(output.lines, fold)
167 const textLine = (text: string, index: number, key: string) =>
168 Box({
169 key: `${key}-${String(index)}`,
170 children: Text({ dimColor: true, wrap: 'truncate-end', children: text === '' ? ' ' : text }),
171 })
172 const hasAllButton = fold === 'open' && output.lines.length > OPEN_LINES
173 return Box({
174 flexDirection: 'column',
175 paddingLeft: look.hasToolMarker ? RESULT_INDENT : 0,
176 children: [
177 ...drawn.map((text, index) => textLine(text, index, 'line')),
178 ...noteLines(output, fold, drawn.length).map((text, index) => textLine(text, index, 'note')),
179 hasAllButton
180 ? Box({
181 key: 'all-lines',
182 children: Button({
183 key: 'all',
184 label: `all ${String(output.lines.length)} lines`,
185 dimColor: true,
186 onPress: () => {
187 actions.fold('all')
188 },
189 }),
190 })
191 : null,
192 ],
193 })
194}
195hooks/output.ts 168 lines1import type { SimpleViewDiffGap } from '../types'
2
3export const EXIT_PREFIX = /^Error: Exit code (\d+)[^\n]*(?:\n|$)/
4
5export type Totals = { added: number; removed: number }
6
7export type StderrSummary = { lines: number; first: string }
8
9export type BashEnd =
10 | {
11 kind: 'done'
12 lines: number
13 interpretation: string | null
14 stderr: StderrSummary | null
15 savedTo: string | null
16 }
17 | { kind: 'exit'; code: number; line: string }
18
19export type FileChange = { path: string; verb: 'Updated' | 'Created' | 'Deleted'; totals: Totals }
20
21export type BashChanges =
22 | { kind: 'tracked'; files: FileChange[]; moreFiles: number }
23 | { kind: 'untracked' }
24 | { kind: 'unreported' }
25
26export type FileEdit = { path: string; totals: Totals | null }
27
28type Hunk = {
29 oldStart: number
30 oldLines: number
31 newStart: number
32 newLines: number
33 lines: string[]
34}
35
36export function isRecord(value: unknown): value is Record<string, unknown> {
37 return typeof value === 'object' && value !== null && !Array.isArray(value)
38}
39
40function isHunk(value: unknown): value is Hunk {
41 return (
42 isRecord(value) &&
43 typeof value.oldStart === 'number' &&
44 typeof value.oldLines === 'number' &&
45 typeof value.newStart === 'number' &&
46 typeof value.newLines === 'number' &&
47 Array.isArray(value.lines) &&
48 value.lines.every((line) => typeof line === 'string')
49 )
50}
51
52export function hunksOf(value: unknown): Hunk[] | null {
53 if (!Array.isArray(value) || !value.every(isHunk)) return null
54 return value
55}
56
57export function totalsOf(hunks: readonly Hunk[]): Totals {
58 const lines = hunks.flatMap((hunk) => hunk.lines)
59 return {
60 added: lines.filter((line) => line.startsWith('+')).length,
61 removed: lines.filter((line) => line.startsWith('-')).length,
62 }
63}
64
65export function lineCount(text: string): number {
66 const kept = text.trimEnd()
67 return kept === '' ? 0 : kept.split(/\r?\n/).length
68}
69
70export function contentLines(content: string): string[] {
71 return content === '' ? [] : content.replace(/\r?\n$/, '').split(/\r?\n/)
72}
73
74function firstNonEmptyLine(text: string): string {
75 return (
76 text
77 .split(/\r?\n/)
78 .map((line) => line.trim())
79 .find((line) => line !== '') ?? ''
80 )
81}
82
83function stderrSummary(stderr: unknown): StderrSummary | null {
84 if (typeof stderr !== 'string' || stderr.trim() === '') return null
85 return { lines: lineCount(stderr.trim()), first: firstNonEmptyLine(stderr) }
86}
87
88export function bashEnd(output: unknown, isErrored: boolean): BashEnd | null {
89 if (isErrored) {
90 if (typeof output !== 'string') return null
91 const found = EXIT_PREFIX.exec(output)
92 if (found?.[1] === undefined) return null
93 return {
94 kind: 'exit',
95 code: Number(found[1]),
96 line: firstNonEmptyLine(output.slice(found[0].length)),
97 }
98 }
99 if (!isRecord(output) || typeof output.stdout !== 'string') return null
100 if (output.interrupted !== false || output.isImage === true) return null
101 if (output.backgroundTaskId !== undefined) return null
102 const { returnCodeInterpretation: interpretation, persistedOutputPath: savedTo } = output
103 return {
104 kind: 'done',
105 lines: lineCount(output.stdout),
106 interpretation: typeof interpretation === 'string' ? interpretation : null,
107 stderr: stderrSummary(output.stderr),
108 savedTo: typeof savedTo === 'string' ? savedTo : null,
109 }
110}
111
112function fileChange(value: unknown): FileChange | null {
113 if (!isRecord(value) || typeof value.filePath !== 'string') return null
114 const hunks = hunksOf(value.hunks)
115 if (hunks === null) return null
116 const verb = value.created === true ? 'Created' : value.deleted === true ? 'Deleted' : 'Updated'
117 return { path: value.filePath, verb, totals: totalsOf(hunks) }
118}
119
120export function bashChanges(output: unknown): BashChanges | null {
121 if (!isRecord(output)) return null
122 const diff = output.bashEditDiff
123 if (diff === undefined) return { kind: 'unreported' }
124 if (!isRecord(diff)) return null
125 if (diff.unavailable === true || diff.skipped === true) return { kind: 'untracked' }
126 if (!Array.isArray(diff.files) || typeof diff.moreFiles !== 'number') return null
127 const files = diff.files.map(fileChange)
128 if (!files.every((file) => file !== null)) return null
129 return { kind: 'tracked', files, moreFiles: diff.moreFiles }
130}
131
132export function diffGap(tool: string, output: unknown): SimpleViewDiffGap | null {
133 if (tool !== 'Bash') return null
134 const changes = bashChanges(output)
135 if (changes === null) return 'unreported'
136 return changes.kind === 'tracked' ? null : changes.kind
137}
138
139function writeTotals(output: Record<string, unknown>, hunks: readonly Hunk[]): Totals | null {
140 if (typeof output.content !== 'string') return null
141 if (hunks.length > 0) return totalsOf(hunks)
142 if (output.type === 'create') return { added: contentLines(output.content).length, removed: 0 }
143 if (output.originalFile === output.content) return { added: 0, removed: 0 }
144 return null
145}
146
147export function fileEdit(tool: string, output: unknown): FileEdit | null {
148 if (!isRecord(output) || typeof output.filePath !== 'string') return null
149 if (output.staged === true) return null
150 const hunks = hunksOf(output.structuredPatch)
151 if (hunks === null) return null
152 if (tool === 'Edit') {
153 return { path: output.filePath, totals: hunks.length > 0 ? totalsOf(hunks) : null }
154 }
155 if (tool === 'Write') return { path: output.filePath, totals: writeTotals(output, hunks) }
156 return null
157}
158
159export function shownPath(path: string, root: string): string {
160 const base = root.replace(/[\\/]+$/, '')
161 if (base !== '' && path.replace(/[\\/]+$/, '') === base) return '.'
162 const separator = path.charAt(base.length)
163 if (base !== '' && path.startsWith(base) && (separator === '/' || separator === '\\')) {
164 return path.slice(base.length + 1)
165 }
166 return path
167}
168hooks/reading.ts 104 lines1import { isRecord, shownPath } from './output'
2
3export const READING_TOOLS: ReadonlySet<string> = new Set(['Read', 'Grep', 'Glob'])
4
5type Noun = { one: string; many: string }
6
7const LINES: Noun = { one: 'line', many: 'lines' }
8const FILES: Noun = { one: 'file', many: 'files' }
9const MATCHES: Noun = { one: 'match', many: 'matches' }
10
11function isCount(value: unknown): value is number {
12 return typeof value === 'number' && Number.isInteger(value) && value >= 0
13}
14
15function counted(count: number, noun: Noun): string {
16 return `${String(count)} ${count === 1 ? noun.one : noun.many}`
17}
18
19function shownOfTotal(shown: number, total: unknown, noun: Noun): string {
20 if (!isCount(total) || total <= shown) return counted(shown, noun)
21 return `${String(shown)} of ${counted(total, noun)}`
22}
23
24function readCount(output: unknown): string | null {
25 if (!isRecord(output) || output.type !== 'text' || !isRecord(output.file)) return null
26 const { numLines, startLine, totalLines } = output.file
27 if (!isCount(numLines) || !isCount(startLine) || !isCount(totalLines)) return null
28 const isWholeFile = startLine <= 1 && numLines >= totalLines
29 if (isWholeFile || numLines === 0) return counted(numLines, LINES)
30 const lastLine = startLine + numLines - 1
31 return `lines ${String(startLine)}-${String(lastLine)} of ${String(totalLines)}`
32}
33
34function grepCount(output: unknown): string | null {
35 if (!isRecord(output)) return null
36 switch (output.mode) {
37 case 'content':
38 return isCount(output.numLines)
39 ? shownOfTotal(output.numLines, output.totalLines, LINES)
40 : null
41 case 'count':
42 return isCount(output.numMatches) ? counted(output.numMatches, MATCHES) : null
43 case 'files_with_matches':
44 return isCount(output.numFiles)
45 ? shownOfTotal(output.numFiles, output.totalFiles, FILES)
46 : null
47 default:
48 return null
49 }
50}
51
52function globCount(output: unknown): string | null {
53 if (!isRecord(output) || !isCount(output.numFiles) || typeof output.truncated !== 'boolean') {
54 return null
55 }
56 const total = isCount(output.totalMatches) ? output.totalMatches : output.numFiles
57 const isFloor =
58 output.countIsComplete === false || (output.truncated && output.totalMatches === undefined)
59 return isFloor ? `${String(total)}+ ${FILES.many}` : counted(total, FILES)
60}
61
62export function readingCount(tool: string, output: unknown): string | null {
63 switch (tool) {
64 case 'Read':
65 return readCount(output)
66 case 'Grep':
67 return grepCount(output)
68 case 'Glob':
69 return globCount(output)
70 default:
71 return null
72 }
73}
74
75function searchTarget(pattern: string, input: Record<string, unknown>, root: string): string[] {
76 if (typeof input.path !== 'string') return [pattern]
77 return [pattern, `in ${shownPath(input.path, root)}`]
78}
79
80export function streamingTarget(tool: string, input: unknown, root: string): string[] | null {
81 if (!isRecord(input)) return null
82 const named = tool === 'Read' ? input.file_path : input.pattern
83 if (named !== undefined && typeof named !== 'string') return null
84 if (tool === 'Read') return named === undefined ? [] : [shownPath(named, root)]
85 if (input.path !== undefined && typeof input.path !== 'string') return null
86 if (named === undefined) return []
87 return searchTarget(tool === 'Grep' ? `"${named}"` : named, input, root)
88}
89
90export function readingTarget(
91 tool: string,
92 input: unknown,
93 output: unknown,
94 root: string,
95): string[] | null {
96 if (tool === 'Read') {
97 if (!isRecord(output) || !isRecord(output.file)) return null
98 const { filePath } = output.file
99 return typeof filePath === 'string' ? [shownPath(filePath, root)] : null
100 }
101 const target = streamingTarget(tool, input, root)
102 return target === null || target.length === 0 ? null : target
103}
104hooks/row.ts 214 lines1import type { Elements, RenderElement, RenderSurface, ThemeKey } from 'claude-code'
2import { elapsedText } from './detail'
3import type { BashEnd, FileChange, StderrSummary, Totals } from './output'
4import { graphemesOf } from './width'
5
6type RowElements = Pick<Elements[RenderSurface], 'Box' | 'Text' | 'Button'>
7type CellStyle = Omit<Parameters<RowElements['Text']>[0], 'children'>
8type Cell = { text: string; style?: CellStyle; canShrink?: boolean; keptCells?: number }
9
10export type RowLook = { elements: RowElements; hasToolMarker: boolean }
11
12export type BashState = { kind: 'pending' } | { kind: 'running' } | BashEnd
13
14export type BashView = {
15 label: string
16 program: string
17 state: BashState
18 elapsedMs: number | null
19}
20
21const LABEL_KEPT_CELLS = 40
22const PENDING_TEXT = '…'
23const TIME_SHOWN_FROM_MS = 1000
24
25function cell(
26 { Box, Text }: RowElements,
27 { text, style = {}, canShrink = false, keptCells }: Cell,
28) {
29 return Box({
30 flexShrink: canShrink ? 1 : 0,
31 ...(keptCells === undefined ? {} : { minWidth: keptCells }),
32 children: Text({ ...style, children: text }),
33 })
34}
35
36function cellsOf(text: string): number {
37 return graphemesOf(text).reduce((sum, part) => sum + part.cells, 0)
38}
39
40function trailing(elements: RowElements, actions: RenderElement | null): RenderElement[] {
41 if (actions === null) return []
42 return [
43 elements.Box({ flexGrow: 1, minWidth: 2 }),
44 elements.Box({ flexShrink: 0, children: actions }),
45 ]
46}
47
48function line(
49 look: RowLook,
50 key: string,
51 marker: ThemeKey,
52 cells: Cell[],
53 actions: RenderElement | null = null,
54): RenderElement {
55 const { elements } = look
56 const columns = elements.Box({
57 flexDirection: 'row',
58 flexShrink: 1,
59 gap: 2,
60 children: cells.filter((part) => part.text !== '').map((part) => cell(elements, part)),
61 })
62 const parts = [columns, ...trailing(elements, actions)]
63 if (!look.hasToolMarker) return elements.Box({ key, flexDirection: 'row', children: parts })
64 const dot = elements.Box({
65 flexShrink: 0,
66 marginRight: 1,
67 children: elements.Text({ color: marker, children: '●' }),
68 })
69 return elements.Box({ key, flexDirection: 'row', children: [dot, ...parts] })
70}
71
72function plural(count: number, word: string): string {
73 return `${String(count)} ${word}${count === 1 ? '' : 's'}`
74}
75
76function stderrCells(stderr: StderrSummary | null): Cell[] {
77 if (stderr === null) return []
78 return [
79 { text: plural(stderr.lines, 'stderr line'), style: { dimColor: true } },
80 { text: stderr.first, style: { dimColor: true, wrap: 'truncate-end' }, canShrink: true },
81 ]
82}
83
84function stateCells(state: BashState): Cell[] {
85 switch (state.kind) {
86 case 'pending':
87 return [{ text: PENDING_TEXT, style: { dimColor: true } }]
88 case 'running':
89 return [{ text: 'running', style: { dimColor: true } }]
90 case 'done':
91 return [
92 state.interpretation === null
93 ? { text: 'exit 0', style: { color: 'success' } }
94 : { text: state.interpretation, style: { dimColor: true } },
95 {
96 text: state.savedTo === null ? plural(state.lines, 'line') : 'output saved to a file',
97 style: { dimColor: true },
98 },
99 ...stderrCells(state.stderr),
100 ]
101 case 'exit':
102 return [
103 { text: `exit ${String(state.code)}`, style: { color: 'error' } },
104 { text: state.line, style: { color: 'error', wrap: 'truncate-end' }, canShrink: true },
105 ]
106 }
107}
108
109function hasCutText(state: BashState): boolean {
110 return state.kind === 'exit' ? state.line !== '' : state.kind === 'done' && state.stderr !== null
111}
112
113function timeText(elapsedMs: number | null): string {
114 if (elapsedMs === null || elapsedMs < TIME_SHOWN_FROM_MS) return ''
115 return elapsedText(elapsedMs)
116}
117
118const MARKERS: Readonly<Record<BashState['kind'], ThemeKey>> = {
119 pending: 'subtle',
120 running: 'subtle',
121 done: 'success',
122 exit: 'error',
123}
124
125export function bashRow(
126 look: RowLook,
127 view: BashView,
128 actions: RenderElement | null = null,
129): RenderElement {
130 return line(
131 look,
132 'row',
133 MARKERS[view.state.kind],
134 [
135 {
136 text: view.label,
137 style: { wrap: 'truncate-end' },
138 canShrink: true,
139 keptCells: hasCutText(view.state)
140 ? Math.min(cellsOf(view.label), LABEL_KEPT_CELLS)
141 : undefined,
142 },
143 { text: view.program, style: { dimColor: true } },
144 ...stateCells(view.state),
145 { text: timeText(view.elapsedMs), style: { dimColor: true } },
146 ],
147 actions,
148 )
149}
150
151export function totalsText({ added, removed }: Totals): string {
152 return `+${String(added)} -${String(removed)}`
153}
154
155export function fileRow(
156 look: RowLook,
157 tool: string,
158 path: string,
159 totals: Totals | null,
160): RenderElement {
161 return line(look, 'row', 'success', [
162 { text: tool },
163 { text: path, style: { wrap: 'truncate-end' }, canShrink: true },
164 { text: totals === null ? '' : totalsText(totals), style: { dimColor: true } },
165 ])
166}
167
168export function readingRow(
169 look: RowLook,
170 tool: string,
171 target: readonly string[],
172 count: string | null,
173): RenderElement {
174 return line(look, 'row', count === null ? 'subtle' : 'success', [
175 { text: tool },
176 ...target.map((text) => ({ text, style: { wrap: 'truncate-end' as const }, canShrink: true })),
177 { text: count ?? PENDING_TEXT, style: { dimColor: true } },
178 ])
179}
180
181export type ChangeLines = { files: readonly FileChange[]; moreFiles: number } | null
182
183const RESULT_INDENT = 2
184
185type ChangeLine = { text: string; isDim: boolean }
186
187function changeLines(changes: ChangeLines): ChangeLine[] {
188 if (changes === null) {
189 return [{ text: 'File changes were not tracked for this call', isDim: true }]
190 }
191 const lines = changes.files.map((file) => ({
192 text: `${file.verb} ${file.path} (${totalsText(file.totals)})`,
193 isDim: false,
194 }))
195 if (changes.moreFiles > 0) {
196 lines.push({ text: `and ${plural(changes.moreFiles, 'more changed file')}`, isDim: true })
197 }
198 return lines
199}
200
201export function changesBlock(look: RowLook, changes: ChangeLines): RenderElement {
202 const { Box, Text } = look.elements
203 return Box({
204 flexDirection: 'column',
205 paddingLeft: look.hasToolMarker ? RESULT_INDENT : 0,
206 children: changeLines(changes).map((line, index) =>
207 Box({
208 key: `change-${String(index)}`,
209 children: Text({ dimColor: line.isDim, wrap: 'truncate-end', children: line.text }),
210 }),
211 ),
212 })
213}
214hooks/width.ts 28 lines1const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
2
3const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}]/u
4
5const EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u
6
7const EMOJI_BY_SELECTOR = /^\p{Emoji}.*\uFE0F/u
8
9const EAST_ASIAN_WIDE =
10 /^[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-ꥠ-가-힣豈-︐-︙︰--⦆¢-₩\u{16FE0}-\u{18CFF}\u{1B000}-\u{1B2FF}\u{1F200}-\u{1F2FF}\u{20000}-\u{2FFFD}\u{30000}-\u{3FFFD}]/u
11
12export function isEmoji(grapheme: string): boolean {
13 return EMOJI_PRESENTATION.test(grapheme) || EMOJI_BY_SELECTOR.test(grapheme)
14}
15
16function cellsOf(grapheme: string): number {
17 if (isEmoji(grapheme)) return 2
18 if (ZERO_WIDTH.test(grapheme)) return 0
19 return EAST_ASIAN_WIDE.test(grapheme) ? 2 : 1
20}
21
22export function graphemesOf(text: string): { grapheme: string; cells: number }[] {
23 return Array.from(GRAPHEMES.segment(text), ({ segment }) => ({
24 grapheme: segment,
25 cells: cellsOf(segment),
26 }))
27}
28types/index.d.ts 39 lines1export type SimpleViewMode = 'on' | 'off'
2
3export type SimpleViewTiming = {
4 startedAt: number
5 elapsedMs: number
6 isRunning: boolean
7}
8
9export type SimpleViewDiffGap = 'unreported' | 'untracked'
10
11export type SimpleViewFold = 'folded' | 'open' | 'all'
12
13export type SimpleViewCall = {
14 generation: number
15 seq: number
16 tool_use_id: string
17 tool: string
18 input: string
19 output: string
20 diff: string
21 diffGap: SimpleViewDiffGap | null
22 isErrored: boolean
23 elapsedMs: number
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'handily-simple-view': {
29 mode: SimpleViewMode
30 generation: number
31 timing: StateFamily<SimpleViewTiming>
32 count: number
33 calls: StateFamily<SimpleViewCall>
34 folds: StateFamily<SimpleViewFold>
35 ready: { root: string }
36 }
37 }
38}
39