@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,322 @@
1
+ import { useState } from 'react'
2
+ import type { PermissionRequest, UserQuestion } from '@workerdeck/protocol'
3
+ import { parseUserQuestions } from '../agent/QuestionPrompt.tsx'
4
+ import { Box, Choices, Hint, PromptInput, Rule, TabStrip, type Choice } from './prompt.tsx'
5
+ import { Blank, Ink, Row } from './row.tsx'
6
+
7
+ /**
8
+ * The AskUserQuestion form, in the CLI's shape: **one question at a time**,
9
+ * behind a strip of chips, ending in a review step.
10
+ *
11
+ * The card version stacks every question on screen at once, which is right for a
12
+ * dialog and wrong here for a reason worth stating: a terminal form is answered
13
+ * with the keyboard, and a stacked form has no answer to "where does `Tab` go" —
14
+ * it has as many focus targets as there are options across every question. One
15
+ * question at a time makes the keys unambiguous (`↑↓` within, `Tab` between,
16
+ * `Enter` to take), and it is why the chips exist: something has to say that
17
+ * answering the first of three is not finishing.
18
+ *
19
+ * The review step is the other half of that. Answers given one screen at a time
20
+ * are answers you cannot see together, so the last chip shows all of them and
21
+ * asks once more before they go back to the model.
22
+ */
23
+
24
+ type Selection = { labels: string[]; other: string; otherActive: boolean }
25
+
26
+ const EMPTY: Selection = { labels: [], other: '', otherActive: false }
27
+
28
+ /** A question's answer: chosen label(s), comma-joined, with any free-text
29
+ * "Other" appended — the shape the CLI's own UI puts in `updatedInput.answers`. */
30
+ function answerFor(selection: Selection): string {
31
+ const parts = [...selection.labels]
32
+ if (selection.otherActive && selection.other.trim()) parts.push(selection.other.trim())
33
+ return parts.join(', ')
34
+ }
35
+
36
+ export interface TerminalQuestionPromptProps {
37
+ /** A pending permission whose toolName is 'AskUserQuestion'. */
38
+ request: PermissionRequest
39
+ /** Allow the tool with `updatedInput` (the original input plus `answers`). */
40
+ onAnswer: (requestId: string, updatedInput: Record<string, unknown>) => void
41
+ /** Deny the tool — the model proceeds without an answer. */
42
+ onDismiss: (requestId: string, message?: string) => void
43
+ }
44
+
45
+ export function TerminalQuestionPrompt({
46
+ request,
47
+ onAnswer,
48
+ onDismiss,
49
+ }: TerminalQuestionPromptProps) {
50
+ const questions = parseUserQuestions(request.input)
51
+ const [selections, setSelections] = useState<Selection[]>(() => questions.map(() => EMPTY))
52
+ const [cursors, setCursors] = useState<number[]>(() => questions.map(() => 0))
53
+ // Tabs are the questions plus the review step, so `questions.length` is the
54
+ // review — one index space, which is what makes Tab a single `+1`.
55
+ const [tab, setTab] = useState(0)
56
+ const [reviewCursor, setReviewCursor] = useState(0)
57
+
58
+ const review = tab >= questions.length
59
+ const selection = selections[tab] ?? EMPTY
60
+ const answered = (index: number) => answerFor(selections[index] ?? EMPTY) !== ''
61
+ const complete = questions.every((_, index) => answered(index))
62
+
63
+ const update = (index: number, patch: Partial<Selection>) =>
64
+ setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
65
+
66
+ // Functional, and it has to be: reading `selections` from the render closure
67
+ // means two toggles in one tick both compute from the same base and the second
68
+ // silently drops the first. A multi-select is exactly where that happens.
69
+ const toggle = (index: number, label: string, multiSelect: boolean) => {
70
+ setSelections((prev) =>
71
+ prev.map((current, i) => {
72
+ if (i !== index) return current
73
+ if (!multiSelect) return { ...current, labels: [label], otherActive: false }
74
+ return {
75
+ ...current,
76
+ labels: current.labels.includes(label)
77
+ ? current.labels.filter((l) => l !== label)
78
+ : [...current.labels, label],
79
+ }
80
+ }),
81
+ )
82
+ }
83
+
84
+ const submit = () => {
85
+ const answers: Record<string, string> = {}
86
+ questions.forEach((question, index) => {
87
+ answers[question.question] = answerFor(selections[index] ?? EMPTY)
88
+ })
89
+ onAnswer(request.id, { ...request.input, answers })
90
+ }
91
+
92
+ const dismiss = () => onDismiss(request.id, 'Question dismissed by user')
93
+
94
+ const move = (delta: number) =>
95
+ setTab((current) => Math.min(questions.length, Math.max(0, current + delta)))
96
+
97
+ return (
98
+ <div
99
+ data-slot='question-prompt'
100
+ onKeyDown={(event) => {
101
+ if (event.key === 'Escape') {
102
+ event.preventDefault()
103
+ dismiss()
104
+ return
105
+ }
106
+ if (event.key === 'Tab') {
107
+ event.preventDefault()
108
+ move(event.shiftKey ? -1 : 1)
109
+ }
110
+ }}>
111
+ <Rule />
112
+ <TabStrip
113
+ tabs={[
114
+ ...questions.map((question, index) => ({
115
+ key: `q${index}`,
116
+ label: question.header || `Question ${index + 1}`,
117
+ glyph: answered(index) ? '▣' : '▢',
118
+ })),
119
+ { key: 'submit', label: 'Submit', glyph: '✓' },
120
+ ]}
121
+ active={tab}
122
+ onSelect={setTab}
123
+ />
124
+ <Blank />
125
+ {review ? (
126
+ <ReviewStep
127
+ questions={questions}
128
+ answers={questions.map((_, index) => answerFor(selections[index] ?? EMPTY))}
129
+ complete={complete}
130
+ cursor={reviewCursor}
131
+ onCursor={setReviewCursor}
132
+ onSubmit={submit}
133
+ onCancel={dismiss}
134
+ />
135
+ ) : (
136
+ <QuestionStep
137
+ // Keyed by question, and load-bearing: without it React reuses the
138
+ // step across a Tab and the option list never re-arms its focus, so
139
+ // the keyboard lands on nothing and the next `3` or `Esc` goes
140
+ // nowhere. Remounting is also what re-runs the focus takeover.
141
+ key={tab}
142
+ question={questions[tab]!}
143
+ selection={selection}
144
+ cursor={cursors[tab] ?? 0}
145
+ onCursor={(next) => setCursors((prev) => prev.map((c, i) => (i === tab ? next : c)))}
146
+ onToggle={(label) => toggle(tab, label, questions[tab]!.multiSelect === true)}
147
+ onOther={(patch) => update(tab, patch)}
148
+ onAdvance={() => move(1)}
149
+ onDismiss={dismiss}
150
+ />
151
+ )}
152
+ <Blank />
153
+ <Hint>
154
+ Enter to select · ↑/↓ to navigate · Tab to switch questions · Esc to cancel
155
+ </Hint>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ function QuestionStep({
161
+ question,
162
+ selection,
163
+ cursor,
164
+ onCursor,
165
+ onToggle,
166
+ onOther,
167
+ onAdvance,
168
+ onDismiss,
169
+ }: {
170
+ question: UserQuestion
171
+ selection: Selection
172
+ cursor: number
173
+ onCursor: (index: number) => void
174
+ onToggle: (label: string) => void
175
+ onOther: (patch: Partial<Selection>) => void
176
+ onAdvance: () => void
177
+ onDismiss: () => void
178
+ }) {
179
+ const multiSelect = question.multiSelect === true
180
+
181
+ const options: Choice[] = [
182
+ ...question.options.map((option, index): Choice => {
183
+ const selected = selection.labels.includes(option.label)
184
+ return {
185
+ key: option.label,
186
+ label: option.label,
187
+ description: option.description,
188
+ // Markers only where there is state to keep: a multi-select accumulates
189
+ // and has to show what is in the set, a one-of answers itself by being
190
+ // chosen and moves on — a column of empty radios in front of it would be
191
+ // three characters of nothing on every row. The CLI draws it the same way.
192
+ checked: multiSelect ? selected : undefined,
193
+ marker: 'check',
194
+ selected: !multiSelect && selected,
195
+ // A preview shows on **focus**, not only on selection: walking a list
196
+ // with the arrow keys, "what does this one look like" is the question
197
+ // the cursor is asking.
198
+ detail:
199
+ option.preview && cursor === index ? (
200
+ <Box>
201
+ {option.preview.split('\n').map((line, row) => (
202
+ <Row key={row} columns={0} tone='dim'>
203
+ {line || ' '}
204
+ </Row>
205
+ ))}
206
+ </Box>
207
+ ) : undefined,
208
+ }
209
+ }),
210
+ {
211
+ key: '__other',
212
+ label: 'Other…',
213
+ // Always markered, in both kinds: this row is a *mode* (the field is open
214
+ // or it isn't), which the reader cannot see any other way.
215
+ checked: selection.otherActive,
216
+ marker: 'check' as const,
217
+ detail: selection.otherActive ? (
218
+ <PromptInput
219
+ value={selection.other}
220
+ onChange={(value) => onOther({ other: value })}
221
+ onSubmit={onAdvance}
222
+ onCancel={() => onOther({ otherActive: false, other: '' })}
223
+ placeholder='Type your own answer'
224
+ />
225
+ ) : undefined,
226
+ },
227
+ // Multi-select has no natural end — every toggle leaves the list where it
228
+ // was — so it needs a row that says "done with this one". A one-of answers
229
+ // itself by being chosen and advances on its own.
230
+ ...(multiSelect ? [{ key: '__next', label: 'Submit' }] : []),
231
+ { key: '__chat', label: 'Chat about this' },
232
+ ]
233
+
234
+ return (
235
+ <>
236
+ <Row bold tone='bright'>
237
+ {question.question}
238
+ </Row>
239
+ <Blank />
240
+ <Choices
241
+ label={question.question}
242
+ options={options}
243
+ focused={cursor}
244
+ active={!selection.otherActive}
245
+ onFocus={onCursor}
246
+ onChoose={(index) => {
247
+ if (index < question.options.length) {
248
+ onToggle(question.options[index]!.label)
249
+ // A one-of is finished the moment it is picked; making the reader
250
+ // press Tab as well would be a keystroke that answers nothing.
251
+ if (!multiSelect) onAdvance()
252
+ return
253
+ }
254
+ if (index === question.options.length) {
255
+ onOther({ otherActive: !selection.otherActive })
256
+ return
257
+ }
258
+ if (multiSelect && index === question.options.length + 1) {
259
+ onAdvance()
260
+ return
261
+ }
262
+ onDismiss()
263
+ }}
264
+ />
265
+ </>
266
+ )
267
+ }
268
+
269
+ /** The last chip: every answer together, and one more chance to change it. */
270
+ function ReviewStep({
271
+ questions,
272
+ answers,
273
+ complete,
274
+ cursor,
275
+ onCursor,
276
+ onSubmit,
277
+ onCancel,
278
+ }: {
279
+ questions: UserQuestion[]
280
+ answers: string[]
281
+ complete: boolean
282
+ cursor: number
283
+ onCursor: (index: number) => void
284
+ onSubmit: () => void
285
+ onCancel: () => void
286
+ }) {
287
+ return (
288
+ <>
289
+ <Row bold tone='bright'>
290
+ Review your answers
291
+ </Row>
292
+ <Blank />
293
+ {questions.map((question, index) => (
294
+ <div key={index}>
295
+ <Row glyph='●' glyphTone='dim'>
296
+ {question.question}
297
+ </Row>
298
+ <Row indent={1} glyph='→' glyphTone='green'>
299
+ <Ink tone={answers[index] ? 'green' : 'faint'}>
300
+ {answers[index] || 'not answered'}
301
+ </Ink>
302
+ </Row>
303
+ </div>
304
+ ))}
305
+ <Blank />
306
+ <Row>{complete ? 'Ready to submit your answers?' : 'Some questions are unanswered.'}</Row>
307
+ <Choices
308
+ label='Submit answers'
309
+ options={[
310
+ // Offered even when incomplete: an unanswered question is a legitimate
311
+ // answer to give, and the model is told which ones were skipped. What
312
+ // it must not do is *look* finished, which is what the row above says.
313
+ { key: 'submit', label: complete ? 'Submit answers' : 'Submit anyway' },
314
+ { key: 'cancel', label: 'Cancel', danger: true },
315
+ ]}
316
+ focused={cursor}
317
+ onFocus={onCursor}
318
+ onChoose={(index) => (index === 0 ? onSubmit() : onCancel())}
319
+ />
320
+ </>
321
+ )
322
+ }
@@ -0,0 +1,159 @@
1
+ import type { RateLimitInfo } from '@workerdeck/protocol'
2
+ import type { ConnectionState, TranscriptState } from '@workerdeck/react'
3
+ import { formatCost, formatTokens } from '../../lib/format.ts'
4
+ import {
5
+ contextSeverity,
6
+ meterSeverity,
7
+ modelLabel,
8
+ statusPresentation,
9
+ tightestWindow,
10
+ windowLabel,
11
+ type StatusSeverity,
12
+ } from '../../lib/status.ts'
13
+ import { Ink, Row, type Tone } from './row.tsx'
14
+
15
+ /**
16
+ * The session's readings, as one line.
17
+ *
18
+ * The styled bar puts each of these in its own affordance — a progress ring, a
19
+ * badge, a tooltip — which is right for a dashboard and wrong here for the usual
20
+ * reason: a terminal has one line height, and five widgets of five different
21
+ * heights is five different rhythms in a row that is supposed to be quiet. So
22
+ * they are words, `·`-separated, exactly as the CLI's own status line writes
23
+ * them.
24
+ *
25
+ * The readings themselves come from the same helpers the styled bar uses
26
+ * (`lib/status.ts`), and that matters more than it looks: `statusPresentation`
27
+ * is where the rule lives that **a dropped socket outranks the session status**,
28
+ * because a status held over a dead socket is a stale reading being presented as
29
+ * a live one. Re-deriving that here would be a second answer to a question that
30
+ * already has one.
31
+ */
32
+
33
+ /** Severity → tone. The 80/95 thresholds are `lib/status.ts`'s, not new ones. */
34
+ const SEVERITY_TONE: Record<StatusSeverity, Tone> = {
35
+ none: 'dim',
36
+ warning: 'yellow',
37
+ error: 'red',
38
+ }
39
+
40
+ export interface TerminalStatusLineProps {
41
+ state: TranscriptState
42
+ /** Plan windows to read from, when they should not be the session's own — the
43
+ * gateway's per-profile state merged over this transcript's. See
44
+ * {@link StatusBarProps.rateLimits}. Absent = `state.rateLimits`. */
45
+ rateLimits?: Record<string, RateLimitInfo>
46
+ /** How the client is doing at reaching the gateway. Wins the status slot when
47
+ * the socket is down — see above. */
48
+ connection?: ConnectionState
49
+ /** Opens the panel that answers each reading's own question. Omit and the
50
+ * reading is text rather than something to press. */
51
+ onOpenStatus?: () => void
52
+ onOpenContext?: () => void
53
+ onOpenUsage?: () => void
54
+ }
55
+
56
+ /** One reading. A button only when it leads somewhere — a segment that looks
57
+ * pressable and does nothing is worse than plain text. */
58
+ function Reading({
59
+ tone,
60
+ onPress,
61
+ label,
62
+ children,
63
+ }: {
64
+ tone?: Tone
65
+ onPress?: () => void
66
+ label?: string
67
+ children: React.ReactNode
68
+ }) {
69
+ if (!onPress) return <Ink tone={tone}>{children}</Ink>
70
+ return (
71
+ <button type='button' className='term-reading' onClick={onPress} title={label}>
72
+ <Ink tone={tone}>{children}</Ink>
73
+ </button>
74
+ )
75
+ }
76
+
77
+ export function TerminalStatusLine({
78
+ state,
79
+ rateLimits,
80
+ connection,
81
+ onOpenStatus,
82
+ onOpenContext,
83
+ onOpenUsage,
84
+ }: TerminalStatusLineProps) {
85
+ const presentation = statusPresentation({ status: state.status, connection })
86
+ const usage = state.contextUsage
87
+ const window = tightestWindow(rateLimits ?? state.rateLimits)
88
+ const model = modelLabel({ model: state.model, models: state.models ?? [] })
89
+
90
+ const parts: React.ReactNode[] = [
91
+ <Reading
92
+ key='status'
93
+ tone={SEVERITY_TONE[presentation.severity]}
94
+ onPress={onOpenStatus}
95
+ label='Session status'>
96
+ {presentation.label}
97
+ </Reading>,
98
+ ]
99
+
100
+ if (usage) {
101
+ parts.push(
102
+ <Reading
103
+ key='context'
104
+ tone={SEVERITY_TONE[contextSeverity(usage)]}
105
+ onPress={onOpenContext}
106
+ label='Context window'>
107
+ {Math.round(usage.percentage)}% ctx ({formatTokens(usage.totalTokens)})
108
+ </Reading>,
109
+ )
110
+ }
111
+
112
+ if (window) {
113
+ parts.push(
114
+ <Reading
115
+ key='usage'
116
+ tone={SEVERITY_TONE[meterSeverity(window.info.utilization)]}
117
+ onPress={onOpenUsage}
118
+ label='Plan usage'>
119
+ {Math.round(window.info.utilization ?? 0)}% {windowLabel(window.key)}
120
+ </Reading>,
121
+ )
122
+ }
123
+
124
+ // Cost last of the numbers: it is the reading you check, never the one you act
125
+ // on, and nothing about a turn changes because of it.
126
+ if (state.totalCostUsd > 0) {
127
+ parts.push(
128
+ <Ink key='cost' tone='faint'>
129
+ {formatCost(state.totalCostUsd)}
130
+ </Ink>,
131
+ )
132
+ }
133
+
134
+ if (model) {
135
+ parts.push(
136
+ <Ink key='model' tone='faint'>
137
+ {model}
138
+ </Ink>,
139
+ )
140
+ }
141
+ if (state.permissionMode && state.permissionMode !== 'default') {
142
+ parts.push(
143
+ <Ink key='mode' tone='yellow'>
144
+ {state.permissionMode}
145
+ </Ink>,
146
+ )
147
+ }
148
+
149
+ return (
150
+ <Row data-slot='status-line' tone='dim'>
151
+ {parts.map((part, index) => (
152
+ <span key={index}>
153
+ {index > 0 ? <Ink tone='faint'> · </Ink> : null}
154
+ {part}
155
+ </span>
156
+ ))}
157
+ </Row>
158
+ )
159
+ }
@@ -0,0 +1,147 @@
1
+ import { Fragment, useEffect, useMemo, useState } from 'react'
2
+ import type { TranscriptItem, TranscriptState } from '@workerdeck/react'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import type { TerminalAffordances } from './affordances.tsx'
5
+ import {
6
+ AssistantRow,
7
+ FileRow,
8
+ NoticeRow,
9
+ ToolRunRow,
10
+ ThinkingRow,
11
+ ToolRow,
12
+ TurnResultRow,
13
+ UserRow,
14
+ WorkingRow,
15
+ blockNeedsBlank,
16
+ terminalBlocks,
17
+ } from './items.tsx'
18
+ import { Blank } from './row.tsx'
19
+ import { TerminalSurface } from './surface.tsx'
20
+
21
+ /**
22
+ * The transcript, drawn as a terminal.
23
+ *
24
+ * The whole of the layout is here and it is four lines long, which is the point:
25
+ * items become row blocks, a blank line goes between blocks that do not belong
26
+ * together, and the working line is one more row at the end. There is no gap
27
+ * scale, no density knob and no variant branch — a terminal has one line height
28
+ * and one type size, and everything the theme can express is expressed in which
29
+ * rows exist and what marker each carries.
30
+ *
31
+ * Not virtualized yet: the row blocks are the part that has to be right first,
32
+ * and the existing `Transcript` already owns a hard-won virtualizer plus the
33
+ * scroll-regime rules it needs. This renders into that shell when the rows are
34
+ * settled, rather than growing a second copy of that logic.
35
+ */
36
+ export interface TerminalTranscriptProps {
37
+ state: TranscriptState
38
+ /** Builds the download URL for a delivered file. */
39
+ fileUrl?: (path: string) => string
40
+ /** Cell metrics, in whole pixels. See {@link TerminalSurface}. */
41
+ fontSize?: number
42
+ lineHeight?: number
43
+ /** The pointer affordances a real terminal has no way to offer — hover fill,
44
+ * hover-revealed copy. `false` for none. See {@link TerminalAffordances}. */
45
+ affordances?: TerminalAffordances | boolean
46
+ className?: string
47
+ }
48
+
49
+ /**
50
+ * One transcript item as terminal rows. Exported because the virtualized shell
51
+ * in `agent/Transcript.tsx` renders rows through it: the theme owns how a row
52
+ * looks, that component owns which rows are mounted, and neither needs a copy
53
+ * of the other.
54
+ */
55
+ export function TerminalItemView({
56
+ item,
57
+ fileUrl,
58
+ }: {
59
+ item: TranscriptItem
60
+ fileUrl?: (path: string) => string
61
+ }) {
62
+ switch (item.kind) {
63
+ case 'user':
64
+ return <UserRow item={item} />
65
+ case 'assistant_text':
66
+ return <AssistantRow item={item} />
67
+ case 'thinking':
68
+ return <ThinkingRow item={item} />
69
+ case 'tool_call':
70
+ return <ToolRow item={item} />
71
+ case 'turn_result':
72
+ return <TurnResultRow item={item} />
73
+ case 'notice':
74
+ return <NoticeRow item={item} />
75
+ case 'file_delivered':
76
+ return <FileRow item={item} href={fileUrl?.(item.path)} />
77
+ default:
78
+ return null
79
+ }
80
+ }
81
+
82
+ /** When the current run began — the clock the working line counts from. Held
83
+ * here, not in the row, because the row comes and goes within a single turn (it
84
+ * hides the moment text streams) and a clock restarting at every tool call
85
+ * would be measuring the wrong thing. */
86
+ function useRunStart(status: TranscriptState['status']): number | undefined {
87
+ const running = status === 'running' || status === 'starting'
88
+ const [startedAt, setStartedAt] = useState<number | undefined>(undefined)
89
+ useEffect(() => {
90
+ setStartedAt((previous) => (running ? (previous ?? Date.now()) : undefined))
91
+ }, [running])
92
+ return running ? startedAt : undefined
93
+ }
94
+
95
+ /** Is the model between outputs? Only then does the working line show — while
96
+ * text is streaming, the text itself is the evidence. */
97
+ function working(state: TranscriptState): boolean {
98
+ if (state.status !== 'running' && state.status !== 'starting') return false
99
+ const last = state.items.at(-1)
100
+ if (!last) return true
101
+ if (last.kind === 'assistant_text' && last.streaming) return false
102
+ if (last.kind === 'thinking' && last.id === 'streaming-thinking') return false
103
+ return true
104
+ }
105
+
106
+ export function TerminalTranscript({
107
+ state,
108
+ fileUrl,
109
+ fontSize,
110
+ lineHeight,
111
+ affordances,
112
+ className,
113
+ }: TerminalTranscriptProps) {
114
+ const runStartedAt = useRunStart(state.status)
115
+ const blocks = useMemo(() => terminalBlocks(state.items), [state.items])
116
+ return (
117
+ <TerminalSurface
118
+ fontSize={fontSize}
119
+ lineHeight={lineHeight}
120
+ affordances={affordances}
121
+ // One cell of breathing room at each edge, and the value a full-bleed
122
+ // band cancels so its wash reaches the scroller's edge.
123
+ bleed='1ch'
124
+ className={cn('term-transcript', className)}>
125
+ {blocks.map((block, index) => (
126
+ <Fragment key={block.key}>
127
+ {index > 0 && blockNeedsBlank(blocks[index - 1]!, block) ? <Blank /> : null}
128
+ {'run' in block ? (
129
+ <ToolRunRow items={block.run} />
130
+ ) : (
131
+ <TerminalItemView item={block.item} fileUrl={fileUrl} />
132
+ )}
133
+ </Fragment>
134
+ ))}
135
+ {working(state) ? (
136
+ <>
137
+ {state.items.length > 0 ? <Blank /> : null}
138
+ <WorkingRow
139
+ label={state.status === 'starting' ? 'Starting…' : 'Working…'}
140
+ startedAt={runStartedAt}
141
+ tokens={state.contextUsage?.totalTokens}
142
+ />
143
+ </>
144
+ ) : null}
145
+ </TerminalSurface>
146
+ )
147
+ }