@workerdeck/ui 0.15.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.
- package/README.md +53 -0
- package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-B9CHoq8x.d.mts} +158 -21
- package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DII9MmQ8.mjs} +4254 -1661
- package/build/SessionPanel-DII9MmQ8.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +442 -45
- package/build/index.mjs +105 -2
- package/build/index.mjs.map +1 -1
- package/build/status-Ydzi7n6j.mjs +143 -0
- package/build/status-Ydzi7n6j.mjs.map +1 -0
- package/build/workspace.d.mts +9 -1
- package/build/workspace.mjs +108 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +14 -7
- package/src/components/agent/Composer.tsx +189 -89
- package/src/components/agent/Conversation.tsx +12 -12
- package/src/components/agent/FileCard.tsx +0 -26
- package/src/components/agent/FileTree.tsx +9 -8
- package/src/components/agent/Loader.tsx +22 -72
- package/src/components/agent/Message.tsx +11 -46
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionPanel.tsx +224 -28
- package/src/components/agent/SessionWorkspace.tsx +29 -0
- package/src/components/agent/StatusBar.tsx +20 -4
- package/src/components/agent/ToolCallCard.tsx +17 -111
- package/src/components/agent/Transcript.tsx +710 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/transcript-rows.ts +82 -0
- package/src/components/agent/transcript-variant.tsx +29 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +147 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +727 -0
- package/src/components/terminal/items.tsx +449 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +72 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +663 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +91 -0
- package/src/index.ts +34 -0
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1011 -0
- package/src/styles/theme.css +41 -0
- package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- 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
|
+
)
|