Tidy one-line shell command rows with live output, progress, timing and exit status, plus details on demand.

Live one-line rows for every shell command Claude runs, right in the Claude Code transcript. No side panel.
I got tired of my agent's shells being reduced to this:
<img src="assets/before.png" alt="Claude Code's footer: auto mode on · 1 shell, 2 monitors · 1 agent" width="560">
What is that shell doing? Is it stuck? How far along is it? shellcast puts every shell command Claude runs right in the transcript as a live row, and keeps long background jobs in sight while the agent goes on writing. Commands stay as one-line rows with their own icons, however long they run. Completed tool blocks collapse into a summary of the work and an icon strip, keeping the whole sequence easy to scan:
<strong>Stock Claude Code (left) · shellcast (right)</strong><br> <a href="assets/side-by-side.mp4"><img src="assets/side-by-side.gif" alt="Synchronized before and after: stock Claude Code on the left; shellcast on the right with descriptive tool summaries, command icons, compact writes, and a live build card" width="100%"></a>
Commands stay on one line from start to finish, with live output, reported progress and elapsed time while running. They never automatically expand into a card; use ▸ details when you want more:
Compact command labels omit leading directory setup: cd /long/worktree/path && npm test shows as npm test. Descriptions stay intact, and details retain the full original command.
✔ ⚗ Run the test suite ⎿ Tests 73 passed (73) 5.3s · 13 lines ▸ details
✔ ⚙ Production build ⎿ ✓ built in 4.61s 5.3s · 6 lines ▸ details
✘ $ Lint the codebase ⎿ ✖ 2 problems (2 errors, 0 warnings) exit 1 · 1.8s ▸ details
In a Claude Code terminal session:
/plugin install shellcast --marketplace jeffarese/shellcast
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). It starts working right away. No restart needed.
| State | Card |
|---|---|
| Starting | A quiet one-line row with a spinner. |
| Running | Always a compact row: spinner, title, latest output in fullscreen, reported progress, elapsed time and a timeout warning past half the limit. Long-running commands stay on one line too; command and output details open only when requested. |
| Done | A one-liner: ✔, the description, the last line of output (the command, on the main screen), duration and line count. Chips appear for git commits, pushes and PRs, edited files, saved output, unsandboxed runs and timeouts. |
| Completed block | Consecutive successful tools collapse into a description such as ✔ Searched for 1 pattern, read 1 file, ran 7 shell commands, followed by their icons and elapsed time when observed. Repeated icons get a count. Long summaries wrap to keep the description and icons visible. In fullscreen, ▸ details restores the individual rows; each shell still has its own output toggle. Active blocks, failures, interruptions and pending results stay visible. |
| Failed | The same one-liner in red, with ✘ and the exit code. A call you declined shows as ⊘ not run, not as a failure. |
| Background | A compact row stays above the prompt with the title, latest output, reported progress and elapsed time. At most three shells are shown, with an overflow count for the rest; there are no animated meters in these rows. The footer describes one shell and counts the others. At each main turn's end, the plugin reconciles its list with Claude Code's active tasks, removing stale rows even if a completion notice was missed. The transcript keeps each shell's details and final result; a missing exit status is shown as finished · exit unknown. Set SHELLCAST_BACKGROUND=cards before starting Claude Code to restore full pinned cards. |
| Details | In the fullscreen layout, ▸ details on any card opens the full command, stdout and stderr, timing, task id and output file. |
On the terminal's main screen (not fullscreen), individual rows keep Claude Code's own ⎿ result block, and collapsed groups show a ctrl+o hint. Claude Code's expanded transcript and verbose mode retain every individual row. The desktop app, VS Code and mobile keep their native rows.
Successful Write calls in the terminal keep just the Write(path) header and Wrote N lines to path summary, without the source preview. Errors and writes awaiting owner review keep their native results.
A muted command icon sits between the status indicator and the title, including running, failed and background rows. Reading, listing, navigating, searching, editing, deleting, copying, moving, creating, Git, tests, builds and downloads each have their own icon.
Set SHELLCAST_ICONS before starting Claude Code:
| Value | Icons |
|---|---|
unicode (default) | Plain symbols such as ▤ for reading, ≡ for listing and ↳ for navigation. |
nerd | Set A: thin Codicons outlines. |
nerd-bold | Set B: bolder Font Awesome symbols, with a Git logo and folder-plus icon. |
none | Status indicators only. |
Both Nerd Font sets need a Nerd Font selected in your terminal; the Nerd Font Mono variant keeps the icon column one cell wide.
SHELLCAST_ICONS=nerd-bold claude
To keep the preference, add export SHELLCAST_ICONS=nerd-bold to your shell configuration, or set env.SHELLCAST_ICONS in ~/.claude/settings.json for all Claude sessions. Use nerd to switch back to A. The font choice is explicit, rather than inferred from which fonts are installed.
Labels come from the command, not its description: sed -n gets a document and sed -i a pencil. Leading directory changes are skipped when another command follows (cd project && npm test gets a flask). Detection is best effort for shell scripts; unknown commands get a terminal icon. Icon selection never changes command execution.
Claude Code streams each shell's combined output to <tmp>/claude-<uid>/<project>/<session>/tasks/b<id>.output. A call claims the first such file that appears after it starts, and a 300 ms ticker reads it while the call runs: ANSI codes stripped, \r progress lines collapsed to their latest frame. shellcast only reads that folder. If it can't find it, cards still draw, just without the live tail.
claude --plugin-dir /path/to/shellcast # load from disk, reloads on save
claude plugin validate . # what the engine sees
claude plugin test . # tests/*.test.ts(x)
The demos are real Claude Code sessions in a throwaway sample project, recorded with a scripted stand-in for the model and simulated test/build output so both runs follow the same sequence. The file writes and shellcast rendering run normally.
Layout: hooks/register.tsx (the hooks), hooks/card.tsx (cards), hooks/group.tsx (collapsed tool blocks), hooks/icons.ts (command icons), hooks/live.ts (output tracking), hooks/format.ts (pure helpers), types/index.d.ts (the $.state contract).
hooks/register.tsx 204 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { BuiltinToolResults, Register } from 'claude-code'
3
4import { drawCard, drawPinned, footerTail } from './card'
5import type { BashInput, BashOutput, Card, Pinned } from './card'
6import { countLines, parseNotifications } from './format'
7import { canCollapseGroup, drawGroup } from './group'
8import { iconMode } from './icons'
9import { BLANK, begin, connect, connected, end, ensureTicker, reconcile, settle, textOf } from './live'
10
11const runs = atom({ plugin: 'shellcast', key: 'runs' } as const, BLANK)
12const expanded = atom({ plugin: 'shellcast', key: 'expanded' } as const, false)
13const groupExpanded = atom({ plugin: 'shellcast', key: 'groupExpanded' } as const, false)
14const active = atom({ plugin: 'shellcast', key: 'active' } as const, [])
15const pinned = atom({ plugin: 'shellcast', key: 'pinned' } as const, [])
16
17type Reader = Parameters<typeof read>[0]
18
19/**
20 * Background shells still running, newest last. Reads only the pinned runs,
21 * so a foreground shell's ticks never redraw the band or the footer.
22 */
23async function backgroundShells($: Reader): Promise<(Pinned & { id: string })[]> {
24 const shells: (Pinned & { id: string })[] = []
25 for (const id of await read($, pinned)) {
26 const run = await read($, memberOf(runs, { requestId: id }))
27 if (run.background?.status === 'running') shells.push({ id, title: run.title ?? 'Shell', command: run.command ?? '', run })
28 }
29 return shells
30}
31
32export const register: Register = on => {
33 on('session.start', async ($, e, next) => {
34 const started = await next(e)
35 connect({
36 now: () => $.clock.now(),
37 sessionId: () => $.session.id(),
38 places: async () => [await $.session.cwd(), await $.session.root()],
39 tmpdir: () => $.env.get('CLAUDE_CODE_TMPDIR'),
40 uid: async () => (await $.process.run(['id', '-u'], { timeoutMs: 2000 })).stdout.trim(),
41 tail: async path => (await $.process.run(['tail', '-c', '16384', path], { timeoutMs: 2000 })).stdout,
42 list: path => $.fs.list(path),
43 exists: path => $.fs.exists(path),
44 stat: path => $.fs.stat(path),
45 read: path => $.fs.read(path),
46 every: (ms, fn) => $.clock.every(ms, fn),
47 log: text => $.ui.log(text, { to: 'debug' }),
48 getRun: id => read($, memberOf(runs, { requestId: id })),
49 setRun: (id, change) => update($, memberOf(runs, { requestId: id }), change),
50 getActive: () => read($, active),
51 setActive: change => update($, active, change),
52 setPinned: change => update($, pinned, change),
53 })
54 const ids = await read($, active)
55 if (ids.length > 0) {
56 // Runs pinned before `pinned` existed (a reload mid-shell) pin again.
57 const list = await read($, pinned)
58 const running: string[] = []
59 for (const id of ids) {
60 if ((await read($, memberOf(runs, { requestId: id }))).background?.status === 'running') running.push(id)
61 }
62 const missing = running.filter(id => !list.includes(id))
63 if (missing.length > 0) await update($, pinned, current => [...current, ...missing.filter(id => !current.includes(id))])
64 ensureTicker()
65 }
66 return started
67 })
68
69 // Observe every shell call: its start, its live output, how it ended. The
70 // call itself passes through untouched.
71 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
72 const io = connected()
73 if (io === undefined) return next(e)
74 const title = e.description?.trim() || e.command.split('\n')[0]?.trim() || 'Shell'
75 await begin(io, e.tool_use_id, e.timeout, title, e.command)
76 const ran = await next(e)
77 const output = ran.deny === undefined && ran.isError !== true ? (ran.result as BashOutput) : undefined
78 await end(io, e.tool_use_id, output?.backgroundTaskId)
79 return ran
80 })
81
82 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
83 const ran = await next(e)
84 const io = connected()
85 const taskId = e.task_id ?? e.shell_id
86 if (io !== undefined && taskId !== undefined && ran.deny === undefined && ran.isError !== true) {
87 await settle(io, { taskId, status: 'killed' })
88 }
89 return ran
90 })
91
92 // A retained output file does not prove its process is alive. Reconcile
93 // against the same engine task registry that supplies the native shell count.
94 on('classic.Stop', async ($, e, next) => {
95 const io = connected()
96 if (io !== undefined && e.agent_id === undefined && e.background_tasks !== undefined) {
97 await reconcile(io, e.background_tasks)
98 }
99 return next(e)
100 })
101
102 // A background shell's end arrives as a <task-notification> row.
103 on('session.append', async ($, e, next) => {
104 const io = connected()
105 if (io !== undefined && e.door !== 'response' && e.door !== 'tool-result' && e.door !== 'compaction') {
106 const text = textOf(e.message.content)
107 if (text.includes('<task-notification>')) {
108 for (const note of parseNotifications(text)) await settle(io, note).catch(() => undefined)
109 }
110 }
111 return next(e)
112 })
113
114 on('ui.render', { component: 'ToolUse', props: { tool: 'Bash' } }, async ($, e, next) => {
115 if (e.surface !== 'terminal') return next(e)
116 const input = (typeof e.props.input === 'object' && e.props.input !== null ? e.props.input : {}) as BashInput
117 const command = input.command ?? ''
118 const ownsOutput = e.viewport?.isFullscreen === true
119 const card: Card = {
120 els: $.ui.resolve(e),
121 title: input.description?.trim() || command.split('\n')[0] || 'Shell',
122 command,
123 iconMode: iconMode(await $.env.get('SHELLCAST_ICONS')),
124 run: await read($, memberOf(runs, e)),
125 columns: e.viewport?.columns ?? 100,
126 isOpen: await read($, memberOf(expanded, e)),
127 toggle: ownsOutput ? () => update($, memberOf(expanded, e), open => !open) : undefined,
128 ownsOutput,
129 }
130 return drawCard(card, e.props)
131 })
132
133 // In fullscreen the card draws the output itself; the engine's block would
134 // repeat it. On the main screen the engine's block stays (ctrl+o expands it).
135 on('ui.render', { component: 'ToolResult', props: { tool: 'Bash' } }, ($, e, next) => {
136 if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true) return next(e)
137 const { Box } = $.ui.resolve(e)
138 return <Box />
139 })
140
141 // Keep the native Write(path) row, but omit the source preview beneath it.
142 on('ui.render', { component: 'ToolResult', props: { tool: 'Write' } }, async ($, e, next) => {
143 if (e.surface !== 'terminal' || e.props.isErrored) return next(e)
144 const output = e.props.output as Partial<BuiltinToolResults['Write']> | null | undefined
145 if (output?.staged || typeof output?.filePath !== 'string' || typeof output.content !== 'string') return next(e)
146 const cwd = (await $.session.cwd()).replace(/\/$/, '')
147 const path = output.filePath.startsWith(`${cwd}/`) ? output.filePath.slice(cwd.length + 1) : output.filePath
148 const lines = countLines(output.content)
149 const { Box, Text } = $.ui.resolve(e)
150 return <Box paddingLeft={2}><Text>{`⎿ Wrote ${lines} ${lines === 1 ? 'line' : 'lines'} to ${path}`}</Text></Box>
151 })
152
153 // Keep live blocks open; finished ones become a single row of command icons.
154 // The engine's own expanded mode (ctrl+o/verbose) always takes precedence.
155 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
156 if (e.surface !== 'terminal' || e.props.isExpanded) return next(e)
157 const hasShell = e.props.calls.some(call => call.tool === 'Bash')
158 const unfold = () => next({ ...e, props: { ...e.props, isExpanded: true } })
159 if (e.props.isActive || e.props.calls.length < 2) return hasShell ? unfold() : next(e)
160 const observed = await Promise.all(e.props.calls.map(call => call.tool === 'Bash' && call.tool_use_id !== undefined
161 ? read($, memberOf(runs, { requestId: call.tool_use_id })) : undefined))
162 if (!canCollapseGroup(e.props.calls, observed)) return unfold()
163 const state = memberOf(groupExpanded, e)
164 const interactive = e.viewport?.isFullscreen === true
165 const isOpen = interactive && await read($, state)
166 const els = $.ui.resolve(e)
167 const summary = drawGroup({
168 els, calls: e.props.calls, runs: observed,
169 iconMode: iconMode(await $.env.get('SHELLCAST_ICONS')),
170 columns: e.viewport?.columns ?? 100,
171 isOpen,
172 toggle: interactive ? () => update($, state, open => !open) : undefined,
173 })
174 if (!isOpen) return summary
175 const { Box } = els
176 return <Box flexDirection="column">{summary}{await unfold()}</Box>
177 })
178
179 // A background shell's live row stays pinned above the prompt while it
180 // runs, so it never scrolls away as the agent keeps writing; the footer's
181 // "1 shell" says what it is doing too (it stays when the band is collapsed).
182 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
183 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
184 const shells = await backgroundShells($)
185 if (shells.length === 0) return next(e)
186 const { Box } = $.ui.resolve(e)
187 const below = await next(e)
188 const icons = iconMode(await $.env.get('SHELLCAST_ICONS'))
189 const layout = await $.env.get('SHELLCAST_BACKGROUND') === 'cards' ? 'cards' : 'compact'
190 return (
191 <Box flexDirection="column">
192 {drawPinned($.ui.resolve(e), shells, e.props.bodyColumns, e.props.maxRows, icons, layout)}
193 {below}
194 </Box>
195 )
196 })
197
198 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
199 if (e.surface !== 'terminal' || e.props.tail !== undefined) return next(e)
200 const tail = footerTail(await backgroundShells($))
201 return next(tail === undefined ? e : { ...e, props: { ...e.props, tail } })
202 })
203}
204hooks/card.tsx 581 lines1// The inline card that takes the place of a Bash call's transcript row.
2
3import type { Elements, RenderElement, RenderNode } from 'claude-code'
4
5import type { ShellRun } from '../types'
6import {
7 SPINNER,
8 bar,
9 countLines,
10 detectProgress,
11 duration,
12 exitCodeOf,
13 shimmer,
14 size,
15 sparkline,
16 tailLines,
17} from './format'
18import { READ_WHOLE_BYTES, streamPath } from './live'
19import { commandIcon, commandLabel } from './icons'
20import type { IconMode } from './icons'
21
22type Els = Elements['terminal']
23
24export type BashInput = {
25 command?: string
26 description?: string
27 timeout?: number
28 run_in_background?: boolean
29 dangerouslyDisableSandbox?: boolean
30}
31
32export type BashOutput = {
33 stdout?: string
34 stderr?: string
35 interrupted?: boolean
36 isImage?: boolean
37 backgroundTaskId?: string
38 backgroundedByUser?: boolean
39 timedOutAfterMs?: number
40 dangerouslyDisableSandbox?: boolean
41 returnCodeInterpretation?: string
42 noOutputExpected?: boolean
43 persistedOutputPath?: string
44 persistedOutputSize?: number
45 gitOperation?: {
46 commit?: { sha: string; kind: string; branch?: string }
47 push?: { branch: string }
48 branch?: { ref: string; action: string }
49 pr?: { number: number; action: string }
50 }
51 bashEditDiff?: { files?: { filePath: string }[]; moreFiles?: number }
52}
53
54const BACKGROUND_LINES = 3
55const DONE_LINES = 2
56const FAILED_LINES = 8
57const DETAIL_LINES = 40
58
59type Chip = { text: string; color: string }
60
61export type Card = {
62 els: Els
63 title: string
64 command: string
65 iconMode?: IconMode
66 run: ShellRun
67 columns: number
68 isOpen: boolean
69 toggle: (() => unknown) | undefined
70 /**
71 * Whether the card draws the call's output itself (the fullscreen layout,
72 * where rows redraw) or leaves it to the engine's own result block (the
73 * main screen, where finished rows are printed once and ctrl+o expands).
74 */
75 ownsOutput: boolean
76}
77
78function chipsOf(output: BashOutput | undefined, run: ShellRun): Chip[] {
79 if (output === undefined) return []
80 const chips: Chip[] = []
81 const git = output.gitOperation
82 if (git?.commit) {
83 const on = git.commit.branch === undefined ? '' : ` on ${git.commit.branch}`
84 chips.push({ text: `⎇ ${git.commit.kind} ${git.commit.sha.slice(0, 7)}${on}`, color: 'success' })
85 }
86 if (git?.push) chips.push({ text: `↑ pushed ${git.push.branch}`, color: 'suggestion' })
87 if (git?.branch) chips.push({ text: `⑂ ${git.branch.action} ${git.branch.ref}`, color: 'merged' })
88 if (git?.pr) chips.push({ text: `⇄ PR #${git.pr.number} ${git.pr.action}`, color: 'merged' })
89 const edited = (output.bashEditDiff?.files?.length ?? 0) + (output.bashEditDiff?.moreFiles ?? 0)
90 if (edited > 0) chips.push({ text: `✎ ${edited} file${edited === 1 ? '' : 's'} changed`, color: 'warning' })
91 if (output.timedOutAfterMs !== undefined) {
92 chips.push({ text: `⏱ timed out at ${duration(output.timedOutAfterMs)}, moved to background`, color: 'warning' })
93 } else if (output.backgroundedByUser) {
94 chips.push({ text: '⇣ sent to background', color: 'suggestion' })
95 }
96 if (output.persistedOutputPath !== undefined) {
97 const saved = output.persistedOutputSize === undefined ? '' : ` ${size(output.persistedOutputSize)}`
98 chips.push({ text: `⤓${saved} output saved to file`, color: 'subtle' })
99 }
100 if (output.dangerouslyDisableSandbox) chips.push({ text: '⚠ unsandboxed', color: 'warning' })
101 if (output.isImage) chips.push({ text: '▣ image output', color: 'subtle' })
102 if (output.returnCodeInterpretation) chips.push({ text: `ℹ ${output.returnCodeInterpretation}`, color: 'subtle' })
103 if (run.lines === undefined && run.bytes > READ_WHOLE_BYTES) chips.push({ text: `${size(run.bytes)} streamed`, color: 'subtle' })
104 return chips
105}
106
107function elapsedOf(run: ShellRun): number | undefined {
108 if (run.startedAt === 0) return undefined
109 const from = run.spawnedAt ?? run.startedAt
110 return (run.endedAt ?? run.now) - from
111}
112
113function displayTitle(title: string, command: string): string {
114 return title.trim() === command.split('\n')[0]?.trim() ? commandLabel(command) : title
115}
116
117function Header(
118 card: Card,
119 glyph: string,
120 glyphColor: string,
121 right: RenderNode[],
122 subtitle?: string,
123): RenderElement {
124 const { Box, Text, Button } = card.els
125 const icon = commandIcon(card.command, card.iconMode)
126 return (
127 <Box>
128 <Box flexShrink={0}>
129 <Text color={glyphColor} bold>{`${glyph} `}</Text>
130 {icon !== '' && <Text dimColor>{`${icon} `}</Text>}
131 </Box>
132 <Box flexGrow={1} flexShrink={1}>
133 <Text wrap="truncate-end">
134 <Text bold>{displayTitle(card.title, card.command)}</Text>
135 {subtitle !== undefined && subtitle !== '' && <Text dimColor>{` ${subtitle}`}</Text>}
136 </Text>
137 </Box>
138 <Box flexShrink={0} marginLeft={2}>
139 {right}
140 {card.toggle !== undefined && (
141 <Box marginLeft={2}>
142 <Button
143 key="details"
144 label={card.isOpen ? '▾ less' : '▸ details'}
145 plain
146 dimColor
147 onPress={card.toggle}
148 />
149 </Box>
150 )}
151 </Box>
152 </Box>
153 )
154}
155
156function CommandLine(card: Card, isFull: boolean): RenderElement {
157 const { Box, Text, Code } = card.els
158 const lines = card.command.split('\n')
159 const shown = isFull ? lines.slice(0, 30) : lines.slice(0, 1)
160 const hidden = lines.length - shown.length
161 return (
162 <Box>
163 <Text color="bashBorder" bold>{'$ '}</Text>
164 <Box flexDirection="column" flexShrink={1} flexGrow={1}>
165 <Code language="bash" source={shown.join('\n')} wrap={isFull ? 'wrap' : 'truncate-end'} />
166 {hidden > 0 && <Text dimColor>{`… ${hidden} more line${hidden === 1 ? '' : 's'}`}</Text>}
167 </Box>
168 </Box>
169 )
170}
171
172/** Output lines behind a thin gutter, the way a log viewer frames them. */
173function OutputLines(card: Card, lines: readonly string[], gutter: string, color?: string): RenderElement {
174 const { Box, Text } = card.els
175 return (
176 <Box flexDirection="column">
177 {lines.map(line => (
178 <Box>
179 <Text color={gutter}>{'▏ '}</Text>
180 <Text color={color} dimColor={color === undefined} wrap="truncate-end">{line === '' ? ' ' : line}</Text>
181 </Box>
182 ))}
183 </Box>
184 )
185}
186
187/**
188 * Exactly `count` output rows, the newest last, blank rows first while output
189 * is sparse: a live card keeps its height from its first frame to its last.
190 */
191function LiveLines(card: Card, tail: readonly string[], count: number, gutter: string): RenderElement {
192 const { Box, Text } = card.els
193 if (tail.length === 0) {
194 return (
195 <Box flexDirection="column">
196 <Text dimColor italic>{'▏ waiting for output…'}</Text>
197 {Array.from({ length: count - 1 }, () => <Text>{' '}</Text>)}
198 </Box>
199 )
200 }
201 const shown = tail.slice(-count)
202 return OutputLines(card, [...Array.from({ length: count - shown.length }, () => ''), ...shown], gutter)
203}
204
205/** The live meter: a real bar when the output reports progress, else a shimmer. */
206function Meter(card: Card): RenderElement {
207 const { Box, Text } = card.els
208 // The card's inner width: the terminal less two border and two padding cells, and one spare.
209 const inner = Math.max(20, card.columns - 5)
210 const progress = detectProgress(card.run.tail)
211 const width = Math.max(10, inner - (progress === undefined ? 0 : progress.label.length + 1))
212 if (progress !== undefined) {
213 const { filled, empty } = bar(progress.ratio, width)
214 return (
215 <Box>
216 <Text color="success">{filled}</Text>
217 <Text color="inactive">{empty}</Text>
218 <Text bold>{` ${progress.label}`}</Text>
219 </Box>
220 )
221 }
222 const { before, lit, after } = shimmer(card.run.ticks, width)
223 return (
224 <Box>
225 <Text color="inactive">{before}</Text>
226 <Text color="claude">{lit}</Text>
227 <Text color="inactive">{after}</Text>
228 </Box>
229 )
230}
231
232function Stats(card: Card, isBackground: boolean): RenderElement {
233 const { Text } = card.els
234 const { run } = card
235 const lines = run.lines === undefined ? '' : ` · ${run.lines} line${run.lines === 1 ? '' : 's'}`
236 const recent = run.rates.slice(-4)
237 const rate = recent.length === 0 ? 0 : recent.reduce((sum, value) => sum + value, 0) / recent.length
238 const hasFlow = run.rates.some(value => value > 0)
239 const elapsed = elapsedOf(run) ?? 0
240 const left = run.timeoutMs - elapsed
241 const isLate = !isBackground && run.startedAt !== 0 && elapsed > run.timeoutMs / 2
242 return (
243 <Text dimColor>
244 {run.bytes === 0 ? 'no output yet' : `${size(run.bytes)}${lines}`}
245 {hasFlow && <Text color="suggestion">{` ${sparkline(run.rates, 16)}`}</Text>}
246 {hasFlow && ` ${size(Math.round(rate))}/s`}
247 {isLate && <Text color="warning">{` ⏱ times out in ${duration(Math.max(0, left))}`}</Text>}
248 </Text>
249 )
250}
251
252function Details(card: Card, output: BashOutput | undefined, errorText: string | undefined): RenderElement {
253 const { Box, Text } = card.els
254 const { run } = card
255 const facts: [string, string][] = []
256 if (run.startedAt !== 0) {
257 const at = new Date(run.spawnedAt ?? run.startedAt)
258 const clock = [at.getHours(), at.getMinutes(), at.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
259 facts.push(['started', clock])
260 }
261 const elapsed = elapsedOf(run)
262 if (elapsed !== undefined) facts.push(['took', duration(elapsed)])
263 facts.push(['timeout', duration(run.timeoutMs)])
264 if (run.background !== undefined) facts.push(['task', `${run.background.taskId} (${run.background.status})`])
265 const stream = run.file === undefined ? undefined : streamPath(run.file)
266 if (stream !== undefined) facts.push(['stream', stream])
267 if (output?.persistedOutputPath !== undefined) facts.push(['saved', output.persistedOutputPath])
268 for (const file of (output?.bashEditDiff?.files ?? []).slice(0, 8)) facts.push(['edited', file.filePath])
269
270 const stdout = output?.stdout === undefined ? [] : tailLines(output.stdout, DETAIL_LINES)
271 const stderr = output?.stderr === undefined ? [] : tailLines(output.stderr, DETAIL_LINES)
272 const fallback = errorText !== undefined ? tailLines(errorText, DETAIL_LINES) : run.tail.slice(-DETAIL_LINES)
273 const hasOwn = stdout.length > 0 || stderr.length > 0
274
275 return (
276 <Box flexDirection="column" marginTop={1}>
277 {CommandLine(card, true)}
278 <Box flexDirection="column" marginTop={1}>
279 {hasOwn ? OutputLines(card, stdout, 'inactive') : OutputLines(card, fallback, 'inactive')}
280 {stderr.length > 0 && OutputLines(card, stderr, 'error', 'error')}
281 </Box>
282 <Box flexDirection="column" marginTop={1}>
283 {facts.map(([label, value]) => (
284 <Box>
285 <Box width={9} flexShrink={0}>
286 <Text dimColor>{label}</Text>
287 </Box>
288 <Text wrap="truncate-middle">{value}</Text>
289 </Box>
290 ))}
291 </Box>
292 </Box>
293 )
294}
295
296function Running(card: Card): RenderElement {
297 const { run } = card
298 const elapsed = elapsedOf(run)
299 const progress = detectProgress(run.tail)
300 const meta = ['running', progress?.label, elapsed === undefined ? undefined : duration(elapsed)]
301 .filter(part => part !== undefined).join(' · ')
302 const chips: Chip[] = elapsed !== undefined && elapsed > run.timeoutMs / 2
303 ? [{ text: `⏱ times out in ${duration(Math.max(0, run.timeoutMs - elapsed))}`, color: 'warning' }]
304 : []
305 return Compact(
306 card, SPINNER[run.ticks % SPINNER.length] ?? '⠋', 'claude', meta,
307 run.tail.slice(-1), chips, () => Details(card, undefined, undefined),
308 )
309}
310
311/**
312 * A running background shell's live card, pinned above the prompt: always
313 * the same rows (header, command, three output lines, meter, stats), so the
314 * prompt under it never moves while the shell runs.
315 */
316function BackgroundLive(card: Card, taskId: string): RenderElement {
317 const { Box, Text } = card.els
318 const { run } = card
319 const elapsed = elapsedOf(run)
320 const pulse = run.ticks % 4 < 2 ? '◉' : '○'
321 return (
322 <Box flexDirection="column" borderStyle="dashed" borderColor="suggestion" paddingX={1}>
323 {Header(card, pulse, 'suggestion', [
324 <Text color="suggestion">{'background'}</Text>,
325 <Text dimColor>{` · ${taskId}`}</Text>,
326 <Text dimColor>{elapsed === undefined ? '' : ` · ${duration(elapsed)}`}</Text>,
327 ])}
328 {CommandLine(card, false)}
329 {LiveLines(card, run.tail, BACKGROUND_LINES, 'suggestion')}
330 {Meter(card)}
331 {Stats(card, true)}
332 </Box>
333 )
334}
335
336/**
337 * A shell as one line: glyph, what it is for, its latest output line (or
338 * its command), chips and timing. Details
339 * open the rest; on the main screen the engine's result block follows.
340 */
341function Compact(
342 card: Card,
343 glyph: string,
344 color: string,
345 meta: string,
346 lines: readonly string[],
347 chips: readonly Chip[],
348 details: (() => RenderElement) | null,
349 metaColor?: string,
350): RenderElement {
351 const { Box, Text } = card.els
352 const gist = card.ownsOutput ? lines[lines.length - 1]?.trim() : undefined
353 const firstLine = commandLabel(card.command)
354 const subtitle = gist !== undefined && gist !== '' ? `⎿ ${gist}` : firstLine !== '' ? `$ ${firstLine}` : undefined
355 const right: RenderNode[] = [
356 ...chips.map(chip => <Text color={chip.color}>{`${chip.text} `}</Text>),
357 <Text color={metaColor} dimColor={metaColor === undefined}>{meta}</Text>,
358 ]
359 return (
360 <Box flexDirection="column">
361 {Header(card, glyph, color, right, card.isOpen ? undefined : subtitle)}
362 {card.isOpen && details !== null && <Box paddingLeft={2}>{details()}</Box>}
363 </Box>
364 )
365}
366
367function Failed(card: Card, code: number, text: string): RenderElement {
368 const { Box, Text } = card.els
369 const body = text.replace(/^.*\bExit code \d+\b.*(?:\n|$)/m, '')
370 const lines = tailLines(body, card.isOpen ? DETAIL_LINES : FAILED_LINES)
371 const elapsed = elapsedOf(card.run)
372 if (!card.isOpen) {
373 const meta = elapsed === undefined ? `exit ${code}` : `exit ${code} · ${duration(elapsed)}`
374 return Compact(card, '✘', 'error', meta, lines, [], null, 'error')
375 }
376 return (
377 <Box flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
378 {Header(card, '✘', 'error', [
379 <Text color="error" bold>{`exit ${code}`}</Text>,
380 <Text dimColor>{elapsed === undefined ? '' : ` · ${duration(elapsed)}`}</Text>,
381 ])}
382 {CommandLine(card, card.isOpen)}
383 {lines.length > 0 ? OutputLines(card, lines, 'error') : <Text dimColor italic>{'▏ no output'}</Text>}
384 </Box>
385 )
386}
387
388function metaOf(run: ShellRun, lines: number, prefix: string[] = []): string {
389 const parts = [...prefix]
390 const elapsed = elapsedOf(run)
391 if (elapsed !== undefined) parts.push(duration(elapsed))
392 parts.push(lines === 0 ? 'no output' : `${lines} line${lines === 1 ? '' : 's'}`)
393 return parts.join(' · ')
394}
395
396export type ToolUseProps = {
397 input: unknown
398 output?: unknown
399 isRunning: boolean
400 isErrored: boolean
401 isInterrupted: boolean
402}
403
404export function drawCard(card: Card, props: ToolUseProps): RenderElement {
405 const { run } = card
406 if (props.isRunning) {
407 // Running time never expands a row; only the explicit details toggle does.
408 return Running(card)
409 }
410
411 if (props.isInterrupted) {
412 const elapsed = elapsedOf(run)
413 const meta = elapsed === undefined ? 'interrupted' : `interrupted after ${duration(elapsed)}`
414 return Compact(card, '⊘', 'warning', meta, run.tail.slice(-DONE_LINES), [], () => Details(card, undefined, undefined))
415 }
416
417 if (props.isErrored) {
418 const text = typeof props.output === 'string' ? props.output : ''
419 const code = exitCodeOf(text)
420 if (code !== undefined) return Failed(card, code, text)
421 // Refused before it ran (the dialog, a rule, a hook): not a command failure.
422 const reason = tailLines(text, 2)
423 return Compact(card, '⊘', 'warning', 'not run', reason, [], () => Details(card, undefined, text))
424 }
425
426 if (props.output === undefined && run.endedAt === undefined) {
427 // The model is still writing the call, or it waits on a check before it
428 // runs: neither running nor done yet.
429 const spinner = SPINNER[run.ticks % SPINNER.length] ?? '⠋'
430 return Compact(card, spinner, 'claude', 'starting', [], [], null)
431 }
432
433 const output = typeof props.output === 'object' && props.output !== null ? (props.output as BashOutput) : undefined
434 const chips = chipsOf(output, run)
435 const taskId = output?.backgroundTaskId
436 if (taskId !== undefined) {
437 const status = run.background?.status
438 const isLive = run.startedAt !== 0 && (status === undefined || status === 'running')
439 if (isLive) {
440 // Its live card is pinned above the prompt; here it stays one row, so
441 // the transcript never reflows as the shell runs or ends.
442 const elapsed = elapsedOf(run)
443 const meta = `pinned ↓${elapsed === undefined ? '' : ` · ${duration(elapsed)}`}`
444 return Compact(card, run.ticks % 4 < 2 ? '◉' : '○', 'suggestion', meta, [], chips, () => Details(card, output, undefined), 'suggestion')
445 }
446 if (status === undefined) {
447 return Compact(card, '◉', 'suggestion', `background · ${taskId}`, [], chips, () => Details(card, output, undefined))
448 }
449 const exit = run.background?.exitCode
450 if (status === 'finished') {
451 return Compact(card, '○', 'subtle', metaOf(run, run.lines ?? run.tail.length, ['background', 'finished · exit unknown']),
452 run.tail.slice(-DONE_LINES), chips, () => Details(card, output, undefined))
453 }
454 const isKilled = status === 'killed' || status === 'stopped'
455 const isOk = !isKilled && status === 'completed' && (exit === undefined || exit === 0)
456 const glyph = isKilled ? '■' : isOk ? '✔' : '✘'
457 const color = isKilled ? 'warning' : isOk ? 'success' : 'error'
458 const word = isKilled ? 'stopped' : exit === undefined ? status : `exit ${exit}`
459 const meta = metaOf(run, run.lines ?? run.tail.length, ['background', word])
460 return Compact(card, glyph, color, meta, run.tail.slice(-DONE_LINES), chips, () => Details(card, output, undefined))
461 }
462
463 const stdout = output?.stdout ?? ''
464 const stderr = output?.stderr ?? ''
465 const lines = countLines(stdout) + countLines(stderr)
466 const shown = tailLines(stdout, DONE_LINES)
467 const shownErr = shown.length === 0 ? tailLines(stderr, DONE_LINES) : []
468 const meta = metaOf(run, lines)
469 return Compact(
470 card,
471 '✔',
472 'success',
473 meta,
474 shown.length > 0 ? shown : shownErr,
475 chips,
476 () => Details(card, output, undefined),
477 )
478}
479
480/** A running background shell, for the band and the footer. */
481export type Pinned = { title: string; command: string; run: ShellRun }
482
483/** A title as the band shows it: everything there is in the background already. */
484function bandTitle(title: string): string {
485 return title.replace(/\s+in (?:the )?background$/i, '')
486}
487
488function clip(text: string, max: number): string {
489 return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text
490}
491
492function BandRow(els: Els, { title, command, run }: Pinned, columns: number, iconMode: IconMode): RenderElement {
493 const { Box, Text } = els
494 const icon = commandIcon(command, iconMode)
495 const elapsed = elapsedOf(run)
496 const progress = detectProgress(run.tail)
497 const last = run.tail[run.tail.length - 1]?.trim()
498 return (
499 <Box>
500 <Box flexShrink={0}>
501 <Text color="suggestion">{'◉ '}</Text>
502 {icon !== '' && <Text dimColor>{`${icon} `}</Text>}
503 <Text bold>{`${clip(bandTitle(displayTitle(title, command)), Math.max(12, Math.floor(columns / 3)))} `}</Text>
504 {progress !== undefined && <Text color="success">{progress.label}</Text>}
505 </Box>
506 <Box flexGrow={1} flexShrink={1}>
507 <Text dimColor wrap="truncate-end">{` ${last ?? 'waiting for output…'}`}</Text>
508 </Box>
509 <Box flexShrink={0}>
510 <Text dimColor>{elapsed === undefined ? '' : ` ${duration(elapsed)}`}</Text>
511 </Box>
512 </Box>
513 )
514}
515
516/** Rows a pinned card takes: two borders, header, command, output, meter, stats. */
517const PINNED_ROWS = 2 + 1 + 1 + BACKGROUND_LINES + 1 + 1
518
519/**
520 * Quiet rows by default, bounded independently of the terminal's height.
521 * Full live cards remain an explicit opt-in.
522 */
523export function drawPinned(els: Els, shells: readonly Pinned[], columns: number, maxRows: number, iconMode: IconMode = 'unicode', layout: 'compact' | 'cards' = 'compact'): RenderElement {
524 const { Box, Text } = els
525 let budget = Math.max(0, Math.floor(maxRows))
526 if (layout === 'compact') {
527 const limit = Math.min(3, budget)
528 const shown = shells.slice(0, shells.length > limit ? Math.min(limit, Math.max(0, budget - 1)) : limit)
529 const more = shells.length - shown.length
530 return <Box flexDirection="column">
531 {shown.map(shell => BandRow(els, shell, columns, iconMode))}
532 {more > 0 && budget > shown.length && <Text dimColor>{` +${more} more in the background`}</Text>}
533 </Box>
534 }
535 if (budget === 0) return <Box />
536 const parts: RenderElement[] = []
537 let more = 0
538 for (const shell of shells) {
539 const rowsLeft = shells.length - parts.length
540 if (budget - PINNED_ROWS >= rowsLeft - 1) {
541 const card: Card = {
542 els,
543 title: shell.title,
544 command: shell.command,
545 iconMode,
546 run: shell.run,
547 columns,
548 isOpen: false,
549 toggle: undefined,
550 ownsOutput: true,
551 }
552 parts.push(BackgroundLive(card, shell.run.background?.taskId ?? ''))
553 budget -= PINNED_ROWS
554 } else if (budget > 1 || (budget > 0 && rowsLeft === 1)) {
555 parts.push(BandRow(els, shell, columns, iconMode))
556 budget -= 1
557 } else {
558 more += 1
559 }
560 }
561 return (
562 <Box flexDirection="column">
563 {parts}
564 {more > 0 && budget > 0 && <Text dimColor>{` +${more} more in the background`}</Text>}
565 </Box>
566 )
567}
568
569/** What the footer's "1 shell" is doing, in a few words: `→ Run e2e 12/24 24s`. */
570export function footerTail(shells: readonly Pinned[]): string | undefined {
571 if (shells.length === 0) return undefined
572 const parts = shells.slice(0, 1).map(({ title, command, run }) => {
573 const progress = detectProgress(run.tail)
574 const elapsed = elapsedOf(run)
575 return [clip(bandTitle(displayTitle(title, command)), 22), progress?.label, elapsed === undefined ? undefined : duration(elapsed)]
576 .filter(part => part !== undefined)
577 .join(' ')
578 })
579 return `→ ${parts.join(' · ')}${shells.length > 1 ? ` · +${shells.length - 1} more` : ''}`
580}
581hooks/format.ts 160 lines1// Pure helpers: output cleanup, progress detection and the glyph art the
2// cards draw. Nothing here touches `$`, so tests import it directly.
3
4export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
5
6const SPARKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
7
8// CSI and OSC sequences, then any other control character but tab/newline/CR.
9const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g
10const CONTROL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g
11
12/**
13 * The last `count` visible lines of raw terminal output: escapes stripped,
14 * each line resolved to what a terminal shows after its last carriage return
15 * (so `npm`/`curl` progress bars collapse to their latest frame).
16 *
17 * Only the end of the output is cleaned: a few lines more than asked for,
18 * widened while blank lines leave it short, and never past the last 16 KB.
19 */
20export function tailLines(raw: string, count: number): string[] {
21 const floor = Math.max(0, raw.length - 16384)
22 let want = count + 4
23 for (;;) {
24 let at = raw.length
25 for (let seen = 0; seen < want && at > floor; seen++) at = raw.lastIndexOf('\n', at - 1)
26 // `at` at or below the floor means the whole window was scanned. Testing
27 // `at`, not the slice start, matters: a newline exactly at the floor once
28 // left the start one past it, and this loop never ended.
29 const isWhole = at <= floor
30 const lines = visibleLines(raw.slice(isWhole ? floor : at + 1))
31 if (lines.length >= count || isWhole) return lines.slice(-count)
32 want *= 4
33 }
34}
35
36function visibleLines(raw: string): string[] {
37 const text = raw
38 .replace(/\r\n/g, '\n')
39 .replace(ANSI, '')
40 .replace(CONTROL, '')
41 .replace(/\t/g, ' ')
42 const lines = text.split('\n').map(line => {
43 const cut = line.lastIndexOf('\r')
44 return (cut < 0 ? line : line.slice(cut + 1)).trimEnd()
45 })
46 while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
47 return lines
48}
49
50/** Newline count, plus one for an unterminated last line. */
51export function countLines(text: string): number {
52 if (text.length === 0) return 0
53 let count = 0
54 for (let at = text.indexOf('\n'); at >= 0; at = text.indexOf('\n', at + 1)) count++
55 return text.endsWith('\n') ? count : count + 1
56}
57
58export type Progress = { ratio: number; label: string }
59
60const PERCENT = /(?<![\d.])(\d{1,3}(?:\.\d+)?)\s?%/g
61const FRACTION = /(?:^|[\s[(#])(\d{1,6})\s?\/\s?(\d{1,6})(?=[\s\]):,]|$)/g
62
63/**
64 * A progress reading from the newest output lines: a percentage (`45%`), or a
65 * count (`[3/10]`, `Step 4/7`). Only the last two lines count, so a stale
66 * number from earlier output never pins the bar.
67 */
68export function detectProgress(tail: readonly string[]): Progress | undefined {
69 for (const line of tail.slice(-2).reverse()) {
70 const percents = [...line.matchAll(PERCENT)]
71 const percent = percents[percents.length - 1]
72 if (percent !== undefined) {
73 const value = Number(percent[1])
74 if (value >= 0 && value <= 100) return { ratio: value / 100, label: `${Math.round(value)}%` }
75 }
76 const fractions = [...line.matchAll(FRACTION)]
77 const fraction = fractions[fractions.length - 1]
78 if (fraction !== undefined) {
79 const done = Number(fraction[1])
80 const total = Number(fraction[2])
81 if (total >= 2 && done <= total) return { ratio: done / total, label: `${done}/${total}` }
82 }
83 }
84 return undefined
85}
86
87/** A determinate bar `━━━━━╸────`, as its filled and empty halves. */
88export function bar(ratio: number, width: number): { filled: string; empty: string } {
89 const cells = Math.max(0, Math.min(1, ratio)) * width
90 const full = Math.floor(cells)
91 const half = cells - full >= 0.5 && full < width ? '╸' : ''
92 return { filled: '━'.repeat(full) + half, empty: '─'.repeat(width - full - half.length) }
93}
94
95/** An indeterminate bar: a lit segment bouncing along a dim track. */
96export function shimmer(tick: number, width: number): { before: string; lit: string; after: string } {
97 const lit = Math.max(3, Math.round(width / 5))
98 const span = Math.max(1, width - lit)
99 const phase = tick % (span * 2)
100 const at = phase < span ? phase : span * 2 - phase
101 return { before: '─'.repeat(at), lit: '━'.repeat(lit), after: '─'.repeat(width - at - lit) }
102}
103
104/**
105 * Bytes-per-second samples as a sparkline, scaled to their own peak. Each
106 * point averages its neighbours, so output that lands between ticks reads as
107 * a steady flow rather than a comb; quiet stretches sit on the baseline.
108 */
109export function sparkline(samples: readonly number[], width: number): string {
110 const smooth = samples.map((_, i) => {
111 const window = samples.slice(Math.max(0, i - 2), i + 1)
112 return window.reduce((sum, value) => sum + value, 0) / window.length
113 })
114 const recent = smooth.slice(-width)
115 const peak = Math.max(...recent, 1)
116 return recent.map(value => SPARKS[Math.min(7, Math.floor((value / peak) * 7.999))]).join('')
117}
118
119export function duration(ms: number): string {
120 if (ms < 0) return '0s'
121 if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`
122 const seconds = Math.floor(ms / 1000)
123 if (seconds < 60) return `${seconds}s`
124 const minutes = Math.floor(seconds / 60)
125 if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`
126 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
127}
128
129export function size(bytes: number): string {
130 if (bytes < 1024) return `${bytes} B`
131 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
132 return `${(bytes / 1024 / 1024).toFixed(1)} MB`
133}
134
135/** The `Exit code 2` line of an errored Bash call's text, wherever it sits. */
136export function exitCodeOf(text: string): number | undefined {
137 const found = /\bExit code (\d+)\b/.exec(text)
138 return found === null ? undefined : Number(found[1])
139}
140
141export type Notification = { taskId: string; status: string; exitCode?: number }
142
143/** Every `<task-notification>` in a row's text: which task, how it ended. */
144export function parseNotifications(text: string): Notification[] {
145 const found: Notification[] = []
146 for (const [block] of text.matchAll(/<task-notification>[\s\S]*?<\/task-notification>/g)) {
147 const taskId = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(block)?.[1]
148 const status = /<status>\s*([^<\s]+)\s*<\/status>/.exec(block)?.[1]
149 if (taskId === undefined || status === undefined) continue
150 const code = /exit code[:\s]+(-?\d+)/i.exec(block)?.[1]
151 found.push({ taskId, status, ...(code === undefined ? {} : { exitCode: Number(code) }) })
152 }
153 return found
154}
155
156/** The folder name Claude Code files a project's temp data under. */
157export function projectSlug(path: string): string {
158 return path.replace(/[^a-zA-Z0-9]/g, '-')
159}
160hooks/group.tsx 127 lines1// Completed tool blocks keep their identity without repeating every row.
2
3import type { RenderElement, ToolGroupCall } from 'claude-code'
4
5import type { ShellRun } from '../types'
6import type { BashOutput, Card } from './card'
7import { duration } from './format'
8import { commandIcon } from './icons'
9import type { IconMode } from './icons'
10
11export type Group = {
12 els: Card['els']
13 calls: readonly ToolGroupCall[]
14 runs: readonly (ShellRun | undefined)[]
15 iconMode: IconMode
16 columns: number
17 isOpen: boolean
18 toggle?: () => unknown
19}
20
21const TOOL_COMMAND: Record<string, string> = {
22 Read: 'cat', Grep: 'rg', Glob: 'find', LS: 'ls',
23 Write: 'apply_patch', Edit: 'apply_patch', MultiEdit: 'apply_patch',
24 WebFetch: 'curl', WebSearch: 'rg',
25}
26
27const GROUP_ACTIONS: readonly { tools: readonly string[]; verb: string; noun: string; plural?: string }[] = [
28 { tools: ['Grep', 'Glob'], verb: 'searched for', noun: 'pattern' },
29 { tools: ['Read'], verb: 'read', noun: 'file' },
30 { tools: ['LS'], verb: 'listed', noun: 'directory', plural: 'directories' },
31 { tools: ['Write'], verb: 'wrote', noun: 'file' },
32 { tools: ['Edit', 'MultiEdit'], verb: 'edited', noun: 'file' },
33 { tools: ['WebSearch'], verb: 'ran', noun: 'web search', plural: 'web searches' },
34 { tools: ['WebFetch'], verb: 'fetched', noun: 'page' },
35 { tools: ['Bash'], verb: 'ran', noun: 'shell command' },
36]
37
38/** Describe actual tool calls; shell commands keep their icons without being reclassified as native reads/searches. */
39export function groupSummary(calls: Group['calls']): string {
40 const counts = new Map<string, number>()
41 for (const call of calls) counts.set(call.tool, (counts.get(call.tool) ?? 0) + 1)
42 const parts: string[] = []
43 let remaining = calls.length
44 for (const { tools, verb, noun, plural = `${noun}s` } of GROUP_ACTIONS) {
45 const count = tools.reduce((sum, tool) => sum + (counts.get(tool) ?? 0), 0)
46 if (count === 0) continue
47 parts.push(`${verb} ${count} ${count === 1 ? noun : plural}`)
48 remaining -= count
49 }
50 if (remaining > 0) parts.push(`used ${remaining} ${parts.length > 0 ? 'other ' : ''}${remaining === 1 ? 'tool' : 'tools'}`)
51 const summary = parts.join(', ')
52 return summary.charAt(0).toUpperCase() + summary.slice(1)
53}
54
55/** A live, failed, interrupted, pending, or staged result stays inspectable. */
56export function canCollapseGroup(calls: readonly ToolGroupCall[], runs: Group['runs']): boolean {
57 return calls.length > 1 && calls.every((call, index) => {
58 if (call.isRunning || call.isErrored || call.isInterrupted || call.output === undefined) return false
59 const output = call.output !== null && typeof call.output === 'object'
60 ? call.output as BashOutput & { staged?: boolean }
61 : undefined
62 if (output?.staged) return false
63 if (call.tool === 'Bash' && output?.backgroundTaskId !== undefined) {
64 const background = runs[index]?.background
65 return background?.status === 'completed' && (background.exitCode === undefined || background.exitCode === 0)
66 }
67 return true
68 })
69}
70
71/** Repeated operations get one icon and a count; unknown tools share a terminal. */
72export function groupIcons(calls: Group['calls'], mode: IconMode): string {
73 if (mode === 'none') return ''
74 const counts = new Map<string, number>()
75 for (const call of calls) {
76 const input = call.input !== null && typeof call.input === 'object' ? call.input as { command?: unknown } : undefined
77 const command = call.tool === 'Bash' && typeof input?.command === 'string'
78 ? input.command : TOOL_COMMAND[call.tool] ?? '?'
79 const icon = commandIcon(command, mode)
80 if (icon !== '') counts.set(icon, (counts.get(icon) ?? 0) + 1)
81 }
82 return [...counts].map(([icon, count]) => count > 1 ? `${icon}×${count}` : icon).join(' ')
83}
84
85/** Wall time of an observed shell block, including overlaps only once. */
86export function groupDuration(calls: Group['calls'], runs: Group['runs']): number | undefined {
87 if (calls.length === 0 || calls.some(call => call.tool !== 'Bash')) return undefined
88 let first = Infinity
89 let last = 0
90 for (let i = 0; i < calls.length; i++) {
91 const run = runs[i]
92 const end = run?.background?.endedAt ?? run?.endedAt
93 if (run === undefined || run.startedAt === 0 || end === undefined) return undefined
94 first = Math.min(first, run.spawnedAt ?? run.startedAt)
95 last = Math.max(last, end)
96 }
97 return Math.max(0, last - first)
98}
99
100export function drawGroup(group: Group): RenderElement {
101 const { Box, Text, Button } = group.els
102 const { calls, columns, isOpen, toggle } = group
103 const icons = groupIcons(calls, group.iconMode)
104 const elapsed = columns >= 72 ? groupDuration(calls, group.runs) : undefined
105 return (
106 <Box>
107 <Box flexShrink={0}><Text color="success" bold>{'✔ '}</Text></Box>
108 <Box flexGrow={1} flexShrink={1}>
109 <Text wrap="wrap">
110 <Text bold>{groupSummary(calls)}</Text>
111 {icons !== '' && <Text dimColor>{` ${icons}`}</Text>}
112 </Text>
113 </Box>
114 <Box flexShrink={0} marginLeft={2}>
115 {elapsed !== undefined && <Text dimColor>{duration(elapsed)}</Text>}
116 {toggle !== undefined ? (
117 <Box marginLeft={elapsed === undefined ? 0 : 2}>
118 <Button key="group-details" plain dimColor
119 label={columns < 40 ? isOpen ? '▾' : '▸' : isOpen ? '▾ less' : '▸ details'}
120 onPress={toggle} />
121 </Box>
122 ) : columns >= 48 && <Text dimColor>{`${elapsed === undefined ? '' : ' '}ctrl+o`}</Text>}
123 </Box>
124 </Box>
125 )
126}
127hooks/icons.ts 170 lines1// Command labels only: this never evaluates or changes the shell command.
2
3export type IconMode = 'unicode' | 'nerd' | 'nerd-bold' | 'none'
4
5export function iconMode(value: string | undefined): IconMode {
6 const mode = value?.trim().toLowerCase()
7 return mode === 'nerd' || mode === 'nerd-bold' || mode === 'none' ? mode : 'unicode'
8}
9
10// Unicode, then set A (Codicons outlines), then set B (mostly Font Awesome).
11// https://www.nerdfonts.com/cheat-sheet
12const ICONS = {
13 read: ['▤', '\uea7b', '\uf0f6'], // file / file-text-o
14 list: ['≡', '\ueb84', '\uf03a'], // list-flat / list
15 directory: ['↳', '\ueaf7', '\uf07c'], // folder-opened / folder-open
16 search: ['⌕', '\uea6d', '\uf002'], // search
17 edit: ['✎', '\uea73', '\uf040'], // edit / pencil
18 remove: ['⌫', '\uea81', '\uf1f8'], // trash
19 copy: ['⧉', '\uebcc', '\uf0c5'], // copy
20 move: ['→', '\uebcb', '\uf0ec'], // arrow-swap / exchange
21 create: ['⊞', '\uea80', '\u{f0257}'], // new-folder / md-folder-plus
22 git: ['⑂', '\uea68', '\ue702'], // source-control / dev-git
23 test: ['⚗', '\uea79', '\uf0c3'], // beaker / flask
24 build: ['⚙', '\ueaf8', '\uf013'], // gear
25 fetch: ['⇣', '\ueac2', '\uf019'], // cloud-download / download
26 shell: ['$', '\uea85', '\uf120'], // terminal
27} as const
28
29type Kind = keyof typeof ICONS
30
31const COMMANDS: Record<string, Kind> = {
32 cat: 'read', head: 'read', tail: 'read', less: 'read', more: 'read', bat: 'read', batcat: 'read',
33 ls: 'list', tree: 'list', eza: 'list', exa: 'list',
34 cd: 'directory', pushd: 'directory', popd: 'directory',
35 find: 'search', fd: 'search', fdfind: 'search', grep: 'search', egrep: 'search', fgrep: 'search', rg: 'search',
36 vi: 'edit', vim: 'edit', nvim: 'edit', nano: 'edit', apply_patch: 'edit', patch: 'edit',
37 rm: 'remove', rmdir: 'remove', unlink: 'remove',
38 cp: 'copy', mv: 'move', mkdir: 'create', touch: 'create',
39 git: 'git', gh: 'git',
40 pytest: 'test', 'py.test': 'test', jest: 'test', vitest: 'test', mocha: 'test', ctest: 'test',
41 make: 'build', gmake: 'build', cmake: 'build', ninja: 'build', tsc: 'build', esbuild: 'build',
42 curl: 'fetch', wget: 'fetch',
43}
44
45/** Split only at unquoted shell separators; quoted arguments stay together. */
46function* commands(source: string): Generator<{ words: string[]; separator: string; start: number; end: number }> {
47 let words: string[] = []
48 let start = 0
49 let word = ''
50 let started = false
51 let quote = ''
52 const flush = () => {
53 if (started) words.push(word)
54 word = ''
55 started = false
56 }
57 // Labels need only the start, even for a very large heredoc or script.
58 const text = source.slice(0, 4096)
59 for (let i = 0; i < text.length; i++) {
60 const ch = text[i]!
61 if (ch === '\\' && quote !== "'") {
62 const next = text[i + 1]
63 if (next === '\n') { i++; continue }
64 if (next !== undefined) { word += next; started = true; i++; continue }
65 }
66 if (quote !== '') {
67 if (ch === quote) quote = ''
68 else word += ch
69 continue
70 }
71 if (ch === '"' || ch === "'") { quote = ch; started = true; continue }
72 if (ch === '#' && !started) {
73 while (i < text.length && text[i] !== '\n') i++
74 i--
75 continue
76 }
77 if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') {
78 flush()
79 const separator = (ch === '&' || ch === '|') && text[i + 1] === ch ? ch + text[++i] : ch
80 if (words.length > 0) yield { words, separator, start, end: i + 1 }
81 words = []
82 start = i + 1
83 } else if (/\s/.test(ch)) {
84 flush()
85 } else {
86 word += ch
87 started = true
88 }
89 }
90 flush()
91 if (words.length > 0) yield { words, separator: '', start, end: text.length }
92}
93
94/** A display-only label: omit leading cd setup, retaining the original syntax. */
95export function commandLabel(source: string): string {
96 let start = 0
97 for (const part of commands(source)) {
98 // Be conservative around substitutions, whose contents may contain shell
99 // separators of their own. Never hide a standalone cd or a failure branch.
100 if (part.words[0] !== 'cd' || !['&&', ';', '\n'].includes(part.separator)
101 || /\$\(|`/.test(source.slice(part.start, part.end))) break
102 if (source.slice(part.end).trim() === '') break
103 start = part.end
104 }
105 return source.slice(start).replace(/\\\r?\n/g, ' ').trim().split('\n')[0]?.trim() ?? ''
106}
107
108const basename = (word: string) => word.split('/').pop() ?? word
109const ASSIGNMENT = /^[A-Za-z_][A-Za-z_0-9]*=/
110
111/** Strip common execution wrappers, including their option arguments. */
112function executable(words: string[]): string[] {
113 let i = 0
114 while (i < words.length) {
115 if (ASSIGNMENT.test(words[i]!)) { i++; continue }
116 const name = basename(words[i]!)
117 if (!['env', 'sudo', 'command', 'builtin', 'exec', 'time', 'nohup'].includes(name)) break
118 i++
119 while (words[i]?.startsWith('-')) {
120 const option = words[i++]!
121 if (option === '--') break
122 // These ask about a command without executing it.
123 if (name === 'command' && /^-[vV]+$/.test(option)) return ['command']
124 const takesValue = name === 'env' ? ['-u', '--unset', '-C', '--chdir']
125 : name === 'sudo' ? ['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--close-from', '-D', '--chdir']
126 : name === 'exec' ? ['-a'] : []
127 if (takesValue.includes(option)) i++
128 }
129 }
130 return words.slice(i)
131}
132
133function kindOf(words: string[]): Kind {
134 const name = basename(words[0] ?? '')
135 const args = words.slice(1)
136 if (name === 'sed' || name === 'gsed') {
137 for (let i = 0; i < args.length; i++) {
138 const arg = args[i]!
139 if (arg === '--') break
140 if (/^--in-place(?:=|$)|^-[^-ef]*i/.test(arg)) return 'edit'
141 if (/^--(?:expression|file)$|^-[^-]*[ef]$/.test(arg)) i++
142 }
143 return 'read'
144 }
145 if (/^(?:npm|pnpm|yarn|bun|npx|bunx|cargo|go|make|gmake|cmake)$/.test(name)) {
146 let i = 0
147 while (args[i]?.startsWith('-')) {
148 const option = args[i++]!
149 if (['--prefix', '--cwd', '--dir', '-C', '--workspace', '-w', '--filter'].includes(option)) i++
150 }
151 if (args[i] === 'run' || args[i] === 'run-script' || args[i] === 'exec') i++
152 const task = args[i] ?? ''
153 if (/^(?:test|tests|test:.*|test-.*|pytest|jest|vitest|mocha|ctest)$/.test(task)) return 'test'
154 if (/^(?:build|build:.*|build-.*|compile|tsc|esbuild)$/.test(task)) return 'build'
155 }
156 if (/^python(?:\d+(?:\.\d+)?)?$/.test(name) && args[0] === '-m' && ['pytest', 'unittest'].includes(args[1] ?? '')) return 'test'
157 return Object.prototype.hasOwnProperty.call(COMMANDS, name) ? COMMANDS[name]! : 'shell'
158}
159
160/** Use the first operation after leading directory changes, never argument text. */
161export function commandIcon(command: string, mode: IconMode = 'unicode'): string {
162 if (mode === 'none' || command.trim() === '') return ''
163 let kind: Kind = 'shell'
164 for (const { words, separator } of commands(command)) {
165 kind = kindOf(executable(words))
166 if (kind !== 'directory' || !['&&', ';', '\n'].includes(separator)) break
167 }
168 return ICONS[kind][mode === 'nerd-bold' ? 2 : mode === 'nerd' ? 1 : 0]
169}
170hooks/live.ts 303 lines1// Following a shell while it runs. Claude Code streams each shell's combined
2// output to <tmp>/claude-<uid>/<project>/<session>/tasks/b<id>.output; a call
3// claims the first such file that appeared after it started, and a ticker
4// samples every live one into the call's run until it ends.
5
6import type { FsEntry, FsStat, Timer } from 'claude-code'
7
8import type { ShellRun } from '../types'
9import { countLines, projectSlug, tailLines } from './format'
10import type { Notification } from './format'
11
12/** What the tracker needs from the engine, bound to `$` by the hooks module. */
13export type Io = {
14 now: () => Promise<number>
15 sessionId: () => Promise<string>
16 places: () => Promise<string[]>
17 tmpdir: () => Promise<string | undefined>
18 uid: () => Promise<string>
19 tail: (path: string) => Promise<string>
20 list: (path: string) => Promise<FsEntry[]>
21 exists: (path: string) => Promise<boolean>
22 stat: (path: string) => Promise<FsStat>
23 read: (path: string) => Promise<string>
24 every: (ms: number, fn: () => void) => Timer
25 log: (text: string) => void
26 getRun: (id: string) => Promise<ShellRun>
27 setRun: (id: string, change: (run: ShellRun) => ShellRun) => Promise<ShellRun>
28 getActive: () => Promise<readonly string[]>
29 setActive: (change: (ids: readonly string[]) => string[]) => Promise<readonly string[]>
30 setPinned: (change: (ids: readonly string[]) => string[]) => Promise<readonly string[]>
31}
32
33export const TICK_MS = 300
34export const DEFAULT_TIMEOUT_MS = 120_000
35export const READ_WHOLE_BYTES = 256 * 1024
36const KEEP_LINES = 40
37const RATE_SAMPLES = 24
38
39export const BLANK: ShellRun = {
40 startedAt: 0,
41 now: 0,
42 ticks: 0,
43 timeoutMs: DEFAULT_TIMEOUT_MS,
44 bytes: 0,
45 tail: [],
46 rates: [],
47}
48
49// What the module keeps between ticks. A hot reload starts these over; the
50// runs themselves live in `$.state`, so cards keep drawing either way.
51let io: Io | undefined
52let ticker: Timer | undefined
53let isTicking = false
54let tasks: { session: string; dir: string } | undefined
55let lastScanAt = 0
56let uid: string | undefined
57const baselines = new Map<string, ReadonlySet<string>>()
58const claimed = new Set<string>()
59// Keep the owner after unpinning: a delayed completion notice can still supply
60// the real exit status after an engine snapshot only told us it stopped running.
61const taskOwners = new Map<string, string>()
62
63export function connect(next: Io) {
64 io = next
65}
66
67export function connected(): Io | undefined {
68 return io
69}
70
71/** Where a run's live output file sits, once the tasks folder is known. */
72export function streamPath(file: string): string | undefined {
73 return tasks === undefined ? undefined : `${tasks.dir}/${file}`
74}
75
76async function tempBases(io: Io): Promise<string[]> {
77 uid ??= (await io.uid().catch(() => '')) || undefined
78 if (uid === undefined) return []
79 const custom = await io.tmpdir()
80 const roots = [custom, '/tmp'].filter((root): root is string => root !== undefined && root !== '')
81 return [...new Set(roots.map(root => `${root.replace(/\/+$/, '')}/claude-${uid}`))]
82}
83
84export async function tasksDir(io: Io): Promise<string | undefined> {
85 const session = await io.sessionId()
86 if (tasks?.session === session) return tasks.dir
87 const now = await io.now()
88 const bases = await tempBases(io)
89 const places = new Set(await io.places())
90 for (const base of bases) {
91 for (const place of places) {
92 const dir = `${base}/${projectSlug(place)}/${session}/tasks`
93 if (await io.exists(dir)) return (tasks = { session, dir }).dir
94 }
95 }
96 // A long path is hashed into its folder's name: look the session up
97 // instead, at most every few seconds.
98 if (now - lastScanAt < 5000) return undefined
99 lastScanAt = now
100 for (const base of bases) {
101 for (const project of await io.list(base).catch(() => [])) {
102 if (project.kind !== 'dir') continue
103 const dir = `${base}/${project.name}/${session}/tasks`
104 if (await io.exists(dir)) return (tasks = { session, dir }).dir
105 }
106 }
107 return undefined
108}
109
110async function outputsIn(io: Io, dir: string): Promise<Set<string>> {
111 const entries = await io.list(dir).catch(() => [])
112 return new Set(entries.filter(entry => /^b\w+\.output$/.test(entry.name)).map(entry => entry.name))
113}
114
115async function readOutput(io: Io, path: string, bytes: number) {
116 if (bytes <= READ_WHOLE_BYTES) {
117 const text = await io.read(path)
118 return { text, lines: countLines(text) }
119 }
120 const text = await io.tail(path)
121 return { text: text.slice(text.indexOf('\n') + 1), lines: undefined }
122}
123
124/** One look at a live output file: what changed since the run's last tick. */
125async function sample(io: Io, path: string, run: ShellRun, now: number): Promise<Partial<ShellRun> | 'gone'> {
126 const stat = await io.stat(path).catch(() => undefined)
127 if (stat === undefined) return 'gone'
128 const seconds = Math.max(0.05, (now - run.now) / 1000)
129 const rate = Math.max(0, (stat.size - run.bytes) / seconds)
130 const rates = [...run.rates, rate].slice(-RATE_SAMPLES)
131 if (stat.size === run.bytes) return { rates }
132 const output = await readOutput(io, path, stat.size).catch(() => undefined)
133 if (output === undefined) return { rates }
134 return {
135 rates,
136 bytes: stat.size,
137 tail: tailLines(output.text, KEEP_LINES),
138 ...(output.lines === undefined ? {} : { lines: output.lines }),
139 }
140}
141
142async function tick(io: Io) {
143 if (isTicking) return
144 isTicking = true
145 try {
146 const ids = await io.getActive()
147 if (ids.length === 0) {
148 stopTicker()
149 return
150 }
151 const now = await io.now()
152 const dir = await tasksDir(io).catch(() => undefined)
153 let present: Set<string> | undefined
154 const finished: string[] = []
155 let isUnpinning = false
156 for (const id of ids) {
157 const run = await io.getRun(id)
158 if (run.startedAt === 0) {
159 finished.push(id)
160 continue
161 }
162 let file = run.file
163 const baseline = baselines.get(id)
164 if (file === undefined && dir !== undefined && baseline !== undefined) {
165 present ??= await outputsIn(io, dir)
166 file = [...present].find(name => !baseline.has(name) && !claimed.has(name))
167 if (file !== undefined) {
168 claimed.add(file)
169 io.log(`${id} streams ${dir}/${file}`)
170 }
171 }
172 const seen = file !== undefined && dir !== undefined ? await sample(io, `${dir}/${file}`, run, now) : {}
173 const isGone = seen === 'gone'
174 const isOverdue = file === undefined && now - run.startedAt > run.timeoutMs + 60_000
175 const isSettled = run.background !== undefined && run.background.status !== 'running'
176 if (isGone || isOverdue || isSettled) {
177 finished.push(id)
178 isUnpinning ||= run.background !== undefined
179 }
180 await io.setRun(id, current => ({
181 ...current,
182 ...(isGone ? {} : seen),
183 ...(file !== undefined && current.file === undefined ? { file, spawnedAt: now } : {}),
184 now,
185 ticks: current.ticks + 1,
186 }))
187 }
188 if (finished.length > 0) {
189 await io.setActive(list => list.filter(id => !finished.includes(id)))
190 if (isUnpinning) await io.setPinned(list => list.filter(id => !finished.includes(id)))
191 }
192 } catch (error) {
193 io.log(`tick failed: ${String(error)}`)
194 } finally {
195 isTicking = false
196 }
197}
198
199export function ensureTicker() {
200 if (ticker !== undefined || io === undefined) return
201 const bound = io
202 ticker = bound.every(TICK_MS, () => void tick(bound))
203}
204
205function stopTicker() {
206 ticker?.cancel()
207 ticker = undefined
208}
209
210/** A shell call is about to run: remember which output files already exist. */
211export async function begin(io: Io, id: string, timeoutMs: number | undefined, title: string, command: string) {
212 const startedAt = await io.now()
213 const dir = await tasksDir(io).catch(() => undefined)
214 baselines.set(id, dir === undefined ? new Set() : await outputsIn(io, dir))
215 await io.setRun(id, () => ({ ...BLANK, title, command, startedAt, now: startedAt, timeoutMs: timeoutMs ?? DEFAULT_TIMEOUT_MS }))
216 await io.setActive(list => [...list.filter(other => other !== id), id])
217 ensureTicker()
218}
219
220/** The call returned: done, or moved to the background under `taskId`. */
221export async function end(io: Io, id: string, taskId: string | undefined) {
222 baselines.delete(id)
223 const endedAt = await io.now()
224 if (taskId === undefined) {
225 const run = await io.setRun(id, current => ({
226 ...current,
227 now: endedAt,
228 endedAt,
229 tail: current.tail.slice(-12),
230 rates: [],
231 }))
232 io.log(`${id} ended after ${run.ticks} ticks, ${run.bytes} B, tail ${JSON.stringify(run.tail.slice(-2))}`)
233 await io.setActive(list => list.filter(other => other !== id))
234 return
235 }
236 const file = `${taskId}.output`
237 claimed.add(file)
238 taskOwners.set(taskId, id)
239 await io.setRun(id, run => ({
240 ...run,
241 now: endedAt,
242 file,
243 spawnedAt: run.spawnedAt ?? endedAt,
244 background: { taskId, status: 'running' },
245 }))
246 await io.setPinned(list => [...list.filter(other => other !== id), id])
247 ensureTicker()
248}
249
250/** A background shell ended (its notification arrived, or TaskStop ran). */
251export async function settle(io: Io, note: Notification) {
252 if (!['completed', 'failed', 'killed', 'stopped', 'finished'].includes(note.status)) return
253 const owner = taskOwners.get(note.taskId)
254 const ids = owner === undefined ? await io.getActive() : [owner]
255 for (const id of ids) {
256 const run = await io.getRun(id)
257 if (run.background?.taskId !== note.taskId) continue
258 if (note.status === 'finished' && run.background.status !== 'running') continue
259 taskOwners.set(note.taskId, id)
260 const now = await io.now()
261 const path = run.file === undefined ? undefined : streamPath(run.file)
262 const last = path === undefined ? 'gone' : await sample(io, path, run, now)
263 await io.setRun(id, current => ({
264 ...current,
265 ...(last === 'gone' ? {} : last),
266 now,
267 endedAt: now,
268 background: {
269 taskId: note.taskId,
270 status: note.status,
271 endedAt: now,
272 ...(note.exitCode === undefined ? {} : { exitCode: note.exitCode }),
273 },
274 }))
275 await io.setActive(list => list.filter(other => other !== id))
276 await io.setPinned(list => list.filter(other => other !== id))
277 }
278}
279
280/** The engine's in-flight tasks are authoritative, even if a notice was lost.
281 * Absence proves only that a shell ended, never that it succeeded.
282 */
283export async function reconcile(io: Io, tasks: readonly { id: string; type: string }[]) {
284 const running = new Set(tasks.filter(task => task.type === 'shell').map(task => task.id))
285 for (const id of await io.getActive()) {
286 const run = await io.getRun(id)
287 if (run.background?.status === 'running' && !running.has(run.background.taskId)) {
288 await settle(io, { taskId: run.background.taskId, status: 'finished' })
289 }
290 }
291}
292
293/** The text blocks of a conversation row, joined. */
294export function textOf(content: readonly unknown[]): string {
295 let text = ''
296 for (const block of content) {
297 if (typeof block === 'object' && block !== null && 'text' in block && typeof block.text === 'string') {
298 text += block.text
299 }
300 }
301 return text
302}
303types/index.d.ts 50 lines1/** A background shell's life after its call returned. */
2export type ShellBackground = {
3 taskId: string
4 /** `running`, then the notification's status; `finished` if the exit is unknown. */
5 status: string
6 exitCode?: number
7 endedAt?: number
8}
9
10/** What the mod observed of one Bash call, keyed by its tool_use_id. */
11export type ShellRun = {
12 /** What the call was for: its description, else its command's first line. */
13 title?: string
14 /** The command line it ran. */
15 command?: string
16 /** When `tool.call` fired (includes any permission wait); 0 when unknown. */
17 startedAt: number
18 /** When its output file first appeared: the process really started. */
19 spawnedAt?: number
20 endedAt?: number
21 /** The last tick's clock; drives elapsed time while running. */
22 now: number
23 /** Ticks seen while live; drives the spinner and shimmer frames. */
24 ticks: number
25 timeoutMs: number
26 /** The live output file's name in the session's tasks folder. */
27 file?: string
28 bytes: number
29 lines?: number
30 /** Last lines of output, ANSI stripped, carriage returns resolved. */
31 tail: string[]
32 /** Bytes per second, one sample per tick, newest last. */
33 rates: number[]
34 background?: ShellBackground
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'shellcast': {
40 runs: StateFamily<ShellRun>
41 expanded: StateFamily<boolean>
42 /** Per-tool-block disclosure, separate from each shell's output details. */
43 groupExpanded: StateFamily<boolean>
44 active: string[]
45 /** The background shells still running: the band and footer read only these. */
46 pinned: string[]
47 }
48 }
49}
50