SLOPSHOPPER

prettier

Chat layout: user prompts in a right-aligned pill, replies at 85% width, code panels with Copy, a turn footer and the spinner. Load it before every other mod…

newspinnerrowsguardcommandtoast
v0.1.0no licenseupdated 2026-10-16MoizAsif616/claude-code-utilities/mods/prettier
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prettier
╭────────────────────────────────────────────╮ │ prettier │ │ prettier: Press ctrl+o to see full tool │ │ calls, their output and long prompts; │ │ ctrl+o again to go back. /prettier lists │ ⏺ 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. 42s · tools › /prettier ⎿ prettier: prettier is **on**. `/prettier on`, `/prettier off`, `/prettier reset` (every setting back to its default). ⎿ prettier: ⎿ prettier: - `/prettier-prompt` prompt pill: width, lines, time ⎿ prettier: - `/prettier-response` replies and tool output: width, fold ⎿ prettier: - `/prettier-code` code diffs: width ⎿ prettier: ⠋ Thinking ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
⠋ Thinking
Turn line
42s · tools
Your message
README

prettier

Chat layout for the Claude Code terminal:

  • Your prompts: a pill with a rounded purple border (no fill, one space of padding on the left) on the right, at most 70% of the terminal wide, with the time you sent it under it. A long prompt shows its first 6 lines, then … 14 more lines · ctrl+o to see all. In fullscreen, ⧉ beside the time (same grey) copies that prompt's text alone and reads a green ✓ for 3.5 seconds.
  • Replies and tool output: held to the left 85% of the terminal.
  • Code blocks in replies: each fenced block is a charcoal panel with a thin violet rounded border. Its top row holds the language (python, or code when unlabeled) on a slightly lighter chip at the left and, in fullscreen, ⧉ Copy at the right, which copies only the raw code (never the label) and reads Copied! for 3.5 seconds. A panel is as wide as its longest line, never wider than the reply width; longer lines wrap inside. Bash tool-call rows are not changed.
  • Edit diffs: redrawn by prettier so added/removed lines wrap inside a narrower 75%. While the reply is being written each diff shows whole; once it is done (fullscreen) each edited or created file is one row in place of its tool row, ▸ Edit hooks/app.ts +12 −3 (name in purple, +n green, −n red), that opens to its diff and closes on a click. A new file's content shows as added lines.
  • Reads, searches and commands: a run of them folds to one line, ▸ Ran 2 commands · 1.2s. A click opens it: each call on a filled block with the whole command and its output (up to 40 lines). A click on any block, or on the line, closes it again.
  • Failures: a failed tool's error in a red frame, ✗ Bash failed, as wide as the error and never wider than the reply width. Once the reply is done (fullscreen) the call is one row, ▸ Edit app.ts · ✗ failed, that opens to its frame on a click. A refusal at the permission dialog is not a failure and keeps Claude Code's own drawing.
  • Rows you did not type: background tasks finishing, messages from other agents, sessions or channels show as a quiet card on the left (⚙ Background task · completed · 2m 2s), not as your pill.
  • End of a reply: 39s · ▸ 2 tools under the reply: how long it took, dim like the prompt's time, and how many tools it used in purple. The time of day is shown only under your prompt.
  • Spinner: drawn whole by prettier: a purple animated ⠋ and Claude Code's word, or the tool running now, ⠋ ▸ running Bash….

Seeing the details

Click to toggle (fullscreen mode). Every compact row opens and closes on a click, marked ▸ (closed) and ▾ (open): a folded prompt (▸ 14 more lines / ▾ show less), a folded run of reads and searches (its calls appear under it), a background-task card (▸ read all), and the tool count under a reply (▸ 2 tools lists each call: ✓ Bash npm test · 1.2s). Claude Code only reports mouse clicks to mods in its fullscreen (alternate-screen) layout; on the normal screen these rows stay compact and show ctrl+o where something is hidden.

Press ctrl+o (Claude Code's own transcript view) to see every folded thing in full: each tool call of a group, whole outputs, whole long prompts and messages. Press ctrl+o again to go back. A folded prompt or message says so; the first session after installing shows a one-time tip, and /prettier repeats it.

Commands

CommandSettings (defaults)
/prettierlists the commands; on, off, reset (every setting to its default)
/prettier-promptwidth 70 (% of the terminal), lines 6 (shown before folding), time on
/prettier-responsewidth 85 (% of the terminal), fold on (fold reads and searches)
/prettier-codewidth 75 (% of the terminal, for diffs)

Each command alone lists its settings and what they do. Change one or several at once: /prettier-prompt width 60 lines 4. Settings are kept across sessions.

Long Bash and Read output is already folded by Claude Code itself (… +42 lines (ctrl+o to expand)); prettier leaves that as it is, since a mod cannot tell when ctrl+o is open for a result and would hide the output for good.

Load order: prettier goes FIRST

For Claude or anyone installing this mod: prettier must be loaded before every other mod, in particular before inkstyle. Otherwise the 85% width silently does nothing.

Why

Mods hook the same drawing in a chain: the first mod loaded is the outermost and calls next(e) to reach the ones beneath it. prettier only wraps what is beneath it:

  • Replies and tool rows: inkstyle draws every reply and tool row itself (to color them) and never calls next(e). A mod loaded after inkstyle is never reached for those, so prettier's 85% box is never applied. Loaded first, prettier wraps inkstyle's colored rows in the 85% box.

The spinner is prettier's alone (statusdeck no longer draws it), so its place in the order does not matter for the spinner.

Neither mod imports or reads the other: they are independent in code, and only the order connects them. In the wrong order nothing breaks: replies go back to full width.

How

List prettier first in CLAUDE_CODE_PLUGIN_DIRS (;-separated on Windows, : elsewhere). The mod API puts every installed mod in one tier and does not document the order within it, so this relies on Claude Code loading the list in order: confirm it with the check below.

[Environment]::SetEnvironmentVariable('CLAUDE_CODE_PLUGIN_DIRS',
  "$env:USERPROFILE\.claude\mods\prettier;$env:USERPROFILE\.claude\mods\statusdeck;$env:USERPROFILE\.claude\mods\inkstyle",
  'User')

On macOS/Linux, in your shell profile:

export CLAUDE_CODE_PLUGIN_DIRS="$HOME/.claude/mods/prettier:$HOME/.claude/mods/statusdeck:$HOME/.claude/mods/inkstyle"

Then restart Claude Code. The variable is read at startup, and on Windows a terminal only sees a changed user variable if the whole terminal app started after the change (Windows Terminal, VS Code, any IDE): closing a tab is not enough, since new tabs inherit the app's old copy. Check with echo $env:CLAUDE_CODE_PLUGIN_DIRS before starting claude; or refresh one terminal by hand:

$env:CLAUDE_CODE_PLUGIN_DIRS = [Environment]::GetEnvironmentVariable('CLAUDE_CODE_PLUGIN_DIRS','User')

Checking it worked

Ask Claude anything with a long answer. If the reply wraps at about 85% of the terminal, the order is right. If it runs the full width, prettier is not first.

Known limit

inkstyle sizes tables for the full terminal width. Inside the 85% box their columns are squeezed to fit, so a wide table's │ column lines may come out shorter than its wrapped cells.

Rows Claude Code draws itself (a Bash or Read result, a tool group) are wrapped in the 85% box but sized by Claude Code; if one of them still runs the full width, it needs redrawing in prettier as the edit diff is.

The dim ● <mod>: ... lines Claude Code prints when a mod loads, reloads or fails are drawn by Claude Code itself, and no mod can restyle them. They only appear while mods are being developed or something is wrong with one.

Source 4 files
hooks/register.tsx 859 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement, RenderSurface, Timer } from 'claude-code'
3
4import type { Call, Live, Prompt, Settings, Timing, Turn } from '../types'
5import type { EditDiff } from './layout'
6import {
7  CALL_FILL,
8  CALL_OUTPUT_LINES,
9  CARD_BORDER,
10  COPIED_LABEL,
11  COPIED_MS,
12  COPY_LABEL,
13  EXPAND_KEY,
14  GREEN,
15  GREY,
16  PILL_BORDER,
17  PANEL_BORDER,
18  PANEL_CHIP,
19  PANEL_FILL,
20  PANEL_LABEL,
21  PANEL_PAD_X,
22  PILL_PAD_LEFT,
23  PROMPT_COPIED,
24  PROMPT_COPY,
25  PURPLE,
26  PURPLE_LIGHT,
27  RED,
28  RESULT_GUTTER,
29  ROW_GUTTER,
30  SPINNER_FRAMES,
31  SPIN_MS,
32  callOutput,
33  capText,
34  cardHead,
35  describeCall,
36  describeCallWhole,
37  editDiff,
38  errorText,
39  footerParts,
40  frameWidth,
41  formatClock,
42  formatShort,
43  groupDuration,
44  groupSummary,
45  inputDelta,
46  isRejection,
47  matchTurn,
48  panelLabel,
49  panelWidth,
50  pillWidth,
51  promptTime,
52  share,
53  shortPath,
54  spinnerText,
55  splitFences,
56  toolLabel,
57  untab,
58} from './layout'
59import { COMMANDS, DEFAULTS, EXPAND_HINT, applyArgs, describeAll, describeSection, mergeSettings } from './settings'
60import type { Section } from './settings'
61
62const settingsAtom = atom({ plugin: 'prettier', key: 'settings' } as const, DEFAULTS as Settings)
63const turnsAtom = atom({ plugin: 'prettier', key: 'turns' } as const, [] as Turn[])
64const liveAtom = atom({ plugin: 'prettier', key: 'live' } as const, { running: [], tools: 0, calls: [] } as Live)
65const promptsAtom = atom({ plugin: 'prettier', key: 'prompts' } as const, [] as Prompt[])
66const timingsAtom = atom({ plugin: 'prettier', key: 'timings' } as const, [] as Timing[])
67// Whether a compact row is open, one value per row (memberOf adds its requestId).
68const openAtom = atom({ plugin: 'prettier', key: 'open' } as const, false)
69// Whether a code panel shows `Copied!`, one value per block (`<requestId>:<n>`).
70const copiedAtom = atom({ plugin: 'prettier', key: 'copied' } as const, false)
71const spinAtom = atom({ plugin: 'prettier', key: 'spin' } as const, 0)
72
73const MAX_TURNS = 500
74const MAX_PROMPTS = 300
75const MAX_TIMINGS = 600
76const MAX_CALLS = 100
77const EMPTY_LIVE: Live = { running: [], tools: 0, calls: [] }
78
79let nextId = 0
80let spinner: Timer | undefined
81
82// The spinner's frames turn while the main loop's turn runs.
83function startSpinner($: EngineInterface): void {
84  spinner?.cancel()
85  spinner = $.clock.every(SPIN_MS, () => void update($, spinAtom, n => n + 1))
86}
87
88function stopSpinner(): void {
89  spinner?.cancel()
90  spinner = undefined
91}
92
93async function settingsOf($: EngineInterface): Promise<Settings> {
94  return mergeSettings(await read($, settingsAtom))
95}
96
97async function saveSettings($: EngineInterface, settings: Settings): Promise<void> {
98  await update($, settingsAtom, () => settings)
99  await $.store.set('settings', settings)
100}
101
102// A failed call's error that is folded into its tool row, as an edit's diff is:
103// once its turn is done, in the fullscreen terminal, when the error is text and
104// not a refusal at the permission dialog. Both the tool row and the result row
105// ask here.
106async function foldedFailure(
107  $: EngineInterface,
108  call: { id: string; output: unknown; isErrored: boolean; isRunning: boolean },
109  isFullscreen: boolean,
110): Promise<string | undefined> {
111  if (!isFullscreen || !call.isErrored || call.isRunning || isRejection(call.output)) return undefined
112  const message = errorText(call.output)
113  if (message === undefined) return undefined
114  const isLive = ((await read($, liveAtom)).calls ?? []).some(c => c.id === call.id)
115  return isLive ? undefined : message
116}
117
118// A failed call's error in a red frame as wide as the error, at most `room`.
119function failureFrame(el: Elements['terminal'], tool: string, message: string, room: number): RenderElement {
120  const { Box, Text } = el
121  const title = `✗ ${toolLabel(tool)} failed`
122  return (
123    <Box
124      flexDirection="column"
125      width={frameWidth([title, ...message.split('\n')], room)}
126      flexShrink={0}
127      borderStyle="round"
128      borderColor={RED}
129      paddingX={1}
130    >
131      <Text color={RED} bold>
132        {title}
133      </Text>
134      <Text wrap="wrap">{message}</Text>
135    </Box>
136  )
137}
138
139// The diff of an edit or new file that is folded to one row: once its turn is
140// done, and only where a click can open it again (the fullscreen terminal).
141// The tool row draws that one row and the result row draws nothing, so the
142// two hooks must agree; both ask here.
143async function foldedDiff(
144  $: EngineInterface,
145  call: { tool: string; id: string; output: unknown; isErrored: boolean; isRunning: boolean },
146  isFullscreen: boolean,
147): Promise<EditDiff | undefined> {
148  if (!isFullscreen || call.isErrored || call.isRunning) return undefined
149  const diff = editDiff(call.tool, call.output)
150  if (diff === undefined) return undefined
151  const isLive = ((await read($, liveAtom)).calls ?? []).some(c => c.id === call.id)
152  return isLive ? undefined : diff
153}
154
155// The Copy control of a code panel: the raw code to the clipboard, then
156// `Copied!` for a while.
157// A press while one is still showing its mark is ignored, so one click never
158// puts the text on the clipboard twice.
159const copying = new Set<string>()
160async function copyCode($: EngineInterface, id: string, code: string, surface: RenderSurface): Promise<void> {
161  if (copying.has(id)) return
162  copying.add(id)
163  const done = await $.ui.copy({ text: code, surface })
164  if (!done.isCopied) {
165    copying.delete(id)
166    $.ui.toast(`prettier: the code could not be copied (${done.reason}).`)
167    return
168  }
169  const member = memberOf(copiedAtom, { requestId: id })
170  await update($, member, () => true)
171  $.clock.after(COPIED_MS, () => {
172    copying.delete(id)
173    void update($, member, () => false)
174  })
175}
176
177// Whether a drawing holds Claude Code's own (`{ type: 'engine' }`), as next(e)
178// gives when no mod beneath draws the row. Claude Code refuses a tree that puts
179// its own drawing inside a Box with a width (and then draws its own in place of
180// the whole tree), so such a drawing is never held to a width.
181function hasEngine(node: unknown): boolean {
182  if (Array.isArray(node)) return node.some(hasEngine)
183  if (typeof node !== 'object' || node === null) return false
184  const el = node as { type?: unknown; props?: { children?: unknown } }
185  return el.type === 'engine' || hasEngine(el.props?.children)
186}
187
188async function cwdOf($: EngineInterface): Promise<string> {
189  try {
190    return await $.session.cwd()
191  } catch {
192    // Without the session's folder a path reads as its last parts.
193    return ''
194  }
195}
196
197// One of the three section commands: no arguments lists the section,
198// `name value` pairs change it.
199async function runSection($: EngineInterface, section: Section, args: string): Promise<{ text: string }> {
200  const current = await settingsOf($)
201  if (args.trim() === '') return { text: describeSection(current, section) }
202  const applied = applyArgs(current, section, args)
203  if (applied.changed.length > 0) await saveSettings($, applied.settings)
204  const done = applied.changed.length > 0 ? [`Set ${applied.changed.join(', ')}.`] : []
205  const wrong = applied.errors.map(err => `Not changed: ${err}.`)
206  const off = applied.settings.isEnabled ? [] : ['prettier is off: `/prettier on` to see it.']
207  return { text: [...done, ...wrong, ...off].join('\n') }
208}
209
210export const register: Register = on => {
211  on('session.start', async ($, e, next) => {
212    const stored = await $.store.get('settings')
213    const legacy = await $.store.get('isEnabled')
214    const settings = mergeSettings(stored)
215    if (stored === undefined && typeof legacy === 'boolean') settings.isEnabled = legacy
216    await update($, settingsAtom, () => settings)
217
218    await $.command.register({
219      name: 'prettier',
220      description: 'prettier chat layout: on, off, reset, or list its settings',
221      argumentHint: '[on|off|reset]',
222    })
223    for (const section of Object.keys(COMMANDS) as Section[]) {
224      await $.command.register({
225        name: COMMANDS[section].name,
226        description: COMMANDS[section].description,
227        argumentHint: `[${COMMANDS[section].example}]`,
228      })
229    }
230
231    // Once per machine: say where the details went.
232    if ((await $.store.get('hintShown')) !== true) {
233      $.ui.toast(`prettier: ${EXPAND_HINT} /prettier lists the settings.`, { timeoutMs: 12_000 })
234      await $.store.set('hintShown', true)
235    }
236    return next(e)
237  })
238
239  // ---------------------------------------------------------------- commands
240
241  on('command.run', { command: 'prettier' }, async ($, e) => {
242    const arg = e.args.trim().toLowerCase()
243    const current = await settingsOf($)
244    if (arg === 'on' || arg === 'off') {
245      await saveSettings($, { ...current, isEnabled: arg === 'on' })
246      return { text: arg === 'on' ? 'prettier on.' : "prettier off: the transcript uses Claude Code's own layout." }
247    }
248    if (arg === 'reset') {
249      await saveSettings($, { ...structuredClone(DEFAULTS), isEnabled: current.isEnabled })
250      return { text: 'Every prettier setting is back to its default.' }
251    }
252    if (arg !== '') return { text: `\`${arg}\` is not a choice: \`/prettier on\`, \`/prettier off\` or \`/prettier reset\`.` }
253    return { text: describeAll(current) }
254  })
255
256  const unsaved = { text: 'prettier: the setting could not be saved; nothing changed.' }
257  on('command.run', { command: 'prettier-prompt' }, async ($, e) => runSection($, 'prompt', e.args)).catch(
258    () => unsaved,
259  )
260  on('command.run', { command: 'prettier-response' }, async ($, e) => runSection($, 'response', e.args)).catch(
261    () => unsaved,
262  )
263  on('command.run', { command: 'prettier-code' }, async ($, e) => runSection($, 'code', e.args)).catch(
264    () => unsaved,
265  )
266
267  // ---------------------------------------------------------------- recording
268
269  on('prompt.submit', async ($, e, next) => {
270    const kind = e.origin.kind
271    if ((kind === 'composer' || kind === 'bridge') && e.command === undefined && e.isMeta !== true) {
272      const at = Date.now()
273      await update($, promptsAtom, prompts => [...prompts, { text: e.text, at }].slice(-MAX_PROMPTS))
274    }
275    return next(e)
276  }).catch(($, e, next) => next(e))
277
278  on('turn.start', async ($, e, next) => {
279    if (e.agentId === undefined) {
280      await update($, liveAtom, () => EMPTY_LIVE)
281      startSpinner($)
282    }
283    return next(e)
284  })
285
286  on('tool.call', async ($, e, next) => {
287    if (e.agentId !== undefined) return next(e)
288    nextId += 1
289    const id = e.tool_use_id ?? `prettier-${nextId}`
290    const start = Date.now()
291    const call: Call = { id, tool: toolLabel(e.tool), detail: describeCall(e.tool, e) }
292    await update($, liveAtom, live => ({
293      running: [...live.running, { id, tool: call.tool }],
294      tools: live.tools + 1,
295      calls: [...(live.calls ?? []), call].slice(-MAX_CALLS),
296    }))
297    await update($, timingsAtom, timings => [...timings, { id, start }].slice(-MAX_TIMINGS))
298    let failed = true
299    try {
300      const ran = await next(e)
301      failed = ran.deny !== undefined || ran.isError === true
302      return ran
303    } finally {
304      const end = Date.now()
305      await update($, liveAtom, live => ({
306        ...live,
307        running: live.running.filter(r => r.id !== id),
308        calls: (live.calls ?? []).map(c => (c.id === id ? { ...c, ms: end - start, failed } : c)),
309      }))
310      await update($, timingsAtom, timings => timings.map(t => (t.id === id ? { ...t, end } : t)))
311    }
312  }).catch(($, e, next) => next(e))
313
314  on('turn.complete', async ($, e, next) => {
315    if (e.agentId === undefined) {
316      const live = await read($, liveAtom)
317      const turn: Turn = { durationMs: e.durationMs, tools: live.tools, calls: live.calls ?? [] }
318      await update($, turnsAtom, turns => [...turns, turn].slice(-MAX_TURNS))
319      await update($, liveAtom, () => EMPTY_LIVE)
320      stopSpinner()
321    }
322    return next(e)
323  })
324
325  // ---------------------------------------------------------------- drawing
326  //
327  // LOAD ORDER: prettier must load before any mod that draws these rows itself
328  // without calling next (inkstyle does, for replies and tool rows). Such a mod
329  // ends the chain, so a hook beneath it never runs and the width is lost. List
330  // prettier first in CLAUDE_CODE_PLUGIN_DIRS; see README.md.
331
332  // A user-role row. The person's own prompt is a purple-bordered pill on the right
333  // with its time under it; a row someone else sent (a background task, another
334  // agent, a channel) is a quiet card on the left.
335  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
336    if (e.surface !== 'terminal') return next(e)
337    const settings = await settingsOf($)
338    if (!settings.isEnabled) return next(e)
339    const { Box, Button, Text } = $.ui.resolve({ ...e, surface: 'terminal' as const })
340    const columns = e.viewport?.columns ?? 80
341    const { origin, task, from, text, isExpanded } = e.props
342    const isOwn =
343      origin.kind === 'composer' ||
344      origin.kind === 'bridge' ||
345      (origin.kind === 'unclassified' && task === undefined && from === undefined)
346    // Clicks reach a row only in the fullscreen terminal; elsewhere ctrl+o
347    // (isExpanded) is the one way to see a folded row whole.
348    const canClick = e.viewport?.isFullscreen === true
349    const isOpen = canClick && (await read($, memberOf(openAtom, e)))
350    const toggle = () => update($, memberOf(openAtom, e), v => !v)
351
352    if (isOwn) {
353      const p = settings.prompt
354      const room = Math.max(8, Math.floor((columns * p.width) / 100) - PILL_PAD_LEFT - 2)
355      const folded = capText(text, room, p.lines)
356      const capped = isExpanded || isOpen ? { shown: text, hidden: 0 } : folded
357      const more = `${folded.hidden} more ${folded.hidden === 1 ? 'line' : 'lines'}`
358      const hint =
359        isExpanded || folded.hidden === 0
360          ? undefined
361          : !canClick
362            ? `… ${more} · ${EXPAND_KEY} to see all`
363            : isOpen
364              ? '▾ show less'
365              : `▸ ${more}`
366      const width = pillWidth([...capped.shown.split('\n'), ...(hint === undefined ? [] : [hint])], columns, p.width)
367      const at = p.time ? promptTime(await read($, promptsAtom), text) : undefined
368      const promptCopyId = `${e.requestId}:prompt`
369      const isPromptCopied = canClick && (await read($, memberOf(copiedAtom, { requestId: promptCopyId })))
370      return (
371        <Box flexDirection="column" alignItems="flex-end" width={columns}>
372          <Box
373            flexDirection="column"
374            width={width}
375            flexShrink={0}
376            borderStyle="round"
377            borderColor={PILL_BORDER}
378            paddingLeft={PILL_PAD_LEFT}
379          >
380            <Text wrap="wrap">{capped.shown}</Text>
381            {hint !== undefined &&
382              (canClick ? (
383                <Button plain key="more" onPress={toggle}>
384                  <Text color={PURPLE_LIGHT}>{hint}</Text>
385                </Button>
386              ) : (
387                <Text color={GREY}>{hint}</Text>
388              ))}
389          </Box>
390          {(at !== undefined || canClick) && (
391            <Box flexDirection="row" paddingRight={at !== undefined && canClick ? 0 : 1}>
392              {at !== undefined && <Text color={GREY}>{formatClock(at)}</Text>}
393              {at !== undefined && canClick && <Text color={GREY}>{' '}</Text>}
394              {/* Copies the prompt's own text, nothing else; ✓ for a moment after. */}
395              {canClick && (
396                <Button plain key="copy-prompt" onPress={press => void copyCode($, promptCopyId, text, press.surface)}>
397                  <Text color={isPromptCopied ? GREEN : GREY}>{isPromptCopied ? PROMPT_COPIED : PROMPT_COPY}</Text>
398                </Button>
399              )}
400            </Box>
401          )}
402        </Box>
403      )
404    }
405
406    if (origin.kind === 'auto-continuation' || origin.kind === 'sdk') return next(e)
407    const head = cardHead(origin, task, from)
408    const lines = text.split('\n')
409    const isWhole = isExpanded || isOpen
410    const body = isWhole ? text : lines[0] ?? ''
411    const canFold = !isExpanded && lines.length > 1
412    return (
413      <Box flexDirection="column" width={share(columns, settings.response.width)}>
414        <Box flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={CARD_BORDER} paddingX={1}>
415          <Text>
416            <Text color={head.color}>{`${head.icon} ${head.label}`}</Text>
417            {head.meta !== '' && <Text color={GREY}>{` · ${head.meta}`}</Text>}
418          </Text>
419          {body !== '' && (
420            <Text color={GREY} wrap={isWhole ? 'wrap' : 'truncate-end'}>
421              {body}
422            </Text>
423          )}
424          {canFold &&
425            (canClick ? (
426              <Button plain key="more" onPress={toggle}>
427                <Text color={PURPLE_LIGHT}>{isOpen ? '▾ show less' : '▸ read all'}</Text>
428              </Button>
429            ) : (
430              <Text color={GREY}>{`${EXPAND_KEY} to read all`}</Text>
431            ))}
432        </Box>
433      </Box>
434    )
435  })
436
437  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
438    if (e.surface !== 'terminal') return next(e)
439    const settings = await settingsOf($)
440    if (!settings.isEnabled) return next(e)
441    const { Box, Button, Code, Text } = $.ui.resolve({ ...e, surface: 'terminal' as const })
442    const width = share(e.viewport?.columns ?? 80, settings.response.width)
443    const parts = splitFences(e.props.text)
444    if (!parts.some(p => p.kind === 'code')) {
445      const inner = await next(e)
446      if (hasEngine(inner)) return inner
447      return (
448        <Box flexDirection="column" width={width}>
449          {inner}
450        </Box>
451      )
452    }
453
454    // Prose goes on to the mods beneath (inkstyle's colours), one piece at a
455    // time; each fenced block is a filled panel of prettier's own.
456    const canClick = e.viewport?.isFullscreen === true
457    const room = width - ROW_GUTTER
458    const drawn = []
459    let block = 0
460    let isFirst = e.props.isFirstOfReply
461    for (const part of parts) {
462      if (part.kind === 'prose') {
463        drawn.push(await next({ ...e, props: { ...e.props, text: part.text, isFirstOfReply: isFirst } }))
464        isFirst = false
465        continue
466      }
467      const id = `${e.requestId}:${block}`
468      block += 1
469      const label = panelLabel(part.language)
470      const isCopied = canClick && (await read($, memberOf(copiedAtom, { requestId: id })))
471      const code = part.code
472      drawn.push(
473        <Box key={id} marginLeft={ROW_GUTTER} marginTop={isFirst ? 0 : 1} flexDirection="column">
474          <Box
475            flexDirection="column"
476            width={panelWidth(code, label, room)}
477            flexShrink={0}
478            borderStyle="round"
479            borderColor={PANEL_BORDER}
480            backgroundColor={PANEL_FILL}
481            paddingX={PANEL_PAD_X}
482          >
483            <Box flexDirection="row" justifyContent="space-between">
484              <Text color={PANEL_LABEL} backgroundColor={PANEL_CHIP}>{` ${label} `}</Text>
485              {canClick ? (
486                <Button plain key={`copy-${block}`} onPress={press => void copyCode($, id, code, press.surface)}>
487                  <Text color={isCopied ? GREEN : PANEL_LABEL} backgroundColor={PANEL_FILL}>
488                    {isCopied ? COPIED_LABEL : COPY_LABEL}
489                  </Text>
490                </Button>
491              ) : (
492                <Text backgroundColor={PANEL_FILL}> </Text>
493              )}
494            </Box>
495            <Code source={untab(code)} language={part.language === '' ? undefined : part.language} />
496          </Box>
497        </Box>,
498      )
499      isFirst = false
500    }
501    return (
502      <Box flexDirection="column" width={hasEngine(drawn) ? undefined : width}>
503        {drawn}
504      </Box>
505    )
506  })
507
508  // A tool call's row. A finished edit or new file is one row of its own,
509  // `▸ Edit hooks/app.ts +12 −3`, that a click opens to its diff and closes;
510  // its result row then draws nothing. Any other row is the one beneath
511  // (inkstyle's or the engine's), held to the response width, with an edit's
512  // `+12 −3` after it.
513  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
514    if (e.surface !== 'terminal') return next(e)
515    const settings = await settingsOf($)
516    if (!settings.isEnabled) return next(e)
517    const el = $.ui.resolve({ ...e, surface: 'terminal' as const })
518    const { Box, Button, Code, Text } = el
519    const columns = e.viewport?.columns ?? 80
520    const { tool, tool_use_id, output, isErrored, isRunning, isInterrupted } = e.props
521    const isFullscreen = e.viewport?.isFullscreen === true
522
523    // A finished failed call: one row, `▸ Edit app.ts · ✗ failed`, that a click
524    // opens to its red frame and closes; its result row then draws nothing.
525    const failure = isInterrupted
526      ? undefined
527      : await foldedFailure($, { id: tool_use_id, output, isErrored, isRunning }, isFullscreen)
528    if (failure !== undefined) {
529      const width = share(columns, settings.response.width)
530      const isOpen = await read($, memberOf(openAtom, e))
531      const input = (e.props.input ?? {}) as Record<string, unknown>
532      const path = typeof input.file_path === 'string' ? input.file_path : undefined
533      const detail = path === undefined ? describeCall(tool, e.props.input) : shortPath(path, await cwdOf($))
534      return (
535        <Box flexDirection="column" width={width}>
536          <Box flexDirection="row">
537            <Box width={ROW_GUTTER} flexShrink={0}>
538              <Text color={PURPLE}>{isOpen ? '▾' : '▸'}</Text>
539            </Box>
540            <Button plain key="failure" onPress={() => update($, memberOf(openAtom, e), v => !v)}>
541              <Text color={GREY}>{`${toolLabel(tool)} `}</Text>
542              <Text color={PURPLE}>{detail}</Text>
543              <Text color={RED}>{' · ✗ failed'}</Text>
544            </Button>
545          </Box>
546          {isOpen && <Box marginLeft={ROW_GUTTER}>{failureFrame(el, tool, failure, width - ROW_GUTTER)}</Box>}
547        </Box>
548      )
549    }
550
551    const diff = isInterrupted
552      ? undefined
553      : await foldedDiff($, { tool, id: tool_use_id, output, isErrored, isRunning }, isFullscreen)
554    if (diff === undefined) {
555      const inner = await next(e)
556      if (hasEngine(inner)) return inner
557      // A failed edit changed nothing, so it has no count.
558      const delta = isErrored ? undefined : inputDelta(tool, e.props.input)
559      return (
560        <Box flexDirection="row" width={share(columns, settings.response.width)}>
561          <Box flexDirection="column" flexShrink={1}>
562            {inner}
563          </Box>
564          {delta !== undefined && (
565            <Box flexShrink={0}>
566              <Text>
567                <Text color={GREEN}>{` +${delta.added}`}</Text>
568                <Text color={RED}>{` −${delta.removed}`}</Text>
569              </Text>
570            </Box>
571          )}
572        </Box>
573      )
574    }
575    const isOpen = await read($, memberOf(openAtom, e))
576    const name = diff.path === undefined ? 'file' : shortPath(diff.path, await cwdOf($))
577    return (
578      <Box flexDirection="column" width={share(columns, settings.code.width)}>
579        <Box flexDirection="row">
580          <Box width={ROW_GUTTER} flexShrink={0}>
581            <Text color={PURPLE}>{isOpen ? '▾' : '▸'}</Text>
582          </Box>
583          <Button plain key="diff" onPress={() => update($, memberOf(openAtom, e), v => !v)}>
584            <Text color={GREY}>{diff.isNew ? 'Created ' : `${toolLabel(tool)} `}</Text>
585            <Text color={PURPLE}>{name}</Text>
586            <Text color={GREEN}>{` +${diff.added}`}</Text>
587            <Text color={RED}>{` −${diff.removed}`}</Text>
588          </Button>
589        </Box>
590        {isOpen && (
591          <Box marginLeft={ROW_GUTTER}>
592            <Code source={diff.source} format="diff" path={diff.path} />
593          </Box>
594        )}
595      </Box>
596    )
597  })
598
599  // A run of reads, searches or commands, folded to one line. A click on it (fullscreen)
600  // opens the engine's rows under it and closes them again; ctrl+o, or
601  // `fold off`, shows the engine's rows as they are.
602  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
603    if (e.surface !== 'terminal') return next(e)
604    const settings = await settingsOf($)
605    if (!settings.isEnabled) return next(e)
606    const { Box, Button, Text } = $.ui.resolve({ ...e, surface: 'terminal' as const })
607    const width = share(e.viewport?.columns ?? 80, settings.response.width)
608    if (!settings.response.fold || e.props.isExpanded) {
609      const inner = await next(e)
610      if (hasEngine(inner)) return inner
611      return (
612        <Box flexDirection="column" width={width}>
613          {inner}
614        </Box>
615      )
616    }
617    const canClick = e.viewport?.isFullscreen === true
618    const isOpen = canClick && (await read($, memberOf(openAtom, e)))
619    const calls = e.props.calls
620    const isRunning = calls.some(c => c.isRunning)
621    const failed = calls.filter(c => c.isErrored).length
622    const took = isRunning ? undefined : groupDuration(calls.map(c => c.tool_use_id), await read($, timingsAtom))
623    const mark = isRunning ? '○' : !canClick ? '▸' : isOpen ? '▾' : '▸'
624    const line = (
625      <Text wrap="wrap">
626        <Text>{groupSummary(calls.map(c => c.tool))}</Text>
627        {took !== undefined && <Text color={GREY}>{` · ${formatShort(took)}`}</Text>}
628        {failed > 0 && <Text color={RED}>{` · ✗ ${failed} failed`}</Text>}
629      </Text>
630    )
631    const head = (
632      <Box flexDirection="row">
633        <Box width={ROW_GUTTER} flexShrink={0}>
634          <Text color={isRunning ? PURPLE_LIGHT : PURPLE}>{mark}</Text>
635        </Box>
636        {canClick ? (
637          <Button plain key="group" onPress={() => update($, memberOf(openAtom, e), v => !v)}>
638            {line}
639          </Button>
640        ) : (
641          line
642        )}
643      </Box>
644    )
645    if (!isOpen) {
646      return (
647        <Box flexDirection="column" width={width}>
648          {head}
649        </Box>
650      )
651    }
652    // Opened: prettier draws each call on a filled block, the whole command and
653    // what it gave back; a click anywhere on a block closes the group again.
654    // (Claude Code's own rows would answer a click themselves, not close it.)
655    const timings = await read($, timingsAtom)
656    const close = () => update($, memberOf(openAtom, e), () => false)
657    return (
658      <Box flexDirection="column" width={width}>
659        {head}
660        {calls.map((c, i) => {
661          const lines = callOutput(c.tool, c.output).split('\n')
662          const shown = lines.slice(0, CALL_OUTPUT_LINES).join('\n')
663          const hidden = Math.max(0, lines.length - CALL_OUTPUT_LINES)
664          const timing = timings.find(t => t.id === c.tool_use_id)
665          const ms = timing?.end === undefined ? undefined : timing.end - timing.start
666          const mark = c.isRunning ? '○' : c.isErrored ? '✗' : '✓'
667          const markColor = c.isRunning ? PURPLE_LIGHT : c.isErrored ? RED : GREEN
668          return (
669            <Box
670              key={`call-${i}`}
671              flexDirection="column"
672              marginLeft={ROW_GUTTER}
673              marginTop={i === 0 ? 0 : 1}
674              backgroundColor={CALL_FILL}
675              paddingX={1}
676            >
677              <Button plain key={`call-${i}`} onPress={close}>
678                <Text color={markColor}>{`${mark} `}</Text>
679                <Text color={PURPLE} bold>
680                  {toolLabel(c.tool)}
681                </Text>
682                <Text color={GREY}>{` ${describeCallWhole(c.tool, c.input)}`}</Text>
683                {ms !== undefined && <Text color={GREY}>{` · ${formatShort(ms)}`}</Text>}
684                {shown !== '' && <Text>{`\n${shown}`}</Text>}
685                {hidden > 0 && <Text color={GREY}>{`\n… +${hidden} lines · ${EXPAND_KEY} to see all`}</Text>}
686              </Button>
687            </Box>
688          )
689        })}
690      </Box>
691    )
692  })
693
694  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
695    if (e.surface !== 'terminal') return next(e)
696    const settings = await settingsOf($)
697    if (!settings.isEnabled) return next(e)
698    const { Box } = $.ui.resolve({ ...e, surface: 'terminal' as const })
699    const inner = await next(e)
700    if (hasEngine(inner)) return inner
701    return (
702      <Box flexDirection="column" width={share(e.viewport?.columns ?? 80, settings.response.width)}>
703        {inner}
704      </Box>
705    )
706  })
707
708  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
709    if (e.surface !== 'terminal') return next(e)
710    const settings = await settingsOf($)
711    if (!settings.isEnabled) return next(e)
712    const { Box } = $.ui.resolve({ ...e, surface: 'terminal' as const })
713    const inner = await next(e)
714    if (hasEngine(inner)) return inner
715    return (
716      <Box flexDirection="column" width={share(e.viewport?.columns ?? 80, settings.response.width)}>
717        {inner}
718      </Box>
719    )
720  })
721
722  // A tool's result. An edit's or new file's diff is drawn here at the code
723  // width (its `+12 −3` is on the tool row above); a failure in a red frame;
724  // anything else is the engine's drawing at the response width.
725  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
726    if (e.surface !== 'terminal') return next(e)
727    const settings = await settingsOf($)
728    if (!settings.isEnabled) return next(e)
729    const el = $.ui.resolve({ ...e, surface: 'terminal' as const })
730    const { Box, Code, Text } = el
731    const columns = e.viewport?.columns ?? 80
732    const width = share(columns, settings.response.width)
733    const { output, isErrored, tool } = e.props
734
735    if (isErrored && !isRejection(output)) {
736      // Folded into its tool row once the turn is done: drawn there, not here.
737      const folded = await foldedFailure(
738        $,
739        { id: e.props.tool_use_id, output, isErrored, isRunning: false },
740        e.viewport?.isFullscreen === true,
741      )
742      if (folded !== undefined) return <Box />
743      const message = errorText(output)
744      if (message !== undefined) {
745        return (
746          <Box flexDirection="row" width={width}>
747            <Box width={RESULT_GUTTER.length} flexShrink={0}>
748              <Text dimColor>{RESULT_GUTTER}</Text>
749            </Box>
750            {failureFrame(el, tool, message, width - RESULT_GUTTER.length)}
751          </Box>
752        )
753      }
754      const inner = await next(e)
755      if (hasEngine(inner)) return inner
756      return (
757        <Box flexDirection="row" width={width}>
758          <Box width={RESULT_GUTTER.length} flexShrink={0}>
759            <Text dimColor>{RESULT_GUTTER}</Text>
760          </Box>
761          <Box flexDirection="column" flexShrink={1} borderStyle="round" borderColor={RED} paddingX={1}>
762            <Text color={RED} bold>{`✗ ${toolLabel(tool)} failed`}</Text>
763            {inner}
764          </Box>
765        </Box>
766      )
767    }
768
769    const diff = isErrored ? undefined : editDiff(tool, output)
770    if (diff === undefined) {
771      const inner = await next(e)
772      if (hasEngine(inner)) return inner
773      return (
774        <Box flexDirection="column" width={width}>
775          {inner}
776        </Box>
777      )
778    }
779
780    // A finished edit is one row, drawn by the tool row's hook above: its
781    // result draws nothing. While its turn runs (or where no click could open
782    // it again) the diff shows whole here.
783    const folded = await foldedDiff(
784      $,
785      { tool, id: e.props.tool_use_id, output, isErrored, isRunning: false },
786      e.viewport?.isFullscreen === true,
787    )
788    if (folded !== undefined) return <Box />
789    return (
790      <Box flexDirection="row" width={share(columns, settings.code.width)}>
791        <Box width={RESULT_GUTTER.length} flexShrink={0}>
792          <Text dimColor>{RESULT_GUTTER}</Text>
793        </Box>
794        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
795          <Code source={diff.source} format="diff" path={diff.path} />
796        </Box>
797      </Box>
798    )
799  })
800
801  // The line closing a turn, under the reply's text: `39s · ▸ 2 tools`, the
802  // length dim like a prompt's time, the count in purple. A click on the count
803  // (fullscreen) lists the turn's calls under it, and closes the list again.
804  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
805    if (e.surface !== 'terminal') return next(e)
806    const settings = await settingsOf($)
807    if (!settings.isEnabled) return next(e)
808    const { Box, Button, Text } = $.ui.resolve({ ...e, surface: 'terminal' as const })
809    const turn = matchTurn(await read($, turnsAtom), e.props.durationMs)
810    const { took, used } = footerParts(e.props.durationMs, turn?.tools)
811    const calls = turn?.calls ?? []
812    const canClick = e.viewport?.isFullscreen === true && calls.length > 0
813    const isOpen = canClick && (await read($, memberOf(openAtom, e)))
814    const room = Math.max(10, share(e.viewport?.columns ?? 80, settings.response.width) - ROW_GUTTER - 4)
815    return (
816      <Box flexDirection="column" marginLeft={ROW_GUTTER}>
817        <Box flexDirection="row">
818          <Text color={GREY}>{took}</Text>
819          {used !== undefined && <Text color={GREY}>{' · '}</Text>}
820          {used !== undefined &&
821            (canClick ? (
822              <Button plain key="tools" onPress={() => update($, memberOf(openAtom, e), v => !v)}>
823                <Text color={PURPLE_LIGHT}>{`${isOpen ? '▾' : '▸'} ${used}`}</Text>
824              </Button>
825            ) : (
826              <Text color={PURPLE_LIGHT}>{used}</Text>
827            ))}
828        </Box>
829        {isOpen &&
830          calls.map(c => (
831            <Box key={c.id} flexDirection="row" marginLeft={2}>
832              <Box width={2} flexShrink={0}>
833                <Text color={c.failed === true ? RED : GREEN}>{c.failed === true ? '✗' : '✓'}</Text>
834              </Box>
835              <Text wrap="truncate-end">
836                <Text color={PURPLE_LIGHT}>{c.tool}</Text>
837                {c.detail !== '' && <Text color={GREY}>{` ${c.detail.slice(0, room)}`}</Text>}
838                {c.ms !== undefined && <Text color={GREY}>{` · ${formatShort(c.ms)}`}</Text>}
839              </Text>
840            </Box>
841          ))}
842      </Box>
843    )
844  })
845
846  // The line animated while a turn runs, drawn whole by prettier: a purple
847  // braille frame and the engine's word, or the tool running now
848  // (`⠋ ▸ running Bash…`). No time or tokens: those are in the footer.
849  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
850    if (e.surface !== 'terminal') return next(e)
851    const settings = await settingsOf($)
852    if (!settings.isEnabled) return next(e)
853    const { Text } = $.ui.resolve({ ...e, surface: 'terminal' as const })
854    const { running } = await read($, liveAtom)
855    const frame = SPINNER_FRAMES[(await read($, spinAtom)) % SPINNER_FRAMES.length]
856    return <Text color={PURPLE}>{`${frame} ${spinnerText(e.props, running[running.length - 1]?.tool)}`}</Text>
857  })
858}
859
hooks/layout.ts 481 lines
1import type { Prompt, Timing, Turn } from '../types'
2
3// inkstyle's palette, so the two mods read as one; prettier's own dim fills.
4export const PURPLE = '#A855F7'
5export const PURPLE_LIGHT = '#C4B5FD'
6export const GREY = '#71717A'
7export const RED = '#EF4444'
8export const GREEN = '#22C55E'
9export const CARD_BORDER = '#3F3F46'
10
11// The prompt pill's rounded border: inkstyle's deep structure purple.
12export const PILL_BORDER = '#7C3AED'
13
14// One space inside the pill on the left; the text runs to the border on the right.
15export const PILL_PAD_LEFT = 1
16export const RESULT_GUTTER = '  ⎿  '
17export const ROW_GUTTER = 2
18export const EXPAND_KEY = 'ctrl+o'
19
20// A fenced code block's panel: charcoal fill, thin violet border, lavender label.
21export const PANEL_FILL = '#111114'
22export const PANEL_BORDER = '#8B7CBD'
23export const PANEL_LABEL = '#B9A7E8'
24// The language chip's fill, a shade lighter than the panel's.
25export const PANEL_CHIP = '#2E2A3A'
26export const PANEL_PAD_X = 1
27export const COPY_LABEL = '⧉ Copy'
28export const COPIED_LABEL = 'Copied!'
29export const COPIED_MS = 3500
30
31// The copy mark beside a prompt's time, and what it reads just after a copy.
32// A space each side: terminals draw ⧉ wider than its one cell, so the hover
33// fill needs the cells around it to cover the whole glyph.
34export const PROMPT_COPY = ' ⧉ '
35export const PROMPT_COPIED = ' ✓ '
36
37// An opened group: each call on a filled block, its output up to this many lines.
38export const CALL_FILL = '#1C1B22'
39export const CALL_OUTPUT_LINES = 40
40
41// ---------------------------------------------------------------- measuring
42
43export function cellWidth(text: string): number {
44  let n = 0
45  for (const ch of text) {
46    const code = ch.codePointAt(0) ?? 0
47    // Wide East Asian characters and most emoji take two cells.
48    n += (code >= 0x1100 && code <= 0x115f) || (code >= 0x2e80 && code <= 0xa4cf) ||
49      (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) ||
50      (code >= 0xfe30 && code <= 0xfe4f) || (code >= 0xff00 && code <= 0xff60) ||
51      (code >= 0x1f300 && code <= 0x1faff)
52      ? 2
53      : 1
54  }
55  return n
56}
57
58// `percent` of the terminal, in whole cells, never under 20.
59export function share(columns: number, percent: number): number {
60  return Math.max(20, Math.floor((columns * percent) / 100))
61}
62
63// The pill's outer width: its widest line plus the left space and border, no wider
64// than `percent` of the terminal.
65export function pillWidth(lines: readonly string[], columns: number, percent: number): number {
66  const widest = Math.max(1, ...lines.map(cellWidth))
67  return Math.min(widest + PILL_PAD_LEFT + 2, Math.max(12, Math.floor((columns * percent) / 100)))
68}
69
70// ---------------------------------------------------------------- an edit's count
71
72// Lines added and removed, after the lines both sides share at either end.
73function lineDelta(before: string, after: string): { added: number; removed: number } {
74  const a = before === '' ? [] : before.split('\n')
75  const b = after === '' ? [] : after.split('\n')
76  let head = 0
77  while (head < a.length && head < b.length && a[head] === b[head]) head += 1
78  let tail = 0
79  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) {
80    tail += 1
81  }
82  return { added: b.length - head - tail, removed: a.length - head - tail }
83}
84
85// The `+12 −3` of an Edit or Write call, read from its input so it shows while
86// the call runs; undefined for other tools and for a change of no lines.
87export function inputDelta(tool: string, input: unknown): { added: number; removed: number } | undefined {
88  const fields = (input !== null && typeof input === 'object' ? input : {}) as Record<string, unknown>
89  const text = (key: string) => (typeof fields[key] === 'string' ? (fields[key] as string) : '')
90  const delta =
91    tool === 'Edit'
92      ? lineDelta(text('old_string'), text('new_string'))
93      : tool === 'Write'
94        ? { added: text('content') === '' ? 0 : text('content').split('\n').length, removed: 0 }
95        : undefined
96  return delta === undefined || (delta.added === 0 && delta.removed === 0) ? undefined : delta
97}
98
99// ---------------------------------------------------------------- spinner
100
101export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
102export const SPIN_MS = 160
103
104// The spinner's words: the tool running now, else the engine's message or
105// word; one ellipsis after, unless the text already ends in one.
106export function spinnerText(props: { word: string; message: string | null; suffix: string }, tool?: string): string {
107  const text = tool !== undefined ? `▸ running ${tool}` : props.message ?? props.word
108  return text.endsWith('…') ? text : `${text}${props.suffix}`
109}
110
111// ---------------------------------------------------------------- code panels
112
113// A reply cut at its fenced code blocks: prose between them, and each block's
114// language (empty when unlabeled) and raw code. A fence still open at the end
115// (a reply being written) runs to the end.
116export type ReplyPart = { kind: 'prose'; text: string } | { kind: 'code'; language: string; code: string }
117
118const FENCE_OPEN = /^([ \t]*)(`{3,}|~{3,})[ \t]*([^`\s]*)[^`]*$/
119
120export function splitFences(text: string): ReplyPart[] {
121  const parts: ReplyPart[] = []
122  const lines = text.split('\n')
123  let prose: string[] = []
124  const flushProse = () => {
125    const joined = prose.join('\n')
126    if (joined.trim() !== '') parts.push({ kind: 'prose', text: joined.replace(/^\n+|\n+$/g, '') })
127    prose = []
128  }
129  for (let i = 0; i < lines.length; i++) {
130    const open = FENCE_OPEN.exec(lines[i] ?? '')
131    if (open === null) {
132      prose.push(lines[i] ?? '')
133      continue
134    }
135    flushProse()
136    const indent = (open[1] ?? '').length
137    const fence = open[2] ?? '```'
138    const close = new RegExp(`^[ \\t]*${fence[0] === '`' ? '`' : '~'}{${fence.length},}[ \\t]*$`)
139    const code: string[] = []
140    i++
141    while (i < lines.length && !close.test(lines[i] ?? '')) {
142      // Drop the fence's own indent from each line (a block inside a list item).
143      const line = lines[i] ?? ''
144      const lead = /^[ \t]*/.exec(line)?.[0].length ?? 0
145      code.push(line.slice(Math.min(lead, indent)))
146      i++
147    }
148    parts.push({ kind: 'code', language: open[3] ?? '', code: code.join('\n') })
149  }
150  flushProse()
151  return parts
152}
153
154// The label in the panel's top border.
155export function panelLabel(language: string): string {
156  return language === '' ? 'code' : language.toLowerCase()
157}
158
159// Tabs as four spaces, so what is measured is what is drawn.
160export function untab(code: string): string {
161  return code.replace(/\t/g, '    ')
162}
163
164// The panel's outer width: the longest line, or the top row (the language chip,
165// a space, the Copy control) when wider, plus padding and border; never more
166// than `room`.
167export function panelWidth(code: string, label: string, room: number): number {
168  const widest = Math.max(0, ...untab(code).split('\n').map(cellWidth))
169  const controls = cellWidth(label) + 2 + 1 + Math.max(cellWidth(COPY_LABEL), cellWidth(COPIED_LABEL))
170  const inner = Math.max(widest, controls) + 2 * PANEL_PAD_X
171  return Math.max(12, Math.min(inner + 2, room))
172}
173
174// ---------------------------------------------------------------- folding a prompt
175
176export type Capped = { shown: string; hidden: number }
177
178// The head of `text` that fits in `rows` rows of `room` cells, and how many of
179// its lines are left out (a line cut part-way counts as left out).
180export function capText(text: string, room: number, rows: number): Capped {
181  const lines = text.replace(/\r\n?/g, '\n').split('\n')
182  const kept: string[] = []
183  let used = 0
184  for (let i = 0; i < lines.length; i++) {
185    const line = lines[i] ?? ''
186    const need = Math.max(1, Math.ceil(cellWidth(line) / Math.max(1, room)))
187    if (used + need <= rows) {
188      kept.push(line)
189      used += need
190      continue
191    }
192    const left = rows - used
193    if (left > 0) {
194      // Fill the rows that are left with the head of this line.
195      let cut = ''
196      for (const ch of line) {
197        if (cellWidth(cut + ch) > left * room - 1) break
198        cut += ch
199      }
200      kept.push(`${cut}…`)
201    }
202    return { shown: kept.join('\n'), hidden: lines.length - i }
203  }
204  return { shown: text, hidden: 0 }
205}
206
207// ---------------------------------------------------------------- diffs
208
209type Hunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
210export type EditDiff = { path: string | undefined; source: string; added: number; removed: number; isNew: boolean }
211
212const plural = (n: number, word: string, many = `${word}s`) => `${n} ${n === 1 ? word : many}`
213
214// An Edit's or Write's result as unified-diff hunks: a new file's whole
215// content as added lines. Undefined when it carries none (an unchanged file),
216// which the engine then draws.
217export function editDiff(tool: string, output: unknown): EditDiff | undefined {
218  if (tool !== 'Edit' && tool !== 'Write') return undefined
219  if (output === null || typeof output !== 'object') return undefined
220  const out = output as { structuredPatch?: unknown; filePath?: unknown; type?: unknown; content?: unknown }
221  const path = typeof out.filePath === 'string' ? out.filePath : undefined
222  if (tool === 'Write' && out.type === 'create' && typeof out.content === 'string') {
223    const lines = out.content.replace(/\r\n?/g, '\n').replace(/\n$/, '').split('\n')
224    const source = `@@ -0,0 +1,${lines.length} @@\n${lines.map(l => `+${l}`).join('\n')}`
225    return { path, source, added: lines.length, removed: 0, isNew: true }
226  }
227  if (!Array.isArray(out.structuredPatch) || out.structuredPatch.length === 0) return undefined
228  const hunks = out.structuredPatch as Hunk[]
229  let added = 0
230  let removed = 0
231  const parts = hunks.map(h => {
232    for (const line of h.lines) {
233      if (line.startsWith('+')) added += 1
234      else if (line.startsWith('-')) removed += 1
235    }
236    return `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n${h.lines.join('\n')}`
237  })
238  return { path, source: parts.join('\n'), added, removed, isNew: false }
239}
240
241// A file's path relative to the session's folder when inside it, else its
242// last three parts.
243export function shortPath(path: string, cwd: string): string {
244  const p = path.replace(/\\/g, '/')
245  const root = cwd.replace(/\\/g, '/').replace(/\/$/, '')
246  if (p.toLowerCase().startsWith(`${root.toLowerCase()}/`)) return p.slice(root.length + 1)
247  const parts = p.split('/').filter(s => s !== '')
248  return parts.length > 3 ? `…/${parts.slice(-3).join('/')}` : p
249}
250
251// As the engine sums a diff up: `Added 3 lines, removed 1 line`.
252export function diffSummary(added: number, removed: number): string {
253  if (added > 0 && removed > 0) return `Added ${plural(added, 'line')}, removed ${plural(removed, 'line')}`
254  if (added > 0) return `Added ${plural(added, 'line')}`
255  if (removed > 0) return `Removed ${plural(removed, 'line')}`
256  return 'No lines changed'
257}
258
259// ---------------------------------------------------------------- errors
260
261// A refusal at the permission dialog is an error to the model, not a failure
262// to show in red: the engine draws those itself.
263export function isRejection(output: unknown): boolean {
264  return typeof output === 'string' && /user (doesn't|does not) want|rejected|was denied|permission denied by the user/i.test(output)
265}
266
267// A failed call's error as plain words: Claude Code's <tool_use_error> wrapper
268// dropped, tabs as spaces, no carriage returns. Undefined when it is no text.
269export function errorText(output: unknown): string | undefined {
270  if (typeof output !== 'string') return undefined
271  const text = output.replace(/<\/?tool_use_error>/g, '').replace(/\r/g, '').replace(/\t/g, '    ').trim()
272  return text === '' ? undefined : text
273}
274
275// The red frame's outer width: its widest line (the title or the error) plus a
276// space and a border each side, never more than `room`.
277export function frameWidth(lines: readonly string[], room: number): number {
278  const widest = Math.max(1, ...lines.map(cellWidth))
279  return Math.max(12, Math.min(widest + 4, room))
280}
281
282// ---------------------------------------------------------------- times
283
284// As the engine formats a turn's length: `3s`, `1m 4s`, `1h 2m`.
285export function formatDuration(ms: number): string {
286  const total = Math.max(0, Math.round(ms / 1000))
287  const h = Math.floor(total / 3600)
288  const m = Math.floor((total % 3600) / 60)
289  const s = total % 60
290  if (h > 0) return `${h}h ${m}m`
291  if (m > 0) return `${m}m ${s}s`
292  return `${s}s`
293}
294
295// A short duration for a tool run: `0.4s` under ten seconds, else as above.
296export function formatShort(ms: number): string {
297  return ms < 10_000 ? `${(Math.max(0, ms) / 1000).toFixed(1)}s` : formatDuration(ms)
298}
299
300// The local wall-clock time, `14:02`.
301export function formatClock(at: number): string {
302  const d = new Date(at)
303  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
304}
305
306// How far apart turn.complete's duration and the footer's may be and still match.
307const MATCH_SLACK_MS = 2000
308
309// The recorded turn whose length is nearest the footer's, newest first on a tie.
310export function matchTurn(turns: readonly Turn[], durationMs: number): Turn | undefined {
311  let best: Turn | undefined
312  for (let i = turns.length - 1; i >= 0; i--) {
313    const t = turns[i]
314    if (t === undefined) continue
315    const gap = Math.abs(t.durationMs - durationMs)
316    if (gap <= MATCH_SLACK_MS && (best === undefined || gap < Math.abs(best.durationMs - durationMs))) best = t
317  }
318  return best
319}
320
321// The closing line's two parts, `39s` and `2 tools`; no count where none is known.
322export function footerParts(durationMs: number, tools: number | undefined): { took: string; used?: string } {
323  const took = formatDuration(durationMs)
324  return tools === undefined || tools === 0 ? { took } : { took, used: plural(tools, 'tool') }
325}
326
327// When the person sent this prompt: the newest record with the same words.
328export function promptTime(prompts: readonly Prompt[], text: string): number | undefined {
329  const want = text.trim()
330  for (let i = prompts.length - 1; i >= 0; i--) {
331    const p = prompts[i]
332    if (p !== undefined && p.text.trim() === want) return p.at
333  }
334  return undefined
335}
336
337// ---------------------------------------------------------------- tool groups
338
339// What a tool call worked on, in one short line: a command's first line, a
340// file's last two path parts, a pattern, a URL, a query.
341export function describeCall(tool: string, input: unknown): string {
342  const flat = callText(tool, input, false).replace(/\s+/g, ' ').trim()
343  return flat.length > 80 ? `${flat.slice(0, 79)}…` : flat
344}
345
346// What a tool call worked on, whole: a command with all its lines, a file's
347// full path, a pattern, a URL, a query.
348export function describeCallWhole(tool: string, input: unknown): string {
349  const text = callText(tool, input, true)
350  return tool === 'Bash' || tool === 'PowerShell' ? text.trim() : text.replace(/\s+/g, ' ').trim()
351}
352
353function callText(tool: string, input: unknown, isWhole: boolean): string {
354  const i = (input !== null && typeof input === 'object' ? input : {}) as Record<string, unknown>
355  const str = (k: string) => (typeof i[k] === 'string' && i[k] !== '' ? (i[k] as string) : undefined)
356  const short = (p: string) => p.replace(/\\/g, '/').split('/').filter(s => s !== '').slice(-2).join('/')
357  const path = str('file_path') ?? str('notebook_path') ?? str('path')
358  let text: string
359  switch (tool) {
360    case 'Bash':
361    case 'PowerShell':
362      text = isWhole ? str('command') ?? '' : (str('command') ?? '').split('\n')[0] ?? ''
363      break
364    case 'Read':
365    case 'Edit':
366    case 'Write':
367    case 'NotebookEdit':
368      text = path === undefined ? '' : isWhole ? path.replace(/\\/g, '/') : short(path)
369      break
370    case 'Grep':
371    case 'Glob':
372      text = str('pattern') ?? ''
373      break
374    case 'WebFetch':
375      text = (str('url') ?? '').replace(/^https?:\/\//, '')
376      break
377    case 'WebSearch':
378      text = str('query') ?? ''
379      break
380    case 'Agent':
381      text = str('description') ?? ''
382      break
383    case 'Skill':
384      text = str('skill') ?? ''
385      break
386    default: {
387      const first = Object.entries(i).find(([k, v]) => typeof v === 'string' && k !== 'tool' && k !== 'tool_use_id')
388      text = typeof first?.[1] === 'string' ? first[1] : ''
389    }
390  }
391  return text
392}
393
394// What a finished call gave back, as text to show under it in an opened
395// group: a command's output, the matches of a search, a read's line count.
396export function callOutput(tool: string, output: unknown): string {
397  if (typeof output === 'string') return output.trim()
398  if (output === null || typeof output !== 'object') return ''
399  const o = output as Record<string, unknown>
400  const text = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
401  if (tool === 'Bash' || tool === 'PowerShell') return [text(o.stdout), text(o.stderr)].filter(t => t !== '').join('\n')
402  if (tool === 'Read') {
403    const file = o.file as Record<string, unknown> | undefined
404    return typeof file?.numLines === 'number' ? `${file.numLines} ${file.numLines === 1 ? 'line' : 'lines'}` : ''
405  }
406  if (text(o.content) !== '') return text(o.content)
407  if (Array.isArray(o.filenames)) return o.filenames.filter(f => typeof f === 'string').join('\n')
408  return ''
409}
410
411// `mcp__server__tool` reads as its tool's name.
412export function toolLabel(tool: string): string {
413  const mcp = /^mcp__.+?__(.+)$/.exec(tool)
414  return mcp?.[1] ?? tool
415}
416
417const VERBS: Record<string, { verb: string; one: string; many: string }> = {
418  Read: { verb: 'Read', one: 'file', many: 'files' },
419  Grep: { verb: 'Searched', one: 'pattern', many: 'patterns' },
420  Glob: { verb: 'Searched', one: 'pattern', many: 'patterns' },
421  LS: { verb: 'Listed', one: 'folder', many: 'folders' },
422  Bash: { verb: 'Ran', one: 'command', many: 'commands' },
423  PowerShell: { verb: 'Ran', one: 'command', many: 'commands' },
424  WebFetch: { verb: 'Fetched', one: 'page', many: 'pages' },
425  WebSearch: { verb: 'Searched the web', one: 'time', many: 'times' },
426  NotebookRead: { verb: 'Read', one: 'notebook', many: 'notebooks' },
427}
428
429// `Read 3 files, searched 2 patterns`, in the order the kinds first appear.
430export function groupSummary(tools: readonly string[]): string {
431  const counts = new Map<string, { verb: string; one: string; many: string; n: number }>()
432  for (const tool of tools) {
433    const v = VERBS[tool] ?? { verb: `Used ${toolLabel(tool)}`, one: 'time', many: 'times' }
434    const key = `${v.verb}|${v.one}`
435    const cur = counts.get(key)
436    if (cur === undefined) counts.set(key, { ...v, n: 1 })
437    else cur.n += 1
438  }
439  const parts = [...counts.values()].map(c => `${c.verb} ${plural(c.n, c.one, c.many)}`)
440  return parts.map((p, i) => (i === 0 ? p : p.charAt(0).toLowerCase() + p.slice(1))).join(', ')
441}
442
443// The wall time the group's calls took, first start to last end; undefined
444// while one runs or when a call's timing was not recorded.
445export function groupDuration(ids: readonly (string | undefined)[], timings: readonly Timing[]): number | undefined {
446  let start = Infinity
447  let end = -Infinity
448  for (const id of ids) {
449    const t = id === undefined ? undefined : timings.find(x => x.id === id)
450    if (t === undefined || t.end === undefined) return undefined
451    start = Math.min(start, t.start)
452    end = Math.max(end, t.end)
453  }
454  return ids.length === 0 ? undefined : end - start
455}
456
457// ---------------------------------------------------------------- message cards
458
459export type CardHead = { icon: string; label: string; color: string; meta: string }
460
461type Origin = { kind: string }
462type Task = { status?: string; durationMs?: number }
463
464// The heading of a card for a row the person did not type: a background
465// task's notification, or a message another agent, session or channel sent.
466export function cardHead(origin: Origin, task: Task | undefined, from: { name: string } | undefined): CardHead {
467  if (task !== undefined || origin.kind === 'task-notification') {
468    const status = task?.status
469    const color = status === 'failed' ? RED : status === 'completed' ? GREEN : GREY
470    const meta = [status, task?.durationMs === undefined ? undefined : formatDuration(task.durationMs)]
471      .filter((s): s is string => s !== undefined && s !== '')
472      .join(' · ')
473    return { icon: '⚙', label: 'Background task', color, meta }
474  }
475  if (origin.kind === 'scheduled-trigger') return { icon: '⏰', label: 'Scheduled task', color: PURPLE_LIGHT, meta: '' }
476  if (origin.kind === 'channel') return { icon: '#', label: from?.name ?? 'Channel', color: PURPLE_LIGHT, meta: 'channel' }
477  if (origin.kind === 'plugin') return { icon: '◆', label: 'From a mod', color: PURPLE_LIGHT, meta: '' }
478  if (origin.kind === 'slack-ping') return { icon: '✉', label: from?.name ?? 'Slack', color: PURPLE_LIGHT, meta: 'Slack' }
479  return { icon: '✉', label: from === undefined ? 'Message' : `From ${from.name}`, color: PURPLE_LIGHT, meta: '' }
480}
481
hooks/settings.ts 139 lines
1import type { Settings } from '../types'
2
3export const DEFAULTS: Settings = {
4  isEnabled: true,
5  prompt: { width: 70, lines: 6, time: true },
6  response: { width: 85, fold: true },
7  code: { width: 75 },
8}
9
10export type Section = 'prompt' | 'response' | 'code'
11
12type Field =
13  | { kind: 'number'; min: number; max: number; about: string }
14  | { kind: 'switch'; about: string }
15  | { kind: 'choice'; options: readonly string[]; about: string }
16
17// Every setting a command takes, in the order its help lists them.
18export const FIELDS: { [S in Section]: Record<string, Field> } = {
19  prompt: {
20    width: { kind: 'number', min: 30, max: 100, about: 'widest the prompt pill gets, in % of the terminal' },
21    lines: { kind: 'number', min: 1, max: 50, about: 'lines of a long prompt shown before it folds' },
22    time: { kind: 'switch', about: 'the time under each prompt' },
23  },
24  response: {
25    width: { kind: 'number', min: 40, max: 100, about: 'widest replies and tool output get, in % of the terminal' },
26    fold: { kind: 'switch', about: 'fold runs of reads and searches into one line' },
27  },
28  code: {
29    width: { kind: 'number', min: 30, max: 100, about: 'widest an edit\'s added and removed lines get, in % of the terminal' },
30  },
31}
32
33export const COMMANDS: { [S in Section]: { name: string; description: string; example: string } } = {
34  prompt: { name: 'prettier-prompt', description: 'Prompt pill: width, lines, time', example: 'width 60 lines 4' },
35  response: { name: 'prettier-response', description: 'Replies and tool output: width, fold', example: 'width 80 fold off' },
36  code: { name: 'prettier-code', description: 'Code diffs: width', example: 'width 70' },
37}
38
39export const EXPAND_HINT = 'Press ctrl+o to see full tool calls, their output and long prompts; ctrl+o again to go back.'
40
41function fieldValue(field: Field, value: unknown): string {
42  if (field.kind === 'switch') return value === true ? 'on' : 'off'
43  return String(value)
44}
45
46function fieldRange(field: Field): string {
47  if (field.kind === 'number') return `${field.min}–${field.max}`
48  if (field.kind === 'switch') return 'on | off'
49  return field.options.join(' | ')
50}
51
52// The settings a stored value holds, every missing or invalid one at its default.
53export function mergeSettings(stored: unknown): Settings {
54  const out: Settings = structuredClone(DEFAULTS)
55  if (stored === null || typeof stored !== 'object') return out
56  const s = stored as Record<string, unknown>
57  if (typeof s.isEnabled === 'boolean') out.isEnabled = s.isEnabled
58  for (const section of Object.keys(FIELDS) as Section[]) {
59    const given = s[section]
60    if (given === null || typeof given !== 'object') continue
61    const target = out[section] as Record<string, unknown>
62    for (const [key, field] of Object.entries(FIELDS[section])) {
63      const v = (given as Record<string, unknown>)[key]
64      if (field.kind === 'number' && typeof v === 'number' && v >= field.min && v <= field.max) target[key] = v
65      if (field.kind === 'switch' && typeof v === 'boolean') target[key] = v
66      if (field.kind === 'choice' && typeof v === 'string' && field.options.includes(v)) target[key] = v
67    }
68  }
69  return out
70}
71
72export type Applied = { settings: Settings; changed: string[]; errors: string[] }
73
74// Applies `width 60 lines 4` (pairs of name and value) to one section.
75export function applyArgs(settings: Settings, section: Section, args: string): Applied {
76  const out: Settings = structuredClone(settings)
77  const target = out[section] as Record<string, unknown>
78  const fields = FIELDS[section]
79  const words = args.trim().toLowerCase().split(/\s+/).filter(w => w !== '')
80  const changed: string[] = []
81  const errors: string[] = []
82  for (let i = 0; i < words.length; i += 2) {
83    const name = words[i] ?? ''
84    const raw = words[i + 1]
85    const field = fields[name]
86    if (field === undefined) {
87      errors.push(`\`${name}\` is not a setting here; the settings are ${Object.keys(fields).map(k => `\`${k}\``).join(', ')}`)
88      continue
89    }
90    if (raw === undefined) {
91      errors.push(`\`${name}\` needs a value: ${fieldRange(field)}`)
92      continue
93    }
94    if (field.kind === 'number') {
95      const n = Number(raw.replace(/%$/, ''))
96      if (!Number.isInteger(n) || n < field.min || n > field.max) {
97        errors.push(`\`${name}\` must be a whole number from ${field.min} to ${field.max}`)
98        continue
99      }
100      target[name] = n
101    } else if (field.kind === 'switch') {
102      if (raw !== 'on' && raw !== 'off') {
103        errors.push(`\`${name}\` must be on or off`)
104        continue
105      }
106      target[name] = raw === 'on'
107    } else {
108      if (!field.options.includes(raw)) {
109        errors.push(`\`${name}\` must be one of ${field.options.join(', ')}`)
110        continue
111      }
112      target[name] = raw
113    }
114    changed.push(`${name} ${raw.replace(/%$/, '')}`)
115  }
116  return { settings: out, changed, errors }
117}
118
119// What a command prints with no arguments: each setting, its value and what it does.
120export function describeSection(settings: Settings, section: Section): string {
121  const { name, example } = COMMANDS[section]
122  const values = settings[section] as Record<string, unknown>
123  const lines = Object.entries(FIELDS[section]).map(
124    ([key, field]) => `- \`${key} ${fieldValue(field, values[key])}\` ${field.about} (${fieldRange(field)})`,
125  )
126  return [...lines, '', `To change: \`/${name} ${example}\``].join('\n')
127}
128
129// What /prettier prints with no arguments: the switch, the three commands, the key.
130export function describeAll(settings: Settings): string {
131  return [
132    `prettier is **${settings.isEnabled ? 'on' : 'off'}**. \`/prettier on\`, \`/prettier off\`, \`/prettier reset\` (every setting back to its default).`,
133    '',
134    ...(Object.keys(COMMANDS) as Section[]).map(s => `- \`/${COMMANDS[s].name}\` ${COMMANDS[s].description.toLowerCase()}`),
135    '',
136    EXPAND_HINT,
137  ].join('\n')
138}
139
types/index.d.ts 53 lines
1// What /prettier-prompt sets: the pill holding the person's own prompt.
2export type PromptSettings = { width: number; lines: number; time: boolean }
3
4// What /prettier-response sets: everything a turn draws.
5export type ResponseSettings = { width: number; fold: boolean }
6
7// What /prettier-code sets: an edit's diff.
8export type CodeSettings = { width: number }
9
10export type Settings = {
11  isEnabled: boolean
12  prompt: PromptSettings
13  response: ResponseSettings
14  code: CodeSettings
15}
16
17// One main-loop tool call of a turn: what it worked on, how long it took,
18// whether it failed.
19export type Call = { id: string; tool: string; detail: string; ms?: number; failed?: boolean }
20
21// A finished main-loop turn: its length as turn.complete reported it, how many
22// tool calls it made, and those calls (absent on a turn recorded before).
23export type Turn = { durationMs: number; tools: number; calls?: Call[] }
24
25// The main loop's turn in progress: the tool calls running now, oldest first,
26// how many it has made so far, and each of them.
27export type Live = { running: { id: string; tool: string }[]; tools: number; calls?: Call[] }
28
29// A prompt the person sent, and when (epoch ms).
30export type Prompt = { text: string; at: number }
31
32// When a main-loop tool call started and ended (epoch ms).
33export type Timing = { id: string; start: number; end?: number }
34
35declare module 'claude-code' {
36  interface PluginState {
37    prettier: {
38      settings: Settings
39      turns: Turn[]
40      live: Live
41      prompts: Prompt[]
42      timings: Timing[]
43      // Whether a compact row (a folded prompt, a group, a turn's tools) is open,
44      // by the row's requestId.
45      open: StateFamily<boolean>
46      // Whether a code panel's Copy was just pressed, by `<requestId>:<block>`.
47      copied: StateFamily<boolean>
48      // The spinner's animation step while the main loop's turn runs.
49      spin: number
50    }
51  }
52}
53