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