@workerdeck/ui 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +60 -0
  2. package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
  3. package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
  4. package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
  5. package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
  6. package/build/format.d.mts +39 -4
  7. package/build/format.mjs +2 -118
  8. package/build/index.d.mts +493 -45
  9. package/build/index.mjs +343 -17
  10. package/build/index.mjs.map +1 -1
  11. package/build/status-Ydzi7n6j.mjs +143 -0
  12. package/build/status-Ydzi7n6j.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -1
  14. package/build/workspace.mjs +108 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +16 -7
  17. package/src/components/agent/Composer.tsx +189 -89
  18. package/src/components/agent/Conversation.tsx +12 -12
  19. package/src/components/agent/FileCard.tsx +0 -26
  20. package/src/components/agent/FileTree.tsx +9 -8
  21. package/src/components/agent/Loader.tsx +22 -72
  22. package/src/components/agent/Message.tsx +11 -46
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/ProjectIcon.tsx +119 -0
  25. package/src/components/agent/QuestionPrompt.tsx +0 -122
  26. package/src/components/agent/Reasoning.tsx +5 -19
  27. package/src/components/agent/Response.tsx +1 -132
  28. package/src/components/agent/SessionBrowser.tsx +84 -3
  29. package/src/components/agent/SessionPanel.tsx +249 -28
  30. package/src/components/agent/SessionWorkspace.tsx +29 -0
  31. package/src/components/agent/StatusBar.tsx +20 -4
  32. package/src/components/agent/ToolCallCard.tsx +85 -112
  33. package/src/components/agent/Transcript.tsx +780 -203
  34. package/src/components/agent/UsageDialog.tsx +20 -106
  35. package/src/components/agent/UsageMeters.tsx +133 -0
  36. package/src/components/agent/pulse.tsx +3 -2
  37. package/src/components/agent/tool-result-fetch.tsx +36 -0
  38. package/src/components/agent/tool-result-image.tsx +209 -0
  39. package/src/components/agent/transcript-rows.ts +173 -0
  40. package/src/components/agent/transcript-variant.tsx +29 -51
  41. package/src/components/agent/use-height-epoch.ts +60 -0
  42. package/src/components/agent/use-path-links.ts +147 -0
  43. package/src/components/agent/use-transcript-jumps.ts +190 -0
  44. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  45. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  46. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  47. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  48. package/src/components/terminal/StatusLine.tsx +159 -0
  49. package/src/components/terminal/TerminalTranscript.tsx +225 -0
  50. package/src/components/terminal/affordances.tsx +118 -0
  51. package/src/components/terminal/blocks.ts +232 -0
  52. package/src/components/terminal/diff.tsx +130 -0
  53. package/src/components/terminal/height.ts +770 -0
  54. package/src/components/terminal/image-box.ts +53 -0
  55. package/src/components/terminal/items.tsx +486 -0
  56. package/src/components/terminal/markdown.tsx +191 -0
  57. package/src/components/terminal/press.tsx +120 -0
  58. package/src/components/terminal/prompt.tsx +343 -0
  59. package/src/components/terminal/result-preview.ts +86 -0
  60. package/src/components/terminal/row.tsx +132 -0
  61. package/src/components/terminal/scrubber.tsx +784 -0
  62. package/src/components/terminal/surface.tsx +80 -0
  63. package/src/components/terminal/tool-run.ts +224 -0
  64. package/src/index.ts +36 -1
  65. package/src/lib/status.ts +59 -3
  66. package/src/lib/tool-icon.ts +14 -0
  67. package/src/styles/terminal.css +1081 -0
  68. package/src/styles/theme.css +41 -0
  69. package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
  70. package/build/format.mjs.map +0 -1
  71. package/src/components/agent/line-prompt.tsx +0 -249
@@ -0,0 +1,53 @@
1
+ import { formatBytes } from '../../lib/format.ts'
2
+
3
+ /**
4
+ * The box a tool result's image is drawn in, and the words drawn in it before
5
+ * the bytes arrive.
6
+ *
7
+ * Its own module, and pure, for `result-preview.ts`'s reason with a constant
8
+ * standing where a string stood: `items.tsx` draws these boxes and `height.ts`
9
+ * predicts their pixel height for the virtualizer's `estimateSize` without a
10
+ * DOM. Two spellings of the box would be two different heights, and the row
11
+ * would grow or shrink the moment it mounted.
12
+ *
13
+ * **A fixed box, sized in whole lines, reserved from plan time.** An image's
14
+ * intrinsic dimensions are not knowable before its bytes are, and the
15
+ * alternative — an `exact: false` row corrected on mount — would demote *most*
16
+ * rows of an image-bearing session to estimates and bring back the growing
17
+ * scrollbar the calculator exists to kill. A box that does not depend on what is
18
+ * inside it is exact by definition, at the cost of some letterboxing.
19
+ */
20
+
21
+ /**
22
+ * Whole lines per image. 12 ≈ 240px at an 18px line — big enough that a
23
+ * screenshot is legible as *what it is* (which is the whole reason images became
24
+ * visible at all), small enough that a call returning four of them does not
25
+ * become a screenful.
26
+ */
27
+ export const IMAGE_BOX_LINES = 12
28
+
29
+ /**
30
+ * What the box says before the fetch lands.
31
+ *
32
+ * `bytes` is the decoded size the gateway stamped on the reference — the client
33
+ * holds no bytes at all until it asks for them, so this is a number it cannot
34
+ * compute, the same reason `total_chars` rides beside a truncated head.
35
+ * `formatBytes` rather than a spelling of its own: the panel says "336.0 KB"
36
+ * everywhere else, and a second byte formatter is a second thing to keep in
37
+ * step.
38
+ */
39
+ export function imagePlaceholder(image: { bytes: number }): string {
40
+ return `image · ${formatBytes(image.bytes)}`
41
+ }
42
+
43
+ /**
44
+ * What it says when the fetch failed — a stale address after a dormant wake
45
+ * (the route 404s rather than serving another call's pixels), a gateway too old
46
+ * to know the route, a dropped connection.
47
+ *
48
+ * It occupies the same box, because the alternative is the row changing height
49
+ * on a network failure. Silence is not an option here the way it is for
50
+ * `HostImage`: that card names the host path in its result text, so a reader can
51
+ * still find the picture; a replayed image part has no path to name.
52
+ */
53
+ export const IMAGE_UNAVAILABLE = 'image unavailable'
@@ -0,0 +1,486 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { TranscriptItem } from '@workerdeck/react'
3
+ import { formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
4
+ import { isMutatingTool } from '../../lib/tool-icon.ts'
5
+ import { usePulse } from '../agent/pulse.tsx'
6
+ import { CopyAction, WithActions } from './affordances.tsx'
7
+ import { TerminalDiff } from './diff.tsx'
8
+ import { TerminalMarkdown } from './markdown.tsx'
9
+ import { Pressable, useRevealOnOpen } from './press.tsx'
10
+ import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, imagePlaceholder } from './image-box.ts'
11
+ import { collapsedResult } from './result-preview.ts'
12
+ import { useToolResultFetcher } from '../agent/tool-result-fetch.tsx'
13
+ import { useToolResultImageSrc } from '../agent/tool-result-image.tsx'
14
+ import { runFailed, runSummary } from './tool-run.ts'
15
+ import { type ToolCallItem } from './blocks.ts'
16
+ import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
17
+
18
+ // The pure block model — which rows exist — lives in `blocks.ts` now that a
19
+ // task block made it non-trivial; re-exported here because this file is where
20
+ // consumers have always found it.
21
+ export {
22
+ blockNeedsBlank,
23
+ isRunCall,
24
+ needsBlank,
25
+ parentOf,
26
+ taskChildItems,
27
+ terminalBlocks,
28
+ type ItemBlock,
29
+ type LeafBlock,
30
+ type RunBlock,
31
+ type TaskBlock,
32
+ type TerminalBlock,
33
+ type ToolCallItem,
34
+ } from './blocks.ts'
35
+
36
+ /**
37
+ * One transcript item, drawn as terminal rows.
38
+ *
39
+ * Each renderer here answers the same two questions the CLI answers: which
40
+ * marker goes in the gutter, and what the body says. Nothing chooses a spacing,
41
+ * a radius or a border — a row is a row, and the space between blocks is a
42
+ * {@link Blank} decided by the transcript, which is the only thing that knows
43
+ * whether two blocks belong together.
44
+ *
45
+ * The markers are the CLI's:
46
+ *
47
+ * | glyph | means |
48
+ * |-------|------------------------------------------|
49
+ * | `❯` | what you typed |
50
+ * | `●` | what the model said, or a tool it called |
51
+ * | `⎿` | that tool's output, one level in |
52
+ * | `✻` | thinking |
53
+ * | `!` | a notice from the runner, not the model |
54
+ */
55
+
56
+ /**
57
+ * The prompt marker, in the transcript and in the composer both.
58
+ *
59
+ * `❯` rather than `>`: it is the shell prompt of every terminal anyone has
60
+ * configured this decade, and it reads as a *prompt* where `>` reads as a
61
+ * quotation or a greater-than. Exported because the composer is the same
62
+ * marker in the same gutter cell — that is the whole claim of the terminal
63
+ * composer, and two spellings of it would put the caret one glyph off the
64
+ * column the rows above start on.
65
+ */
66
+ export const PROMPT_GLYPH = '❯'
67
+
68
+ /** How much the expanded row shows before offering the rest. The collapsed
69
+ * budget is `collapsedResult`'s, shared with the height calculator.
70
+ *
71
+ * Exported for one test and not from the package: protocol's
72
+ * `TOOL_RESULT_HEAD_CHARS` is chosen to exceed it, so that a truncated result's
73
+ * open state is byte-identical to an untruncated one and only the uncapped
74
+ * `full` press ever fetches. That relationship is asserted, not assumed. */
75
+ export const RESULT_PREVIEW_CHARS = 2000
76
+
77
+ /** Whole lines up to a character budget — never zero, because a single line
78
+ * longer than the budget still has to be shown or the row would open onto
79
+ * nothing. */
80
+ function clipToChars(lines: string[], maxChars: number): string[] {
81
+ const out: string[] = []
82
+ let chars = 0
83
+ for (const line of lines) {
84
+ if (out.length > 0 && chars + line.length > maxChars) break
85
+ out.push(line)
86
+ chars += line.length + 1
87
+ }
88
+ return out
89
+ }
90
+
91
+ export function UserRow({ item }: { item: Extract<TranscriptItem, { kind: 'user' }> }) {
92
+ return (
93
+ <div className='term-user'>
94
+ {item.attachments?.length ? (
95
+ <Row glyph={PROMPT_GLYPH} glyphTone='dim' tone='dim'>
96
+ {item.attachments.map((attachment) => attachment.name).join(', ')}
97
+ </Row>
98
+ ) : null}
99
+ {/* Every line of a multi-line prompt keeps the band and the column; only
100
+ the first keeps the marker, exactly as a shell continuation does. */}
101
+ {item.text
102
+ ? item.text.split('\n').map((line, index) => (
103
+ <Row
104
+ key={index}
105
+ glyph={index === 0 ? PROMPT_GLYPH : undefined}
106
+ glyphTone='dim'
107
+ tone='fg'>
108
+ {line || ' '}
109
+ </Row>
110
+ ))
111
+ : null}
112
+ </div>
113
+ )
114
+ }
115
+
116
+ export function AssistantRow({
117
+ item,
118
+ }: {
119
+ item: Extract<TranscriptItem, { kind: 'assistant_text' }>
120
+ }) {
121
+ return (
122
+ // Copy the markdown source, not the rendered text: what you paste into an
123
+ // issue or a commit message should keep its lists and its code fences.
124
+ // Absent while streaming — half a message is not a thing anyone wants on
125
+ // their clipboard, and the button would appear mid-sentence.
126
+ <WithActions
127
+ actions={item.streaming ? null : <CopyAction text={item.text} label='Copy message' />}>
128
+ <Row glyph='●' glyphTone='fg' tone='fg'>
129
+ <TerminalMarkdown streaming={item.streaming}>{item.text}</TerminalMarkdown>
130
+ </Row>
131
+ </WithActions>
132
+ )
133
+ }
134
+
135
+ export function ThinkingRow({ item }: { item: Extract<TranscriptItem, { kind: 'thinking' }> }) {
136
+ return (
137
+ <Row glyph='✻' glyphTone='dim' tone='dim'>
138
+ <span className='term-em'>{item.text}</span>
139
+ </Row>
140
+ )
141
+ }
142
+
143
+ /** The gutter dot's colour: the call's state, said without a badge. */
144
+ const TOOL_TONE: Record<string, Tone> = {
145
+ running: 'blue',
146
+ pending: 'blue',
147
+ deferred: 'yellow',
148
+ settled: 'dim',
149
+ failed: 'red',
150
+ }
151
+
152
+ export function ToolRow({ item }: { item: ToolCallItem }) {
153
+ const [open, setOpen] = useState(false)
154
+ const [full, setFull] = useState(false)
155
+ // Set while the rest of a truncated result is in flight. Row-local, unlike the
156
+ // text itself, which lands in transcript state — this is a spinner, not a
157
+ // fact about the session.
158
+ const [fetching, setFetching] = useState(false)
159
+ const fetchResult = useToolResultFetcher()
160
+ const reveal = useRevealOnOpen(open)
161
+ const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
162
+ const busy = status === 'running' || status === 'pending'
163
+ const isError = status === 'failed' || item.result?.isError === true
164
+ // Ticks only while this row is really running: an idle transcript of a hundred
165
+ // settled calls starts no timers at all.
166
+ const pulse = usePulse(busy)
167
+
168
+ const text = item.result?.text ?? ''
169
+ const lines = text.trimEnd().split('\n')
170
+ // Three states, not two. Collapsed shows a few lines; open shows the output up
171
+ // to a character budget; `full` lifts the budget. The middle one is the reason
172
+ // the budget exists at all: a tool result can be a hundred thousand characters
173
+ // (a test run, a `find /`), and the whole of it lands in **one** virtual row —
174
+ // the virtualizer mounts rows, so it cannot help with what is inside a single
175
+ // one. Without the clip, expanding one row commits thousands of DOM nodes and
176
+ // the transcript stops being smooth for the rest of the session.
177
+ // The true total when the replay delivered only a head — the row must count
178
+ // what is missing, not what it happens to hold.
179
+ const collapsed = collapsedResult(lines, item.result?.totalChars)
180
+ const preview = open
181
+ ? full
182
+ ? lines
183
+ : clipToChars(lines, RESULT_PREVIEW_CHARS)
184
+ : collapsed.shown
185
+ const hidden = lines.length - preview.length
186
+ const clipped = open && !full && hidden > 0
187
+ // The replay sent a head. `full` then means "fetch the rest", not "lift the
188
+ // clip" — and the marker outlives the clip, because a head short enough to fit
189
+ // the open budget still is not the result.
190
+ const truncated = item.result?.truncated === true
191
+ const missing = truncated ? (item.result?.totalChars ?? 0) - text.length : 0
192
+
193
+ const tone: Tone = isError
194
+ ? 'red'
195
+ : // A settled write is green: skimming a run, "what did it change" is the
196
+ // question you come back to, and the one you might need to undo.
197
+ status === 'settled' && isMutatingTool(item.name)
198
+ ? 'green'
199
+ : (TOOL_TONE[status] ?? 'dim')
200
+
201
+ // What is worth having on the clipboard from a tool call is the command you
202
+ // would re-run, when there is one, and otherwise its output.
203
+ const command = (item.input as { command?: unknown } | null)?.command
204
+ const copyable = typeof command === 'string' ? command : text
205
+
206
+ return (
207
+ // Open, the whole block keeps a fill: an expansion that runs past the top of
208
+ // the screen otherwise leaves no mark of where it began, and the reader has
209
+ // to guess which rows they opened.
210
+ <div ref={reveal} className={open ? 'term-open' : undefined}>
211
+ <WithActions
212
+ actions={copyable ? <CopyAction text={copyable} label='Copy' /> : null}>
213
+ <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
214
+ <Row glyph={busy ? pulse : '●'} glyphTone={tone} tone='fg'>
215
+ <Ink bold tone='bright'>
216
+ {item.name}
217
+ </Ink>
218
+ <Ink tone='dim'>({toolInputPreview(item.input)})</Ink>
219
+ {item.backend && item.backend !== 'server' ? (
220
+ <Ink tone='faint'> · {item.backend}</Ink>
221
+ ) : null}
222
+ </Row>
223
+ </Pressable>
224
+ {/* Above the output, because when a call returned a picture the picture is
225
+ what the call was: a screenshot's result text is "took a screenshot".
226
+ Drawn collapsed as well as open — this is not detail behind a press,
227
+ it is the answer. */}
228
+ {item.result?.images?.map((image) => (
229
+ <TerminalImage key={image.partIndex} toolUseId={item.id} image={image} />
230
+ ))}
231
+ {/* A file edit shows its diff, not its result prose: "The file has been
232
+ updated" is what the *model* needed to hear, and the change is what the
233
+ reader did. The text stays reachable by expanding. */}
234
+ {item.patch && !open ? (
235
+ <TerminalDiff patch={item.patch} />
236
+ ) : text ? (
237
+ <>
238
+ {preview.map((line, index) => (
239
+ <Row
240
+ key={index}
241
+ indent={1}
242
+ columns={3}
243
+ glyph={index === 0 ? '⎿' : undefined}
244
+ tone={isError ? 'red' : 'dim'}>
245
+ {line || ' '}
246
+ </Row>
247
+ ))}
248
+ {/* One row for "there is more", pressable exactly when pressing it
249
+ would do something. Collapsed, the count is a label — the header
250
+ above is already the toggle, and a second control for the same act
251
+ is one too many. Open and clipped, it is the way to the rest.
252
+ Collapsed spells its own label (it may be counting characters
253
+ rather than lines, having cut inside one), and it is the string
254
+ `height.ts` sizes the row from. */}
255
+ {!open ? (
256
+ collapsed.more ? (
257
+ <Row indent={1} columns={3} tone='faint'>
258
+ {collapsed.more}
259
+ </Row>
260
+ ) : null
261
+ ) : clipped || truncated ? (
262
+ <Row indent={1} columns={3} tone='faint'>
263
+ {fetching ? (
264
+ // Never a row that does nothing when pressed: it says what it is
265
+ // doing instead. See `planToolCall`'s comment on the same rule.
266
+ <>… fetching {(item.result?.totalChars ?? 0).toLocaleString()} chars</>
267
+ ) : clipped || truncated ? (
268
+ <button
269
+ type='button'
270
+ className='term-press term-link'
271
+ onClick={() => {
272
+ // One press, two acts, in the order that keeps the row
273
+ // honest: lift the clip immediately (that part is local and
274
+ // instant), and fetch the rest when there is a rest. The
275
+ // fetched text lands in transcript state, so the row
276
+ // re-renders with the marker gone.
277
+ setFull(true)
278
+ if (!truncated) return
279
+ setFetching(true)
280
+ void fetchResult(item.id).finally(() => setFetching(false))
281
+ }}>
282
+ {truncated
283
+ ? `… +${missing.toLocaleString()} chars — fetch the rest`
284
+ : `… +${hidden} line${hidden === 1 ? '' : 's'} — show all ${text.length.toLocaleString()} chars`}
285
+ </button>
286
+ ) : (
287
+ <>
288
+ … +{hidden} line{hidden === 1 ? '' : 's'}
289
+ </>
290
+ )}
291
+ </Row>
292
+ ) : hidden > 0 ? (
293
+ <Row indent={1} columns={3} tone='faint'>
294
+ … +{hidden} line{hidden === 1 ? '' : 's'}
295
+ </Row>
296
+ ) : null}
297
+ </>
298
+ ) : null}
299
+ </WithActions>
300
+ </div>
301
+ )
302
+ }
303
+
304
+ /** One image part of a tool result, as the reducer holds it. */
305
+ type ToolResultImage = NonNullable<NonNullable<ToolCallItem['result']>['images']>[number]
306
+
307
+ /**
308
+ * A picture a tool returned, in a box of {@link IMAGE_BOX_LINES} whole lines.
309
+ *
310
+ * **Three states, one height.** Before the fetch lands the box is a wash and the
311
+ * size the gateway declared; after it, the picture, letterboxed inside the same
312
+ * box; on a refusal, `image unavailable` in it. Nothing here may ever collapse
313
+ * to nothing — that is `HostImage`'s return-null-then-pop, which in a
314
+ * *virtualized* list is not a flicker but a reflow of every row below it, and
315
+ * the height calculator would have been lying about the row from plan time.
316
+ *
317
+ * The box is why the calculator can stay exact: it is a constant, not a function
318
+ * of pixels nobody has downloaded yet.
319
+ */
320
+ function TerminalImage({ toolUseId, image }: { toolUseId: string; image: ToolResultImage }) {
321
+ const { src, failed } = useToolResultImageSrc({ toolUseId, ...image })
322
+ return (
323
+ <Row indent={1} columns={3}>
324
+ <div
325
+ className='term-image'
326
+ data-state={src ? 'loaded' : failed ? 'failed' : 'pending'}
327
+ // The one measurement in this file, and it is the shared constant
328
+ // spelled once — `height.ts` adds exactly this many lines for exactly
329
+ // this box.
330
+ style={{ height: `calc(var(--term-line) * ${IMAGE_BOX_LINES})` }}>
331
+ {src ? (
332
+ <img src={src} alt={imagePlaceholder(image)} />
333
+ ) : (
334
+ <Ink tone='faint'>{failed ? IMAGE_UNAVAILABLE : imagePlaceholder(image)}</Ink>
335
+ )}
336
+ </div>
337
+ </Row>
338
+ )
339
+ }
340
+
341
+ /**
342
+ * A run of tool calls, as one line.
343
+ *
344
+ * The CLI's own compression, and the reason it works: a tool call is almost
345
+ * never what you came back to read. `Bash(pnpm -w typecheck)` and forty lines of
346
+ * its output say nothing the model's next sentence doesn't say better, and six
347
+ * of them in a row bury that sentence a screen and a half down. So a run
348
+ * collapses to its count and gets out of the way — and opens, in full, the
349
+ * moment it is the thing you actually want.
350
+ *
351
+ * The membership, wording and failure rules live in `tool-run.ts`, shared with
352
+ * the height calculator. A failure never breaks the run — fragmenting it around
353
+ * one would hide the failure in a longer list rather than surface it — but only
354
+ * the run's **last** call colours it, because that is the run's outcome and an
355
+ * outcome is what a collapsed row can honestly claim (see `runFailed`).
356
+ */
357
+ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
358
+ const [open, setOpen] = useState(false)
359
+ const reveal = useRevealOnOpen(open)
360
+ const busy = items.some((item) => {
361
+ const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
362
+ return status === 'running' || status === 'pending'
363
+ })
364
+ const failed = runFailed(items)
365
+ const pulse = usePulse(busy)
366
+
367
+ return (
368
+ <div ref={reveal} className={open ? 'term-open' : undefined}>
369
+ <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
370
+ {/* No marker once settled: a run of calls is an aside, and a bullet
371
+ would give it the weight of something the model said. While one is
372
+ running the pulse earns the gutter — that much is news. */}
373
+ <Row
374
+ glyph={busy ? pulse : undefined}
375
+ glyphTone={busy ? 'mark' : undefined}
376
+ tone={failed ? 'red' : 'dim'}>
377
+ {runSummary(items, busy)}
378
+ </Row>
379
+ </Pressable>
380
+ {open ? (
381
+ <div>
382
+ {items.map((item) => (
383
+ <ToolRow key={item.id} item={item} />
384
+ ))}
385
+ </div>
386
+ ) : null}
387
+ </div>
388
+ )
389
+ }
390
+
391
+ export function TurnResultRow({
392
+ item,
393
+ }: {
394
+ item: Extract<TranscriptItem, { kind: 'turn_result' }>
395
+ }) {
396
+ return (
397
+ <div>
398
+ <Row tone={item.isError ? 'red' : 'faint'}>
399
+ {item.isError ? item.subtype : 'done'} · {formatDuration(item.durationMs)} ·{' '}
400
+ {formatCost(item.totalCostUsd)}
401
+ </Row>
402
+ {/* A failed turn's reasons are the whole point of the row — dropping them
403
+ leaves "error_during_execution" and nothing to act on. */}
404
+ {item.errors?.map((message, index) => (
405
+ <Row key={index} tone='red'>
406
+ {message}
407
+ </Row>
408
+ ))}
409
+ </div>
410
+ )
411
+ }
412
+
413
+ export function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }> }) {
414
+ const error = item.level === 'error'
415
+ return (
416
+ <Row glyph='!' glyphTone={error ? 'red' : 'yellow'} tone={error ? 'red' : 'dim'}>
417
+ {item.text}
418
+ </Row>
419
+ )
420
+ }
421
+
422
+ export function FileRow({
423
+ item,
424
+ href,
425
+ }: {
426
+ item: Extract<TranscriptItem, { kind: 'file_delivered' }>
427
+ href?: string
428
+ }) {
429
+ return (
430
+ <Row glyph='⤓' glyphTone='blue' tone='dim'>
431
+ {href ? (
432
+ <a className='term-link' data-tone='blue' href={href} download>
433
+ {item.path}
434
+ </a>
435
+ ) : (
436
+ <Ink tone='blue'>{item.path}</Ink>
437
+ )}
438
+ <Ink tone='faint'> · {formatBytes(item.bytes)}</Ink>
439
+ {item.description ? <Ink tone='faint'> · {item.description}</Ink> : null}
440
+ </Row>
441
+ )
442
+ }
443
+
444
+ /** A once-a-second clock, running only while `on`. */
445
+ function useTicker(on: boolean): number {
446
+ const [now, setNow] = useState(() => Date.now())
447
+ useEffect(() => {
448
+ if (!on) return
449
+ const timer = setInterval(() => setNow(Date.now()), 1000)
450
+ return () => clearInterval(timer)
451
+ }, [on])
452
+ return now
453
+ }
454
+
455
+ /**
456
+ * The working line: the mark's pulse, the word, and the run's readings — the
457
+ * CLI's own status line, which is a *row of the transcript* rather than a
458
+ * spinner floating over it.
459
+ */
460
+ export function WorkingRow({
461
+ label,
462
+ startedAt,
463
+ tokens,
464
+ }: {
465
+ label: string
466
+ startedAt?: number
467
+ tokens?: number
468
+ }) {
469
+ const pulse = usePulse(true)
470
+ // The row owns its clock rather than taking `now` from above, because it is
471
+ // mounted only while a turn is in flight: the ticking starts and stops with
472
+ // the thing being timed, and an idle transcript runs no interval at all.
473
+ const now = useTicker(startedAt !== undefined)
474
+ const elapsed = startedAt === undefined ? undefined : formatDuration(now - startedAt)
475
+ const readings = [elapsed, tokens ? `↓ ${(tokens / 1000).toFixed(1)}k tokens` : undefined].filter(
476
+ Boolean,
477
+ )
478
+ return (
479
+ <Row glyph={pulse} glyphTone='mark' tone='mark'>
480
+ {label}
481
+ {readings.length ? <Ink tone='faint'> ({readings.join(' · ')})</Ink> : null}
482
+ </Row>
483
+ )
484
+ }
485
+
486
+ export { Band, Blank }