SLOPSHOPPER

shellcast

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

newbandspinnerrowsguardprocess
★ 2v0.2.3no licenseupdated 2026-10-08jeffarese/shellcast
A shopper browsing a rack in a slop shop
README

shellcast

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

Install

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.

What it shows

StateCard
StartingA quiet one-line row with a spinner.
RunningAlways 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.
DoneA 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 blockConsecutive 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.
FailedThe same one-liner in red, with ✘ and the exit code. A call you declined shows as ⊘ not run, not as a failure.
BackgroundA 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.
DetailsIn 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.

Command icons

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:

ValueIcons
unicode (default)Plain symbols such as ▤ for reading, ≡ for listing and ↳ for navigation.
nerdSet A: thin Codicons outlines.
nerd-boldSet B: bolder Font Awesome symbols, with a Git logo and folder-plus icon.
noneStatus 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.

How live output works

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.

Develop

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

Source 7 files
hooks/register.tsx 204 lines
1import { 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}
204
hooks/card.tsx 581 lines
1// 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}
581
hooks/format.ts 160 lines
1// 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}
160
hooks/group.tsx 127 lines
1// 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}
127
hooks/icons.ts 170 lines
1// 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}
170
hooks/live.ts 303 lines
1// 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}
303
types/index.d.ts 50 lines
1/** 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