@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.
- package/README.md +72 -0
- package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-B9CHoq8x.d.mts} +213 -27
- package/build/{SessionPanel-CKQa4i0Y.mjs → SessionPanel-DII9MmQ8.mjs} +5192 -2542
- 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 +494 -45
- package/build/index.mjs +182 -24
- 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 +13 -1
- package/build/workspace.mjs +111 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +14 -7
- package/src/components/agent/Composer.tsx +251 -84
- 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 -43
- 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/SessionBrowser.tsx +35 -25
- package/src/components/agent/SessionPanel.tsx +312 -63
- package/src/components/agent/SessionWorkspace.tsx +36 -0
- package/src/components/agent/StatusBar.tsx +70 -15
- 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 +43 -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/components/ui/Badge.tsx +6 -1
- package/src/components/ui/Empty.tsx +56 -0
- package/src/components/ui/Splitter.tsx +14 -0
- package/src/index.ts +36 -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 +73 -0
- package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- 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
|
+
}
|