@workerdeck/ui 0.13.0 → 0.16.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 (69) hide show
  1. package/README.md +72 -0
  2. package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-B9CHoq8x.d.mts} +213 -27
  3. package/build/{SessionPanel-CKQa4i0Y.mjs → SessionPanel-DII9MmQ8.mjs} +5192 -2542
  4. package/build/SessionPanel-DII9MmQ8.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 +494 -45
  9. package/build/index.mjs +182 -24
  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 +13 -1
  14. package/build/workspace.mjs +111 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +14 -7
  17. package/src/components/agent/Composer.tsx +251 -84
  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 -43
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/QuestionPrompt.tsx +0 -122
  25. package/src/components/agent/Reasoning.tsx +5 -19
  26. package/src/components/agent/Response.tsx +1 -132
  27. package/src/components/agent/SessionBrowser.tsx +35 -25
  28. package/src/components/agent/SessionPanel.tsx +312 -63
  29. package/src/components/agent/SessionWorkspace.tsx +36 -0
  30. package/src/components/agent/StatusBar.tsx +70 -15
  31. package/src/components/agent/ToolCallCard.tsx +17 -111
  32. package/src/components/agent/Transcript.tsx +710 -203
  33. package/src/components/agent/UsageDialog.tsx +20 -106
  34. package/src/components/agent/UsageMeters.tsx +133 -0
  35. package/src/components/agent/pulse.tsx +3 -2
  36. package/src/components/agent/transcript-rows.ts +82 -0
  37. package/src/components/agent/transcript-variant.tsx +43 -51
  38. package/src/components/agent/use-height-epoch.ts +60 -0
  39. package/src/components/agent/use-path-links.ts +147 -0
  40. package/src/components/agent/use-transcript-jumps.ts +190 -0
  41. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  42. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  43. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  44. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  45. package/src/components/terminal/StatusLine.tsx +159 -0
  46. package/src/components/terminal/TerminalTranscript.tsx +147 -0
  47. package/src/components/terminal/affordances.tsx +118 -0
  48. package/src/components/terminal/diff.tsx +130 -0
  49. package/src/components/terminal/height.ts +727 -0
  50. package/src/components/terminal/items.tsx +449 -0
  51. package/src/components/terminal/markdown.tsx +191 -0
  52. package/src/components/terminal/press.tsx +120 -0
  53. package/src/components/terminal/prompt.tsx +343 -0
  54. package/src/components/terminal/result-preview.ts +72 -0
  55. package/src/components/terminal/row.tsx +132 -0
  56. package/src/components/terminal/scrubber.tsx +663 -0
  57. package/src/components/terminal/surface.tsx +80 -0
  58. package/src/components/terminal/tool-run.ts +91 -0
  59. package/src/components/ui/Badge.tsx +6 -1
  60. package/src/components/ui/Empty.tsx +56 -0
  61. package/src/components/ui/Splitter.tsx +14 -0
  62. package/src/index.ts +36 -0
  63. package/src/lib/status.ts +59 -3
  64. package/src/lib/tool-icon.ts +14 -0
  65. package/src/styles/terminal.css +1011 -0
  66. package/src/styles/theme.css +73 -0
  67. package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
  68. package/build/format.mjs.map +0 -1
  69. package/src/components/agent/line-prompt.tsx +0 -249
@@ -0,0 +1,449 @@
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 { collapsedResult } from './result-preview.ts'
11
+ import { foldsTogether, runSummary } from './tool-run.ts'
12
+ import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
13
+
14
+ /**
15
+ * One transcript item, drawn as terminal rows.
16
+ *
17
+ * Each renderer here answers the same two questions the CLI answers: which
18
+ * marker goes in the gutter, and what the body says. Nothing chooses a spacing,
19
+ * a radius or a border — a row is a row, and the space between blocks is a
20
+ * {@link Blank} decided by the transcript, which is the only thing that knows
21
+ * whether two blocks belong together.
22
+ *
23
+ * The markers are the CLI's:
24
+ *
25
+ * | glyph | means |
26
+ * |-------|------------------------------------------|
27
+ * | `❯` | what you typed |
28
+ * | `●` | what the model said, or a tool it called |
29
+ * | `⎿` | that tool's output, one level in |
30
+ * | `✻` | thinking |
31
+ * | `!` | a notice from the runner, not the model |
32
+ */
33
+
34
+ /**
35
+ * The prompt marker, in the transcript and in the composer both.
36
+ *
37
+ * `❯` rather than `>`: it is the shell prompt of every terminal anyone has
38
+ * configured this decade, and it reads as a *prompt* where `>` reads as a
39
+ * quotation or a greater-than. Exported because the composer is the same
40
+ * marker in the same gutter cell — that is the whole claim of the terminal
41
+ * composer, and two spellings of it would put the caret one glyph off the
42
+ * column the rows above start on.
43
+ */
44
+ export const PROMPT_GLYPH = '❯'
45
+
46
+ /** How much the expanded row shows before offering the rest. The collapsed
47
+ * budget is `collapsedResult`'s, shared with the height calculator. */
48
+ const RESULT_PREVIEW_CHARS = 2000
49
+
50
+ /** Whole lines up to a character budget — never zero, because a single line
51
+ * longer than the budget still has to be shown or the row would open onto
52
+ * nothing. */
53
+ function clipToChars(lines: string[], maxChars: number): string[] {
54
+ const out: string[] = []
55
+ let chars = 0
56
+ for (const line of lines) {
57
+ if (out.length > 0 && chars + line.length > maxChars) break
58
+ out.push(line)
59
+ chars += line.length + 1
60
+ }
61
+ return out
62
+ }
63
+
64
+ export function UserRow({ item }: { item: Extract<TranscriptItem, { kind: 'user' }> }) {
65
+ return (
66
+ <div className='term-user'>
67
+ {item.attachments?.length ? (
68
+ <Row glyph={PROMPT_GLYPH} glyphTone='dim' tone='dim'>
69
+ {item.attachments.map((attachment) => attachment.name).join(', ')}
70
+ </Row>
71
+ ) : null}
72
+ {/* Every line of a multi-line prompt keeps the band and the column; only
73
+ the first keeps the marker, exactly as a shell continuation does. */}
74
+ {item.text
75
+ ? item.text.split('\n').map((line, index) => (
76
+ <Row
77
+ key={index}
78
+ glyph={index === 0 ? PROMPT_GLYPH : undefined}
79
+ glyphTone='dim'
80
+ tone='fg'>
81
+ {line || ' '}
82
+ </Row>
83
+ ))
84
+ : null}
85
+ </div>
86
+ )
87
+ }
88
+
89
+ export function AssistantRow({
90
+ item,
91
+ }: {
92
+ item: Extract<TranscriptItem, { kind: 'assistant_text' }>
93
+ }) {
94
+ return (
95
+ // Copy the markdown source, not the rendered text: what you paste into an
96
+ // issue or a commit message should keep its lists and its code fences.
97
+ // Absent while streaming — half a message is not a thing anyone wants on
98
+ // their clipboard, and the button would appear mid-sentence.
99
+ <WithActions
100
+ actions={item.streaming ? null : <CopyAction text={item.text} label='Copy message' />}>
101
+ <Row glyph='●' glyphTone='fg' tone='fg'>
102
+ <TerminalMarkdown streaming={item.streaming}>{item.text}</TerminalMarkdown>
103
+ </Row>
104
+ </WithActions>
105
+ )
106
+ }
107
+
108
+ export function ThinkingRow({ item }: { item: Extract<TranscriptItem, { kind: 'thinking' }> }) {
109
+ return (
110
+ <Row glyph='✻' glyphTone='dim' tone='dim'>
111
+ <span className='term-em'>{item.text}</span>
112
+ </Row>
113
+ )
114
+ }
115
+
116
+ /** The gutter dot's colour: the call's state, said without a badge. */
117
+ const TOOL_TONE: Record<string, Tone> = {
118
+ running: 'blue',
119
+ pending: 'blue',
120
+ deferred: 'yellow',
121
+ settled: 'dim',
122
+ failed: 'red',
123
+ }
124
+
125
+ export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
126
+
127
+ export function ToolRow({ item }: { item: ToolCallItem }) {
128
+ const [open, setOpen] = useState(false)
129
+ const [full, setFull] = useState(false)
130
+ const reveal = useRevealOnOpen(open)
131
+ const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
132
+ const busy = status === 'running' || status === 'pending'
133
+ const isError = status === 'failed' || item.result?.isError === true
134
+ // Ticks only while this row is really running: an idle transcript of a hundred
135
+ // settled calls starts no timers at all.
136
+ const pulse = usePulse(busy)
137
+
138
+ const text = item.result?.text ?? ''
139
+ const lines = text.trimEnd().split('\n')
140
+ // Three states, not two. Collapsed shows a few lines; open shows the output up
141
+ // to a character budget; `full` lifts the budget. The middle one is the reason
142
+ // the budget exists at all: a tool result can be a hundred thousand characters
143
+ // (a test run, a `find /`), and the whole of it lands in **one** virtual row —
144
+ // the virtualizer mounts rows, so it cannot help with what is inside a single
145
+ // one. Without the clip, expanding one row commits thousands of DOM nodes and
146
+ // the transcript stops being smooth for the rest of the session.
147
+ const collapsed = collapsedResult(lines)
148
+ const preview = open
149
+ ? full
150
+ ? lines
151
+ : clipToChars(lines, RESULT_PREVIEW_CHARS)
152
+ : collapsed.shown
153
+ const hidden = lines.length - preview.length
154
+ const clipped = open && !full && hidden > 0
155
+
156
+ const tone: Tone = isError
157
+ ? 'red'
158
+ : // A settled write is green: skimming a run, "what did it change" is the
159
+ // question you come back to, and the one you might need to undo.
160
+ status === 'settled' && isMutatingTool(item.name)
161
+ ? 'green'
162
+ : (TOOL_TONE[status] ?? 'dim')
163
+
164
+ // What is worth having on the clipboard from a tool call is the command you
165
+ // would re-run, when there is one, and otherwise its output.
166
+ const command = (item.input as { command?: unknown } | null)?.command
167
+ const copyable = typeof command === 'string' ? command : text
168
+
169
+ return (
170
+ // Open, the whole block keeps a fill: an expansion that runs past the top of
171
+ // the screen otherwise leaves no mark of where it began, and the reader has
172
+ // to guess which rows they opened.
173
+ <div ref={reveal} className={open ? 'term-open' : undefined}>
174
+ <WithActions
175
+ actions={copyable ? <CopyAction text={copyable} label='Copy' /> : null}>
176
+ <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
177
+ <Row glyph={busy ? pulse : '●'} glyphTone={tone} tone='fg'>
178
+ <Ink bold tone='bright'>
179
+ {item.name}
180
+ </Ink>
181
+ <Ink tone='dim'>({toolInputPreview(item.input)})</Ink>
182
+ {item.backend && item.backend !== 'server' ? (
183
+ <Ink tone='faint'> · {item.backend}</Ink>
184
+ ) : null}
185
+ </Row>
186
+ </Pressable>
187
+ {/* A file edit shows its diff, not its result prose: "The file has been
188
+ updated" is what the *model* needed to hear, and the change is what the
189
+ reader did. The text stays reachable by expanding. */}
190
+ {item.patch && !open ? (
191
+ <TerminalDiff patch={item.patch} />
192
+ ) : text ? (
193
+ <>
194
+ {preview.map((line, index) => (
195
+ <Row
196
+ key={index}
197
+ indent={1}
198
+ columns={3}
199
+ glyph={index === 0 ? '⎿' : undefined}
200
+ tone={isError ? 'red' : 'dim'}>
201
+ {line || ' '}
202
+ </Row>
203
+ ))}
204
+ {/* One row for "there is more", pressable exactly when pressing it
205
+ would do something. Collapsed, the count is a label — the header
206
+ above is already the toggle, and a second control for the same act
207
+ is one too many. Open and clipped, it is the way to the rest.
208
+ Collapsed spells its own label (it may be counting characters
209
+ rather than lines, having cut inside one), and it is the string
210
+ `height.ts` sizes the row from. */}
211
+ {!open ? (
212
+ collapsed.more ? (
213
+ <Row indent={1} columns={3} tone='faint'>
214
+ {collapsed.more}
215
+ </Row>
216
+ ) : null
217
+ ) : hidden > 0 ? (
218
+ <Row indent={1} columns={3} tone='faint'>
219
+ {clipped ? (
220
+ <button
221
+ type='button'
222
+ className='term-press term-link'
223
+ onClick={() => setFull(true)}>
224
+ … +{hidden} line{hidden === 1 ? '' : 's'} — show all{' '}
225
+ {text.length.toLocaleString()} chars
226
+ </button>
227
+ ) : (
228
+ <>
229
+ … +{hidden} line{hidden === 1 ? '' : 's'}
230
+ </>
231
+ )}
232
+ </Row>
233
+ ) : null}
234
+ </>
235
+ ) : null}
236
+ </WithActions>
237
+ </div>
238
+ )
239
+ }
240
+
241
+ /** Is this a row the transcript folds into a run? Any tool call is — see
242
+ * `tool-run.ts` for why this is no longer shell-only. */
243
+ export function isRunCall(item: TranscriptItem): item is ToolCallItem {
244
+ return item.kind === 'tool_call'
245
+ }
246
+
247
+ /**
248
+ * A run of tool calls, as one line.
249
+ *
250
+ * The CLI's own compression, and the reason it works: a tool call is almost
251
+ * never what you came back to read. `Bash(pnpm -w typecheck)` and forty lines of
252
+ * its output say nothing the model's next sentence doesn't say better, and six
253
+ * of them in a row bury that sentence a screen and a half down. So a run
254
+ * collapses to its count and gets out of the way — and opens, in full, the
255
+ * moment it is the thing you actually want.
256
+ *
257
+ * The membership and wording rules live in `tool-run.ts`, shared with the height
258
+ * calculator. A failed member does not break the run — it *colours* it, which is
259
+ * the same call the scrubber makes: a failure is worth seeing, and fragmenting
260
+ * the run around it would hide it in a longer list rather than surface it.
261
+ */
262
+ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
263
+ const [open, setOpen] = useState(false)
264
+ const reveal = useRevealOnOpen(open)
265
+ const busy = items.some((item) => {
266
+ const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
267
+ return status === 'running' || status === 'pending'
268
+ })
269
+ const failed = items.some((item) => item.status === 'failed' || item.result?.isError === true)
270
+ const pulse = usePulse(busy)
271
+
272
+ return (
273
+ <div ref={reveal} className={open ? 'term-open' : undefined}>
274
+ <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
275
+ {/* No marker once settled: a run of calls is an aside, and a bullet
276
+ would give it the weight of something the model said. While one is
277
+ running the pulse earns the gutter — that much is news. */}
278
+ <Row
279
+ glyph={busy ? pulse : undefined}
280
+ glyphTone={busy ? 'mark' : undefined}
281
+ tone={failed ? 'red' : 'dim'}>
282
+ {runSummary(items, busy)}
283
+ </Row>
284
+ </Pressable>
285
+ {open ? (
286
+ <div>
287
+ {items.map((item) => (
288
+ <ToolRow key={item.id} item={item} />
289
+ ))}
290
+ </div>
291
+ ) : null}
292
+ </div>
293
+ )
294
+ }
295
+
296
+ /**
297
+ * Fold consecutive tool calls into runs, leaving everything else alone.
298
+ *
299
+ * Shared by both renderers — the virtualized shell in `agent/Transcript.tsx` and
300
+ * the plain {@link TerminalTranscript} — because which rows exist is part of what
301
+ * the theme *is*, and a client that grouped differently would be showing a
302
+ * different transcript of the same session.
303
+ */
304
+ export type TerminalBlock =
305
+ | { key: string; item: TranscriptItem; index: number }
306
+ | { key: string; run: ToolCallItem[]; index: number }
307
+
308
+ /**
309
+ * @param offset What `items[0]`'s index is in the whole transcript — the
310
+ * virtualized shell folds each side of the recap boundary separately, and the
311
+ * rows still have to say where they sit for the catch-up dimming.
312
+ * @param fold Whether to group at all. `false` gives one block per item,
313
+ * which is what the cards variant renders: this is the terminal theme's rule
314
+ * and must not silently reshape another renderer's row list.
315
+ */
316
+ export function terminalBlocks(
317
+ items: readonly TranscriptItem[],
318
+ offset = 0,
319
+ fold = true,
320
+ ): TerminalBlock[] {
321
+ const out: TerminalBlock[] = []
322
+ for (const [position, item] of items.entries()) {
323
+ const index = offset + position
324
+ const previous = out.at(-1)
325
+ if (fold && isRunCall(item)) {
326
+ if (previous && 'run' in previous && foldsTogether(previous.run[0]!, item)) {
327
+ previous.run.push(item)
328
+ } else {
329
+ // Keyed by the run's *first* call, so the key is stable as the run grows.
330
+ out.push({ key: `run:${item.id}`, run: [item], index })
331
+ }
332
+ continue
333
+ }
334
+ out.push({ key: `${item.kind}:${item.id}`, item, index })
335
+ }
336
+ return out
337
+ }
338
+
339
+ export function TurnResultRow({
340
+ item,
341
+ }: {
342
+ item: Extract<TranscriptItem, { kind: 'turn_result' }>
343
+ }) {
344
+ return (
345
+ <div>
346
+ <Row tone={item.isError ? 'red' : 'faint'}>
347
+ {item.isError ? item.subtype : 'done'} · {formatDuration(item.durationMs)} ·{' '}
348
+ {formatCost(item.totalCostUsd)}
349
+ </Row>
350
+ {/* A failed turn's reasons are the whole point of the row — dropping them
351
+ leaves "error_during_execution" and nothing to act on. */}
352
+ {item.errors?.map((message, index) => (
353
+ <Row key={index} tone='red'>
354
+ {message}
355
+ </Row>
356
+ ))}
357
+ </div>
358
+ )
359
+ }
360
+
361
+ export function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }> }) {
362
+ const error = item.level === 'error'
363
+ return (
364
+ <Row glyph='!' glyphTone={error ? 'red' : 'yellow'} tone={error ? 'red' : 'dim'}>
365
+ {item.text}
366
+ </Row>
367
+ )
368
+ }
369
+
370
+ export function FileRow({
371
+ item,
372
+ href,
373
+ }: {
374
+ item: Extract<TranscriptItem, { kind: 'file_delivered' }>
375
+ href?: string
376
+ }) {
377
+ return (
378
+ <Row glyph='⤓' glyphTone='blue' tone='dim'>
379
+ {href ? (
380
+ <a className='term-link' data-tone='blue' href={href} download>
381
+ {item.path}
382
+ </a>
383
+ ) : (
384
+ <Ink tone='blue'>{item.path}</Ink>
385
+ )}
386
+ <Ink tone='faint'> · {formatBytes(item.bytes)}</Ink>
387
+ {item.description ? <Ink tone='faint'> · {item.description}</Ink> : null}
388
+ </Row>
389
+ )
390
+ }
391
+
392
+ /** A once-a-second clock, running only while `on`. */
393
+ function useTicker(on: boolean): number {
394
+ const [now, setNow] = useState(() => Date.now())
395
+ useEffect(() => {
396
+ if (!on) return
397
+ const timer = setInterval(() => setNow(Date.now()), 1000)
398
+ return () => clearInterval(timer)
399
+ }, [on])
400
+ return now
401
+ }
402
+
403
+ /**
404
+ * The working line: the mark's pulse, the word, and the run's readings — the
405
+ * CLI's own status line, which is a *row of the transcript* rather than a
406
+ * spinner floating over it.
407
+ */
408
+ export function WorkingRow({
409
+ label,
410
+ startedAt,
411
+ tokens,
412
+ }: {
413
+ label: string
414
+ startedAt?: number
415
+ tokens?: number
416
+ }) {
417
+ const pulse = usePulse(true)
418
+ // The row owns its clock rather than taking `now` from above, because it is
419
+ // mounted only while a turn is in flight: the ticking starts and stops with
420
+ // the thing being timed, and an idle transcript runs no interval at all.
421
+ const now = useTicker(startedAt !== undefined)
422
+ const elapsed = startedAt === undefined ? undefined : formatDuration(now - startedAt)
423
+ const readings = [elapsed, tokens ? `↓ ${(tokens / 1000).toFixed(1)}k tokens` : undefined].filter(
424
+ Boolean,
425
+ )
426
+ return (
427
+ <Row glyph={pulse} glyphTone='mark' tone='mark'>
428
+ {label}
429
+ {readings.length ? <Ink tone='faint'> ({readings.join(' · ')})</Ink> : null}
430
+ </Row>
431
+ )
432
+ }
433
+
434
+ /** Spacing between two items: a blank line, unless the pair belongs together.
435
+ * Tool output already sits under its call, and a run of tool calls reads as one
436
+ * block — the CLI leaves no blank line inside either. */
437
+ export function needsBlank(previous: TranscriptItem, next: TranscriptItem): boolean {
438
+ if (previous.kind === 'tool_call' && next.kind === 'tool_call') return false
439
+ return true
440
+ }
441
+
442
+ /** The same rule over blocks: a shell run counts as the tool calls it folded. */
443
+ export function blockNeedsBlank(previous: TerminalBlock, next: TerminalBlock): boolean {
444
+ const before = 'run' in previous ? 'tool_call' : previous.item.kind
445
+ const after = 'run' in next ? 'tool_call' : next.item.kind
446
+ return !(before === 'tool_call' && after === 'tool_call')
447
+ }
448
+
449
+ export { Band, Blank }
@@ -0,0 +1,191 @@
1
+ import { memo, type ReactNode } from 'react'
2
+ import { Streamdown, type Components } from 'streamdown'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { CopyAction, WithActions } from './affordances.tsx'
5
+ import { Band } from './row.tsx'
6
+
7
+ /**
8
+ * Markdown on the character grid.
9
+ *
10
+ * The `lines` variant did this by letting the renderer draw its prose defaults
11
+ * and then overriding roughly sixty declarations back off with `!important` —
12
+ * every margin, every list gap, the fenced-code card's four nested boxes, the
13
+ * table's frame and its floating button pill. That is a losing position by
14
+ * construction: each renderer upgrade is a new set of boxes to find and unpaint,
15
+ * and the CSS says what the output must *not* look like rather than what it is.
16
+ *
17
+ * So this maps the elements instead. Streamdown keeps what only it can do —
18
+ * streaming-safe parsing of half-written markdown — and every block it emits is
19
+ * built from the same {@link Row}/{@link Band} primitives the rest of the theme
20
+ * uses. There is no `!important` here and there is no CSS fighting anything.
21
+ *
22
+ * The rendering rules are a terminal's, not a document's:
23
+ *
24
+ * - **One type size.** Headings are weight and colour; a bigger glyph would
25
+ * break the only line height the grid has.
26
+ * - **Markers are cells.** A bullet is `- ` (two columns), an ordered marker
27
+ * `1. ` (three), and the text starts on the next column exactly as it does in
28
+ * the markdown source — so a wrapped line hangs under the text, not the
29
+ * bullet, and nesting costs one marker width per level.
30
+ * - **Code is a band, not a card.** No frame, no language strip, no floating
31
+ * buttons: a fenced block is a wash of dim text running to the screen edge,
32
+ * which is what a terminal shows.
33
+ */
34
+
35
+ /** Pull the text out of a fenced block's React children (`<code>…</code>`). */
36
+ function codeText(node: ReactNode): string {
37
+ if (node === null || node === undefined || typeof node === 'boolean') return ''
38
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
39
+ if (Array.isArray(node)) return node.map(codeText).join('')
40
+ const element = node as { props?: { children?: ReactNode } }
41
+ return element.props ? codeText(element.props.children) : ''
42
+ }
43
+
44
+ /** The language a fence declared, from the `language-*` class react-markdown
45
+ * puts on the inner `<code>`. Kept as a data attribute rather than used to
46
+ * highlight: the CLI does not colour a fenced block inside a message, and a
47
+ * second highlighter here would be a second theme to keep in sync. */
48
+ function fenceLanguage(node: ReactNode): string | undefined {
49
+ const child = Array.isArray(node) ? node.find(Boolean) : node
50
+ const className = (child as { props?: { className?: string } } | undefined)?.props?.className
51
+ const match = /language-([\w-]+)/.exec(className ?? '')
52
+ return match?.[1]
53
+ }
54
+
55
+ /**
56
+ * A fenced block: a band of dim text, and the one place a copy affordance earns
57
+ * its keep most — a command in a message is there to be run.
58
+ *
59
+ * The renderer's own copy/download buttons are turned off (`controls={false}`)
60
+ * in favour of this: they are web buttons floating over the content in another
61
+ * application's idiom, and they are not switchable by the surface the way
62
+ * everything else here is.
63
+ */
64
+ function CodeBand({ code, language }: { code: string; language?: string }) {
65
+ return (
66
+ <WithActions className='term-block' actions={<CopyAction text={code} label='Copy code' />}>
67
+ <Band className='term-code' data-language={language}>
68
+ <pre className='term-pre'>{code}</pre>
69
+ </Band>
70
+ </WithActions>
71
+ )
72
+ }
73
+
74
+ /** Headings differ only in tone — a terminal has one type size, so `h1` and `h4`
75
+ * cannot differ in anything else. Return-typed so the parameter picks up the
76
+ * renderer's own component signature rather than a narrower hand-written one. */
77
+ const heading = (tone: 'bright' | 'fg'): Components['h1'] =>
78
+ function Heading({ children }) {
79
+ return (
80
+ <div className='term-block' data-tone={tone} data-weight='bold'>
81
+ {children}
82
+ </div>
83
+ )
84
+ }
85
+
86
+ const TERMINAL_COMPONENTS: Components = {
87
+ p: ({ children }) => <div className='term-block'>{children}</div>,
88
+
89
+ h1: heading('bright'),
90
+ h2: heading('bright'),
91
+ h3: heading('bright'),
92
+ h4: heading('fg'),
93
+ h5: heading('fg'),
94
+ h6: heading('fg'),
95
+
96
+ // `term-block` on every block-level output, without exception: it is what the
97
+ // one-blank-line-between-blocks rule keys on, and a block that forgets it butts
98
+ // straight up against its neighbour (a list running into the paragraph after
99
+ // it, which is exactly how this was found).
100
+ ul: ({ children }) => <ul className='term-block term-list'>{children}</ul>,
101
+ ol: ({ children }) => <ol className='term-block term-list term-list-ordered'>{children}</ol>,
102
+ // The marker is the gutter's `::before` (a CSS counter for the ordered case),
103
+ // so a list item is literally a Row: same two columns, same hanging indent,
104
+ // and a nested list inside the body indents by exactly one marker width.
105
+ li: ({ children }) => (
106
+ <li className='term-row term-li'>
107
+ <span className='term-gutter' aria-hidden />
108
+ <div className='term-body'>{children}</div>
109
+ </li>
110
+ ),
111
+
112
+ blockquote: ({ children }) => (
113
+ <blockquote className='term-block term-quote' data-tone='dim'>
114
+ {children}
115
+ </blockquote>
116
+ ),
117
+
118
+ hr: () => <div className='term-block term-rule' aria-hidden />,
119
+
120
+ // Fenced code. `pre` owns the whole block — the inner `<code>` is only where
121
+ // the text and the language live — so the band is built here and `code` never
122
+ // sees a fence.
123
+ pre: ({ children }) => <CodeBand code={codeText(children)} language={fenceLanguage(children)} />,
124
+ code: ({ children }) => (
125
+ <code className='term-inline-code' data-tone='blue'>
126
+ {children}
127
+ </code>
128
+ ),
129
+
130
+ strong: ({ children }) => (
131
+ <strong data-tone='bright' data-weight='bold'>
132
+ {children}
133
+ </strong>
134
+ ),
135
+ em: ({ children }) => <em className='term-em'>{children}</em>,
136
+ a: ({ children, href }) => (
137
+ <a className='term-link' data-tone='blue' href={href} target='_blank' rel='noreferrer'>
138
+ {children}
139
+ </a>
140
+ ),
141
+
142
+ // Tables keep the grid by being a grid: monospace cells, one line per row, and
143
+ // dim box-drawing rules instead of borders that would land between cells.
144
+ table: ({ children }) => (
145
+ <div className='term-block term-table-wrap'>
146
+ <table className='term-table'>{children}</table>
147
+ </div>
148
+ ),
149
+ // Every table element, and not just the ones that looked wrong: any element
150
+ // left unmapped keeps the renderer's own padded, bordered default, and a
151
+ // single one of those puts its rows off the line grid (`td`'s `py-2` was
152
+ // making table rows 23px in an 18px theme).
153
+ thead: ({ children }) => <thead className='term-thead'>{children}</thead>,
154
+ tbody: ({ children }) => <tbody>{children}</tbody>,
155
+ tr: ({ children }) => <tr>{children}</tr>,
156
+ th: ({ children }) => (
157
+ <th data-tone='bright' data-weight='bold'>
158
+ {children}
159
+ </th>
160
+ ),
161
+ td: ({ children }) => <td>{children}</td>,
162
+ }
163
+
164
+ export interface TerminalMarkdownProps {
165
+ children: string
166
+ /** Streaming text: tolerate half-written markdown (unclosed fences, half links). */
167
+ streaming?: boolean
168
+ className?: string
169
+ }
170
+
171
+ export const TerminalMarkdown = memo(
172
+ function TerminalMarkdown({ children, streaming, className }: TerminalMarkdownProps) {
173
+ return (
174
+ <Streamdown
175
+ mode={streaming ? 'streaming' : 'static'}
176
+ parseIncompleteMarkdown={streaming}
177
+ // The renderer's copy/download affordances are web buttons floating over
178
+ // the content. A terminal has none, and the transcript's own selection
179
+ // is how you copy from one.
180
+ controls={false}
181
+ components={TERMINAL_COMPONENTS}
182
+ className={cn('term-md', className)}>
183
+ {children}
184
+ </Streamdown>
185
+ )
186
+ },
187
+ (prev, next) =>
188
+ prev.children === next.children &&
189
+ prev.streaming === next.streaming &&
190
+ prev.className === next.className,
191
+ )