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…

Chat layout for the Claude Code terminal:
… 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.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 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.▸ 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.✗ 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.⚙ Background task · completed · 2m 2s), not as your pill.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.⠋ and Claude Code's word, or the tool running now, ⠋ ▸ running Bash….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.
| Command | Settings (defaults) |
|---|---|
/prettier | lists the commands; on, off, reset (every setting to its default) |
/prettier-prompt | width 70 (% of the terminal), lines 6 (shown before folding), time on |
/prettier-response | width 85 (% of the terminal), fold on (fold reads and searches) |
/prettier-code | width 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.
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.
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:
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.
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')
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.
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.
hooks/register.tsx 859 lines1import { 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}
859hooks/layout.ts 481 lines1import 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}
481hooks/settings.ts 139 lines1import 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}
139types/index.d.ts 53 lines1// 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