@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.
@@ -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 { TerminalBlock, ToolCallItem } from './items.tsx'
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
- if ('run' in block) return blockHeight(block, epoch).px
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, or a folded tool run (collapsed = its one
720
- * summary line, built by the same function the row draws). */
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 { foldsTogether, runSummary } from './tool-run.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'
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
- const RESULT_PREVIEW_CHARS = 2000
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
- const collapsed = collapsedResult(lines)
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
- ) : hidden > 0 ? (
261
+ ) : clipped || truncated ? (
218
262
  <Row indent={1} columns={3} tone='faint'>
219
- {clipped ? (
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={() => setFull(true)}>
224
- +{hidden} line{hidden === 1 ? '' : 's'} show all{' '}
225
- {text.length.toLocaleString()} chars
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
- /** Is this a row the transcript folds into a run? Any tool call is — see
242
- * `tool-run.ts` for why this is no longer shell-only. */
243
- export function isRunCall(item: TranscriptItem): item is ToolCallItem {
244
- return item.kind === 'tool_call'
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 wording rules live in `tool-run.ts`, shared with the height
258
- * calculator. A failed member does not break the run — it *colours* it, which is
259
- * the same call the scrubber makes: a failure is worth seeing, and fragmenting
260
- * the run around it would hide it in a longer list rather than surface it.
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.some((item) => item.status === 'failed' || item.result?.isError === true)
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 }