@workerdeck/ui 0.16.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 +7 -0
- package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnNYEX80.d.mts} +13 -1
- package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DMPhsNlW.mjs} +1015 -332
- package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
- package/build/index.d.mts +54 -3
- package/build/index.mjs +240 -17
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +1 -1
- package/build/workspace.mjs +1 -1
- package/package.json +8 -6
- package/src/components/agent/ProjectIcon.tsx +119 -0
- package/src/components/agent/SessionBrowser.tsx +84 -3
- package/src/components/agent/SessionPanel.tsx +25 -0
- package/src/components/agent/ToolCallCard.tsx +72 -5
- package/src/components/agent/Transcript.tsx +77 -7
- 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 +113 -22
- package/src/components/terminal/TerminalTranscript.tsx +80 -2
- package/src/components/terminal/blocks.ts +232 -0
- package/src/components/terminal/height.ts +49 -6
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +115 -78
- package/src/components/terminal/result-preview.ts +20 -6
- package/src/components/terminal/scrubber.tsx +146 -25
- package/src/components/terminal/tool-run.ts +133 -0
- package/src/index.ts +2 -1
- package/src/styles/terminal.css +75 -5
- package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal theme's block model — **which rows exist**.
|
|
3
|
+
*
|
|
4
|
+
* Pure and separate from `items.tsx` because which rows exist is part of what
|
|
5
|
+
* the theme *is*: the virtualizer counts these, `height.ts` sizes them, the
|
|
6
|
+
* scrubber addresses them, and both renderers — the virtualized shell in
|
|
7
|
+
* `agent/Transcript.tsx` and the plain `TerminalTranscript` — must fold
|
|
8
|
+
* identically or two clients would be showing different transcripts of the
|
|
9
|
+
* same session. `items.tsx` re-exports everything here, so its old imports
|
|
10
|
+
* keep working; the components stay there, the model lives here.
|
|
11
|
+
*
|
|
12
|
+
* Two folds happen in one pass:
|
|
13
|
+
*
|
|
14
|
+
* - **Runs.** Consecutive tool calls fold into one row (`tool-run.ts` owns the
|
|
15
|
+
* membership rule and the summary line).
|
|
16
|
+
* - **Tasks.** A `Task` tool call *absorbs* every item whose
|
|
17
|
+
* `parentToolUseId` names it — its subagent's brief, thinking, text and
|
|
18
|
+
* tool calls — into ONE row, **wherever those items fall in the stream**.
|
|
19
|
+
* Subagents run in parallel, so their items interleave with each other and
|
|
20
|
+
* with top-level work; a consecutive-run rule cannot group them, which is
|
|
21
|
+
* why absorption is by parent id and not by adjacency. The absorbed items
|
|
22
|
+
* are folded again *within* the block (a subagent's consecutive calls
|
|
23
|
+
* become runs — `foldsTogether` already keys on `parentToolUseId`), and the
|
|
24
|
+
* block is always collapsed by default: that preserves the height
|
|
25
|
+
* calculator's invariant that an unmounted row is collapsed by definition.
|
|
26
|
+
*
|
|
27
|
+
* The absorption rule, precisely: a task block forms for a **top-level** tool
|
|
28
|
+
* call (`parentToolUseId` empty) that has at least one child in the slice,
|
|
29
|
+
* and an item is absorbed iff its parent is such a call. Everything else
|
|
30
|
+
* renders as its own row, which settles the edges deliberately:
|
|
31
|
+
*
|
|
32
|
+
* - A **childless** `Task` call is a plain tool call and folds into runs —
|
|
33
|
+
* right for a task still spawning, and for a resumed session whose
|
|
34
|
+
* children were compacted away entirely.
|
|
35
|
+
* - An **orphan** child (its parent call absent from the slice) keeps today's
|
|
36
|
+
* behaviour: its own row, stepped in behind a rule. The recap boundary is
|
|
37
|
+
* the load-bearing case — the shell folds each side separately, so a task
|
|
38
|
+
* split by the boundary shows its post-boundary children *below* the seam
|
|
39
|
+
* rather than hiding new work inside a collapsed row above it, the same
|
|
40
|
+
* claim the run fold makes about never counting across "what you already
|
|
41
|
+
* read".
|
|
42
|
+
* - A **grandchild** (parent is itself a subagent's call — unreachable from
|
|
43
|
+
* today's engines, which do not nest sidechains) is not absorbed and not
|
|
44
|
+
* dropped: it renders top-level, stepped in. An unmapped item must be
|
|
45
|
+
* visible, never gone.
|
|
46
|
+
* - Two top-level calls separated only by absorbed items **fold together**:
|
|
47
|
+
* the interleaved step was another frame's work, and once it is absorbed
|
|
48
|
+
* the two calls are adjacent on screen — the count matches what the reader
|
|
49
|
+
* sees.
|
|
50
|
+
*/
|
|
51
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
52
|
+
import { foldsTogether } from './tool-run.ts'
|
|
53
|
+
|
|
54
|
+
export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The id of the tool call this item was produced inside, or `undefined` at the
|
|
58
|
+
* top level. One spelling for both shapes the reducer emits: `assistant_text`
|
|
59
|
+
* / `thinking` / `tool_call` carry `parentToolUseId: string | null` on every
|
|
60
|
+
* instance, while `user` carries it **optionally** (a human prompt has no
|
|
61
|
+
* parent at all — the key exists only on a subagent's brief). Callers must go
|
|
62
|
+
* through this rather than reading the field, or the absent-key case silently
|
|
63
|
+
* types as a compile error on one kind and a miss on another.
|
|
64
|
+
*/
|
|
65
|
+
export function parentOf(item: TranscriptItem): string | undefined {
|
|
66
|
+
const parent = 'parentToolUseId' in item ? item.parentToolUseId : undefined
|
|
67
|
+
return parent ?? undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Is this a row the transcript folds into a run? Any tool call is — see
|
|
71
|
+
* `tool-run.ts` for why this is no longer shell-only. */
|
|
72
|
+
export function isRunCall(item: TranscriptItem): item is ToolCallItem {
|
|
73
|
+
return item.kind === 'tool_call'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One transcript item as its own row. */
|
|
77
|
+
export type ItemBlock = { key: string; item: TranscriptItem; index: number }
|
|
78
|
+
/** A folded run of consecutive tool calls — one row for `run.length` items.
|
|
79
|
+
* At the top level its coverage is contiguous (`[index, index + run.length)`);
|
|
80
|
+
* inside a task block the members' global indices may be scattered (the run is
|
|
81
|
+
* consecutive in the *subagent's* stream, not the transcript's). */
|
|
82
|
+
export type RunBlock = {
|
|
83
|
+
key: string
|
|
84
|
+
run: ToolCallItem[]
|
|
85
|
+
/** Every member's global transcript index, in stream order — `childIndices`'
|
|
86
|
+
* sibling, and needed for the same reason: a run folded across an absorbed
|
|
87
|
+
* gap has no `[index, index + len)` coverage, so a member's ordinal within
|
|
88
|
+
* the run (what the scrubber anchors a failure by) is unrecoverable from
|
|
89
|
+
* `index` arithmetic. */
|
|
90
|
+
indices: number[]
|
|
91
|
+
index: number
|
|
92
|
+
}
|
|
93
|
+
/** What a task block's children fold into. Never a task block itself — the
|
|
94
|
+
* engines do not nest sidechains, and a hypothetical grandchild renders
|
|
95
|
+
* top-level rather than vanishing (see the module comment). */
|
|
96
|
+
export type LeafBlock = ItemBlock | RunBlock
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A `Task` call and everything produced inside it, as ONE row — collapsed by
|
|
100
|
+
* default, pressable to expand, the same shape as the folded tool run.
|
|
101
|
+
*
|
|
102
|
+
* - `task` is the call itself; `index` its own transcript index, which is the
|
|
103
|
+
* row's address (rows stay ordered by `index`).
|
|
104
|
+
* - `children` are the absorbed items in stream order, folded exactly as
|
|
105
|
+
* top-level rows are; each leaf's `index` is its first member's *global*
|
|
106
|
+
* transcript index.
|
|
107
|
+
* - `childIndices` is the flat list of every absorbed item's global index, in
|
|
108
|
+
* stream order. It exists because absorption is the one exception to row
|
|
109
|
+
* contiguity: a child run's members can straddle other rows' starts, so no
|
|
110
|
+
* `[start, start + len)` arithmetic can say what this row covers —
|
|
111
|
+
* `rowIndexForItem` answers from this list instead.
|
|
112
|
+
*/
|
|
113
|
+
export type TaskBlock = {
|
|
114
|
+
key: string
|
|
115
|
+
task: ToolCallItem
|
|
116
|
+
children: LeafBlock[]
|
|
117
|
+
childIndices: number[]
|
|
118
|
+
index: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fold consecutive tool calls into runs and absorb subagent items into task
|
|
123
|
+
* blocks, leaving everything else alone.
|
|
124
|
+
*/
|
|
125
|
+
export type TerminalBlock = ItemBlock | RunBlock | TaskBlock
|
|
126
|
+
|
|
127
|
+
/** The absorbed items, flat and in stream order — what `taskSummary` counts
|
|
128
|
+
* and the collapsed row's one line is built from. */
|
|
129
|
+
export function taskChildItems(block: TaskBlock): TranscriptItem[] {
|
|
130
|
+
return block.children.flatMap((child) => ('run' in child ? child.run : [child.item]))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Append one item to a leaf-block list, folding it into the previous run when
|
|
134
|
+
* the membership rule allows — the one fold implementation, used for the
|
|
135
|
+
* top-level stream and for each task's children alike. */
|
|
136
|
+
function pushLeaf(out: LeafBlock[], item: TranscriptItem, index: number): void {
|
|
137
|
+
const previous = out.at(-1)
|
|
138
|
+
if (isRunCall(item)) {
|
|
139
|
+
if (previous && 'run' in previous && foldsTogether(previous.run[0]!, item)) {
|
|
140
|
+
previous.run.push(item)
|
|
141
|
+
previous.indices.push(index)
|
|
142
|
+
} else {
|
|
143
|
+
// Keyed by the run's *first* call, so the key is stable as the run grows.
|
|
144
|
+
out.push({ key: `run:${item.id}`, run: [item], indices: [index], index })
|
|
145
|
+
}
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
out.push({ key: `${item.kind}:${item.id}`, item, index })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param offset What `items[0]`'s index is in the whole transcript — the
|
|
153
|
+
* virtualized shell folds each side of the recap boundary separately, and the
|
|
154
|
+
* rows still have to say where they sit for the catch-up dimming.
|
|
155
|
+
* @param fold Whether to group at all. `false` gives one block per item —
|
|
156
|
+
* no runs *and no task absorption* — which is what the cards variant
|
|
157
|
+
* renders: this is the terminal theme's rule and must not silently reshape
|
|
158
|
+
* another renderer's row list.
|
|
159
|
+
*/
|
|
160
|
+
export function terminalBlocks(
|
|
161
|
+
items: readonly TranscriptItem[],
|
|
162
|
+
offset = 0,
|
|
163
|
+
fold = true,
|
|
164
|
+
): TerminalBlock[] {
|
|
165
|
+
if (!fold) {
|
|
166
|
+
return items.map((item, position) => ({
|
|
167
|
+
key: `${item.kind}:${item.id}`,
|
|
168
|
+
item,
|
|
169
|
+
index: offset + position,
|
|
170
|
+
}))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Which top-level tool calls have children in this slice, and what those
|
|
174
|
+
// children are. Collected over the whole slice before any block is built:
|
|
175
|
+
// membership is by parent id, not adjacency, so a call cannot know it is a
|
|
176
|
+
// task until every item has been seen.
|
|
177
|
+
const topLevelCalls = new Set<string>()
|
|
178
|
+
for (const item of items) {
|
|
179
|
+
if (item.kind === 'tool_call' && parentOf(item) === undefined) topLevelCalls.add(item.id)
|
|
180
|
+
}
|
|
181
|
+
const childrenOf = new Map<string, { item: TranscriptItem; index: number }[]>()
|
|
182
|
+
items.forEach((item, position) => {
|
|
183
|
+
const parent = parentOf(item)
|
|
184
|
+
if (parent !== undefined && topLevelCalls.has(parent)) {
|
|
185
|
+
const list = childrenOf.get(parent)
|
|
186
|
+
if (list) list.push({ item, index: offset + position })
|
|
187
|
+
else childrenOf.set(parent, [{ item, index: offset + position }])
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const out: TerminalBlock[] = []
|
|
192
|
+
for (const [position, item] of items.entries()) {
|
|
193
|
+
const index = offset + position
|
|
194
|
+
const parent = parentOf(item)
|
|
195
|
+
// Absorbed into its task's row — it must not also appear as its own.
|
|
196
|
+
if (parent !== undefined && childrenOf.has(parent)) continue
|
|
197
|
+
if (item.kind === 'tool_call') {
|
|
198
|
+
const children = childrenOf.get(item.id)
|
|
199
|
+
if (children) {
|
|
200
|
+
const folded: LeafBlock[] = []
|
|
201
|
+
for (const child of children) pushLeaf(folded, child.item, child.index)
|
|
202
|
+
out.push({
|
|
203
|
+
key: `task:${item.id}`,
|
|
204
|
+
task: item,
|
|
205
|
+
children: folded,
|
|
206
|
+
childIndices: children.map((child) => child.index),
|
|
207
|
+
index,
|
|
208
|
+
})
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
pushLeaf(out as LeafBlock[], item, index)
|
|
213
|
+
}
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Spacing between two items: a blank line, unless the pair belongs together.
|
|
218
|
+
* Tool output already sits under its call, and a run of tool calls reads as one
|
|
219
|
+
* block — the CLI leaves no blank line inside either. */
|
|
220
|
+
export function needsBlank(previous: TranscriptItem, next: TranscriptItem): boolean {
|
|
221
|
+
if (previous.kind === 'tool_call' && next.kind === 'tool_call') return false
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The same rule over blocks: a run counts as the tool calls it folded, and a
|
|
226
|
+
* task block counts as the `Task` call it stands for — a collapsed task row
|
|
227
|
+
* sits flush with the tool rows of the same turn, exactly as the call itself
|
|
228
|
+
* did before it grew children. */
|
|
229
|
+
export function blockNeedsBlank(previous: TerminalBlock, next: TerminalBlock): boolean {
|
|
230
|
+
const kind = (block: TerminalBlock) => ('item' in block ? block.item.kind : 'tool_call')
|
|
231
|
+
return !(kind(previous) === 'tool_call' && kind(next) === 'tool_call')
|
|
232
|
+
}
|
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
formatDuration,
|
|
7
7
|
toolInputPreview,
|
|
8
8
|
} from '../../lib/format.ts'
|
|
9
|
-
import type
|
|
9
|
+
import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
|
|
10
|
+
import { IMAGE_BOX_LINES } from './image-box.ts'
|
|
10
11
|
import { collapsedResult } from './result-preview.ts'
|
|
11
|
-
import { runSummary } from './tool-run.ts'
|
|
12
|
+
import { runSummary, taskSummary } from './tool-run.ts'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The terminal theme's row-height calculator.
|
|
@@ -37,6 +38,26 @@ import { runSummary } from './tool-run.ts'
|
|
|
37
38
|
* row is always collapsed — and an expanded row is by definition mounted,
|
|
38
39
|
* which means the virtualizer has its real measurement. There is no
|
|
39
40
|
* open/expanded branch here on purpose.
|
|
41
|
+
*
|
|
42
|
+
* **This is a decided, permanent divergence from iOS**, not a gap anyone
|
|
43
|
+
* should close. There (`apps/ios/WorkerDeckKit/.../TerminalExpansion.swift`)
|
|
44
|
+
* nothing self-measures — a `UICollectionViewLayout` takes every frame from
|
|
45
|
+
* the height book — so expansion has to be an *input to the planner*, and a
|
|
46
|
+
* height the book does not know about is a frame the layout gets wrong. That
|
|
47
|
+
* inversion is what lets the iOS scrubber be expansion-aware (a band over the
|
|
48
|
+
* region you opened; a failed member of an *open* run marking on its own
|
|
49
|
+
* line) and it is the one thing this client's rail cannot do. Lifting
|
|
50
|
+
* expansion to shared state here to match would buy one rail feature and cost
|
|
51
|
+
* this invariant — the central simplification of the whole calculator — so
|
|
52
|
+
* the two clients share the *rule* and differ in how much of it each can see.
|
|
53
|
+
*
|
|
54
|
+
* And the gap is entailed by **where the state can live**, not by how it is
|
|
55
|
+
* plumbed: a paint-only registry would preserve this invariant, but web
|
|
56
|
+
* expansion exists only while a row is *mounted*, so the rail it fed could
|
|
57
|
+
* only ever band the rows currently on screen — the one place an overview
|
|
58
|
+
* rail is useless, because the yellow wash is already visible there.
|
|
59
|
+
* Said here as well as in the Swift, because this is the file where someone
|
|
60
|
+
* would go looking to "fix" it.
|
|
40
61
|
* - **Mutation is object replacement.** The transcript reducer never mutates an
|
|
41
62
|
* item in place — streaming text, a result arriving, a patch attaching each
|
|
42
63
|
* produce a new object — which is what lets {@link HeightEpoch}'s cache key on
|
|
@@ -97,7 +118,13 @@ export function createHeightEpoch(width: number, ch: number, line: number): Heig
|
|
|
97
118
|
* The inter-row gap is the *pair's* business (`gapBefore`), not the row's, so
|
|
98
119
|
* it is added by the caller. */
|
|
99
120
|
export function estimateBlockPx(block: TerminalBlock, epoch: HeightEpoch): number {
|
|
100
|
-
|
|
121
|
+
// Only item blocks cache. A run's array is rebuilt every render, so its
|
|
122
|
+
// identity is worthless as a key — and a task block must not key on its
|
|
123
|
+
// `task` item either: children arrive without the call object changing (the
|
|
124
|
+
// reducer replaces the *child*), so a height cached against the task would
|
|
125
|
+
// survive exactly the mutation that changes the summary line. Both are one
|
|
126
|
+
// `wrapOne` over a short string (~2µs), so neither needs the cache.
|
|
127
|
+
if (!('item' in block)) return blockHeight(block, epoch).px
|
|
101
128
|
const hit = epoch.cache.get(block.item)
|
|
102
129
|
if (hit) return hit.px
|
|
103
130
|
const computed = itemHeight(block.item, epoch)
|
|
@@ -653,6 +680,15 @@ function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc
|
|
|
653
680
|
const backend = item.backend && item.backend !== 'server' ? ` · ${item.backend}` : ''
|
|
654
681
|
let acc = rowH(`${item.name}(${preview})${backend}`, m, { gutterCells: 2, extraPx })
|
|
655
682
|
|
|
683
|
+
// Each replayed image part draws a box of whole lines, and it draws it in
|
|
684
|
+
// every state — placeholder, picture, failure — so the height is settled
|
|
685
|
+
// before the first byte is asked for and the load can never reflow the list.
|
|
686
|
+
// No wrap and no `exact: false`: the box is the constant, not the image (see
|
|
687
|
+
// `image-box.ts`).
|
|
688
|
+
const images = item.result?.images
|
|
689
|
+
if (images?.length)
|
|
690
|
+
acc = add(acc, { px: images.length * IMAGE_BOX_LINES * m.line, exact: true })
|
|
691
|
+
|
|
656
692
|
if (item.patch) return add(acc, diffHeight(item.patch, m, extraPx))
|
|
657
693
|
const text = item.result?.text ?? ''
|
|
658
694
|
if (!text) return acc
|
|
@@ -660,7 +696,7 @@ function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc
|
|
|
660
696
|
// the lines and the exact trailing label, so this cannot drift from what
|
|
661
697
|
// `items.tsx` draws — which it previously could, and which its own comment
|
|
662
698
|
// said only the dev audit was catching.
|
|
663
|
-
const { shown, more } = collapsedResult(text.trimEnd().split('\n'))
|
|
699
|
+
const { shown, more } = collapsedResult(text.trimEnd().split('\n'), item.result?.totalChars)
|
|
664
700
|
for (const line of shown) {
|
|
665
701
|
// indent=1 with columns=3 resolves the indent against the row's own
|
|
666
702
|
// --term-cell: 3ch of padding + 3ch of gutter.
|
|
@@ -716,9 +752,16 @@ export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight
|
|
|
716
752
|
}
|
|
717
753
|
}
|
|
718
754
|
|
|
719
|
-
/** A virtual row's height: an item,
|
|
720
|
-
* summary line, built by the same function
|
|
755
|
+
/** A virtual row's height: an item, a folded tool run, or a task block —
|
|
756
|
+
* either fold collapsed is its one summary line, built by the same function
|
|
757
|
+
* the row draws (`runSummary` / `taskSummary`), rendered as one standard
|
|
758
|
+
* 2-cell-gutter `Row`. No expanded branch for the task block either: it is
|
|
759
|
+
* always collapsed by default, which is the invariant that keeps every
|
|
760
|
+
* unmounted row's estimate exact. */
|
|
721
761
|
export function blockHeight(block: TerminalBlock, m: CellMetrics): ComputedHeight {
|
|
762
|
+
if ('task' in block) {
|
|
763
|
+
return rowH(taskSummary(block.task, taskChildItems(block)), m)
|
|
764
|
+
}
|
|
722
765
|
if ('run' in block) {
|
|
723
766
|
const busy = block.run.some((item) => item.status === 'running' || item.status === 'pending')
|
|
724
767
|
return rowH(runSummary(block.run, busy), m)
|
|
@@ -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'
|
|
@@ -7,10 +7,32 @@ import { CopyAction, WithActions } from './affordances.tsx'
|
|
|
7
7
|
import { TerminalDiff } from './diff.tsx'
|
|
8
8
|
import { TerminalMarkdown } from './markdown.tsx'
|
|
9
9
|
import { Pressable, useRevealOnOpen } from './press.tsx'
|
|
10
|
+
import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, imagePlaceholder } from './image-box.ts'
|
|
10
11
|
import { collapsedResult } from './result-preview.ts'
|
|
11
|
-
import {
|
|
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'
|
|
12
16
|
import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
|
|
13
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
|
+
|
|
14
36
|
/**
|
|
15
37
|
* One transcript item, drawn as terminal rows.
|
|
16
38
|
*
|
|
@@ -44,8 +66,13 @@ import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
|
|
|
44
66
|
export const PROMPT_GLYPH = '❯'
|
|
45
67
|
|
|
46
68
|
/** How much the expanded row shows before offering the rest. The collapsed
|
|
47
|
-
* budget is `collapsedResult`'s, shared with the height calculator.
|
|
48
|
-
|
|
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
|
|
49
76
|
|
|
50
77
|
/** Whole lines up to a character budget — never zero, because a single line
|
|
51
78
|
* longer than the budget still has to be shown or the row would open onto
|
|
@@ -122,11 +149,14 @@ const TOOL_TONE: Record<string, Tone> = {
|
|
|
122
149
|
failed: 'red',
|
|
123
150
|
}
|
|
124
151
|
|
|
125
|
-
export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
|
|
126
|
-
|
|
127
152
|
export function ToolRow({ item }: { item: ToolCallItem }) {
|
|
128
153
|
const [open, setOpen] = useState(false)
|
|
129
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()
|
|
130
160
|
const reveal = useRevealOnOpen(open)
|
|
131
161
|
const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
|
|
132
162
|
const busy = status === 'running' || status === 'pending'
|
|
@@ -144,7 +174,9 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
|
|
|
144
174
|
// the virtualizer mounts rows, so it cannot help with what is inside a single
|
|
145
175
|
// one. Without the clip, expanding one row commits thousands of DOM nodes and
|
|
146
176
|
// the transcript stops being smooth for the rest of the session.
|
|
147
|
-
|
|
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)
|
|
148
180
|
const preview = open
|
|
149
181
|
? full
|
|
150
182
|
? lines
|
|
@@ -152,6 +184,11 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
|
|
|
152
184
|
: collapsed.shown
|
|
153
185
|
const hidden = lines.length - preview.length
|
|
154
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
|
|
155
192
|
|
|
156
193
|
const tone: Tone = isError
|
|
157
194
|
? 'red'
|
|
@@ -184,6 +221,13 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
|
|
|
184
221
|
) : null}
|
|
185
222
|
</Row>
|
|
186
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
|
+
))}
|
|
187
231
|
{/* A file edit shows its diff, not its result prose: "The file has been
|
|
188
232
|
updated" is what the *model* needed to hear, and the change is what the
|
|
189
233
|
reader did. The text stays reachable by expanding. */}
|
|
@@ -214,15 +258,30 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
|
|
|
214
258
|
{collapsed.more}
|
|
215
259
|
</Row>
|
|
216
260
|
) : null
|
|
217
|
-
) :
|
|
261
|
+
) : clipped || truncated ? (
|
|
218
262
|
<Row indent={1} columns={3} tone='faint'>
|
|
219
|
-
{
|
|
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 ? (
|
|
220
268
|
<button
|
|
221
269
|
type='button'
|
|
222
270
|
className='term-press term-link'
|
|
223
|
-
onClick={() =>
|
|
224
|
-
|
|
225
|
-
|
|
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`}
|
|
226
285
|
</button>
|
|
227
286
|
) : (
|
|
228
287
|
<>
|
|
@@ -230,6 +289,10 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
|
|
|
230
289
|
</>
|
|
231
290
|
)}
|
|
232
291
|
</Row>
|
|
292
|
+
) : hidden > 0 ? (
|
|
293
|
+
<Row indent={1} columns={3} tone='faint'>
|
|
294
|
+
… +{hidden} line{hidden === 1 ? '' : 's'}
|
|
295
|
+
</Row>
|
|
233
296
|
) : null}
|
|
234
297
|
</>
|
|
235
298
|
) : null}
|
|
@@ -238,10 +301,41 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
|
|
|
238
301
|
)
|
|
239
302
|
}
|
|
240
303
|
|
|
241
|
-
/**
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
)
|
|
245
339
|
}
|
|
246
340
|
|
|
247
341
|
/**
|
|
@@ -254,10 +348,11 @@ export function isRunCall(item: TranscriptItem): item is ToolCallItem {
|
|
|
254
348
|
* collapses to its count and gets out of the way — and opens, in full, the
|
|
255
349
|
* moment it is the thing you actually want.
|
|
256
350
|
*
|
|
257
|
-
* The membership and
|
|
258
|
-
* calculator. A
|
|
259
|
-
*
|
|
260
|
-
* the run
|
|
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`).
|
|
261
356
|
*/
|
|
262
357
|
export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
|
|
263
358
|
const [open, setOpen] = useState(false)
|
|
@@ -266,7 +361,7 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
|
|
|
266
361
|
const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
|
|
267
362
|
return status === 'running' || status === 'pending'
|
|
268
363
|
})
|
|
269
|
-
const failed = items
|
|
364
|
+
const failed = runFailed(items)
|
|
270
365
|
const pulse = usePulse(busy)
|
|
271
366
|
|
|
272
367
|
return (
|
|
@@ -293,49 +388,6 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
|
|
|
293
388
|
)
|
|
294
389
|
}
|
|
295
390
|
|
|
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
391
|
export function TurnResultRow({
|
|
340
392
|
item,
|
|
341
393
|
}: {
|
|
@@ -431,19 +483,4 @@ export function WorkingRow({
|
|
|
431
483
|
)
|
|
432
484
|
}
|
|
433
485
|
|
|
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
486
|
export { Band, Blank }
|