@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.
@@ -5021,6 +5021,32 @@ function scrollParent(from) {
5021
5021
  return null;
5022
5022
  }
5023
5023
  //#endregion
5024
+ //#region src/components/terminal/image-box.ts
5025
+ /**
5026
+ * What the box says before the fetch lands.
5027
+ *
5028
+ * `bytes` is the decoded size the gateway stamped on the reference — the client
5029
+ * holds no bytes at all until it asks for them, so this is a number it cannot
5030
+ * compute, the same reason `total_chars` rides beside a truncated head.
5031
+ * `formatBytes` rather than a spelling of its own: the panel says "336.0 KB"
5032
+ * everywhere else, and a second byte formatter is a second thing to keep in
5033
+ * step.
5034
+ */
5035
+ function imagePlaceholder(image) {
5036
+ return `image · ${formatBytes(image.bytes)}`;
5037
+ }
5038
+ /**
5039
+ * What it says when the fetch failed — a stale address after a dormant wake
5040
+ * (the route 404s rather than serving another call's pixels), a gateway too old
5041
+ * to know the route, a dropped connection.
5042
+ *
5043
+ * It occupies the same box, because the alternative is the row changing height
5044
+ * on a network failure. Silence is not an option here the way it is for
5045
+ * `HostImage`: that card names the host path in its result text, so a reader can
5046
+ * still find the picture; a replayed image part has no path to name.
5047
+ */
5048
+ const IMAGE_UNAVAILABLE = "image unavailable";
5049
+ //#endregion
5024
5050
  //#region src/components/terminal/result-preview.ts
5025
5051
  /**
5026
5052
  * How much of a tool result a collapsed row shows, and what it says it hid.
@@ -5056,7 +5082,16 @@ const PREVIEW_CHARS = 400;
5056
5082
  * lines otherwise — a one-line JSON blob has no hidden lines to count, and
5057
5083
  * "+0 lines" under a visibly cut-off row is worse than saying nothing.
5058
5084
  */
5059
- function collapsedResult(lines) {
5085
+ /**
5086
+ * `totalChars` is the **untruncated** length when the replay delivered only a
5087
+ * head (protocol's `ToolResultBlock.total_chars`). Passing it is not cosmetic:
5088
+ * computed from the head this row would say "… +7,600 chars" where the truth is
5089
+ * 641,003, and the wrong string is a *different pixel height* — which is exactly
5090
+ * the drift this module exists to prevent, since `height.ts` sizes the row by
5091
+ * wrapping this same text. Omitted for a whole result, where the lines are the
5092
+ * whole truth.
5093
+ */
5094
+ function collapsedResult(lines, totalChars) {
5060
5095
  const shown = [];
5061
5096
  let chars = 0;
5062
5097
  let cut = false;
@@ -5071,9 +5106,15 @@ function collapsedResult(lines) {
5071
5106
  shown.push(line);
5072
5107
  chars += line.length + 1;
5073
5108
  }
5109
+ const held = lines.join("\n").length;
5110
+ const total = totalChars ?? held;
5074
5111
  if (cut) return {
5075
5112
  shown,
5076
- more: `… +${(lines.join("\n").length - chars).toLocaleString()} chars`
5113
+ more: `… +${(total - chars).toLocaleString()} chars`
5114
+ };
5115
+ if (totalChars !== void 0 && total > held) return {
5116
+ shown,
5117
+ more: `… +${(total - chars).toLocaleString()} chars`
5077
5118
  };
5078
5119
  const hidden = lines.length - shown.length;
5079
5120
  return {
@@ -5082,6 +5123,157 @@ function collapsedResult(lines) {
5082
5123
  };
5083
5124
  }
5084
5125
  //#endregion
5126
+ //#region src/components/agent/tool-result-fetch.tsx
5127
+ const FetchContext = createContext(async () => false);
5128
+ function ToolResultFetchProvider({ value, children }) {
5129
+ return /* @__PURE__ */ jsx(FetchContext.Provider, {
5130
+ value: value ?? noop$1,
5131
+ children
5132
+ });
5133
+ }
5134
+ const noop$1 = async () => false;
5135
+ function useToolResultFetcher() {
5136
+ return useContext(FetchContext);
5137
+ }
5138
+ //#endregion
5139
+ //#region src/components/agent/tool-result-image.tsx
5140
+ const noop = async () => void 0;
5141
+ const ImageContext = createContext(noop);
5142
+ function ToolResultImageProvider({ value, children }) {
5143
+ return /* @__PURE__ */ jsx(ImageContext.Provider, {
5144
+ value: value ?? noop,
5145
+ children
5146
+ });
5147
+ }
5148
+ function useToolResultImageLoader() {
5149
+ return useContext(ImageContext);
5150
+ }
5151
+ /**
5152
+ * Long enough that a fast scrub through an image-heavy session fetches nothing
5153
+ * it flew past, short enough to be invisible to a reader who stopped.
5154
+ *
5155
+ * There is no second visibility system here on purpose: the transcript is
5156
+ * virtualized, so a *mounted* row is by definition within an overscan of the
5157
+ * viewport — the virtualizer already is the IntersectionObserver, and a second
5158
+ * answer to a question that has one is how the two disagree.
5159
+ */
5160
+ const MOUNT_SETTLE_MS = 150;
5161
+ /**
5162
+ * One box's load, for either theme.
5163
+ *
5164
+ * Fires once the row has been mounted for {@link MOUNT_SETTLE_MS}, and then
5165
+ * **runs to completion** — an aborted fetch re-pays the whole image on the
5166
+ * return visit, and the gateway is HTTP/1.1, so the browser's per-origin
5167
+ * connection cap is the concurrency throttle for free.
5168
+ *
5169
+ * The effect keys on the address's *primitives*, never on the ref object: the
5170
+ * reducer replaces items on every streamed delta, so an object-identity dep
5171
+ * would re-run this on every token of the turn after it.
5172
+ */
5173
+ function useToolResultImageSrc(ref) {
5174
+ const load = useToolResultImageLoader();
5175
+ const [state, setState] = useState({ failed: false });
5176
+ const { toolUseId, sourceSeq, partIndex, mediaType, bytes } = ref;
5177
+ useEffect(() => {
5178
+ let live = true;
5179
+ setState({ failed: false });
5180
+ const timer = setTimeout(() => {
5181
+ load({
5182
+ toolUseId,
5183
+ sourceSeq,
5184
+ partIndex,
5185
+ mediaType,
5186
+ bytes
5187
+ }).then((src) => {
5188
+ if (live) setState({
5189
+ src,
5190
+ failed: src === void 0
5191
+ });
5192
+ }).catch(() => {
5193
+ if (live) setState({ failed: true });
5194
+ });
5195
+ }, MOUNT_SETTLE_MS);
5196
+ return () => {
5197
+ live = false;
5198
+ clearTimeout(timer);
5199
+ };
5200
+ }, [
5201
+ load,
5202
+ toolUseId,
5203
+ sourceSeq,
5204
+ partIndex,
5205
+ mediaType,
5206
+ bytes
5207
+ ]);
5208
+ return state;
5209
+ }
5210
+ /** ~64 MB of decoded pictures held at once. At the corpus's 335 KB median that
5211
+ * is ~190 images, which no viewport holds; the budget exists so a session
5212
+ * scrolled end to end does not pin every screenshot it passed. */
5213
+ const CACHE_BUDGET_BYTES = 64 * 1024 * 1024;
5214
+ /**
5215
+ * `useHostImage`'s shape, generalized to the replay route — and **bounded**,
5216
+ * which `useHostImage` is not.
5217
+ *
5218
+ * The promise-per-key cache is what makes this callable from a transcript row at
5219
+ * all: rows re-render on every streamed delta, and an uncached resolver would
5220
+ * re-fetch each time. The LRU is the part that is new. Object URLs pin their
5221
+ * blob until revoked, so a fully-scrolled hundred-image session would otherwise
5222
+ * hold ~50 MB until the panel unmounted — and evicting means revoking, or the
5223
+ * eviction frees a `Map` entry and nothing else.
5224
+ *
5225
+ * Re-fetching on a return scroll is fine, and is the whole design: the bytes are
5226
+ * one authenticated request away, which is precisely what makes it cheap not to
5227
+ * have shipped them in the attach.
5228
+ */
5229
+ function useToolResultImages(client, sessionId) {
5230
+ const cache = useRef(/* @__PURE__ */ new Map());
5231
+ useEffect(() => () => {
5232
+ for (const entry of cache.current.values()) if (entry.url) URL.revokeObjectURL(entry.url);
5233
+ cache.current.clear();
5234
+ }, []);
5235
+ return useCallback((ref) => {
5236
+ if (!sessionId) return Promise.resolve(void 0);
5237
+ const key = `${sessionId}:${ref.sourceSeq}:${ref.toolUseId}:${ref.partIndex}`;
5238
+ const hit = cache.current.get(key);
5239
+ if (hit) {
5240
+ cache.current.delete(key);
5241
+ cache.current.set(key, hit);
5242
+ return hit.pending;
5243
+ }
5244
+ const pending = client.toolResultImage(sessionId, ref.sourceSeq, ref.toolUseId, ref.partIndex).then((blob) => {
5245
+ if (blob.size === 0) return void 0;
5246
+ const url = URL.createObjectURL(blob);
5247
+ const entry = cache.current.get(key);
5248
+ if (entry) {
5249
+ entry.url = url;
5250
+ entry.bytes = blob.size;
5251
+ evict(cache.current, key);
5252
+ } else URL.revokeObjectURL(url);
5253
+ return url;
5254
+ }).catch(() => void 0);
5255
+ cache.current.set(key, {
5256
+ pending,
5257
+ bytes: ref.bytes
5258
+ });
5259
+ return pending;
5260
+ }, [client, sessionId]);
5261
+ }
5262
+ /** Drop oldest-first until the held bytes fit the budget, revoking as it goes.
5263
+ * `keep` is the entry just resolved — evicting the picture a row is about to
5264
+ * draw would be a fetch spent on nothing. */
5265
+ function evict(cache, keep) {
5266
+ let held = 0;
5267
+ for (const entry of cache.values()) held += entry.bytes;
5268
+ for (const [key, entry] of cache) {
5269
+ if (held <= CACHE_BUDGET_BYTES) return;
5270
+ if (key === keep) continue;
5271
+ if (entry.url) URL.revokeObjectURL(entry.url);
5272
+ cache.delete(key);
5273
+ held -= entry.bytes;
5274
+ }
5275
+ }
5276
+ //#endregion
5085
5277
  //#region src/components/terminal/tool-run.ts
5086
5278
  /**
5087
5279
  * What breaks a run.
@@ -5138,8 +5330,238 @@ function runSummary(items, busy) {
5138
5330
  const breakdown = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([family, count]) => `${count} ${family}`).join(", ");
5139
5331
  return `${verb}${n} tool${n === 1 ? "" : "s"} · ${breakdown}${tail}`;
5140
5332
  }
5333
+ const clip = (text, max = 80) => text.length > max ? text.slice(0, max - 1) + "…" : text;
5334
+ const trimmed = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
5335
+ /**
5336
+ * The row's identity half: which task this is.
5337
+ *
5338
+ * The Claude SDK's `Task` input carries `subagent_type` (e.g. "Explore") and a
5339
+ * 3–5 word `description`, and both are worth the line: parallel tasks are the
5340
+ * whole reason the block exists, and two rows both reading `Task(…)` answer
5341
+ * nothing. `Task(Explore · find the auth check)` — falling back to the
5342
+ * ordinary input preview when an engine sends neither, so the header is never
5343
+ * emptier than a plain tool row's.
5344
+ */
5345
+ function taskLabel(task) {
5346
+ const input = task.input;
5347
+ const description = trimmed(input?.description);
5348
+ const agent = trimmed(input?.subagent_type);
5349
+ const inner = agent && description ? `${agent} · ${clip(description)}` : agent ?? (description ? clip(description) : toolInputPreview(task.input));
5350
+ return `${task.name}(${inner})`;
5351
+ }
5352
+ const callBusy = (call) => call.status === "running" || call.status === "pending";
5353
+ /** Did this one call fail? Both spellings are needed: an out-of-loop execution
5354
+ * failure sets `status` with no `is_error` block to read, and an engine can flag
5355
+ * `is_error` on a call the reducer has not settled yet. */
5356
+ const callFailed = (call) => call.status === "failed" || call.result?.isError === true;
5357
+ /**
5358
+ * Does a folded run colour red? **Only when its last call failed.**
5359
+ *
5360
+ * It used to be `some`, on the argument that a failure should colour the block
5361
+ * rather than fragment it. The argument was right about not fragmenting and
5362
+ * wrong about `some`: a run is a sequence the model worked through, and a
5363
+ * failure it recovered from two calls later is how work goes — a grep that
5364
+ * matched nothing, a build fixed on the second go. Reddening the whole run for
5365
+ * it means a normal working session is painted red, which spends the colour
5366
+ * that should have been left for the one thing still broken.
5367
+ *
5368
+ * The last call is the run's *outcome*, and an outcome is what a collapsed row
5369
+ * can honestly claim. The failures inside it are not hidden — they are one
5370
+ * press away, each red on its own row, and the recap counts every one. The
5371
+ * **scrubber agrees with this rule** rather than overriding it: it marks a
5372
+ * failed call only when the call is its row's outcome, which for a run is
5373
+ * exactly this one. It used to mark every member on the argument that the
5374
+ * rail asks a different question; against a real session that was nine alarms
5375
+ * on the rail for a transcript reddening one row.
5376
+ */
5377
+ function runFailed(items) {
5378
+ const last = items[items.length - 1];
5379
+ return last !== void 0 && callFailed(last);
5380
+ }
5381
+ /** Is anything inside still going? The call itself, normally — the Task
5382
+ * settles only when its subagent finishes — but a bridged or deferred child
5383
+ * can outlive it, and a pulse that stopped while a child still worked would
5384
+ * read as a hang. */
5385
+ function taskBusy(task, children) {
5386
+ return callBusy(task) || children.some((child) => child.kind === "tool_call" && callBusy(child));
5387
+ }
5388
+ /**
5389
+ * Does the row colour red? **The task's own outcome, and nothing else.**
5390
+ *
5391
+ * It used to be "or any child call's", which does not survive contact with a
5392
+ * real subagent: an agent that ran a hundred calls, one of them a grep that
5393
+ * matched nothing, came back with a red line saying it had failed. It had not —
5394
+ * it had done exactly what it was asked, and the transcript said otherwise in
5395
+ * the one colour reserved for things that need a human.
5396
+ *
5397
+ * This is the call `SubagentInfo.status` already makes, and it made it for this
5398
+ * reason (see `packages/protocol`): the sub-agent's **own** `tool_result`
5399
+ * `is_error`, deliberately not `taskFailed`. The argument there was that a
5400
+ * nothing-matched grep must not read as a failed run *beside a session name*;
5401
+ * what a hundred-call agent shows is that it must not read that way beside the
5402
+ * `Task` row either. Two surfaces, one rule, one spelling.
5403
+ *
5404
+ * Nothing is concealed by this. A failed child is red on its own row, one press
5405
+ * away, and the recap counts it. The **scrubber follows this rule too** and no
5406
+ * longer marks such a child: a red tick on the rail says precisely what this
5407
+ * row is forbidden from saying. The sub-agent band still says an agent ran
5408
+ * here, and the task's own red tick still says it came back broken — which is
5409
+ * what the two channels are for.
5410
+ */
5411
+ function taskFailed(task) {
5412
+ return callFailed(task);
5413
+ }
5414
+ /**
5415
+ * The collapsed task row's one line: identity, then scale.
5416
+ *
5417
+ * `Task(Explore · find the auth check) · 7 tools…` while the subagent works —
5418
+ * the count grows as it does, which is the row's progress reading, and the
5419
+ * trailing ellipsis is the same in-flight signal `runSummary` uses (the pulse
5420
+ * in the gutter carries the beat). Settled, the ellipsis drops:
5421
+ * `… · 7 tools`. "Tools" and not "tool calls" because `runSummary` already
5422
+ * chose that word for the same count one row over.
5423
+ *
5424
+ * The counts are counted from the absorbed children, never read from the
5425
+ * engine's structured Task output — WorkerDeck does not plumb structured tool
5426
+ * results to clients, so a transcript replayed tomorrow must spell the same
5427
+ * line from the same items it holds today.
5428
+ *
5429
+ * With no tool calls yet — the subagent thinking, or only its brief arrived —
5430
+ * the line says `working…`, because `0 tools…` reads as a stall; settled with
5431
+ * none it says `done`.
5432
+ */
5433
+ function taskSummary(task, children) {
5434
+ const busy = taskBusy(task, children);
5435
+ const calls = children.reduce((n, child) => n + (child.kind === "tool_call" ? 1 : 0), 0);
5436
+ const label = taskLabel(task);
5437
+ if (calls === 0) return busy ? `${label} · working…` : `${label} · done`;
5438
+ return `${label} · ${calls} tool${calls === 1 ? "" : "s"}${busy ? "…" : ""}`;
5439
+ }
5440
+ //#endregion
5441
+ //#region src/components/terminal/blocks.ts
5442
+ /**
5443
+ * The id of the tool call this item was produced inside, or `undefined` at the
5444
+ * top level. One spelling for both shapes the reducer emits: `assistant_text`
5445
+ * / `thinking` / `tool_call` carry `parentToolUseId: string | null` on every
5446
+ * instance, while `user` carries it **optionally** (a human prompt has no
5447
+ * parent at all — the key exists only on a subagent's brief). Callers must go
5448
+ * through this rather than reading the field, or the absent-key case silently
5449
+ * types as a compile error on one kind and a miss on another.
5450
+ */
5451
+ function parentOf(item) {
5452
+ return ("parentToolUseId" in item ? item.parentToolUseId : void 0) ?? void 0;
5453
+ }
5454
+ /** Is this a row the transcript folds into a run? Any tool call is — see
5455
+ * `tool-run.ts` for why this is no longer shell-only. */
5456
+ function isRunCall(item) {
5457
+ return item.kind === "tool_call";
5458
+ }
5459
+ /** The absorbed items, flat and in stream order — what `taskSummary` counts
5460
+ * and the collapsed row's one line is built from. */
5461
+ function taskChildItems(block) {
5462
+ return block.children.flatMap((child) => "run" in child ? child.run : [child.item]);
5463
+ }
5464
+ /** Append one item to a leaf-block list, folding it into the previous run when
5465
+ * the membership rule allows — the one fold implementation, used for the
5466
+ * top-level stream and for each task's children alike. */
5467
+ function pushLeaf(out, item, index) {
5468
+ const previous = out.at(-1);
5469
+ if (isRunCall(item)) {
5470
+ if (previous && "run" in previous && foldsTogether(previous.run[0], item)) {
5471
+ previous.run.push(item);
5472
+ previous.indices.push(index);
5473
+ } else out.push({
5474
+ key: `run:${item.id}`,
5475
+ run: [item],
5476
+ indices: [index],
5477
+ index
5478
+ });
5479
+ return;
5480
+ }
5481
+ out.push({
5482
+ key: `${item.kind}:${item.id}`,
5483
+ item,
5484
+ index
5485
+ });
5486
+ }
5487
+ /**
5488
+ * @param offset What `items[0]`'s index is in the whole transcript — the
5489
+ * virtualized shell folds each side of the recap boundary separately, and the
5490
+ * rows still have to say where they sit for the catch-up dimming.
5491
+ * @param fold Whether to group at all. `false` gives one block per item —
5492
+ * no runs *and no task absorption* — which is what the cards variant
5493
+ * renders: this is the terminal theme's rule and must not silently reshape
5494
+ * another renderer's row list.
5495
+ */
5496
+ function terminalBlocks(items, offset = 0, fold = true) {
5497
+ if (!fold) return items.map((item, position) => ({
5498
+ key: `${item.kind}:${item.id}`,
5499
+ item,
5500
+ index: offset + position
5501
+ }));
5502
+ const topLevelCalls = /* @__PURE__ */ new Set();
5503
+ for (const item of items) if (item.kind === "tool_call" && parentOf(item) === void 0) topLevelCalls.add(item.id);
5504
+ const childrenOf = /* @__PURE__ */ new Map();
5505
+ items.forEach((item, position) => {
5506
+ const parent = parentOf(item);
5507
+ if (parent !== void 0 && topLevelCalls.has(parent)) {
5508
+ const list = childrenOf.get(parent);
5509
+ if (list) list.push({
5510
+ item,
5511
+ index: offset + position
5512
+ });
5513
+ else childrenOf.set(parent, [{
5514
+ item,
5515
+ index: offset + position
5516
+ }]);
5517
+ }
5518
+ });
5519
+ const out = [];
5520
+ for (const [position, item] of items.entries()) {
5521
+ const index = offset + position;
5522
+ const parent = parentOf(item);
5523
+ if (parent !== void 0 && childrenOf.has(parent)) continue;
5524
+ if (item.kind === "tool_call") {
5525
+ const children = childrenOf.get(item.id);
5526
+ if (children) {
5527
+ const folded = [];
5528
+ for (const child of children) pushLeaf(folded, child.item, child.index);
5529
+ out.push({
5530
+ key: `task:${item.id}`,
5531
+ task: item,
5532
+ children: folded,
5533
+ childIndices: children.map((child) => child.index),
5534
+ index
5535
+ });
5536
+ continue;
5537
+ }
5538
+ }
5539
+ pushLeaf(out, item, index);
5540
+ }
5541
+ return out;
5542
+ }
5543
+ /** Spacing between two items: a blank line, unless the pair belongs together.
5544
+ * Tool output already sits under its call, and a run of tool calls reads as one
5545
+ * block — the CLI leaves no blank line inside either. */
5546
+ function needsBlank(previous, next) {
5547
+ if (previous.kind === "tool_call" && next.kind === "tool_call") return false;
5548
+ return true;
5549
+ }
5550
+ /** The same rule over blocks: a run counts as the tool calls it folded, and a
5551
+ * task block counts as the `Task` call it stands for — a collapsed task row
5552
+ * sits flush with the tool rows of the same turn, exactly as the call itself
5553
+ * did before it grew children. */
5554
+ function blockNeedsBlank(previous, next) {
5555
+ const kind = (block) => "item" in block ? block.item.kind : "tool_call";
5556
+ return !(kind(previous) === "tool_call" && kind(next) === "tool_call");
5557
+ }
5141
5558
  /** How much the expanded row shows before offering the rest. The collapsed
5142
- * budget is `collapsedResult`'s, shared with the height calculator. */
5559
+ * budget is `collapsedResult`'s, shared with the height calculator.
5560
+ *
5561
+ * Exported for one test and not from the package: protocol's
5562
+ * `TOOL_RESULT_HEAD_CHARS` is chosen to exceed it, so that a truncated result's
5563
+ * open state is byte-identical to an untruncated one and only the uncapped
5564
+ * `full` press ever fetches. That relationship is asserted, not assumed. */
5143
5565
  const RESULT_PREVIEW_CHARS$1 = 2e3;
5144
5566
  /** Whole lines up to a character budget — never zero, because a single line
5145
5567
  * longer than the budget still has to be shown or the row would open onto
@@ -5209,6 +5631,8 @@ const TOOL_TONE = {
5209
5631
  function ToolRow({ item }) {
5210
5632
  const [open, setOpen] = useState(false);
5211
5633
  const [full, setFull] = useState(false);
5634
+ const [fetching, setFetching] = useState(false);
5635
+ const fetchResult = useToolResultFetcher();
5212
5636
  const reveal = useRevealOnOpen(open);
5213
5637
  const status = item.status ?? (item.result === void 0 ? "running" : "settled");
5214
5638
  const busy = status === "running" || status === "pending";
@@ -5216,10 +5640,12 @@ function ToolRow({ item }) {
5216
5640
  const pulse = usePulse(busy);
5217
5641
  const text = item.result?.text ?? "";
5218
5642
  const lines = text.trimEnd().split("\n");
5219
- const collapsed = collapsedResult(lines);
5643
+ const collapsed = collapsedResult(lines, item.result?.totalChars);
5220
5644
  const preview = open ? full ? lines : clipToChars(lines, RESULT_PREVIEW_CHARS$1) : collapsed.shown;
5221
5645
  const hidden = lines.length - preview.length;
5222
5646
  const clipped = open && !full && hidden > 0;
5647
+ const truncated = item.result?.truncated === true;
5648
+ const missing = truncated ? (item.result?.totalChars ?? 0) - text.length : 0;
5223
5649
  const tone = isError ? "red" : status === "settled" && isMutatingTool(item.name) ? "green" : TOOL_TONE[status] ?? "dim";
5224
5650
  const command = item.input?.command;
5225
5651
  const copyable = typeof command === "string" ? command : text;
@@ -5231,76 +5657,123 @@ function ToolRow({ item }) {
5231
5657
  text: copyable,
5232
5658
  label: "Copy"
5233
5659
  }) : null,
5234
- children: [/* @__PURE__ */ jsx(Pressable, {
5235
- onPress: () => setOpen((v) => !v),
5236
- expanded: open,
5237
- children: /* @__PURE__ */ jsxs(Row, {
5238
- glyph: busy ? pulse : "●",
5239
- glyphTone: tone,
5240
- tone: "fg",
5241
- children: [
5242
- /* @__PURE__ */ jsx(Ink, {
5243
- bold: true,
5244
- tone: "bright",
5245
- children: item.name
5246
- }),
5247
- /* @__PURE__ */ jsxs(Ink, {
5248
- tone: "dim",
5249
- children: [
5250
- "(",
5251
- toolInputPreview(item.input),
5252
- ")"
5253
- ]
5254
- }),
5255
- item.backend && item.backend !== "server" ? /* @__PURE__ */ jsxs(Ink, {
5256
- tone: "faint",
5257
- children: [" · ", item.backend]
5258
- }) : null
5259
- ]
5260
- })
5261
- }), item.patch && !open ? /* @__PURE__ */ jsx(TerminalDiff, { patch: item.patch }) : text ? /* @__PURE__ */ jsxs(Fragment$1, { children: [preview.map((line, index) => /* @__PURE__ */ jsx(Row, {
5262
- indent: 1,
5263
- columns: 3,
5264
- glyph: index === 0 ? "⎿" : void 0,
5265
- tone: isError ? "red" : "dim",
5266
- children: line || "\xA0"
5267
- }, index)), !open ? collapsed.more ? /* @__PURE__ */ jsx(Row, {
5268
- indent: 1,
5269
- columns: 3,
5270
- tone: "faint",
5271
- children: collapsed.more
5272
- }) : null : hidden > 0 ? /* @__PURE__ */ jsx(Row, {
5273
- indent: 1,
5274
- columns: 3,
5275
- tone: "faint",
5276
- children: clipped ? /* @__PURE__ */ jsxs("button", {
5277
- type: "button",
5278
- className: "term-press term-link",
5279
- onClick: () => setFull(true),
5660
+ children: [
5661
+ /* @__PURE__ */ jsx(Pressable, {
5662
+ onPress: () => setOpen((v) => !v),
5663
+ expanded: open,
5664
+ children: /* @__PURE__ */ jsxs(Row, {
5665
+ glyph: busy ? pulse : "●",
5666
+ glyphTone: tone,
5667
+ tone: "fg",
5668
+ children: [
5669
+ /* @__PURE__ */ jsx(Ink, {
5670
+ bold: true,
5671
+ tone: "bright",
5672
+ children: item.name
5673
+ }),
5674
+ /* @__PURE__ */ jsxs(Ink, {
5675
+ tone: "dim",
5676
+ children: [
5677
+ "(",
5678
+ toolInputPreview(item.input),
5679
+ ")"
5680
+ ]
5681
+ }),
5682
+ item.backend && item.backend !== "server" ? /* @__PURE__ */ jsxs(Ink, {
5683
+ tone: "faint",
5684
+ children: [" · ", item.backend]
5685
+ }) : null
5686
+ ]
5687
+ })
5688
+ }),
5689
+ item.result?.images?.map((image) => /* @__PURE__ */ jsx(TerminalImage, {
5690
+ toolUseId: item.id,
5691
+ image
5692
+ }, image.partIndex)),
5693
+ item.patch && !open ? /* @__PURE__ */ jsx(TerminalDiff, { patch: item.patch }) : text ? /* @__PURE__ */ jsxs(Fragment$1, { children: [preview.map((line, index) => /* @__PURE__ */ jsx(Row, {
5694
+ indent: 1,
5695
+ columns: 3,
5696
+ glyph: index === 0 ? "" : void 0,
5697
+ tone: isError ? "red" : "dim",
5698
+ children: line || "\xA0"
5699
+ }, index)), !open ? collapsed.more ? /* @__PURE__ */ jsx(Row, {
5700
+ indent: 1,
5701
+ columns: 3,
5702
+ tone: "faint",
5703
+ children: collapsed.more
5704
+ }) : null : clipped || truncated ? /* @__PURE__ */ jsx(Row, {
5705
+ indent: 1,
5706
+ columns: 3,
5707
+ tone: "faint",
5708
+ children: fetching ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
5709
+ "… fetching ",
5710
+ (item.result?.totalChars ?? 0).toLocaleString(),
5711
+ " chars"
5712
+ ] }) : clipped || truncated ? /* @__PURE__ */ jsx("button", {
5713
+ type: "button",
5714
+ className: "term-press term-link",
5715
+ onClick: () => {
5716
+ setFull(true);
5717
+ if (!truncated) return;
5718
+ setFetching(true);
5719
+ fetchResult(item.id).finally(() => setFetching(false));
5720
+ },
5721
+ children: truncated ? `… +${missing.toLocaleString()} chars — fetch the rest` : `… +${hidden} line${hidden === 1 ? "" : "s"} — show all ${text.length.toLocaleString()} chars`
5722
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
5723
+ "… +",
5724
+ hidden,
5725
+ " line",
5726
+ hidden === 1 ? "" : "s"
5727
+ ] })
5728
+ }) : hidden > 0 ? /* @__PURE__ */ jsxs(Row, {
5729
+ indent: 1,
5730
+ columns: 3,
5731
+ tone: "faint",
5280
5732
  children: [
5281
5733
  "… +",
5282
5734
  hidden,
5283
5735
  " line",
5284
- hidden === 1 ? "" : "s",
5285
- " — show all",
5286
- " ",
5287
- text.length.toLocaleString(),
5288
- " chars"
5736
+ hidden === 1 ? "" : "s"
5289
5737
  ]
5290
- }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
5291
- "… +",
5292
- hidden,
5293
- " line",
5294
- hidden === 1 ? "" : "s"
5295
- ] })
5296
- }) : null] }) : null]
5738
+ }) : null] }) : null
5739
+ ]
5297
5740
  })
5298
5741
  });
5299
5742
  }
5300
- /** Is this a row the transcript folds into a run? Any tool call is — see
5301
- * `tool-run.ts` for why this is no longer shell-only. */
5302
- function isRunCall(item) {
5303
- return item.kind === "tool_call";
5743
+ /**
5744
+ * A picture a tool returned, in a box of {@link IMAGE_BOX_LINES} whole lines.
5745
+ *
5746
+ * **Three states, one height.** Before the fetch lands the box is a wash and the
5747
+ * size the gateway declared; after it, the picture, letterboxed inside the same
5748
+ * box; on a refusal, `image unavailable` in it. Nothing here may ever collapse
5749
+ * to nothing — that is `HostImage`'s return-null-then-pop, which in a
5750
+ * *virtualized* list is not a flicker but a reflow of every row below it, and
5751
+ * the height calculator would have been lying about the row from plan time.
5752
+ *
5753
+ * The box is why the calculator can stay exact: it is a constant, not a function
5754
+ * of pixels nobody has downloaded yet.
5755
+ */
5756
+ function TerminalImage({ toolUseId, image }) {
5757
+ const { src, failed } = useToolResultImageSrc({
5758
+ toolUseId,
5759
+ ...image
5760
+ });
5761
+ return /* @__PURE__ */ jsx(Row, {
5762
+ indent: 1,
5763
+ columns: 3,
5764
+ children: /* @__PURE__ */ jsx("div", {
5765
+ className: "term-image",
5766
+ "data-state": src ? "loaded" : failed ? "failed" : "pending",
5767
+ style: { height: `calc(var(--term-line) * 12)` },
5768
+ children: src ? /* @__PURE__ */ jsx("img", {
5769
+ src,
5770
+ alt: imagePlaceholder(image)
5771
+ }) : /* @__PURE__ */ jsx(Ink, {
5772
+ tone: "faint",
5773
+ children: failed ? IMAGE_UNAVAILABLE : imagePlaceholder(image)
5774
+ })
5775
+ })
5776
+ });
5304
5777
  }
5305
5778
  /**
5306
5779
  * A run of tool calls, as one line.
@@ -5312,10 +5785,11 @@ function isRunCall(item) {
5312
5785
  * collapses to its count and gets out of the way — and opens, in full, the
5313
5786
  * moment it is the thing you actually want.
5314
5787
  *
5315
- * The membership and wording rules live in `tool-run.ts`, shared with the height
5316
- * calculator. A failed member does not break the run — it *colours* it, which is
5317
- * the same call the scrubber makes: a failure is worth seeing, and fragmenting
5318
- * the run around it would hide it in a longer list rather than surface it.
5788
+ * The membership, wording and failure rules live in `tool-run.ts`, shared with
5789
+ * the height calculator. A failure never breaks the run — fragmenting it around
5790
+ * one would hide the failure in a longer list rather than surface it — but only
5791
+ * the run's **last** call colours it, because that is the run's outcome and an
5792
+ * outcome is what a collapsed row can honestly claim (see `runFailed`).
5319
5793
  */
5320
5794
  function ToolRunRow({ items }) {
5321
5795
  const [open, setOpen] = useState(false);
@@ -5324,7 +5798,7 @@ function ToolRunRow({ items }) {
5324
5798
  const status = item.status ?? (item.result === void 0 ? "running" : "settled");
5325
5799
  return status === "running" || status === "pending";
5326
5800
  });
5327
- const failed = items.some((item) => item.status === "failed" || item.result?.isError === true);
5801
+ const failed = runFailed(items);
5328
5802
  const pulse = usePulse(busy);
5329
5803
  return /* @__PURE__ */ jsxs("div", {
5330
5804
  ref: reveal,
@@ -5341,36 +5815,6 @@ function ToolRunRow({ items }) {
5341
5815
  }), open ? /* @__PURE__ */ jsx("div", { children: items.map((item) => /* @__PURE__ */ jsx(ToolRow, { item }, item.id)) }) : null]
5342
5816
  });
5343
5817
  }
5344
- /**
5345
- * @param offset What `items[0]`'s index is in the whole transcript — the
5346
- * virtualized shell folds each side of the recap boundary separately, and the
5347
- * rows still have to say where they sit for the catch-up dimming.
5348
- * @param fold Whether to group at all. `false` gives one block per item,
5349
- * which is what the cards variant renders: this is the terminal theme's rule
5350
- * and must not silently reshape another renderer's row list.
5351
- */
5352
- function terminalBlocks(items, offset = 0, fold = true) {
5353
- const out = [];
5354
- for (const [position, item] of items.entries()) {
5355
- const index = offset + position;
5356
- const previous = out.at(-1);
5357
- if (fold && isRunCall(item)) {
5358
- if (previous && "run" in previous && foldsTogether(previous.run[0], item)) previous.run.push(item);
5359
- else out.push({
5360
- key: `run:${item.id}`,
5361
- run: [item],
5362
- index
5363
- });
5364
- continue;
5365
- }
5366
- out.push({
5367
- key: `${item.kind}:${item.id}`,
5368
- item,
5369
- index
5370
- });
5371
- }
5372
- return out;
5373
- }
5374
5818
  function TurnResultRow$1({ item }) {
5375
5819
  return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs(Row, {
5376
5820
  tone: item.isError ? "red" : "faint",
@@ -5456,19 +5900,6 @@ function WorkingRow({ label, startedAt, tokens }) {
5456
5900
  }) : null]
5457
5901
  });
5458
5902
  }
5459
- /** Spacing between two items: a blank line, unless the pair belongs together.
5460
- * Tool output already sits under its call, and a run of tool calls reads as one
5461
- * block — the CLI leaves no blank line inside either. */
5462
- function needsBlank(previous, next) {
5463
- if (previous.kind === "tool_call" && next.kind === "tool_call") return false;
5464
- return true;
5465
- }
5466
- /** The same rule over blocks: a shell run counts as the tool calls it folded. */
5467
- function blockNeedsBlank(previous, next) {
5468
- const before = "run" in previous ? "tool_call" : previous.item.kind;
5469
- const after = "run" in next ? "tool_call" : next.item.kind;
5470
- return !(before === "tool_call" && after === "tool_call");
5471
- }
5472
5903
  //#endregion
5473
5904
  //#region src/components/terminal/surface.tsx
5474
5905
  function TerminalSurface({ fontSize, lineHeight, bleed, affordances, className, style, children, ...props }) {
@@ -7145,7 +7576,7 @@ function Hint({ children }) {
7145
7576
  * background gradients rather than a border, so it costs no layout: a 1px border
7146
7577
  * would push its contents a pixel off the column every other row sits on.
7147
7578
  */
7148
- function Box({ children, className }) {
7579
+ function Box$1({ children, className }) {
7149
7580
  return /* @__PURE__ */ jsx("div", {
7150
7581
  className: cn("term-box", className),
7151
7582
  children
@@ -7522,7 +7953,7 @@ function QuestionStep({ question, selection, cursor, onCursor, onToggle, onOther
7522
7953
  checked: multiSelect ? selected : void 0,
7523
7954
  marker: "check",
7524
7955
  selected: !multiSelect && selected,
7525
- detail: option.preview && cursor === index ? /* @__PURE__ */ jsx(Box, { children: option.preview.split("\n").map((line, row) => /* @__PURE__ */ jsx(Row, {
7956
+ detail: option.preview && cursor === index ? /* @__PURE__ */ jsx(Box$1, { children: option.preview.split("\n").map((line, row) => /* @__PURE__ */ jsx(Row, {
7526
7957
  columns: 0,
7527
7958
  tone: "dim",
7528
7959
  children: line || " "
@@ -8387,14 +8818,18 @@ function resultLanguage(item) {
8387
8818
  function ToolCallCard({ item, hostImage, className }) {
8388
8819
  const [open, setOpen] = useState(false);
8389
8820
  const [fullResult, setFullResult] = useState(false);
8821
+ const [fetching, setFetching] = useState(false);
8822
+ const fetchResult = useToolResultFetcher();
8390
8823
  const imagePath = imagePathOf(item);
8391
8824
  const status = item.status ?? (item.result === void 0 ? "running" : "settled");
8392
8825
  const badge = STATE_BADGE[status];
8393
8826
  const isError = status === "failed" || item.result?.isError === true;
8394
8827
  const Icon = toolIcon(item.name);
8395
8828
  const resultText = item.result?.text ?? "";
8396
- const truncated = !fullResult && resultText.length > RESULT_PREVIEW_CHARS;
8397
- const shownResult = truncated ? resultText.slice(0, RESULT_PREVIEW_CHARS) : resultText;
8829
+ const clipped = !fullResult && resultText.length > RESULT_PREVIEW_CHARS;
8830
+ const shownResult = clipped ? resultText.slice(0, RESULT_PREVIEW_CHARS) : resultText;
8831
+ const headOnly = item.result?.truncated === true;
8832
+ const totalChars = item.result?.totalChars ?? resultText.length;
8398
8833
  const details = open ? /* @__PURE__ */ jsxs("div", {
8399
8834
  className: "flex flex-col gap-2 border-t border-border p-2.5",
8400
8835
  children: [
@@ -8412,13 +8847,25 @@ function ToolCallCard({ item, hostImage, className }) {
8412
8847
  language: resultLanguage(item),
8413
8848
  label: isError ? "Error" : "Result",
8414
8849
  className: cn(isError && "border-danger/40 [&_pre]:text-danger")
8415
- }), truncated ? /* @__PURE__ */ jsxs("button", {
8850
+ }), fetching ? /* @__PURE__ */ jsxs("p", {
8851
+ className: "mt-1 text-label text-fg-3",
8852
+ children: [
8853
+ "Fetching ",
8854
+ totalChars.toLocaleString(),
8855
+ " chars…"
8856
+ ]
8857
+ }) : clipped || headOnly ? /* @__PURE__ */ jsxs("button", {
8416
8858
  type: "button",
8417
8859
  className: "mt-1 text-label text-fg-3 underline-offset-2 hover:underline",
8418
- onClick: () => setFullResult(true),
8860
+ onClick: () => {
8861
+ setFullResult(true);
8862
+ if (!headOnly) return;
8863
+ setFetching(true);
8864
+ fetchResult(item.id).finally(() => setFetching(false));
8865
+ },
8419
8866
  children: [
8420
8867
  "Show all ",
8421
- resultText.length.toLocaleString(),
8868
+ totalChars.toLocaleString(),
8422
8869
  " chars"
8423
8870
  ]
8424
8871
  }) : null] }) : null
@@ -8428,6 +8875,13 @@ function ToolCallCard({ item, hostImage, className }) {
8428
8875
  path: imagePath,
8429
8876
  load: hostImage
8430
8877
  }) : null;
8878
+ const resultImages = item.result?.images?.length ? /* @__PURE__ */ jsx("div", {
8879
+ className: "flex flex-col gap-2 border-t border-border p-2.5",
8880
+ children: item.result.images.map((ref) => /* @__PURE__ */ jsx(ResultImage, {
8881
+ toolUseId: item.id,
8882
+ image: ref
8883
+ }, ref.partIndex))
8884
+ }) : null;
8431
8885
  return /* @__PURE__ */ jsxs("div", {
8432
8886
  "data-slot": "tool-call",
8433
8887
  "data-state": status,
@@ -8466,6 +8920,7 @@ function ToolCallCard({ item, hostImage, className }) {
8466
8920
  ]
8467
8921
  }),
8468
8922
  image,
8923
+ resultImages,
8469
8924
  details
8470
8925
  ]
8471
8926
  });
@@ -8481,6 +8936,34 @@ function PlainPayload({ code, label, className }) {
8481
8936
  });
8482
8937
  }
8483
8938
  /**
8939
+ * An image part of the result, fetched by reference.
8940
+ *
8941
+ * The frame is a **fixed height in all three states** — placeholder, picture,
8942
+ * failure — which is the one rule this shares with the terminal theme and the
8943
+ * only reason it is worth a component: the transcript is virtualized in both,
8944
+ * and a box that appears when the bytes land shoves every row below it down
8945
+ * while the reader is mid-sentence. Unlike `HostImage`, a failure here is *said*
8946
+ * rather than swallowed: there is no host path in the result text to fall back
8947
+ * on, so silence would be a blank frame with no account of itself.
8948
+ */
8949
+ function ResultImage({ toolUseId, image }) {
8950
+ const { src, failed } = useToolResultImageSrc({
8951
+ toolUseId,
8952
+ ...image
8953
+ });
8954
+ return /* @__PURE__ */ jsx("div", {
8955
+ className: "flex h-60 items-start overflow-hidden rounded-md border border-border bg-surface-hover",
8956
+ children: src ? /* @__PURE__ */ jsx("img", {
8957
+ src,
8958
+ alt: imagePlaceholder(image),
8959
+ className: "h-full max-w-full object-contain"
8960
+ }) : /* @__PURE__ */ jsx("span", {
8961
+ className: "p-2 text-label text-fg-4",
8962
+ children: failed ? IMAGE_UNAVAILABLE : imagePlaceholder(image)
8963
+ })
8964
+ });
8965
+ }
8966
+ /**
8484
8967
  * A picture that lives on the host, fetched through the gateway's host-file
8485
8968
  * route and shown inline.
8486
8969
  *
@@ -8525,7 +9008,7 @@ function createHeightEpoch(width, ch, line) {
8525
9008
  * The inter-row gap is the *pair's* business (`gapBefore`), not the row's, so
8526
9009
  * it is added by the caller. */
8527
9010
  function estimateBlockPx(block, epoch) {
8528
- if ("run" in block) return blockHeight(block, epoch).px;
9011
+ if (!("item" in block)) return blockHeight(block, epoch).px;
8529
9012
  const hit = epoch.cache.get(block.item);
8530
9013
  if (hit) return hit.px;
8531
9014
  const computed = itemHeight(block.item, epoch);
@@ -9089,10 +9572,15 @@ function toolRowHeight(item, m, extraPx) {
9089
9572
  gutterCells: 2,
9090
9573
  extraPx
9091
9574
  });
9575
+ const images = item.result?.images;
9576
+ if (images?.length) acc = add(acc, {
9577
+ px: images.length * 12 * m.line,
9578
+ exact: true
9579
+ });
9092
9580
  if (item.patch) return add(acc, diffHeight(item.patch, m, extraPx));
9093
9581
  const text = item.result?.text ?? "";
9094
9582
  if (!text) return acc;
9095
- const { shown, more } = collapsedResult(text.trimEnd().split("\n"));
9583
+ const { shown, more } = collapsedResult(text.trimEnd().split("\n"), item.result?.totalChars);
9096
9584
  for (const line of shown) acc = add(acc, rowH(line || " ", m, {
9097
9585
  indentCells: 3,
9098
9586
  gutterCells: 3,
@@ -9138,9 +9626,14 @@ function itemHeight(item, m) {
9138
9626
  };
9139
9627
  }
9140
9628
  }
9141
- /** A virtual row's height: an item, or a folded tool run (collapsed = its one
9142
- * summary line, built by the same function the row draws). */
9629
+ /** A virtual row's height: an item, a folded tool run, or a task block —
9630
+ * either fold collapsed is its one summary line, built by the same function
9631
+ * the row draws (`runSummary` / `taskSummary`), rendered as one standard
9632
+ * 2-cell-gutter `Row`. No expanded branch for the task block either: it is
9633
+ * always collapsed by default, which is the invariant that keeps every
9634
+ * unmounted row's estimate exact. */
9143
9635
  function blockHeight(block, m) {
9636
+ if ("task" in block) return rowH(taskSummary(block.task, taskChildItems(block)), m);
9144
9637
  if ("run" in block) {
9145
9638
  const busy = block.run.some((item) => item.status === "running" || item.status === "pending");
9146
9639
  return rowH(runSummary(block.run, busy), m);
@@ -9157,22 +9650,27 @@ function nearestMember(cluster, y) {
9157
9650
  return best?.mark;
9158
9651
  }
9159
9652
  /**
9160
- * Two lanes and a full-width annotation, which is a claim about what a rail is
9161
- * *for*: the two things you navigate by are what you asked and what came back,
9162
- * so they get a lane each and split the rail evenly. Everything else — an error,
9163
- * a waiting approval, a bookmark, the catch-up seam is an **annotation on the
9164
- * run** rather than a step through it, so it spans the full width and reads as
9165
- * a different class of thing rather than as a third column of steps.
9653
+ * The two lanes are **channels, not classes**: left is what went *in* your
9654
+ * prompts, and the sub-agents you dispatched and right is what came *out* —
9655
+ * each turn's answer, and everything that went wrong producing one. That is the
9656
+ * question a reader actually asks of a rail ("where did I say something", "where
9657
+ * did it go wrong"), and it puts every failure in one column instead of
9658
+ * scattering some down the middle.
9659
+ *
9660
+ * Full width is reserved for what is not a channel at all: a waiting approval
9661
+ * (which is the session asking *you*, pinned at the foot), a bookmark (the
9662
+ * reader's own annotation) and the catch-up seam (a boundary across both).
9166
9663
  *
9167
9664
  * It also buys the marks their width back: three lanes in a 16px rail is 5px a
9168
9665
  * lane, which is a hard target to hit and a hard colour to see.
9169
9666
  */
9170
9667
  const LANE = {
9171
9668
  user: "l",
9669
+ subagent: "l",
9172
9670
  turn: "r",
9173
9671
  turnFailed: "r",
9174
- toolFailed: "f",
9175
- error: "f",
9672
+ toolFailed: "r",
9673
+ error: "r",
9176
9674
  approval: "f",
9177
9675
  recap: "f",
9178
9676
  bookmark: "f"
@@ -9186,10 +9684,12 @@ const LOUDNESS = {
9186
9684
  user: 3,
9187
9685
  turn: 2,
9188
9686
  bookmark: 1,
9687
+ subagent: 1,
9189
9688
  recap: 0
9190
9689
  };
9191
9690
  const KIND_NAME = {
9192
9691
  user: "you",
9692
+ subagent: "sub-agent",
9193
9693
  turn: "response · turn end",
9194
9694
  turnFailed: "turn failed",
9195
9695
  toolFailed: "tool failed",
@@ -9235,9 +9735,27 @@ function excerpt(item) {
9235
9735
  default: return "";
9236
9736
  }
9237
9737
  }
9738
+ /**
9739
+ * Exported for `test/scrubber.test.ts` and nothing else — it is not part of the
9740
+ * package's surface (`index.ts` does not re-export it). Both of the bugs this
9741
+ * function has shipped were pure-logic ones a unit test catches: a live answer
9742
+ * with no `turn_result` yet went unmarked for the whole two minutes it was the
9743
+ * only thing worth navigating to, and a replayed history — which carries no turn
9744
+ * rows at all — came back with an empty right lane.
9745
+ */
9238
9746
  function buildClusters(props, railH) {
9239
- const { items, bookmarks, recapRow, pendingApprovals, rowIndexFor, offsetOfRow, sizeOfRow, totalSize, viewportH } = props;
9747
+ const { items, bookmarks, recapRow, pendingApprovals, rowIndexFor, offsetOfRow, sizeOfRow, positionInRow, totalSize, viewportH } = props;
9240
9748
  const marks = [];
9749
+ const subagentParents = /* @__PURE__ */ new Set();
9750
+ for (const item of items) {
9751
+ const parent = parentOf(item);
9752
+ if (parent !== void 0) subagentParents.add(parent);
9753
+ }
9754
+ const rowOutcome = /* @__PURE__ */ new Map();
9755
+ items.forEach((item, index) => {
9756
+ if (item.kind !== "tool_call" || parentOf(item) !== void 0) return;
9757
+ rowOutcome.set(rowIndexFor(index), index);
9758
+ });
9241
9759
  let segment = {};
9242
9760
  const closeSegment = () => {
9243
9761
  const anchor = segment.response ?? segment.turn;
@@ -9250,7 +9768,12 @@ function buildClusters(props, railH) {
9250
9768
  segment = {};
9251
9769
  };
9252
9770
  items.forEach((item, index) => {
9253
- if (item.kind === "user") {
9771
+ if (item.kind === "tool_call" && subagentParents.has(item.id)) marks.push({
9772
+ kind: "subagent",
9773
+ itemIndex: index,
9774
+ rowIndex: rowIndexFor(index)
9775
+ });
9776
+ if (item.kind === "user" && parentOf(item) === void 0) {
9254
9777
  closeSegment();
9255
9778
  marks.push({
9256
9779
  kind: "user",
@@ -9266,7 +9789,7 @@ function buildClusters(props, railH) {
9266
9789
  itemIndex: index,
9267
9790
  rowIndex: rowIndexFor(index)
9268
9791
  });
9269
- else if (item.kind === "tool_call" && (item.status === "failed" || item.result?.isError === true)) marks.push({
9792
+ else if (item.kind === "tool_call" && (item.status === "failed" || item.result?.isError === true) && rowOutcome.get(rowIndexFor(index)) === index) marks.push({
9270
9793
  kind: "toolFailed",
9271
9794
  itemIndex: index,
9272
9795
  rowIndex: rowIndexFor(index)
@@ -9287,8 +9810,10 @@ function buildClusters(props, railH) {
9287
9810
  const scale = railScale(railH, totalSize, viewportH);
9288
9811
  const lanes = /* @__PURE__ */ new Map();
9289
9812
  for (const mark of marks) {
9290
- const h = Math.max(MIN_MARK, Math.round(sizeOfRow(mark.rowIndex) * scale));
9291
- const y = Math.min(Math.max(0, railH - h), Math.round(offsetOfRow(mark.rowIndex) * scale));
9813
+ const within = mark.itemIndex >= 0 ? positionInRow?.(mark.itemIndex) : void 0;
9814
+ const rowH = sizeOfRow(mark.rowIndex);
9815
+ const h = within ? MIN_MARK : Math.max(MIN_MARK, Math.round(rowH * scale));
9816
+ const y = Math.min(Math.max(0, railH - h), Math.round((offsetOfRow(mark.rowIndex) + (within ? within.ordinal / within.count * rowH : 0)) * scale));
9292
9817
  const lane = LANE[mark.kind];
9293
9818
  const list = lanes.get(lane) ?? [];
9294
9819
  list.push({
@@ -9560,12 +10085,14 @@ function TerminalScrubber(props) {
9560
10085
  }
9561
10086
  //#endregion
9562
10087
  //#region src/components/agent/transcript-rows.ts
9563
- /** The item a row is spaced *as*. A run stands for the calls it folded, so a
9564
- * run and a lone tool call below it still read as one block. */
10088
+ /** The item a row is spaced *as*. A run stands for the calls it folded, and a
10089
+ * task block for the `Task` call it absorbed into all tool calls, so a run,
10090
+ * a task and a lone tool call below them still read as one block. */
9565
10091
  function rowItem(row) {
9566
10092
  if (!row) return void 0;
9567
10093
  if ("item" in row) return row.item;
9568
10094
  if ("run" in row) return row.run[0];
10095
+ if ("task" in row) return row.task;
9569
10096
  }
9570
10097
  /**
9571
10098
  * Does a blank line go above this row, in the terminal theme?
@@ -9581,26 +10108,63 @@ function gapBefore(rows, index) {
9581
10108
  return needsBlank(before, after);
9582
10109
  }
9583
10110
  /**
10111
+ * Which items each task block absorbed, as itemIndex → rowIndex — the one
10112
+ * lookup {@link rowIndexForItem} cannot answer from ordering (see its comment).
10113
+ * Memoized per rows array identity: the shell builds `rows` in a `useMemo`, so
10114
+ * within one row list this is built once, and a WeakMap means a discarded list
10115
+ * takes its map with it. Memoization only — the answer is a pure function of
10116
+ * the array.
10117
+ */
10118
+ const absorbedCache = /* @__PURE__ */ new WeakMap();
10119
+ function absorbedRows(rows) {
10120
+ const hit = absorbedCache.get(rows);
10121
+ if (hit) return hit;
10122
+ const map = /* @__PURE__ */ new Map();
10123
+ rows.forEach((row, rowIndex) => {
10124
+ if ("task" in row) for (const itemIndex of row.childIndices) map.set(itemIndex, rowIndex);
10125
+ });
10126
+ absorbedCache.set(rows, map);
10127
+ return map;
10128
+ }
10129
+ /**
9584
10130
  * Transcript-item index → virtual-row index — **the off-by-a-fold trap.**
9585
10131
  *
9586
10132
  * The virtualizer's rows are {@link TerminalBlock}s, not items: a folded tool
9587
- * run occupies ONE row for `run.length` consecutive items, and the recap
9588
- * boundary is a row with *no* item index at all, shifting every row after it
9589
- * by one. `virtualizer.scrollToIndex(itemIndex)` is therefore wrong by
9590
- * construction on any folded or spliced transcript — every jump that starts
9591
- * from an item (the scrubber's marks, a future bookmark) must come through
9592
- * here first.
9593
- *
9594
- * The rule: the **last non-recap row whose first item index is ≤ the target**.
9595
- * Rows are ordered by `index` (a run's row covers
9596
- * `[index, index + run.length)`), so this is a binary search; the recap row
9597
- * is skipped by giving it its successor's start for navigation (both qualify
9598
- * at the boundary, and "last wins" lands on the real row) while never letting
9599
- * it be the answer. Exhaustively checked against a linear reference — every
9600
- * fixture × every item index × several splice positions by
9601
- * `__wdCheckMapping` in `dev/App.tsx`.
10133
+ * run occupies ONE row for `run.length` consecutive items, a task block
10134
+ * occupies ONE row for its `Task` call *plus every item its subagent produced*,
10135
+ * and the recap boundary is a row with *no* item index at all, shifting every
10136
+ * row after it by one. `virtualizer.scrollToIndex(itemIndex)` is therefore
10137
+ * wrong by construction on any folded or spliced transcript every jump that
10138
+ * starts from an item (the scrubber's marks, a future bookmark) must come
10139
+ * through here first.
10140
+ *
10141
+ * The contract, in two halves:
10142
+ *
10143
+ * - An index a task block **absorbed** maps to that block's row, wherever the
10144
+ * child fell in the stream. Subagents run in parallel, so absorbed indices
10145
+ * interleave arbitrarily with later rows' starts no ordering argument can
10146
+ * find their row, which is why they are answered first, from a per-row-list
10147
+ * map ({@link absorbedRows}) built once per rows array. A row's coverage is
10148
+ * its `childIndices`, never `[index, index + N)` arithmetic.
10149
+ * - Every other index maps to the **last non-recap row whose start (`index`)
10150
+ * is ≤ the target** — the original rule, still a binary search. Rows stay
10151
+ * ordered by `index`, and the ordering argument is now: between one row's
10152
+ * start and the next row's, every index is either absorbed (answered above)
10153
+ * or a member of the earlier row — note that is *weaker* than the old
10154
+ * contiguity claim, because a run can fold across an absorbed gap (two
10155
+ * top-level calls separated only by a subagent's step are adjacent on
10156
+ * screen), so `[index, index + run.length)` arithmetic no longer describes
10157
+ * a run's coverage; membership does. The recap row is skipped by giving it
10158
+ * its successor's start for navigation (both qualify at the boundary, and
10159
+ * "last wins" lands on the real row) while never letting it be the answer.
10160
+ *
10161
+ * Exhaustively checked against a linear reference — every fixture × every item
10162
+ * index × several splice positions — by `__wdCheckMapping` in `dev/App.tsx`,
10163
+ * and against constructed interleavings in `test/transcript-rows.test.ts`.
9602
10164
  */
9603
10165
  function rowIndexForItem(rows, itemIndex) {
10166
+ const absorbed = absorbedRows(rows).get(itemIndex);
10167
+ if (absorbed !== void 0) return absorbed;
9604
10168
  let lo = 0;
9605
10169
  let hi = rows.length - 1;
9606
10170
  let best = 0;
@@ -9620,6 +10184,49 @@ function rowIndexForItem(rows, itemIndex) {
9620
10184
  }
9621
10185
  return best;
9622
10186
  }
10187
+ const positionCache = /* @__PURE__ */ new WeakMap();
10188
+ function rowPositions(rows) {
10189
+ const hit = positionCache.get(rows);
10190
+ if (hit) return hit;
10191
+ const map = /* @__PURE__ */ new Map();
10192
+ for (const row of rows) if ("task" in row) {
10193
+ const count = row.childIndices.length;
10194
+ row.childIndices.forEach((itemIndex, ordinal) => map.set(itemIndex, {
10195
+ ordinal,
10196
+ count
10197
+ }));
10198
+ } else if ("run" in row && row.run.length > 1) {
10199
+ const count = row.run.length;
10200
+ row.indices.forEach((itemIndex, ordinal) => map.set(itemIndex, {
10201
+ ordinal,
10202
+ count
10203
+ }));
10204
+ }
10205
+ positionCache.set(rows, map);
10206
+ return map;
10207
+ }
10208
+ /**
10209
+ * Where an item sits inside a row that holds MORE than itself — a task block's
10210
+ * absorbed child, or a member of a folded run of two or more. `undefined` for
10211
+ * everything else, including a row's own head item (the `Task` call, a run's
10212
+ * first member is *not* exempt) and a **singleton run**: there the row's extent
10213
+ * IS the item's, and a mark spanning it is honest.
10214
+ *
10215
+ * That carve-out is load-bearing rather than tidy: `pushLeaf` makes *every*
10216
+ * top-level tool call a `RunBlock`, usually of length 1, so without it every
10217
+ * ordinary failed call's scrubber mark would shrink from its row's extent to a
10218
+ * tick and the rail would stop reading as a map — a regression traded for a fix.
10219
+ *
10220
+ * The scrubber is the consumer: a mark for a shared-row item anchors at
10221
+ * `ordinal / count` of the row's *measured* height instead of inheriting an
10222
+ * extent that is mostly other items' work (one failed child of a hundred-call
10223
+ * task painted the whole expanded block red). Memoized per rows array identity
10224
+ * exactly like {@link absorbedRows}, and pure — the answer is a function of the
10225
+ * array alone, and a discarded array takes its map with it.
10226
+ */
10227
+ function positionInRow(rows, itemIndex) {
10228
+ return rowPositions(rows).get(itemIndex);
10229
+ }
9623
10230
  //#endregion
9624
10231
  //#region src/components/agent/use-height-epoch.ts
9625
10232
  /**
@@ -9788,6 +10395,55 @@ function TerminalItemView({ item, fileUrl }) {
9788
10395
  default: return null;
9789
10396
  }
9790
10397
  }
10398
+ /**
10399
+ * A `Task` and everything the subagent it spawned produced, as one row.
10400
+ *
10401
+ * The same claim the tool-run fold makes, and a stronger one: a subagent is
10402
+ * *sixty* rows of somebody else's working — a brief, a dozen greps, its own
10403
+ * thinking — and none of it is what you came back to read. What you came back
10404
+ * to read is the report, and the report is the model's next sentence. So the
10405
+ * whole frame collapses to one line saying what was asked and how big the
10406
+ * answer was, and opens in full the moment it is the thing you want.
10407
+ *
10408
+ * **Always collapsed when unmounted**, and that is load-bearing rather than
10409
+ * tidy: `height.ts` predicts this row as exactly one wrapped `taskSummary`, and
10410
+ * expansion is component-local state that dies with the row. A row auto-opening
10411
+ * because its subagent happens to be running would make its own height
10412
+ * unpredictable — which is why the live signal is *in* the collapsed line (the
10413
+ * pulse, and a count that climbs) rather than in an open block.
10414
+ *
10415
+ * The children are the theme's ordinary rows, stepped in behind a rule, and
10416
+ * they fold among themselves: a subagent's consecutive tool calls are as much
10417
+ * an aside inside its frame as they are in the main thread.
10418
+ */
10419
+ function TaskRow({ block, fileUrl }) {
10420
+ const [open, setOpen] = useState(false);
10421
+ const reveal = useRevealOnOpen(open);
10422
+ const children = useMemo(() => taskChildItems(block), [block]);
10423
+ const busy = taskBusy(block.task, children);
10424
+ const failed = taskFailed(block.task);
10425
+ const pulse = usePulse(busy);
10426
+ return /* @__PURE__ */ jsxs("div", {
10427
+ ref: reveal,
10428
+ className: open ? "term-open" : void 0,
10429
+ children: [/* @__PURE__ */ jsx(Pressable, {
10430
+ onPress: () => setOpen((v) => !v),
10431
+ expanded: open,
10432
+ children: /* @__PURE__ */ jsx(Row, {
10433
+ glyph: busy ? pulse : "●",
10434
+ glyphTone: failed ? "red" : busy ? "mark" : "dim",
10435
+ tone: failed ? "red" : "fg",
10436
+ children: taskSummary(block.task, children)
10437
+ })
10438
+ }), open ? /* @__PURE__ */ jsx("div", {
10439
+ className: "term-nested",
10440
+ children: block.children.map((leaf, index) => /* @__PURE__ */ jsxs(Fragment, { children: [index > 0 && blockNeedsBlank(block.children[index - 1], leaf) ? /* @__PURE__ */ jsx(Blank, {}) : null, "run" in leaf ? /* @__PURE__ */ jsx(ToolRunRow, { items: leaf.run }) : /* @__PURE__ */ jsx(TerminalItemView, {
10441
+ item: leaf.item,
10442
+ fileUrl
10443
+ })] }, leaf.key))
10444
+ }) : null]
10445
+ });
10446
+ }
9791
10447
  /** When the current run began — the clock the working line counts from. Held
9792
10448
  * here, not in the row, because the row comes and goes within a single turn (it
9793
10449
  * hides the moment text streams) and a clock restarting at every tool call
@@ -9819,9 +10475,12 @@ function TerminalTranscript({ state, fileUrl, fontSize, lineHeight, affordances,
9819
10475
  affordances,
9820
10476
  bleed: "1ch",
9821
10477
  className: cn("term-transcript", className),
9822
- children: [blocks.map((block, index) => /* @__PURE__ */ jsxs(Fragment, { children: [index > 0 && blockNeedsBlank(blocks[index - 1], block) ? /* @__PURE__ */ jsx(Blank, {}) : null, "run" in block ? /* @__PURE__ */ jsx(ToolRunRow, { items: block.run }) : /* @__PURE__ */ jsx(TerminalItemView, {
10478
+ children: [blocks.map((block, index) => /* @__PURE__ */ jsxs(Fragment, { children: [index > 0 && blockNeedsBlank(blocks[index - 1], block) ? /* @__PURE__ */ jsx(Blank, {}) : null, "run" in block ? /* @__PURE__ */ jsx(ToolRunRow, { items: block.run }) : "item" in block ? /* @__PURE__ */ jsx(TerminalItemView, {
9823
10479
  item: block.item,
9824
10480
  fileUrl
10481
+ }) : /* @__PURE__ */ jsx(TaskRow, {
10482
+ block,
10483
+ fileUrl
9825
10484
  })] }, block.key)), working(state) ? /* @__PURE__ */ jsxs(Fragment$1, { children: [state.items.length > 0 ? /* @__PURE__ */ jsx(Blank, {}) : null, /* @__PURE__ */ jsx(WorkingRow, {
9826
10485
  label: state.status === "starting" ? "Starting…" : "Working…",
9827
10486
  startedAt: runStartedAt,
@@ -10133,10 +10792,10 @@ function StickyPromptLane({ top, height, gapClass, scrollRoot, index, measureRef
10133
10792
  * transient UI state (an expanded tool card, an opened reasoning block) resets
10134
10793
  * once the row scrolls far enough away to unmount.
10135
10794
  */
10136
- function TranscriptRows({ rows, boundary, since, terminal, replaying, stickyPrompt, gap, fontSize, lineHeight, items, pendingApprovals, scrubber, scrubberMarks, affordances, fileUrl, attachmentUrl, hostImage, jumpToRecapRef, repinRef }) {
10795
+ function TranscriptRows({ rows, boundary, since, terminal, replaying, stickyPrompt, gap, fontSize, lineHeight, items, pendingApprovals, scrubber, scrubberMarks, affordances, fileUrl, attachmentUrl, hostImage, jumpToRecapRef, repinRef, reveal }) {
10137
10796
  const stick = useStickToBottomContext();
10138
10797
  const [scrollElement, setScrollElement] = useState(null);
10139
- const promptRows = useMemo(() => rows.flatMap((row, index) => "item" in row && row.item.kind === "user" ? [index] : []), [rows]);
10798
+ const promptRows = useMemo(() => rows.flatMap((row, index) => "item" in row && row.item.kind === "user" && parentOf(row.item) === void 0 ? [index] : []), [rows]);
10140
10799
  const pinRef = useRef({
10141
10800
  enabled: false,
10142
10801
  promptRows: []
@@ -10185,7 +10844,7 @@ function TranscriptRows({ rows, boundary, since, terminal, replaying, stickyProm
10185
10844
  if (terminal && epoch) {
10186
10845
  const row = rows[index];
10187
10846
  const gapPx = index > 0 && gapBefore(rows, index) ? epoch.line : 0;
10188
- if (row && ("item" in row || "run" in row)) return estimateBlockPx(row, epoch) + gapPx;
10847
+ if (row && !("line" in row)) return estimateBlockPx(row, epoch) + gapPx;
10189
10848
  return epoch.line + gapPx;
10190
10849
  }
10191
10850
  return (terminal ? 36 : 100) + gap.px;
@@ -10239,6 +10898,14 @@ function TranscriptRows({ rows, boundary, since, terminal, replaying, stickyProm
10239
10898
  jumpToRecapRef,
10240
10899
  repinRef
10241
10900
  });
10901
+ const revealNonce = reveal?.nonce;
10902
+ const revealId = reveal?.toolUseId;
10903
+ useEffect(() => {
10904
+ if (revealId === void 0) return;
10905
+ const itemIndex = items.findIndex((item) => item.kind === "tool_call" && item.id === revealId);
10906
+ if (itemIndex < 0) return;
10907
+ jumpToRow(rowIndexForItem(rows, itemIndex), "start");
10908
+ }, [revealNonce]);
10242
10909
  const scrubInteractive = resolveAffordances(affordances).hover;
10243
10910
  const recapIndex = rows.findIndex((row) => row.key === "recap");
10244
10911
  const recapRow = recapIndex >= 0 ? {
@@ -10314,12 +10981,18 @@ function TranscriptRows({ rows, boundary, since, terminal, replaying, stickyProm
10314
10981
  hostImage,
10315
10982
  terminal
10316
10983
  })
10317
- }) : /* @__PURE__ */ jsx(RecapRow, {
10984
+ }) : "line" in row ? /* @__PURE__ */ jsx(RecapRow, {
10318
10985
  line: row.line,
10319
10986
  since,
10320
10987
  terminal
10988
+ }) : /* @__PURE__ */ jsx("div", {
10989
+ className: cn(read(boundary, row.index) && "opacity-45"),
10990
+ children: /* @__PURE__ */ jsx(TaskRow, {
10991
+ block: row,
10992
+ fileUrl
10993
+ })
10321
10994
  });
10322
- if (terminal && stickyPrompt && "item" in row && row.item.kind === "user") {
10995
+ if (terminal && stickyPrompt && "item" in row && row.item.kind === "user" && parentOf(row.item) === void 0) {
10323
10996
  const next = promptRows.find((index) => index > virtualRow.index);
10324
10997
  const laneEnd = next === void 0 ? virtualizer.getTotalSize() : measurements[next]?.start ?? virtualRow.start;
10325
10998
  return /* @__PURE__ */ jsx(StickyPromptLane, {
@@ -10345,6 +11018,7 @@ function TranscriptRows({ rows, boundary, since, terminal, replaying, stickyProm
10345
11018
  recapRow,
10346
11019
  bookmarks: scrubberMarks ?? [],
10347
11020
  rowIndexFor: (itemIndex) => rowIndexForItem(rows, itemIndex),
11021
+ positionInRow: (itemIndex) => positionInRow(rows, itemIndex),
10348
11022
  offsetOfRow: (rowIndex) => virtualizer.measurementsCache[rowIndex]?.start ?? 0,
10349
11023
  sizeOfRow: (rowIndex) => virtualizer.measurementsCache[rowIndex]?.size ?? 0,
10350
11024
  totalSize: virtualizer.getTotalSize(),
@@ -10357,7 +11031,7 @@ function TranscriptRows({ rows, boundary, since, terminal, replaying, stickyProm
10357
11031
  }), scrollElement.parentElement) : null]
10358
11032
  });
10359
11033
  }
10360
- function Transcript({ state, fileUrl, attachmentUrl, canBrowseFiles, hostImage, variant = "cards", density = "comfortable", fontSize, lineHeight, affordances, stickyPrompt = false, scrubber, scrubberMarks, replaying = false, catchUp, jumpToRecapRef, repinRef, className }) {
11034
+ function Transcript({ state, fileUrl, attachmentUrl, canBrowseFiles, hostImage, variant = "cards", density = "comfortable", fontSize, lineHeight, affordances, stickyPrompt = false, scrubber, scrubberMarks, replaying = false, catchUp, jumpToRecapRef, repinRef, reveal, className }) {
10361
11035
  const terminal = variant === "terminal";
10362
11036
  const gap = ROW_GAP[variant][density];
10363
11037
  const runStartedAt = useRunStart(state.status);
@@ -10416,7 +11090,8 @@ function Transcript({ state, fileUrl, attachmentUrl, canBrowseFiles, hostImage,
10416
11090
  attachmentUrl,
10417
11091
  hostImage,
10418
11092
  jumpToRecapRef,
10419
- repinRef
11093
+ repinRef,
11094
+ reveal
10420
11095
  }), showLoader(state) ? terminal ? /* @__PURE__ */ jsxs(Fragment$1, { children: [state.items.length > 0 ? /* @__PURE__ */ jsx("div", {
10421
11096
  className: "term-blank",
10422
11097
  "aria-hidden": true
@@ -10650,14 +11325,14 @@ const INTERACTIVE = [
10650
11325
  * the engine name — an absent capability hides the control instead of offering
10651
11326
  * one that can only fail.
10652
11327
  */
10653
- function SessionPanel({ client, sessionId, header, panelSurface = "internal", statusSurface = "internal", statusPlacement = "top", onOpenPanel, onVitals, transcriptVariant = "cards", transcriptDensity = "comfortable", transcriptFont = "sans", affordances, terminalMetrics, scrubber = false, scrubberMarks, stickyPrompt = false, controlsSurface = "internal", onControls, focusComposerOnClick = false, unseen, readOnly = false, toolHost, cacheTranscript, className }) {
11328
+ function SessionPanel({ client, sessionId, header, panelSurface = "internal", statusSurface = "internal", statusPlacement = "top", onOpenPanel, onVitals, transcriptVariant = "cards", transcriptDensity = "comfortable", transcriptFont = "sans", affordances, terminalMetrics, scrubber = false, scrubberMarks, reveal, stickyPrompt = false, controlsSurface = "internal", onControls, focusComposerOnClick = false, unseen, readOnly = false, toolHost, cacheTranscript, className }) {
10654
11329
  const external = panelSurface === "external";
10655
11330
  const statusExternal = statusSurface === "external";
10656
11331
  const controlsInStatus = controlsSurface === "status" && !statusExternal;
10657
11332
  const controlsExternal = controlsSurface === "external" || controlsInStatus;
10658
11333
  const [protocolError, setProtocolError] = useState(void 0);
10659
11334
  const [panel, setPanel] = useState();
10660
- const { state, connection, replaying, protocolMismatch, models, effectiveModel, handle, send, approve, deny, interrupt, setModel, setPermissionMode, reconnectNow } = useClaudeSession(client, sessionId, {
11335
+ const { state, connection, replaying, protocolMismatch, models, effectiveModel, handle, send, approve, deny, interrupt, setModel, setPermissionMode, reconnectNow, loadFullResult } = useClaudeSession(client, sessionId, {
10661
11336
  onProtocolError: setProtocolError,
10662
11337
  cacheTranscript
10663
11338
  });
@@ -10762,6 +11437,7 @@ function SessionPanel({ client, sessionId, header, panelSurface = "internal", st
10762
11437
  const hostFiles = useHostFileSearch(client, state.cwd);
10763
11438
  const windows = useMemo(() => orderUsageWindows(usage), [usage]);
10764
11439
  const hostImage = useHostImage(client, sessionId, state.producedFiles);
11440
+ const resultImages = useToolResultImages(client, sessionId);
10765
11441
  const composerRef = useRef(null);
10766
11442
  const jumpToRecap = useRef(null);
10767
11443
  const repinTranscript = useRef(null);
@@ -10858,178 +11534,185 @@ function SessionPanel({ client, sessionId, header, panelSurface = "internal", st
10858
11534
  value: transcriptVariant,
10859
11535
  children: /* @__PURE__ */ jsx(TranscriptDensityProvider, {
10860
11536
  value: transcriptDensity,
10861
- children: /* @__PURE__ */ jsxs("div", {
10862
- "data-slot": "session-panel",
10863
- "data-agent-font": transcriptFont,
10864
- onClick: handleClick,
10865
- className: cn("flex h-full min-h-0 flex-col overflow-hidden bg-bg", className),
10866
- children: [
10867
- headerTakesActions ? header({ actions: menu }) : header,
10868
- statusPlacement === "top" ? statusBar : null,
10869
- protocolMismatch !== void 0 ? /* @__PURE__ */ jsxs(Notice, {
10870
- level: "warning",
11537
+ children: /* @__PURE__ */ jsx(ToolResultFetchProvider, {
11538
+ value: loadFullResult,
11539
+ children: /* @__PURE__ */ jsx(ToolResultImageProvider, {
11540
+ value: resultImages,
11541
+ children: /* @__PURE__ */ jsxs("div", {
11542
+ "data-slot": "session-panel",
11543
+ "data-agent-font": transcriptFont,
11544
+ onClick: handleClick,
11545
+ className: cn("flex h-full min-h-0 flex-col overflow-hidden bg-bg", className),
10871
11546
  children: [
10872
- "Server speaks protocol v",
10873
- protocolMismatch,
10874
- ", this build renders v",
10875
- PROTOCOL_VERSION,
10876
- ". Some events may not render."
10877
- ]
10878
- }) : null,
10879
- protocolError ? /* @__PURE__ */ jsx(Notice, {
10880
- level: "error",
10881
- onDismiss: () => setProtocolError(void 0),
10882
- children: protocolError
10883
- }) : null,
10884
- /* @__PURE__ */ jsx(Transcript, {
10885
- state,
10886
- fileUrl: sessionId ? (path) => client.sessionFileUrl(sessionId, path) : void 0,
10887
- attachmentUrl: sessionId ? (id) => client.attachmentUrl(sessionId, id) : void 0,
10888
- canBrowseFiles: hostFiles.available,
10889
- hostImage,
10890
- variant: transcriptVariant,
10891
- density: transcriptDensity,
10892
- fontSize: terminalMetrics?.fontSize,
10893
- lineHeight: terminalMetrics?.lineHeight,
10894
- affordances,
10895
- stickyPrompt,
10896
- scrubber,
10897
- scrubberMarks,
10898
- replaying,
10899
- catchUp: catchUp && newCount > 0 ? {
10900
- from: catchUp.itemCount,
10901
- since: catchUp.since
10902
- } : void 0,
10903
- jumpToRecapRef: jumpToRecap,
10904
- repinRef: repinTranscript
10905
- }),
10906
- catchUp && newCount > 0 && !replaying ? /* @__PURE__ */ jsx("div", {
10907
- className: "px-3 pb-1",
10908
- children: /* @__PURE__ */ jsxs("div", {
10909
- "data-slot": "catch-up",
10910
- className: "mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-fg-3",
10911
- children: [
10912
- /* @__PURE__ */ jsx("span", {
10913
- "aria-hidden": true,
10914
- className: cn("select-none", terminal ? "text-fg-3" : "text-accent"),
10915
- children: ""
10916
- }),
10917
- /* @__PURE__ */ jsxs("span", {
10918
- className: "min-w-0 flex-1 truncate",
11547
+ headerTakesActions ? header({ actions: menu }) : header,
11548
+ statusPlacement === "top" ? statusBar : null,
11549
+ protocolMismatch !== void 0 ? /* @__PURE__ */ jsxs(Notice, {
11550
+ level: "warning",
11551
+ children: [
11552
+ "Server speaks protocol v",
11553
+ protocolMismatch,
11554
+ ", this build renders v",
11555
+ PROTOCOL_VERSION,
11556
+ ". Some events may not render."
11557
+ ]
11558
+ }) : null,
11559
+ protocolError ? /* @__PURE__ */ jsx(Notice, {
11560
+ level: "error",
11561
+ onDismiss: () => setProtocolError(void 0),
11562
+ children: protocolError
11563
+ }) : null,
11564
+ /* @__PURE__ */ jsx(Transcript, {
11565
+ state,
11566
+ fileUrl: sessionId ? (path) => client.sessionFileUrl(sessionId, path) : void 0,
11567
+ attachmentUrl: sessionId ? (id) => client.attachmentUrl(sessionId, id) : void 0,
11568
+ canBrowseFiles: hostFiles.available,
11569
+ hostImage,
11570
+ variant: transcriptVariant,
11571
+ density: transcriptDensity,
11572
+ fontSize: terminalMetrics?.fontSize,
11573
+ lineHeight: terminalMetrics?.lineHeight,
11574
+ affordances,
11575
+ stickyPrompt,
11576
+ scrubber,
11577
+ scrubberMarks,
11578
+ replaying,
11579
+ catchUp: catchUp && newCount > 0 ? {
11580
+ from: catchUp.itemCount,
11581
+ since: catchUp.since
11582
+ } : void 0,
11583
+ reveal,
11584
+ jumpToRecapRef: jumpToRecap,
11585
+ repinRef: repinTranscript
11586
+ }),
11587
+ catchUp && newCount > 0 && !replaying ? /* @__PURE__ */ jsx("div", {
11588
+ className: "px-3 pb-1",
11589
+ children: /* @__PURE__ */ jsxs("div", {
11590
+ "data-slot": "catch-up",
11591
+ className: "mx-auto flex w-full max-w-[var(--wd-content-max-w,48rem)] items-center gap-2 text-label text-fg-3",
10919
11592
  children: [
10920
- newCount,
10921
- " new ",
10922
- newCount === 1 ? "row" : "rows",
10923
- catchUp.since !== void 0 ? ` since you were last here` : ""
11593
+ /* @__PURE__ */ jsx("span", {
11594
+ "aria-hidden": true,
11595
+ className: cn("select-none", terminal ? "text-fg-3" : "text-accent"),
11596
+ children: ""
11597
+ }),
11598
+ /* @__PURE__ */ jsxs("span", {
11599
+ className: "min-w-0 flex-1 truncate",
11600
+ children: [
11601
+ newCount,
11602
+ " new ",
11603
+ newCount === 1 ? "row" : "rows",
11604
+ catchUp.since !== void 0 ? ` since you were last here` : ""
11605
+ ]
11606
+ }),
11607
+ /* @__PURE__ */ jsx("button", {
11608
+ type: "button",
11609
+ onClick: () => jumpToRecap.current?.(),
11610
+ className: "shrink-0 underline-offset-2 hover:text-fg-1 hover:underline",
11611
+ children: "jump"
11612
+ }),
11613
+ /* @__PURE__ */ jsx("button", {
11614
+ type: "button",
11615
+ onClick: () => setCaughtUp(true),
11616
+ className: "shrink-0 underline-offset-2 hover:text-fg-1 hover:underline",
11617
+ children: "dismiss"
11618
+ })
10924
11619
  ]
11620
+ })
11621
+ }) : null,
11622
+ !readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? /* @__PURE__ */ jsx("div", {
11623
+ className: cn(terminal ? "pb-2" : "px-3 pb-2"),
11624
+ children: /* @__PURE__ */ jsx(PromptSurface, {
11625
+ terminal,
11626
+ metrics: terminalMetrics,
11627
+ affordances,
11628
+ children: state.pendingApprovals.map((request) => {
11629
+ const isQuestion = request.toolName === "AskUserQuestion" && parseUserQuestions(request.input).length > 0;
11630
+ if (terminal) return isQuestion ? /* @__PURE__ */ jsx(TerminalQuestionPrompt, {
11631
+ request,
11632
+ onAnswer: approve,
11633
+ onDismiss: (id) => deny(id, "Question dismissed by user")
11634
+ }, request.id) : /* @__PURE__ */ jsx(TerminalPermissionPrompt, {
11635
+ request,
11636
+ onApprove: approve,
11637
+ onDeny: deny
11638
+ }, request.id);
11639
+ return isQuestion ? /* @__PURE__ */ jsx(QuestionPrompt, {
11640
+ request,
11641
+ onAnswer: approve,
11642
+ onDismiss: (id) => deny(id, "Question dismissed by user")
11643
+ }, request.id) : /* @__PURE__ */ jsx(PermissionPrompt, {
11644
+ request,
11645
+ onApprove: approve,
11646
+ onDeny: deny
11647
+ }, request.id);
11648
+ })
11649
+ })
11650
+ }) : null,
11651
+ readOnly ? null : /* @__PURE__ */ jsx(Composer, {
11652
+ ref: composerRef,
11653
+ onSend: handleSend,
11654
+ onInterrupt: interrupt,
11655
+ busy,
11656
+ disabled: ended || !sessionId,
11657
+ commands: capabilities.slashCommands ? commands : void 0,
11658
+ skills: capabilities.skillsList ? state.skills : void 0,
11659
+ attachments,
11660
+ onSearchFiles: hostFiles.available ? (query, options) => hostFiles.search(query, {
11661
+ ...options,
11662
+ limit: 8
11663
+ }) : void 0,
11664
+ layout: controlsExternal ? "inline" : "stacked",
11665
+ toolbar: controlsExternal ? void 0 : sessionControls,
11666
+ fontSize: terminalMetrics?.fontSize,
11667
+ lineHeight: terminalMetrics?.lineHeight,
11668
+ affordances
11669
+ }),
11670
+ statusPlacement === "bottom" ? statusBar : null,
11671
+ !external ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
11672
+ /* @__PURE__ */ jsx(SessionInfoDialog, {
11673
+ state,
11674
+ client,
11675
+ sessionId,
11676
+ open: panel === "info",
11677
+ onOpenChange: (next) => setPanel(next ? "info" : void 0)
11678
+ }),
11679
+ /* @__PURE__ */ jsx(ContextDialog, {
11680
+ usage: state.contextUsage,
11681
+ open: panel === "context",
11682
+ onOpenChange: (next) => setPanel(next ? "context" : void 0)
11683
+ }),
11684
+ /* @__PURE__ */ jsx(UsageDialog, {
11685
+ rateLimits: windows,
11686
+ subscriptionType: state.subscriptionType,
11687
+ engine: state.engine ?? "claude",
11688
+ totalCostUsd: state.totalCostUsd,
11689
+ updatedAt: usageUpdatedAt,
11690
+ open: panel === "usage",
11691
+ onOpenChange: (next) => setPanel(next ? "usage" : void 0)
10925
11692
  }),
10926
- /* @__PURE__ */ jsx("button", {
10927
- type: "button",
10928
- onClick: () => jumpToRecap.current?.(),
10929
- className: "shrink-0 underline-offset-2 hover:text-fg-1 hover:underline",
10930
- children: "jump"
11693
+ /* @__PURE__ */ jsx(McpDialog, {
11694
+ client,
11695
+ sessionId,
11696
+ canManageServers: capabilities.mcpServerActions,
11697
+ open: panel === "mcp",
11698
+ onOpenChange: (next) => setPanel(next ? "mcp" : void 0)
10931
11699
  }),
10932
- /* @__PURE__ */ jsx("button", {
10933
- type: "button",
10934
- onClick: () => setCaughtUp(true),
10935
- className: "shrink-0 underline-offset-2 hover:text-fg-1 hover:underline",
10936
- children: "dismiss"
11700
+ /* @__PURE__ */ jsx(SkillsDialog, {
11701
+ skills: state.skills,
11702
+ open: panel === "skills",
11703
+ onOpenChange: (next) => setPanel(next ? "skills" : void 0),
11704
+ onUse: (skill) => composerRef.current?.insertText(skillPrompt(skill))
11705
+ }),
11706
+ /* @__PURE__ */ jsx(HostFilesDialog, {
11707
+ client,
11708
+ cwd: state.cwd,
11709
+ open: panel === "files",
11710
+ onOpenChange: (next) => setPanel(next ? "files" : void 0)
10937
11711
  })
10938
- ]
10939
- })
10940
- }) : null,
10941
- !readOnly && capabilities.interactiveApprovals && state.pendingApprovals.length > 0 ? /* @__PURE__ */ jsx("div", {
10942
- className: cn(terminal ? "pb-2" : "px-3 pb-2"),
10943
- children: /* @__PURE__ */ jsx(PromptSurface, {
10944
- terminal,
10945
- metrics: terminalMetrics,
10946
- affordances,
10947
- children: state.pendingApprovals.map((request) => {
10948
- const isQuestion = request.toolName === "AskUserQuestion" && parseUserQuestions(request.input).length > 0;
10949
- if (terminal) return isQuestion ? /* @__PURE__ */ jsx(TerminalQuestionPrompt, {
10950
- request,
10951
- onAnswer: approve,
10952
- onDismiss: (id) => deny(id, "Question dismissed by user")
10953
- }, request.id) : /* @__PURE__ */ jsx(TerminalPermissionPrompt, {
10954
- request,
10955
- onApprove: approve,
10956
- onDeny: deny
10957
- }, request.id);
10958
- return isQuestion ? /* @__PURE__ */ jsx(QuestionPrompt, {
10959
- request,
10960
- onAnswer: approve,
10961
- onDismiss: (id) => deny(id, "Question dismissed by user")
10962
- }, request.id) : /* @__PURE__ */ jsx(PermissionPrompt, {
10963
- request,
10964
- onApprove: approve,
10965
- onDeny: deny
10966
- }, request.id);
10967
- })
10968
- })
10969
- }) : null,
10970
- readOnly ? null : /* @__PURE__ */ jsx(Composer, {
10971
- ref: composerRef,
10972
- onSend: handleSend,
10973
- onInterrupt: interrupt,
10974
- busy,
10975
- disabled: ended || !sessionId,
10976
- commands: capabilities.slashCommands ? commands : void 0,
10977
- skills: capabilities.skillsList ? state.skills : void 0,
10978
- attachments,
10979
- onSearchFiles: hostFiles.available ? (query, options) => hostFiles.search(query, {
10980
- ...options,
10981
- limit: 8
10982
- }) : void 0,
10983
- layout: controlsExternal ? "inline" : "stacked",
10984
- toolbar: controlsExternal ? void 0 : sessionControls,
10985
- fontSize: terminalMetrics?.fontSize,
10986
- lineHeight: terminalMetrics?.lineHeight,
10987
- affordances
10988
- }),
10989
- statusPlacement === "bottom" ? statusBar : null,
10990
- !external ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
10991
- /* @__PURE__ */ jsx(SessionInfoDialog, {
10992
- state,
10993
- client,
10994
- sessionId,
10995
- open: panel === "info",
10996
- onOpenChange: (next) => setPanel(next ? "info" : void 0)
10997
- }),
10998
- /* @__PURE__ */ jsx(ContextDialog, {
10999
- usage: state.contextUsage,
11000
- open: panel === "context",
11001
- onOpenChange: (next) => setPanel(next ? "context" : void 0)
11002
- }),
11003
- /* @__PURE__ */ jsx(UsageDialog, {
11004
- rateLimits: windows,
11005
- subscriptionType: state.subscriptionType,
11006
- engine: state.engine ?? "claude",
11007
- totalCostUsd: state.totalCostUsd,
11008
- updatedAt: usageUpdatedAt,
11009
- open: panel === "usage",
11010
- onOpenChange: (next) => setPanel(next ? "usage" : void 0)
11011
- }),
11012
- /* @__PURE__ */ jsx(McpDialog, {
11013
- client,
11014
- sessionId,
11015
- canManageServers: capabilities.mcpServerActions,
11016
- open: panel === "mcp",
11017
- onOpenChange: (next) => setPanel(next ? "mcp" : void 0)
11018
- }),
11019
- /* @__PURE__ */ jsx(SkillsDialog, {
11020
- skills: state.skills,
11021
- open: panel === "skills",
11022
- onOpenChange: (next) => setPanel(next ? "skills" : void 0),
11023
- onUse: (skill) => composerRef.current?.insertText(skillPrompt(skill))
11024
- }),
11025
- /* @__PURE__ */ jsx(HostFilesDialog, {
11026
- client,
11027
- cwd: state.cwd,
11028
- open: panel === "files",
11029
- onOpenChange: (next) => setPanel(next ? "files" : void 0)
11030
- })
11031
- ] }) : null
11032
- ]
11712
+ ] }) : null
11713
+ ]
11714
+ })
11715
+ })
11033
11716
  })
11034
11717
  })
11035
11718
  });
@@ -11124,4 +11807,4 @@ function Notice({ level, onDismiss, children }) {
11124
11807
  //#endregion
11125
11808
  export { TranscriptDensityProvider as $, PermissionModeSelect as A, MenuTrigger as At, TerminalSurface as B, Button as Bt, TerminalQuestionPrompt as C, DialogHeader as Ct, parseUserQuestions as D, MenuContent as Dt, QuestionPrompt as E, Menu$1 as Et, McpDialog as F, SelectTrigger as Ft, Blank as G, TerminalDiff as H, cn as Ht, HostFilesDialog as I, SelectValue as It, CopyAction as J, Ink as K, ContextDialog as L, Input as Lt, permissionModeMeta as M, SelectContent as Mt, ModelSelect as N, SelectItem as Nt, PermissionPrompt as O, MenuItem as Ot, SkillsDialog as P, SelectItemText as Pt, toolIcon as Q, Composer as R, Badge as Rt, SessionInfoDialog as S, DialogContent as St, QUESTION_BEHAVIORS as T, DialogTrigger as Tt, previewPatch as U, TerminalMarkdown as V, buttonVariants as Vt, Band as W, useAffordances as X, WithActions as Y, isMutatingTool as Z, Conversation as _, TooltipContent as _t, Transcript as a, mentionTrigger as at, StatusBar as b, DialogBody as bt, ToolCallCard as c, plainTextToSegments as ct, Response as d, Splitter as dt, TranscriptVariantProvider as et, PromptTokenText as f, CodeBlock as ft, FileCard as g, Tip as gt, Loader as h, copyText as ht, useMinuteClock as i, hashtagTrigger as it, permissionModeChoices as j, Select$1 as jt, PERMISSION_MODES as k, MenuSeparator as kt, SessionEmptyState as l, segmentsToPlainText as lt, MessageContent as m, CopyButton as mt, UsageDialog as n, useTranscriptVariant as nt, TerminalItemView as o, usePromptAreaState as ot, Message as p, Spinner as pt, Row as q, UsageMeters as r, commandTrigger as rt, TerminalTranscript as s, PromptArea as st, SessionPanel as t, useTranscriptDensity as tt, Reasoning as u, ProgressRing as ut, ConversationContent as v, TooltipProvider as vt, TerminalPermissionPrompt as w, DialogRow as wt, STATUS_META as x, DialogClose as xt, ConversationScrollButton as y, Dialog$1 as yt, skillPrompt as z, badgeVariants as zt };
11126
11809
 
11127
- //# sourceMappingURL=SessionPanel-DII9MmQ8.mjs.map
11810
+ //# sourceMappingURL=SessionPanel-DMPhsNlW.mjs.map