@workerdeck/ui 0.16.0 → 0.18.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-CnU_IJ3-.d.mts} +36 -9
- package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DPx8Iz8a.mjs} +1432 -384
- package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
- package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
- package/build/format.d.mts +16 -5
- package/build/format.mjs +2 -3
- package/build/index.d.mts +288 -8
- package/build/index.mjs +620 -165
- package/build/index.mjs.map +1 -1
- package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
- package/build/status-BE-zg88x.mjs.map +1 -0
- package/build/workspace.d.mts +4 -1
- package/build/workspace.mjs +4 -3
- package/build/workspace.mjs.map +1 -1
- package/package.json +8 -6
- package/src/components/agent/ContextRing.tsx +41 -0
- package/src/components/agent/EngineIcon.tsx +40 -0
- package/src/components/agent/ProjectIcon.tsx +119 -0
- package/src/components/agent/SessionBrowser.tsx +191 -17
- package/src/components/agent/SessionPanel.tsx +177 -2
- package/src/components/agent/SessionSteps.tsx +233 -0
- package/src/components/agent/SessionWorkspace.tsx +4 -0
- package/src/components/agent/StatusBar.tsx +4 -2
- package/src/components/agent/SubagentStrip.tsx +134 -0
- package/src/components/agent/ToolCallCard.tsx +72 -5
- package/src/components/agent/Transcript.tsx +212 -23
- 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 +122 -23
- package/src/components/terminal/TerminalTranscript.tsx +154 -2
- package/src/components/terminal/affordances.tsx +34 -0
- package/src/components/terminal/blocks.ts +260 -0
- package/src/components/terminal/height.ts +73 -6
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +116 -79
- package/src/components/terminal/result-preview.ts +20 -6
- package/src/components/terminal/scrubber.tsx +172 -26
- package/src/components/terminal/tool-run.ts +177 -0
- package/src/index.ts +20 -1
- package/src/lib/status.ts +16 -3
- package/src/styles/terminal.css +85 -5
- package/src/styles/theme.css +42 -0
- package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
- package/build/format-DqR56Y8l.mjs.map +0 -1
- package/build/status-Ydzi7n6j.mjs +0 -143
- package/build/status-Ydzi7n6j.mjs.map +0 -1
|
@@ -0,0 +1,260 @@
|
|
|
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
|
+
/**
|
|
71
|
+
* **The frame membership rule**: the items a sub-agent produced, and nothing
|
|
72
|
+
* else — what the takeover renders instead of the whole conversation.
|
|
73
|
+
*
|
|
74
|
+
* One exported function rather than a `filter` at each call site, because this
|
|
75
|
+
* is a rule two renderers have to agree on: iOS mirrors the terminal model out
|
|
76
|
+
* of this module, and a phone that framed a sub-agent slightly differently would
|
|
77
|
+
* be a second answer to "what is this agent doing".
|
|
78
|
+
*
|
|
79
|
+
* It picks up the brief (a `user` item *with* a parent), the thinking, the
|
|
80
|
+
* streamed text (deltas are namespaced per sidechain, so `streaming:<parentId>`
|
|
81
|
+
* carries the parent like any other item), every tool call with its result, and
|
|
82
|
+
* the final report. It excludes the spawning `Task` call itself — that is the
|
|
83
|
+
* frame, not a row in it — and every other agent's work.
|
|
84
|
+
*
|
|
85
|
+
* The slice is safe to hand straight to {@link terminalBlocks} at offset 0:
|
|
86
|
+
* nothing in it is top-level, so nothing absorbs, and consecutive calls still
|
|
87
|
+
* fold into runs because {@link foldsTogether} keys on an *equal* parent rather
|
|
88
|
+
* than on absence of one. Row indices are internally consistent because the rows
|
|
89
|
+
* and the `items` they came from are the same array.
|
|
90
|
+
*/
|
|
91
|
+
export function subagentItems(
|
|
92
|
+
items: readonly TranscriptItem[],
|
|
93
|
+
parentToolUseId: string,
|
|
94
|
+
): TranscriptItem[] {
|
|
95
|
+
return items.filter((item) => parentOf(item) === parentToolUseId)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Is this a row the transcript folds into a run? Any tool call is — see
|
|
99
|
+
* `tool-run.ts` for why this is no longer shell-only. */
|
|
100
|
+
export function isRunCall(item: TranscriptItem): item is ToolCallItem {
|
|
101
|
+
return item.kind === 'tool_call'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One transcript item as its own row. */
|
|
105
|
+
export type ItemBlock = { key: string; item: TranscriptItem; index: number }
|
|
106
|
+
/** A folded run of consecutive tool calls — one row for `run.length` items.
|
|
107
|
+
* At the top level its coverage is contiguous (`[index, index + run.length)`);
|
|
108
|
+
* inside a task block the members' global indices may be scattered (the run is
|
|
109
|
+
* consecutive in the *subagent's* stream, not the transcript's). */
|
|
110
|
+
export type RunBlock = {
|
|
111
|
+
key: string
|
|
112
|
+
run: ToolCallItem[]
|
|
113
|
+
/** Every member's global transcript index, in stream order — `childIndices`'
|
|
114
|
+
* sibling, and needed for the same reason: a run folded across an absorbed
|
|
115
|
+
* gap has no `[index, index + len)` coverage, so a member's ordinal within
|
|
116
|
+
* the run (what the scrubber anchors a failure by) is unrecoverable from
|
|
117
|
+
* `index` arithmetic. */
|
|
118
|
+
indices: number[]
|
|
119
|
+
index: number
|
|
120
|
+
}
|
|
121
|
+
/** What a task block's children fold into. Never a task block itself — the
|
|
122
|
+
* engines do not nest sidechains, and a hypothetical grandchild renders
|
|
123
|
+
* top-level rather than vanishing (see the module comment). */
|
|
124
|
+
export type LeafBlock = ItemBlock | RunBlock
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A `Task` call and everything produced inside it, as ONE row — collapsed by
|
|
128
|
+
* default, pressable to expand, the same shape as the folded tool run.
|
|
129
|
+
*
|
|
130
|
+
* - `task` is the call itself; `index` its own transcript index, which is the
|
|
131
|
+
* row's address (rows stay ordered by `index`).
|
|
132
|
+
* - `children` are the absorbed items in stream order, folded exactly as
|
|
133
|
+
* top-level rows are; each leaf's `index` is its first member's *global*
|
|
134
|
+
* transcript index.
|
|
135
|
+
* - `childIndices` is the flat list of every absorbed item's global index, in
|
|
136
|
+
* stream order. It exists because absorption is the one exception to row
|
|
137
|
+
* contiguity: a child run's members can straddle other rows' starts, so no
|
|
138
|
+
* `[start, start + len)` arithmetic can say what this row covers —
|
|
139
|
+
* `rowIndexForItem` answers from this list instead.
|
|
140
|
+
*/
|
|
141
|
+
export type TaskBlock = {
|
|
142
|
+
key: string
|
|
143
|
+
task: ToolCallItem
|
|
144
|
+
children: LeafBlock[]
|
|
145
|
+
childIndices: number[]
|
|
146
|
+
index: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fold consecutive tool calls into runs and absorb subagent items into task
|
|
151
|
+
* blocks, leaving everything else alone.
|
|
152
|
+
*/
|
|
153
|
+
export type TerminalBlock = ItemBlock | RunBlock | TaskBlock
|
|
154
|
+
|
|
155
|
+
/** The absorbed items, flat and in stream order — what `taskSummary` counts
|
|
156
|
+
* and the collapsed row's one line is built from. */
|
|
157
|
+
export function taskChildItems(block: TaskBlock): TranscriptItem[] {
|
|
158
|
+
return block.children.flatMap((child) => ('run' in child ? child.run : [child.item]))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Append one item to a leaf-block list, folding it into the previous run when
|
|
162
|
+
* the membership rule allows — the one fold implementation, used for the
|
|
163
|
+
* top-level stream and for each task's children alike. */
|
|
164
|
+
function pushLeaf(out: LeafBlock[], item: TranscriptItem, index: number): void {
|
|
165
|
+
const previous = out.at(-1)
|
|
166
|
+
if (isRunCall(item)) {
|
|
167
|
+
if (previous && 'run' in previous && foldsTogether(previous.run[0]!, item)) {
|
|
168
|
+
previous.run.push(item)
|
|
169
|
+
previous.indices.push(index)
|
|
170
|
+
} else {
|
|
171
|
+
// Keyed by the run's *first* call, so the key is stable as the run grows.
|
|
172
|
+
out.push({ key: `run:${item.id}`, run: [item], indices: [index], index })
|
|
173
|
+
}
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
out.push({ key: `${item.kind}:${item.id}`, item, index })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param offset What `items[0]`'s index is in the whole transcript — the
|
|
181
|
+
* virtualized shell folds each side of the recap boundary separately, and the
|
|
182
|
+
* rows still have to say where they sit for the catch-up dimming.
|
|
183
|
+
* @param fold Whether to group at all. `false` gives one block per item —
|
|
184
|
+
* no runs *and no task absorption* — which is what the cards variant
|
|
185
|
+
* renders: this is the terminal theme's rule and must not silently reshape
|
|
186
|
+
* another renderer's row list.
|
|
187
|
+
*/
|
|
188
|
+
export function terminalBlocks(
|
|
189
|
+
items: readonly TranscriptItem[],
|
|
190
|
+
offset = 0,
|
|
191
|
+
fold = true,
|
|
192
|
+
): TerminalBlock[] {
|
|
193
|
+
if (!fold) {
|
|
194
|
+
return items.map((item, position) => ({
|
|
195
|
+
key: `${item.kind}:${item.id}`,
|
|
196
|
+
item,
|
|
197
|
+
index: offset + position,
|
|
198
|
+
}))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Which top-level tool calls have children in this slice, and what those
|
|
202
|
+
// children are. Collected over the whole slice before any block is built:
|
|
203
|
+
// membership is by parent id, not adjacency, so a call cannot know it is a
|
|
204
|
+
// task until every item has been seen.
|
|
205
|
+
const topLevelCalls = new Set<string>()
|
|
206
|
+
for (const item of items) {
|
|
207
|
+
if (item.kind === 'tool_call' && parentOf(item) === undefined) topLevelCalls.add(item.id)
|
|
208
|
+
}
|
|
209
|
+
const childrenOf = new Map<string, { item: TranscriptItem; index: number }[]>()
|
|
210
|
+
items.forEach((item, position) => {
|
|
211
|
+
const parent = parentOf(item)
|
|
212
|
+
if (parent !== undefined && topLevelCalls.has(parent)) {
|
|
213
|
+
const list = childrenOf.get(parent)
|
|
214
|
+
if (list) list.push({ item, index: offset + position })
|
|
215
|
+
else childrenOf.set(parent, [{ item, index: offset + position }])
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const out: TerminalBlock[] = []
|
|
220
|
+
for (const [position, item] of items.entries()) {
|
|
221
|
+
const index = offset + position
|
|
222
|
+
const parent = parentOf(item)
|
|
223
|
+
// Absorbed into its task's row — it must not also appear as its own.
|
|
224
|
+
if (parent !== undefined && childrenOf.has(parent)) continue
|
|
225
|
+
if (item.kind === 'tool_call') {
|
|
226
|
+
const children = childrenOf.get(item.id)
|
|
227
|
+
if (children) {
|
|
228
|
+
const folded: LeafBlock[] = []
|
|
229
|
+
for (const child of children) pushLeaf(folded, child.item, child.index)
|
|
230
|
+
out.push({
|
|
231
|
+
key: `task:${item.id}`,
|
|
232
|
+
task: item,
|
|
233
|
+
children: folded,
|
|
234
|
+
childIndices: children.map((child) => child.index),
|
|
235
|
+
index,
|
|
236
|
+
})
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
pushLeaf(out as LeafBlock[], item, index)
|
|
241
|
+
}
|
|
242
|
+
return out
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Spacing between two items: a blank line, unless the pair belongs together.
|
|
246
|
+
* Tool output already sits under its call, and a run of tool calls reads as one
|
|
247
|
+
* block — the CLI leaves no blank line inside either. */
|
|
248
|
+
export function needsBlank(previous: TranscriptItem, next: TranscriptItem): boolean {
|
|
249
|
+
if (previous.kind === 'tool_call' && next.kind === 'tool_call') return false
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The same rule over blocks: a run counts as the tool calls it folded, and a
|
|
254
|
+
* task block counts as the `Task` call it stands for — a collapsed task row
|
|
255
|
+
* sits flush with the tool rows of the same turn, exactly as the call itself
|
|
256
|
+
* did before it grew children. */
|
|
257
|
+
export function blockNeedsBlank(previous: TerminalBlock, next: TerminalBlock): boolean {
|
|
258
|
+
const kind = (block: TerminalBlock) => ('item' in block ? block.item.kind : 'tool_call')
|
|
259
|
+
return !(kind(previous) === 'tool_call' && kind(next) === 'tool_call')
|
|
260
|
+
}
|
|
@@ -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)
|
|
@@ -105,6 +132,30 @@ export function estimateBlockPx(block: TerminalBlock, epoch: HeightEpoch): numbe
|
|
|
105
132
|
return computed.px
|
|
106
133
|
}
|
|
107
134
|
|
|
135
|
+
/**
|
|
136
|
+
* How many lines of a sub-agent's brief the collapsed row shows.
|
|
137
|
+
*
|
|
138
|
+
* Four, and the number is a judgement rather than a measurement: a brief runs
|
|
139
|
+
* to thousands of characters, and an uncapped one buries the work it was asking
|
|
140
|
+
* for under its own instructions. Four is enough to recognise the task and
|
|
141
|
+
* short enough that the agent's first line stays on screen beside it.
|
|
142
|
+
*/
|
|
143
|
+
export const BRIEF_LINES = 4
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The collapsed brief row's height — the frame's first row, which is synthetic
|
|
147
|
+
* and so cannot go through {@link estimateBlockPx}.
|
|
148
|
+
*
|
|
149
|
+
* Clipped, so it is `min(wrapped, BRIEF_LINES)` lines plus the row the header
|
|
150
|
+
* occupies. Expanding is local state on a *mounted* row, which the virtualizer
|
|
151
|
+
* re-measures; what has to be right before it mounts is the collapsed height,
|
|
152
|
+
* exactly as a task row is always collapsed when unmounted for the same reason.
|
|
153
|
+
*/
|
|
154
|
+
export function briefPx(text: string, m: CellMetrics): number {
|
|
155
|
+
const cols = Math.max(1, Math.floor(m.width / m.ch + EPS))
|
|
156
|
+
return (Math.min(textLines(text, cols).lines, BRIEF_LINES) + 1) * m.line
|
|
157
|
+
}
|
|
158
|
+
|
|
108
159
|
/** Measure the advance of `0` on the live surface. A DOM read — call it from
|
|
109
160
|
* the epoch's measurement pass (an effect / ResizeObserver callback), never
|
|
110
161
|
* from render. The probe is absolutely positioned, so it contributes no layout
|
|
@@ -653,6 +704,15 @@ function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc
|
|
|
653
704
|
const backend = item.backend && item.backend !== 'server' ? ` · ${item.backend}` : ''
|
|
654
705
|
let acc = rowH(`${item.name}(${preview})${backend}`, m, { gutterCells: 2, extraPx })
|
|
655
706
|
|
|
707
|
+
// Each replayed image part draws a box of whole lines, and it draws it in
|
|
708
|
+
// every state — placeholder, picture, failure — so the height is settled
|
|
709
|
+
// before the first byte is asked for and the load can never reflow the list.
|
|
710
|
+
// No wrap and no `exact: false`: the box is the constant, not the image (see
|
|
711
|
+
// `image-box.ts`).
|
|
712
|
+
const images = item.result?.images
|
|
713
|
+
if (images?.length)
|
|
714
|
+
acc = add(acc, { px: images.length * IMAGE_BOX_LINES * m.line, exact: true })
|
|
715
|
+
|
|
656
716
|
if (item.patch) return add(acc, diffHeight(item.patch, m, extraPx))
|
|
657
717
|
const text = item.result?.text ?? ''
|
|
658
718
|
if (!text) return acc
|
|
@@ -660,7 +720,7 @@ function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc
|
|
|
660
720
|
// the lines and the exact trailing label, so this cannot drift from what
|
|
661
721
|
// `items.tsx` draws — which it previously could, and which its own comment
|
|
662
722
|
// said only the dev audit was catching.
|
|
663
|
-
const { shown, more } = collapsedResult(text.trimEnd().split('\n'))
|
|
723
|
+
const { shown, more } = collapsedResult(text.trimEnd().split('\n'), item.result?.totalChars)
|
|
664
724
|
for (const line of shown) {
|
|
665
725
|
// indent=1 with columns=3 resolves the indent against the row's own
|
|
666
726
|
// --term-cell: 3ch of padding + 3ch of gutter.
|
|
@@ -716,9 +776,16 @@ export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight
|
|
|
716
776
|
}
|
|
717
777
|
}
|
|
718
778
|
|
|
719
|
-
/** A virtual row's height: an item,
|
|
720
|
-
* summary line, built by the same function
|
|
779
|
+
/** A virtual row's height: an item, a folded tool run, or a task block —
|
|
780
|
+
* either fold collapsed is its one summary line, built by the same function
|
|
781
|
+
* the row draws (`runSummary` / `taskSummary`), rendered as one standard
|
|
782
|
+
* 2-cell-gutter `Row`. No expanded branch for the task block either: it is
|
|
783
|
+
* always collapsed by default, which is the invariant that keeps every
|
|
784
|
+
* unmounted row's estimate exact. */
|
|
721
785
|
export function blockHeight(block: TerminalBlock, m: CellMetrics): ComputedHeight {
|
|
786
|
+
if ('task' in block) {
|
|
787
|
+
return rowH(taskSummary(block.task, taskChildItems(block)), m)
|
|
788
|
+
}
|
|
722
789
|
if ('run' in block) {
|
|
723
790
|
const busy = block.run.some((item) => item.status === 'running' || item.status === 'pending')
|
|
724
791
|
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'
|