SLOPSHOPPER

handily-simple-view

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…

newrowsguardcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handily-simple-view
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /simple ⎿ handily-simple-view: off for this session. Tool calls draw as Claude Code draws them. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

simple-view

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.

Install

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

Rows

CallRowResult block under it
Bash, Read, Grep, Glob, before the rundescription or tool, the parts of the input it can read, dim …as the engine draws it
Bash, runningdescription, program, dim running, time since the call startedas the engine draws it
Bash, no errordescription, program, exit 0, stdout line count, timeone Updated line per changed file
Bash, Exit code Ndescription, program, exit N, the first non-empty error line, timeempty
Edit, Writetool, file path, added and removed line totalsempty
Readtool, file path, line count, or the range of lines that the call readempty
Greptool, pattern in quotes, in and the folder, the count of the output modeempty
Globtool, pattern, in and the folder, the count of matched filesempty
Read, Grep, Glob, runningtool, the path or the pattern from the input, dim …as the engine draws it
  • The description is the description that the model gave. Without one, the row shows the first command segment. An ellipsis (…) marks a cut or a segment that follows.
  • The program is the first word of the first segment that is not cd, pushd or popd. A VAR=value word before it is skipped.
  • When the engine gives a meaning for a non-zero exit that is not an error, such as No matches found for grep, the row shows that meaning in place of exit 0.
  • When a call without an error wrote to stderr, the row adds the stderr line count and the first non-empty stderr line, dim and cut at the row end. The result block does not change.
  • When the engine saved a large output to a file, stdout holds only a preview. The row then shows output saved to a file in place of the line count, and /simple show N prints the path of that file.
  • The result block names each changed file as 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.
  • A missing 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.
  • After 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.
  • The 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.
  • When an error line or a stderr line follows the description, the description keeps up to 40 cells. The terminal cuts the error line or the stderr line first.
  • A path inside the session root shows relative to the root, and the root itself shows as .. Another path shows in full.
  • The row shows the time only from 1 second. The engine gives no run time for a Bash call, so the mod clock measures the time around the call. It includes the time that a permission question waited for an answer.
  • A 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.
  • A 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.
  • A 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.
  • Claude Code 2.1.295 offered no 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.

While the input streams

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  …
  • A 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.
  • A 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.
  • A call is before its run while it does not run, has no output, and has no error and no abort. In that state a missing field has not arrived yet, so the row leaves it out. A field of the wrong type is not an input that streams, so the engine draws the row. This rule holds for a complete input too.
  • In a live check with Claude Code 2.1.296, the input of a call stayed {} until the model had written all of it. The row showed ● Bash … first, then the description and the program with … until the call started.

Open and copy the output

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.
  • When the engine saved a large output to a file, the row shows only the preview that the engine kept. copy reads the saved file and copies all of it.
  • The opened lines are dim and never wrap: a long line is cut at the row end. 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.
  • A running call has 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.
  • Each row opens and folds alone. A call with no output and an 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.

When the engine row stays

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.
  • The call was interrupted, refused or ran in the background.
  • The call errored without the Error: Exit code N text, for example a timeout or a refusal at the permission question.
  • The output or the input is not a shape that the mod reads, or a staged 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.
  • A Read, Grep or Glob call that errored.
  • An input field of the wrong type, also while the input streams, such as a command that is not text.
  • Drawing the row failed. The mod writes the reason to the debug log.
  • Any tool other than 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.

Commands

CommandEffect
/simpleTurns the view off or on for this session, and replies with the new mode. It starts on
/simple show NPrints 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.
  • ctrl+o cannot expand a single row. The engine reuses the drawn row and does not call the mod again. Use more on the row, or /simple show N.

Memory limit

/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.

Known limits

  • /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.
  • The running time is updated once per second while the call runs.
Source 9 files
hooks/register.ts 496 lines
1import 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}
496
hooks/command.ts 100 lines
1const 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}
100
hooks/detail.ts 136 lines
1import 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}
136
hooks/fold.ts 195 lines
1import 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}
195
hooks/output.ts 168 lines
1import 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}
168
hooks/reading.ts 104 lines
1import { 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}
104
hooks/row.ts 214 lines
1import 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}
214
hooks/width.ts 28 lines
1const 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}
28
types/index.d.ts 39 lines
1export 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