SLOPSHOPPER

simple-view

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.

newrowsguardcommandtoasttimer
v0.2.0MITupdated 2026-10-09niksavis/handily/mods/simple-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 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 ⎿ 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 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.

Install

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

Rows

CallRowResult block under 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
  • 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.
  • A path inside the session root shows relative to the root. Another path shows in full.
  • The time is measured by the mod clock around the call. It includes the time that a permission question waited for an answer.
  • 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.

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.
  • Drawing the row failed. The mod writes the reason to the debug log.
  • Any tool other than 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.

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: 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 8 files
hooks/register.ts 432 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 { 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}
432
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 167 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  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}
167
hooks/row.ts 169 lines
1import 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}
169
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    '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