One short row for each Bash, Edit and Write call, with buttons to open and copy its output, /simple to switch it and /simple show to print a call in full.

simple-view draws one short row for each Bash, Edit and Write 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 missing folder ls exit 2 ls: cannot access '/nonexistent-folder': No such… 0.4s [ more ] [ copy ]
● Append a probe line printf exit 0 0 lines 2.8s
Updated README.md (+1 -0)
● Edit src/app.ts +2 -1
The approved mocks are in docs/mocks.md, sections 6 and 7.2. The design is in docs/design.md, section 4.8.
simple-view depends on quiet-items, and quiet-items depends on workitems. Install all three:
/plugin install workitems --marketplace niksavis/handily
/plugin install quiet-items --marketplace niksavis/handily
/plugin install simple-view --marketplace niksavis/handily
| Call | Row | Result block under 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 |
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.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.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.Bash, Edit and Write.In the normal view the engine folds runs of read-only calls into one group line, such as Listed 2 directories, ran 2 shell commands. simple-view leaves that line as the engine draws it. The ctrl+o transcript unfolds the 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: quiet-items owns that value and only its owner writes it. Use /quiet-items to switch the tracker rows.hooks/register.ts 432 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 { bashRow, changesBlock, fileRow, type BashState, type RowLook } from './row'
24
25const COMMAND = 'simple'
26const TICK_MS = 1000
27const COUNT_TRIES = 5
28const DEFAULT_MODE: SimpleViewMode = 'on'
29const MODE = { plugin: 'simple-view', key: 'mode' } as const
30const COUNT = { plugin: 'simple-view', key: 'count' } as const
31const GENERATION = { plugin: 'simple-view', key: 'generation' } as const
32const QUIET_ITEMS_MODE = { plugin: 'quiet-items', key: 'mode' } as const
33const RESERVED_KEYS: ReadonlySet<string> = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
34const FILE_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write'])
35
36const ON_TEXT =
37 'on for this session. Bash, Edit and Write calls draw as one row. /simple show N prints call N in full, where 1 is the last call.'
38const OFF_TEXT = 'off for this session. Tool calls draw as Claude Code draws them.'
39const USAGE_TEXT =
40 '/simple takes no argument, or show N. /simple toggles this session; /simple show N prints the N-th last tool call in full.'
41const NO_CALL_TEXT = '/simple show has no tool call to print yet in this session.'
42
43type Answer = Awaited<ReturnType<EngineInterface['tool']['call']>>
44type BashInput = { command: string; description?: string; run_in_background?: boolean }
45
46function isBashInput(input: unknown): input is BashInput {
47 return (
48 isRecord(input) &&
49 typeof input.command === 'string' &&
50 (input.description === undefined || typeof input.description === 'string')
51 )
52}
53
54function rangeText(kept: number): string {
55 return `/simple show takes a call number from 1 to ${String(kept)}, where 1 is the last tool call.`
56}
57
58function logFailure($: EngineInterface, what: string, error: unknown): void {
59 $.ui.log(`simple-view: ${what}: ${String(error)}`)
60}
61
62async function toggle($: EngineInterface): Promise<string> {
63 const { value: current = DEFAULT_MODE } = await $.state.get(MODE)
64 const mode: SimpleViewMode = current === 'on' ? 'off' : 'on'
65 await $.state.set(MODE, mode)
66 return mode === 'on' ? ON_TEXT : OFF_TEXT
67}
68
69async function generationOf($: EngineInterface): Promise<number> {
70 const { value = 0 } = await $.state.get(GENERATION)
71 return value
72}
73
74function timingKey(generation: number, toolUseId: string): string {
75 return `${String(generation)}:${toolUseId}`
76}
77
78async function forgetSession($: EngineInterface): Promise<void> {
79 try {
80 await $.state.set(GENERATION, (await generationOf($)) + 1)
81 await $.state.set(COUNT, 0)
82 } catch (error) {
83 logFailure($, 'the calls of the ended session stay reachable', error)
84 }
85}
86
87async function show($: EngineInterface, raw: string | undefined): Promise<string> {
88 const generation = await generationOf($)
89 const { value: count = 0 } = await $.state.get(COUNT)
90 const kept = Math.min(count, KEPT_CALLS)
91 if (kept === 0) return NO_CALL_TEXT
92 if (raw === undefined || !/^\d+$/.test(raw)) return rangeText(kept)
93 const back = Number(raw)
94 if (back < 1 || back > kept) return `No call ${raw} is kept. ${rangeText(kept)}`
95 const seq = count - back
96 const { value: call } = await $.state.get({
97 plugin: 'simple-view',
98 key: 'calls',
99 id: slotOf(seq),
100 })
101 if (call?.generation !== generation || call.seq !== seq) {
102 return `No call ${raw} is kept. ${rangeText(kept)}`
103 }
104 return showText(call, back, kept)
105}
106
107async function tick($: EngineInterface, id: string): Promise<void> {
108 try {
109 const ref = { plugin: 'simple-view', key: 'timing', id } as const
110 const { value, version } = await $.state.get(ref)
111 if (value?.isRunning !== true) return
112 const now = await $.clock.now()
113 await $.state.set(ref, { ...value, elapsedMs: now - value.startedAt }, { ifVersion: version })
114 } catch (error) {
115 logFailure($, `no running time for ${id}`, error)
116 }
117}
118
119async function startTicker($: EngineInterface, id: string, startedAt: number): Promise<Timer> {
120 await $.state.set(
121 { plugin: 'simple-view', key: 'timing', id },
122 { startedAt, elapsedMs: 0, isRunning: true },
123 )
124 return $.clock.every(TICK_MS, () => {
125 void tick($, id)
126 })
127}
128
129async function settleTiming(
130 $: EngineInterface,
131 id: string,
132 startedAt: number,
133 elapsedMs: number,
134): Promise<void> {
135 try {
136 await $.state.set(
137 { plugin: 'simple-view', key: 'timing', id },
138 { startedAt, elapsedMs, isRunning: false },
139 )
140 } catch (error) {
141 logFailure($, `no time for ${id}`, error)
142 }
143}
144
145async function remember(
146 $: EngineInterface,
147 facts: Omit<CallFacts, 'seq' | 'generation'>,
148): Promise<void> {
149 try {
150 for (let attempt = 0; attempt < COUNT_TRIES; attempt += 1) {
151 const generation = await generationOf($)
152 const { value: count = 0, version } = await $.state.get(COUNT)
153 const { isSet } = await $.state.set(COUNT, count + 1, { ifVersion: version })
154 if (!isSet) continue
155 await $.state.set(
156 { plugin: 'simple-view', key: 'calls', id: slotOf(count) },
157 callRecord({ ...facts, generation, seq: count }),
158 )
159 return
160 }
161 $.ui.log(`simple-view: /simple show misses ${facts.tool_use_id}; other calls kept the count`)
162 } catch (error) {
163 logFailure($, `/simple show misses ${facts.tool_use_id}`, error)
164 }
165}
166
167type Ended = {
168 facts: Omit<CallFacts, 'seq' | 'generation' | 'elapsedMs'>
169 startedAt: number
170 timing: string | null
171}
172
173async function recordCall($: EngineInterface, { facts, startedAt, timing }: Ended): Promise<void> {
174 let elapsedMs: number
175 try {
176 elapsedMs = (await $.clock.now()) - startedAt
177 } catch (error) {
178 logFailure($, `no time for ${facts.tool_use_id}`, error)
179 return
180 }
181 if (timing !== null) await settleTiming($, timing, startedAt, elapsedMs)
182 await remember($, { ...facts, elapsedMs })
183}
184
185function inputOf(e: object): Record<string, unknown> {
186 return Object.fromEntries(Object.entries(e).filter(([key]) => !RESERVED_KEYS.has(key)))
187}
188
189async function timingOf($: EngineInterface, toolUseId: string) {
190 const id = timingKey(await generationOf($), toolUseId)
191 const { value } = await $.state.get({ plugin: 'simple-view', key: 'timing', id })
192 return value
193}
194
195async function copyOutput(
196 $: EngineInterface,
197 output: CallOutput,
198 surface: RenderSurface,
199): Promise<void> {
200 try {
201 const text = output.savedTo === null ? output.text : await $.fs.read(output.savedTo)
202 const result = await $.ui.copy({ text, surface })
203 $.ui.toast(
204 result.isCopied
205 ? `Copied ${plural(lineCount(text), 'line')} of output.`
206 : copyRefusalText(result.reason),
207 )
208 } catch (error) {
209 logFailure($, 'the output was not copied', error)
210 $.ui.toast('Not copied. The debug log says why.')
211 }
212}
213
214function foldActions($: EngineInterface, id: string, output: CallOutput | null): FoldActions {
215 return {
216 fold: (next) => {
217 void $.state
218 .set({ plugin: 'simple-view', key: 'folds', id }, next)
219 .catch((error: unknown) => {
220 logFailure($, `the output of ${id} did not ${next === 'folded' ? 'fold' : 'open'}`, error)
221 })
222 },
223 copy:
224 output === null
225 ? undefined
226 : (surface) => {
227 void copyOutput($, output, surface)
228 },
229 }
230}
231
232function commandOutput(command: string): CallOutput {
233 return { text: command, lines: command.split(/\r?\n/), savedTo: null }
234}
235
236async function foldOf($: EngineInterface, id: string): Promise<SimpleViewFold> {
237 const { value = 'folded' } = await $.state.get({ plugin: 'simple-view', key: 'folds', id })
238 return value
239}
240
241async function bashUse(
242 $: EngineInterface,
243 props: RenderPropsOf['ToolUse'],
244 look: RowLook,
245): Promise<RenderElement | null> {
246 if (!isBashInput(props.input) || props.input.run_in_background === true) return null
247 const state: BashState | null = props.isRunning
248 ? { kind: 'running' }
249 : bashEnd(props.output, props.isErrored)
250 if (state === null) return null
251 const timing = await timingOf($, props.tool_use_id)
252 const description = props.input.description?.trim() ?? ''
253 const view = {
254 label: description === '' ? commandLabel(props.input.command) : description,
255 program: programOf(props.input.command),
256 state,
257 elapsedMs: timing?.elapsedMs ?? null,
258 }
259 const isRunning = state.kind === 'running'
260 const output = isRunning ? null : callOutput(props.output, props.isErrored)
261 if (!isRunning && output === null) return bashRow(look, view)
262 const fold = await foldOf($, props.tool_use_id)
263 const actions = foldActions($, props.tool_use_id, output)
264 const row = bashRow(look, view, foldButtons(look, fold, actions))
265 if (fold === 'folded') return row
266 const opened =
267 output === null
268 ? outputBlock(look, commandOutput(props.input.command), 'all', actions)
269 : outputBlock(look, output, fold, actions)
270 const { Box } = look.elements
271 return Box({ flexDirection: 'column', children: [row, opened] })
272}
273
274async function fileUse(
275 $: EngineInterface,
276 props: RenderPropsOf['ToolUse'],
277 look: RowLook,
278): Promise<RenderElement | null> {
279 if (props.isRunning || props.isErrored) return null
280 const edit = fileEdit(props.tool, props.output)
281 if (edit === null) return null
282 return fileRow(look, props.tool, shownPath(edit.path, await $.session.root()), edit.totals)
283}
284
285async function useDrawing(
286 $: EngineInterface,
287 props: RenderPropsOf['ToolUse'],
288 look: RowLook,
289): Promise<RenderElement | null> {
290 if (props.isInterrupted) return null
291 if (props.tool === 'Bash') return bashUse($, props, look)
292 if (FILE_TOOLS.has(props.tool)) return fileUse($, props, look)
293 return null
294}
295
296async function bashResult(
297 $: EngineInterface,
298 props: RenderPropsOf['ToolResult'],
299 look: RowLook,
300): Promise<RenderElement | null> {
301 const end = bashEnd(props.output, props.isErrored)
302 if (end === null) return null
303 if (end.kind === 'exit') return look.elements.Box({})
304 const changes = bashChanges(props.output)
305 if (changes === null) return null
306 if (changes.kind === 'unreported') return look.elements.Box({})
307 if (changes.kind === 'untracked') return changesBlock(look, null)
308 const root = changes.files.length === 0 ? '' : await $.session.root()
309 const files = changes.files.map((file) => ({ ...file, path: shownPath(file.path, root) }))
310 return changesBlock(look, { files, moreFiles: changes.moreFiles })
311}
312
313async function resultDrawing(
314 $: EngineInterface,
315 props: RenderPropsOf['ToolResult'],
316 look: RowLook,
317): Promise<RenderElement | null> {
318 if (props.tool === 'Bash') return bashResult($, props, look)
319 if (!FILE_TOOLS.has(props.tool) || props.isErrored) return null
320 return fileEdit(props.tool, props.output) === null ? null : look.elements.Box({})
321}
322
323async function isSimple($: EngineInterface, id: string): Promise<boolean> {
324 const { value: mode = DEFAULT_MODE } = await $.state.get(MODE)
325 if (mode === 'off') return false
326 const { value: rows } = await $.state.get({ plugin: 'quiet-items', key: 'rows', id })
327 if (rows === undefined || rows.length === 0) return true
328 const { value: quietMode = 'on' } = await $.state.get(QUIET_ITEMS_MODE)
329 return quietMode === 'off'
330}
331
332function isFailed(call: ToolGroupCall): boolean {
333 return call.isErrored && !call.isRunning
334}
335
336function isRunningForeground(call: ToolGroupCall): boolean {
337 return (
338 call.tool === 'Bash' &&
339 call.isRunning &&
340 !(isBashInput(call.input) && call.input.run_in_background === true)
341 )
342}
343
344function shouldUnfold(props: RenderPropsOf['ToolGroup']): boolean {
345 if (props.isExpanded) return false
346 return props.calls.some(isFailed) || (props.isActive && props.calls.some(isRunningForeground))
347}
348
349export const register: Register = (on) => {
350 on('session.start', async ($, e, next) => {
351 await $.command.register({
352 name: COMMAND,
353 description: 'Toggle the one-row view of tool calls, or print a call in full',
354 argumentHint: '[show N]',
355 })
356 await $.state.set({ plugin: 'simple-view', key: 'ready' }, { root: $.plugin.root })
357 return next(e)
358 })
359
360 on('command.run', { command: COMMAND }, async ($, e) => {
361 const [verb, number, ...rest] = e.args
362 .trim()
363 .split(/\s+/)
364 .filter((word) => word !== '')
365 if (verb === undefined) return { text: await toggle($) }
366 if (verb === 'show' && rest.length === 0) return { text: await show($, number) }
367 return { text: USAGE_TEXT }
368 })
369
370 on('session.end', async ($, e, next) => {
371 await forgetSession($)
372 return next(e)
373 })
374
375 on('tool.call', async ($, e, next) => {
376 const id = e.tool_use_id
377 if (e.agentId !== undefined) return next(e)
378 const isTimed = e.tool === 'Bash' && e.run_in_background !== true
379 let startedAt: number
380 let timing: string | null
381 let ticker: Timer | null
382 try {
383 startedAt = await $.clock.now()
384 timing = isTimed ? timingKey(await generationOf($), id) : null
385 ticker = timing === null ? null : await startTicker($, timing, startedAt)
386 } catch (error) {
387 logFailure($, `no time for ${id}`, error)
388 return next(e)
389 }
390 let answer: Answer
391 try {
392 answer = await next(e)
393 } finally {
394 ticker?.cancel()
395 }
396 await recordCall($, {
397 facts: { tool_use_id: id, tool: e.tool, input: inputOf(e), answer },
398 startedAt,
399 timing,
400 })
401 return answer
402 })
403
404 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
405 if (!shouldUnfold(e.props)) return next(e)
406 let isOn = false
407 try {
408 const { value: mode = DEFAULT_MODE } = await $.state.get(MODE)
409 isOn = mode === 'on'
410 } catch (error) {
411 logFailure($, 'the engine folds a group with a failed call', error)
412 }
413 return next(isOn ? { ...e, props: { ...e.props, isExpanded: true } } : e)
414 })
415
416 on('ui.render', { component: ['ToolUse', 'ToolResult'] }, async ($, e, next) => {
417 let drawn: RenderElement | null = null
418 try {
419 if (await isSimple($, e.props.tool_use_id)) {
420 const look = { elements: $.ui.resolve(e), hasToolMarker: e.surface === 'terminal' }
421 drawn =
422 e.component === 'ToolUse'
423 ? await useDrawing($, e.props, look)
424 : await resultDrawing($, e.props, look)
425 }
426 } catch (error) {
427 logFailure($, `the engine draws ${e.props.tool_use_id}`, error)
428 }
429 return drawn ?? next(e)
430 })
431}
432hooks/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 167 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 const separator = path.charAt(base.length)
162 if (base !== '' && path.startsWith(base) && (separator === '/' || separator === '\\')) {
163 return path.slice(base.length + 1)
164 }
165 return path
166}
167hooks/row.ts 169 lines1import type { Elements, RenderElement, RenderSurface, ThemeKey } from 'claude-code'
2import { elapsedText } from './detail'
3import type { BashEnd, FileChange, StderrSummary, Totals } from './output'
4
5type RowElements = Pick<Elements[RenderSurface], 'Box' | 'Text' | 'Button'>
6type CellStyle = Omit<Parameters<RowElements['Text']>[0], 'children'>
7type Cell = { text: string; style?: CellStyle; canShrink?: boolean }
8
9export type RowLook = { elements: RowElements; hasToolMarker: boolean }
10
11export type BashState = { kind: 'running' } | BashEnd
12
13export type BashView = {
14 label: string
15 program: string
16 state: BashState
17 elapsedMs: number | null
18}
19
20function cell({ Box, Text }: RowElements, { text, style = {}, canShrink = false }: Cell) {
21 return Box({ flexShrink: canShrink ? 1 : 0, children: Text({ ...style, children: text }) })
22}
23
24function trailing(elements: RowElements, actions: RenderElement | null): RenderElement[] {
25 if (actions === null) return []
26 return [
27 elements.Box({ flexGrow: 1, minWidth: 2 }),
28 elements.Box({ flexShrink: 0, children: actions }),
29 ]
30}
31
32function line(
33 look: RowLook,
34 key: string,
35 marker: ThemeKey,
36 cells: Cell[],
37 actions: RenderElement | null = null,
38): RenderElement {
39 const { elements } = look
40 const columns = elements.Box({
41 flexDirection: 'row',
42 flexShrink: 1,
43 gap: 2,
44 children: cells.filter((part) => part.text !== '').map((part) => cell(elements, part)),
45 })
46 const parts = [columns, ...trailing(elements, actions)]
47 if (!look.hasToolMarker) return elements.Box({ key, flexDirection: 'row', children: parts })
48 const dot = elements.Box({
49 flexShrink: 0,
50 marginRight: 1,
51 children: elements.Text({ color: marker, children: '●' }),
52 })
53 return elements.Box({ key, flexDirection: 'row', children: [dot, ...parts] })
54}
55
56function plural(count: number, word: string): string {
57 return `${String(count)} ${word}${count === 1 ? '' : 's'}`
58}
59
60function stderrCells(stderr: StderrSummary | null): Cell[] {
61 if (stderr === null) return []
62 return [
63 { text: plural(stderr.lines, 'stderr line'), style: { dimColor: true } },
64 { text: stderr.first, style: { dimColor: true, wrap: 'truncate-end' }, canShrink: true },
65 ]
66}
67
68function stateCells(state: BashState): Cell[] {
69 switch (state.kind) {
70 case 'running':
71 return [{ text: 'running', style: { dimColor: true } }]
72 case 'done':
73 return [
74 state.interpretation === null
75 ? { text: 'exit 0', style: { color: 'success' } }
76 : { text: state.interpretation, style: { dimColor: true } },
77 {
78 text: state.savedTo === null ? plural(state.lines, 'line') : 'output saved to a file',
79 style: { dimColor: true },
80 },
81 ...stderrCells(state.stderr),
82 ]
83 case 'exit':
84 return [
85 { text: `exit ${String(state.code)}`, style: { color: 'error' } },
86 { text: state.line, style: { color: 'error', wrap: 'truncate-end' }, canShrink: true },
87 ]
88 }
89}
90
91const MARKERS: Readonly<Record<BashState['kind'], ThemeKey>> = {
92 running: 'subtle',
93 done: 'success',
94 exit: 'error',
95}
96
97export function bashRow(
98 look: RowLook,
99 view: BashView,
100 actions: RenderElement | null = null,
101): RenderElement {
102 return line(
103 look,
104 'row',
105 MARKERS[view.state.kind],
106 [
107 { text: view.label, style: { wrap: 'truncate-end' }, canShrink: true },
108 { text: view.program, style: { dimColor: true } },
109 ...stateCells(view.state),
110 {
111 text: view.elapsedMs === null ? '' : elapsedText(view.elapsedMs),
112 style: { dimColor: true },
113 },
114 ],
115 actions,
116 )
117}
118
119export function totalsText({ added, removed }: Totals): string {
120 return `+${String(added)} -${String(removed)}`
121}
122
123export function fileRow(
124 look: RowLook,
125 tool: string,
126 path: string,
127 totals: Totals | null,
128): RenderElement {
129 return line(look, 'row', 'success', [
130 { text: tool },
131 { text: path, style: { wrap: 'truncate-end' }, canShrink: true },
132 { text: totals === null ? '' : totalsText(totals), style: { dimColor: true } },
133 ])
134}
135
136export type ChangeLines = { files: readonly FileChange[]; moreFiles: number } | null
137
138const RESULT_INDENT = 2
139
140type ChangeLine = { text: string; isDim: boolean }
141
142function changeLines(changes: ChangeLines): ChangeLine[] {
143 if (changes === null) {
144 return [{ text: 'File changes were not tracked for this call', isDim: true }]
145 }
146 const lines = changes.files.map((file) => ({
147 text: `${file.verb} ${file.path} (${totalsText(file.totals)})`,
148 isDim: false,
149 }))
150 if (changes.moreFiles > 0) {
151 lines.push({ text: `and ${plural(changes.moreFiles, 'more changed file')}`, isDim: true })
152 }
153 return lines
154}
155
156export function changesBlock(look: RowLook, changes: ChangeLines): RenderElement {
157 const { Box, Text } = look.elements
158 return Box({
159 flexDirection: 'column',
160 paddingLeft: look.hasToolMarker ? RESULT_INDENT : 0,
161 children: changeLines(changes).map((line, index) =>
162 Box({
163 key: `change-${String(index)}`,
164 children: Text({ dimColor: line.isDim, wrap: 'truncate-end', children: line.text }),
165 }),
166 ),
167 })
168}
169hooks/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 '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