@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.
@@ -45,7 +45,16 @@ export type CollapsedResult = {
45
45
  * lines otherwise — a one-line JSON blob has no hidden lines to count, and
46
46
  * "+0 lines" under a visibly cut-off row is worse than saying nothing.
47
47
  */
48
- export function collapsedResult(lines: string[]): CollapsedResult {
48
+ /**
49
+ * `totalChars` is the **untruncated** length when the replay delivered only a
50
+ * head (protocol's `ToolResultBlock.total_chars`). Passing it is not cosmetic:
51
+ * computed from the head this row would say "… +7,600 chars" where the truth is
52
+ * 641,003, and the wrong string is a *different pixel height* — which is exactly
53
+ * the drift this module exists to prevent, since `height.ts` sizes the row by
54
+ * wrapping this same text. Omitted for a whole result, where the lines are the
55
+ * whole truth.
56
+ */
57
+ export function collapsedResult(lines: string[], totalChars?: number): CollapsedResult {
49
58
  const shown: string[] = []
50
59
  let chars = 0
51
60
  let cut = false
@@ -62,11 +71,16 @@ export function collapsedResult(lines: string[]): CollapsedResult {
62
71
  chars += line.length + 1
63
72
  }
64
73
 
65
- if (cut) {
66
- // `join` because the newlines are part of what is not being shown.
67
- const hidden = lines.join('\n').length - chars
68
- return { shown, more: `… +${hidden.toLocaleString()} chars` }
69
- }
74
+ // `join` because the newlines are part of what is not being shown — and
75
+ // `totalChars` wins when it exists, because the lines in hand are then a head
76
+ // rather than the result.
77
+ const held = lines.join('\n').length
78
+ const total = totalChars ?? held
79
+ if (cut) return { shown, more: `… +${(total - chars).toLocaleString()} chars` }
80
+ // A truncated result always has more, even when its head happened to fit the
81
+ // line budget: the row must never claim to be showing everything.
82
+ if (totalChars !== undefined && total > held)
83
+ return { shown, more: `… +${(total - chars).toLocaleString()} chars` }
70
84
  const hidden = lines.length - shown.length
71
85
  return { shown, more: hidden > 0 ? `… +${hidden} line${hidden === 1 ? '' : 's'}` : undefined }
72
86
  }
@@ -3,6 +3,7 @@ import { useStickToBottomContext } from 'use-stick-to-bottom'
3
3
  import type { PermissionRequest } from '@workerdeck/protocol'
4
4
  import type { TranscriptItem } from '@workerdeck/react'
5
5
  import { formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
6
+ import { parentOf } from './blocks.ts'
6
7
  import { TerminalSurface } from './surface.tsx'
7
8
 
8
9
  /**
@@ -46,6 +47,7 @@ import { TerminalSurface } from './surface.tsx'
46
47
  type Lane = 'l' | 'r' | 'f'
47
48
  type MarkKind =
48
49
  | 'user'
50
+ | 'subagent'
49
51
  | 'turn'
50
52
  | 'turnFailed'
51
53
  | 'toolFailed'
@@ -80,26 +82,38 @@ function nearestMember(cluster: Cluster, y: number): Mark | undefined {
80
82
  }
81
83
 
82
84
  /**
83
- * Two lanes and a full-width annotation, which is a claim about what a rail is
84
- * *for*: the two things you navigate by are what you asked and what came back,
85
- * so they get a lane each and split the rail evenly. Everything else — an error,
86
- * a waiting approval, a bookmark, the catch-up seam is an **annotation on the
87
- * run** rather than a step through it, so it spans the full width and reads as
88
- * a different class of thing rather than as a third column of steps.
85
+ * The two lanes are **channels, not classes**: left is what went *in* your
86
+ * prompts, and the sub-agents you dispatched and right is what came *out* —
87
+ * each turn's answer, and everything that went wrong producing one. That is the
88
+ * question a reader actually asks of a rail ("where did I say something", "where
89
+ * did it go wrong"), and it puts every failure in one column instead of
90
+ * scattering some down the middle.
91
+ *
92
+ * Full width is reserved for what is not a channel at all: a waiting approval
93
+ * (which is the session asking *you*, pinned at the foot), a bookmark (the
94
+ * reader's own annotation) and the catch-up seam (a boundary across both).
89
95
  *
90
96
  * It also buys the marks their width back: three lanes in a 16px rail is 5px a
91
97
  * lane, which is a hard target to hit and a hard colour to see.
92
98
  */
93
99
  const LANE: Record<MarkKind, Lane> = {
94
100
  user: 'l',
101
+ // Delegated work is input: a sub-agent runs because you asked for it, and its
102
+ // stretch of the transcript is *your* dispatch rather than the session's
103
+ // answer. It also gives a folded `Task` its one honest signal on the rail —
104
+ // collapsed, sixty rows of somebody else's working are one line, and this is
105
+ // the mark that says the region is there at all.
106
+ subagent: 'l',
95
107
  turn: 'r',
96
108
  turnFailed: 'r',
97
- // Full-width, like every other alarm: a failed tool call is something that
98
- // went wrong *during* a step, not a step you scroll between. A lane mark would
99
- // also put it in the response lane, where it would compete with the turn marks
100
- // that are the rail's actual navigation.
101
- toolFailed: 'f',
102
- error: 'f',
109
+ // Output, with the answers: a failed tool call is something the run produced.
110
+ // It had been full-width on the argument that it is an alarm rather than a
111
+ // step but "alarm" is not a lane, and half the failures ending up down the
112
+ // middle while `turnFailed` sat in the right lane meant no single column
113
+ // answered "did anything go wrong". Its rank in LOUDNESS and its 55% strength
114
+ // are what keep it from shouting over the turns it now sits beside.
115
+ toolFailed: 'r',
116
+ error: 'r',
103
117
  approval: 'f',
104
118
  recap: 'f',
105
119
  bookmark: 'f',
@@ -115,17 +129,22 @@ const LOUDNESS: Record<MarkKind, number> = {
115
129
  // keep the cluster. (It cannot merge with `turnFailed` — that is lane `r`, and
116
130
  // merging is per lane.) A failed tool call the model recovered from is routine
117
131
  // in a way a session error is not, hence quieter here and at 55% in the CSS.
118
- // The one thing it does outrank is `bookmark`, which loses its magenta to a
119
- // failure it sits beside.
132
+ // It now shares the response lane with the turn marks, which is the rank that
133
+ // matters: a failure a pixel from a turn end keeps the cluster red.
120
134
  toolFailed: 4,
121
135
  user: 3,
122
136
  turn: 2,
123
137
  bookmark: 1,
138
+ // Lane `l`, so this is only ever weighed against `user`, and a prompt wins:
139
+ // the prompt is the step you navigate by and the sub-agent band is the
140
+ // annotation on it. (It ties with `bookmark`, which it can never meet.)
141
+ subagent: 1,
124
142
  recap: 0,
125
143
  }
126
144
 
127
145
  const KIND_NAME: Record<MarkKind, string> = {
128
146
  user: 'you',
147
+ subagent: 'sub-agent',
129
148
  turn: 'response · turn end',
130
149
  turnFailed: 'turn failed',
131
150
  toolFailed: 'tool failed',
@@ -163,7 +182,7 @@ const MIN_MARK = 2
163
182
  * never exceed `railH` again, for any content, because `viewportH` can never
164
183
  * exceed the denominator.
165
184
  */
166
- function railScale(railH: number, totalSize: number, viewportH: number): number {
185
+ export function railScale(railH: number, totalSize: number, viewportH: number): number {
167
186
  return totalSize > 0 ? railH / Math.max(totalSize, viewportH) : 0
168
187
  }
169
188
 
@@ -223,6 +242,12 @@ export interface TerminalScrubberProps {
223
242
  /** A virtual row's height in content space, same source — what a mark's own
224
243
  * height is scaled from. */
225
244
  sizeOfRow: (rowIndex: number) => number
245
+ /** Where an item sits inside a row shared with other items — a task block's
246
+ * absorbed child or a folded run's member (`positionInRow` in
247
+ * `agent/transcript-rows.ts`). Optional and additive: without it every mark
248
+ * spans its row's extent, which for an expanded task block is the whole
249
+ * subagent area. */
250
+ positionInRow?: (itemIndex: number) => { ordinal: number; count: number } | undefined
226
251
  totalSize: number
227
252
  scrollOffset: number
228
253
  viewportH: number
@@ -234,7 +259,15 @@ export interface TerminalScrubberProps {
234
259
  lineHeight?: number
235
260
  }
236
261
 
237
- function buildClusters(
262
+ /**
263
+ * Exported for `test/scrubber.test.ts` and nothing else — it is not part of the
264
+ * package's surface (`index.ts` does not re-export it). Both of the bugs this
265
+ * function has shipped were pure-logic ones a unit test catches: a live answer
266
+ * with no `turn_result` yet went unmarked for the whole two minutes it was the
267
+ * only thing worth navigating to, and a replayed history — which carries no turn
268
+ * rows at all — came back with an empty right lane.
269
+ */
270
+ export function buildClusters(
238
271
  props: TerminalScrubberProps,
239
272
  railH: number,
240
273
  ): Cluster[] {
@@ -246,10 +279,30 @@ function buildClusters(
246
279
  rowIndexFor,
247
280
  offsetOfRow,
248
281
  sizeOfRow,
282
+ positionInRow,
249
283
  totalSize,
250
284
  viewportH,
251
285
  } = props
252
286
  const marks: Mark[] = []
287
+ // Which top-level calls a sub-agent ran inside — by `parentToolUseId` and
288
+ // never by the spawning call's *name*: the SDK's own convention is `Task`,
289
+ // but it is a convention (a background agent arrives as `Agent`), and an id
290
+ // that other items demonstrably nest under IS a sub-agent whatever spawned
291
+ // it. The same membership rule `terminalBlocks` folds by, for the same reason.
292
+ const subagentParents = new Set<string>()
293
+ for (const item of items) {
294
+ const parent = parentOf(item)
295
+ if (parent !== undefined) subagentParents.add(parent)
296
+ }
297
+ // The **outcome** call of each row: the last top-level tool call the row
298
+ // holds. A failed call is marked only when it is one of these — see the
299
+ // `toolFailed` branch below for why, and note this needs no block lookup,
300
+ // only `rowIndexFor`.
301
+ const rowOutcome = new Map<number, number>()
302
+ items.forEach((item, index) => {
303
+ if (item.kind !== 'tool_call' || parentOf(item) !== undefined) return
304
+ rowOutcome.set(rowIndexFor(index), index)
305
+ })
253
306
  // One right-lane mark per segment, emitted when the segment closes. A segment
254
307
  // is closed by the next prompt, by its own turn end, or by running out of
255
308
  // items — that last one is what a replayed history is made of.
@@ -267,7 +320,22 @@ function buildClusters(
267
320
  segment = {}
268
321
  }
269
322
  items.forEach((item, index) => {
270
- if (item.kind === 'user') {
323
+ // The dispatch itself, marked at its row — which is the folded `Task`
324
+ // block, so the band grows to the whole sub-agent area when it is opened
325
+ // and shrinks back to a tick when it is closed. Deliberately NOT part of
326
+ // the chain below: a `Task` whose own result errored earns a red tick in
327
+ // the response lane *and* this band in the input lane, which is the whole
328
+ // point of the two channels — one says a sub-agent ran here, the other says
329
+ // it came back broken. A failed child inside it still marks separately, at
330
+ // its own fraction of the row.
331
+ if (item.kind === 'tool_call' && subagentParents.has(item.id)) {
332
+ marks.push({ kind: 'subagent', itemIndex: index, rowIndex: rowIndexFor(index) })
333
+ }
334
+ // Top-level prompts only, like the answer check below: a subagent's brief
335
+ // is a `user` item too, and it would both paint a "you" mark for something
336
+ // nobody typed and close the segment mid-turn — which mis-anchors the turn
337
+ // mark whenever a task runs between the prompt and the answer.
338
+ if (item.kind === 'user' && parentOf(item) === undefined) {
271
339
  closeSegment()
272
340
  marks.push({ kind: 'user', itemIndex: index, rowIndex: rowIndexFor(index) })
273
341
  } else if (item.kind === 'turn_result') {
@@ -277,13 +345,44 @@ function buildClusters(
277
345
  } else if (item.kind === 'notice' && item.level === 'error') {
278
346
  marks.push({ kind: 'error', itemIndex: index, rowIndex: rowIndexFor(index) })
279
347
  } else if (
280
- // The same predicate the row itself reddens with (`items.tsx`), and the
281
- // same one the recap counts errors by the two spellings are not
282
- // redundant: an out-of-loop execution failure sets `status` with no
283
- // `is_error` block to read, and an engine can flag `is_error` on a call
284
- // this reducer has not settled yet.
348
+ // **The rail marks what the transcript reddens** the whole rule, and
349
+ // why this is not simply the per-call predicate it used to be.
350
+ //
351
+ // The row model already decided, twice, that a routine failure the model
352
+ // recovered from is not a failure: `runFailed` colours a folded run by
353
+ // its LAST call, and `taskFailed` colours a `Task` by its OWN result and
354
+ // never a child's. Both were changed from `contains` for the same reason
355
+ // — a normal working session came back painted red, spending the colour
356
+ // that should have been left for the one broken thing on a grep that
357
+ // matched nothing. The rail was deliberately exempted, on the argument
358
+ // that its question ("is there anything worth navigating to") differs
359
+ // from the row's ("how did this end").
360
+ //
361
+ // Measured against a real session, the exemption did not survive: 178
362
+ // tool calls, 9 failed, EIGHT OF THE NINE recovered from inside their own
363
+ // run, no failed turn and no session error — nine alarms on the rail for
364
+ // a transcript that reddens one row. A red mark beside nothing red is
365
+ // worse than no mark: it sends a reader hunting for damage that is not
366
+ // there.
367
+ //
368
+ // One uniform test covers all three cases: a call is its row's OUTCOME
369
+ // when it is top level and no later top-level call shares its row. For a
370
+ // folded run that is exactly `runFailed`'s last member; for a lone call
371
+ // it is the call; and for a `Task` it is the task itself, because its
372
+ // children are not top level — which is `taskFailed`, spelled a third way
373
+ // and agreeing. A failed child inside a sub-agent is therefore no longer
374
+ // marked, the same call `taskFailed` makes. The sub-agent band still says
375
+ // it ran and its own red tick still says it came back broken, every
376
+ // failure is still red on its own row, and the recap still counts them
377
+ // all.
378
+ //
379
+ // The disjunction is unchanged and both spellings are still needed: an
380
+ // out-of-loop execution failure sets `status` with no `is_error` block to
381
+ // read, and an engine can flag `is_error` on a call this reducer has not
382
+ // settled yet.
285
383
  item.kind === 'tool_call' &&
286
- (item.status === 'failed' || item.result?.isError === true)
384
+ (item.status === 'failed' || item.result?.isError === true) &&
385
+ rowOutcome.get(rowIndexFor(index)) === index
287
386
  ) {
288
387
  marks.push({ kind: 'toolFailed', itemIndex: index, rowIndex: rowIndexFor(index) })
289
388
  } else if (item.kind === 'assistant_text' && item.parentToolUseId == null) {
@@ -309,8 +408,30 @@ function buildClusters(
309
408
  // A mark's height is its row's, at rail scale, floored at the hit target —
310
409
  // the row the mark *anchors* (for a turn, the final response), which is
311
410
  // where the reader lands and what they came to gauge the size of.
312
- const h = Math.max(MIN_MARK, Math.round(sizeOfRow(mark.rowIndex) * scale))
313
- const y = Math.min(Math.max(0, railH - h), Math.round(offsetOfRow(mark.rowIndex) * scale))
411
+ //
412
+ // EXCEPT an item that SHARES its row (a task block's absorbed child, a
413
+ // folded run's member): there the row's extent is mostly other items' work,
414
+ // and expanded it is the entire subagent area — one failed child of a
415
+ // hundred-call task used to paint a solid red band down the whole rail.
416
+ // Such a mark is a tick at its fractional position within the row.
417
+ // `sizeOfRow` is the virtualizer's *measurement*, so expansion is reflected
418
+ // with no expansion state here (which the scrubber deliberately cannot see,
419
+ // `height.ts`'s "unmounted is collapsed" invariant being load-bearing):
420
+ // collapsed, the fraction rounds onto the row's one line and siblings merge
421
+ // exactly as before; expanded, the ticks distribute down the block —
422
+ // approximately, since children differ in height, which a 12px rail cannot
423
+ // show and exactness would cost the scrubber the one thing it must not know.
424
+ // Applied here rather than per kind because a bookmark on an absorbed child
425
+ // has the identical bug; `recap` is `itemIndex: -1`, hence the guard.
426
+ const within = mark.itemIndex >= 0 ? positionInRow?.(mark.itemIndex) : undefined
427
+ const rowH = sizeOfRow(mark.rowIndex)
428
+ const h = within ? MIN_MARK : Math.max(MIN_MARK, Math.round(rowH * scale))
429
+ const y = Math.min(
430
+ Math.max(0, railH - h),
431
+ Math.round(
432
+ (offsetOfRow(mark.rowIndex) + (within ? (within.ordinal / within.count) * rowH : 0)) * scale,
433
+ ),
434
+ )
314
435
  const lane = LANE[mark.kind]
315
436
  const list = lanes.get(lane) ?? []
316
437
  list.push({ mark, y, h })
@@ -17,6 +17,7 @@
17
17
  * `result-preview.ts` exists. Two spellings would be two different heights.
18
18
  */
19
19
  import type { TranscriptItem } from '@workerdeck/react'
20
+ import { toolInputPreview } from '../../lib/format.ts'
20
21
  import { isShellTool } from '../../lib/tool-icon.ts'
21
22
 
22
23
  type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
@@ -89,3 +90,135 @@ export function runSummary(items: readonly ToolCallItem[], busy: boolean): strin
89
90
  // read, 1 shell" reads as though the sentence ended and then carried on.
90
91
  return `${verb}${n} tool${n === 1 ? '' : 's'} · ${breakdown}${tail}`
91
92
  }
93
+
94
+ /* ── The task block's one line ─────────────────────────────────────────────
95
+ *
96
+ * A `Task` call and everything its subagent produced collapse to one row (see
97
+ * `blocks.ts`), and these are that row's words. Same contract as `runSummary`:
98
+ * `height.ts` wraps these exact strings to predict the row's pixel height with
99
+ * no DOM, so the component must render them verbatim — two spellings would be
100
+ * two different heights.
101
+ */
102
+
103
+ const clip = (text: string, max = 80): string =>
104
+ text.length > max ? text.slice(0, max - 1) + '…' : text
105
+
106
+ const trimmed = (value: unknown): string | undefined =>
107
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
108
+
109
+ /**
110
+ * The row's identity half: which task this is.
111
+ *
112
+ * The Claude SDK's `Task` input carries `subagent_type` (e.g. "Explore") and a
113
+ * 3–5 word `description`, and both are worth the line: parallel tasks are the
114
+ * whole reason the block exists, and two rows both reading `Task(…)` answer
115
+ * nothing. `Task(Explore · find the auth check)` — falling back to the
116
+ * ordinary input preview when an engine sends neither, so the header is never
117
+ * emptier than a plain tool row's.
118
+ */
119
+ export function taskLabel(task: ToolCallItem): string {
120
+ const input = task.input as { description?: unknown; subagent_type?: unknown } | null
121
+ const description = trimmed(input?.description)
122
+ const agent = trimmed(input?.subagent_type)
123
+ const inner =
124
+ agent && description
125
+ ? `${agent} · ${clip(description)}`
126
+ : (agent ?? (description ? clip(description) : toolInputPreview(task.input)))
127
+ return `${task.name}(${inner})`
128
+ }
129
+
130
+ const callBusy = (call: ToolCallItem): boolean =>
131
+ call.status === 'running' || call.status === 'pending'
132
+
133
+ /** Did this one call fail? Both spellings are needed: an out-of-loop execution
134
+ * failure sets `status` with no `is_error` block to read, and an engine can flag
135
+ * `is_error` on a call the reducer has not settled yet. */
136
+ export const callFailed = (call: ToolCallItem): boolean =>
137
+ call.status === 'failed' || call.result?.isError === true
138
+
139
+ /**
140
+ * Does a folded run colour red? **Only when its last call failed.**
141
+ *
142
+ * It used to be `some`, on the argument that a failure should colour the block
143
+ * rather than fragment it. The argument was right about not fragmenting and
144
+ * wrong about `some`: a run is a sequence the model worked through, and a
145
+ * failure it recovered from two calls later is how work goes — a grep that
146
+ * matched nothing, a build fixed on the second go. Reddening the whole run for
147
+ * it means a normal working session is painted red, which spends the colour
148
+ * that should have been left for the one thing still broken.
149
+ *
150
+ * The last call is the run's *outcome*, and an outcome is what a collapsed row
151
+ * can honestly claim. The failures inside it are not hidden — they are one
152
+ * press away, each red on its own row, and the recap counts every one. The
153
+ * **scrubber agrees with this rule** rather than overriding it: it marks a
154
+ * failed call only when the call is its row's outcome, which for a run is
155
+ * exactly this one. It used to mark every member on the argument that the
156
+ * rail asks a different question; against a real session that was nine alarms
157
+ * on the rail for a transcript reddening one row.
158
+ */
159
+ export function runFailed(items: readonly ToolCallItem[]): boolean {
160
+ const last = items[items.length - 1]
161
+ return last !== undefined && callFailed(last)
162
+ }
163
+
164
+ /** Is anything inside still going? The call itself, normally — the Task
165
+ * settles only when its subagent finishes — but a bridged or deferred child
166
+ * can outlive it, and a pulse that stopped while a child still worked would
167
+ * read as a hang. */
168
+ export function taskBusy(task: ToolCallItem, children: readonly TranscriptItem[]): boolean {
169
+ return callBusy(task) || children.some((child) => child.kind === 'tool_call' && callBusy(child))
170
+ }
171
+
172
+ /**
173
+ * Does the row colour red? **The task's own outcome, and nothing else.**
174
+ *
175
+ * It used to be "or any child call's", which does not survive contact with a
176
+ * real subagent: an agent that ran a hundred calls, one of them a grep that
177
+ * matched nothing, came back with a red line saying it had failed. It had not —
178
+ * it had done exactly what it was asked, and the transcript said otherwise in
179
+ * the one colour reserved for things that need a human.
180
+ *
181
+ * This is the call `SubagentInfo.status` already makes, and it made it for this
182
+ * reason (see `packages/protocol`): the sub-agent's **own** `tool_result`
183
+ * `is_error`, deliberately not `taskFailed`. The argument there was that a
184
+ * nothing-matched grep must not read as a failed run *beside a session name*;
185
+ * what a hundred-call agent shows is that it must not read that way beside the
186
+ * `Task` row either. Two surfaces, one rule, one spelling.
187
+ *
188
+ * Nothing is concealed by this. A failed child is red on its own row, one press
189
+ * away, and the recap counts it. The **scrubber follows this rule too** and no
190
+ * longer marks such a child: a red tick on the rail says precisely what this
191
+ * row is forbidden from saying. The sub-agent band still says an agent ran
192
+ * here, and the task's own red tick still says it came back broken — which is
193
+ * what the two channels are for.
194
+ */
195
+ export function taskFailed(task: ToolCallItem): boolean {
196
+ return callFailed(task)
197
+ }
198
+
199
+ /**
200
+ * The collapsed task row's one line: identity, then scale.
201
+ *
202
+ * `Task(Explore · find the auth check) · 7 tools…` while the subagent works —
203
+ * the count grows as it does, which is the row's progress reading, and the
204
+ * trailing ellipsis is the same in-flight signal `runSummary` uses (the pulse
205
+ * in the gutter carries the beat). Settled, the ellipsis drops:
206
+ * `… · 7 tools`. "Tools" and not "tool calls" because `runSummary` already
207
+ * chose that word for the same count one row over.
208
+ *
209
+ * The counts are counted from the absorbed children, never read from the
210
+ * engine's structured Task output — WorkerDeck does not plumb structured tool
211
+ * results to clients, so a transcript replayed tomorrow must spell the same
212
+ * line from the same items it holds today.
213
+ *
214
+ * With no tool calls yet — the subagent thinking, or only its brief arrived —
215
+ * the line says `working…`, because `0 tools…` reads as a stall; settled with
216
+ * none it says `done`.
217
+ */
218
+ export function taskSummary(task: ToolCallItem, children: readonly TranscriptItem[]): string {
219
+ const busy = taskBusy(task, children)
220
+ const calls = children.reduce((n, child) => n + (child.kind === 'tool_call' ? 1 : 0), 0)
221
+ const label = taskLabel(task)
222
+ if (calls === 0) return busy ? `${label} · working…` : `${label} · done`
223
+ return `${label} · ${calls} tool${calls === 1 ? '' : 's'}${busy ? '…' : ''}`
224
+ }
package/src/index.ts CHANGED
@@ -180,7 +180,8 @@ export {
180
180
  export { SessionStatusIcon } from './components/agent/SessionBrowser.tsx'
181
181
  // Lifted out of the VS Code sidebar once the dashboard grew a collapsed rail
182
182
  // that needs the same glyph — two copies of a trademark set is one too many.
183
- export { EngineIcon } from './components/agent/EngineIcon.tsx'
183
+ export { EngineIcon, engineMark } from './components/agent/EngineIcon.tsx'
184
+ export { ProjectIcon } from './components/agent/ProjectIcon.tsx'
184
185
  export {
185
186
  SessionEmptyState,
186
187
  type SessionEmptyStateProps,
@@ -99,6 +99,13 @@
99
99
  /* The user's own prompt row. */
100
100
  --term-user-bg: rgb(255 255 255 / 0.05);
101
101
  --term-row-hover: rgb(255 255 255 / 0.05);
102
+ /* Behind an OPEN block. Yellow, not neutral, and the theme's one deliberate
103
+ reuse of that tone for something other than "waiting on you": an open block
104
+ is a state the reader put the transcript into, and on the phone it wants
105
+ the same colour as the rail mark that says so. Kept very low — this washes
106
+ whole regions, and at band strength an opened run would shout louder than
107
+ anything inside it. */
108
+ --term-open-wash: rgb(215 186 125 / 0.1);
102
109
  }
103
110
 
104
111
  [data-theme='light'] [data-terminal],
@@ -125,6 +132,7 @@
125
132
  --term-band-bg: rgb(0 0 0 / 0.04);
126
133
  --term-user-bg: rgb(0 0 0 / 0.05);
127
134
  --term-row-hover: rgb(0 0 0 / 0.04);
135
+ --term-open-wash: rgb(215 186 125 / 0.25);
128
136
  }
129
137
 
130
138
  /* ── Rows ────────────────────────────────────────────────────────────────────
@@ -284,11 +292,30 @@
284
292
  stack without a step. (The other half is `useRevealOnOpen`, which brings that
285
293
  first line back when it has gone above the fold.) */
286
294
  [data-terminal] .term-open {
287
- background: var(--term-row-hover);
295
+ background: var(--term-open-wash);
288
296
  margin-inline: calc(-1 * var(--term-bleed, 0px));
289
297
  padding-inline: var(--term-bleed, 0px);
290
298
  }
291
299
 
300
+ /* Another frame of reference: the rows a subagent produced, inside the `Task`
301
+ that spawned them.
302
+
303
+ Two cells of indent, whole — the theme's rule is that horizontal measures are
304
+ `ch`, and these rows carry markers in a gutter that has to keep landing on the
305
+ grid the rows above it use. The rule itself is drawn *inside* that padding
306
+ with an inset shadow rather than as a border, which is what makes the 2ch
307
+ exact: a 1px border is layout, and would put every nested glyph one pixel off
308
+ the column for the sake of chrome.
309
+
310
+ `--term-faint` rather than the cards `--border` token, and that is the whole
311
+ reason this class exists: the nested block sits on the *open* block's wash,
312
+ and a border colour tuned for the base ground resolved to rgb(31,31,31) on it
313
+ — a rule nobody could see. The same trap `row-hover` is alpha to avoid. */
314
+ [data-terminal] .term-nested {
315
+ padding-left: 2ch;
316
+ box-shadow: inset 1px 0 0 var(--term-faint);
317
+ }
318
+
292
319
  .term-hoverable {
293
320
  position: relative;
294
321
  }
@@ -920,8 +947,8 @@
920
947
  as long without a tall solid bar shouting over the rail. A minimum-height
921
948
  mark shows only the solid segment, so short and long marks need no separate
922
949
  rules; the *lane* stays the pointer's target, so a 2px mark is still
923
- findable. Full-width annotations (error, approval) stay solid: they are
924
- alarms, not extents. */
950
+ findable. The alarms (error, approval) stay solid: they are alarms, not
951
+ extents. */
925
952
  .term-scrubber .term-scrub-mark[data-kind='user'] {
926
953
  background: linear-gradient(
927
954
  to bottom,
@@ -929,6 +956,19 @@
929
956
  color-mix(in srgb, var(--term-blue) 25%, transparent) 2px
930
957
  );
931
958
  }
959
+ /* A sub-agent's stretch of the transcript, in the input lane: green, because
960
+ every other colour on this rail is already spoken for and none of them means
961
+ "somebody else's working" — blue is you, white is the answer, red is an
962
+ alarm, magenta is your bookmark, yellow is the session waiting on you. Drawn
963
+ as an extent (2px head, 25% tail) like the two marks it shares its geometry
964
+ with: collapsed it is a tick, expanded it is the band the sub-agent covers. */
965
+ .term-scrubber .term-scrub-mark[data-kind='subagent'] {
966
+ background: linear-gradient(
967
+ to bottom,
968
+ var(--term-green) 0 2px,
969
+ color-mix(in srgb, var(--term-green) 25%, transparent) 2px
970
+ );
971
+ }
932
972
  .term-scrubber .term-scrub-mark[data-kind='turn'] {
933
973
  background: linear-gradient(
934
974
  to bottom,
@@ -944,8 +984,8 @@
944
984
  );
945
985
  }
946
986
  .term-scrubber .term-scrub-mark[data-kind='error'] { background: var(--term-red); }
947
- /* A failed tool call is an alarm, so it is full-width and solid like the rest of
948
- them — but at 55%, which is the one thing keeping the rail readable. A session
987
+ /* A failed tool call is an alarm, so it is solid like the rest of them — in the
988
+ response lane, since it is something the run produced — but at 55%, which is the one thing keeping the rail readable. A session
949
989
  error is rare and a turn failure rarer; a tool that failed and was recovered
950
990
  from is routine (a grep that matched nothing, a build fixed on the second go),
951
991
  and at full strength a normal working session paints the rail solid red and
@@ -1009,3 +1049,33 @@
1009
1049
  [data-term-scrubber-host]::-webkit-scrollbar {
1010
1050
  display: none;
1011
1051
  }
1052
+
1053
+ /* ── Images ──────────────────────────────────────────────────────────────────
1054
+ *
1055
+ * A picture a tool returned, drawn in a box of whole lines (`IMAGE_BOX_LINES`,
1056
+ * set inline by `TerminalImage` so the constant has one spelling across the
1057
+ * renderer and the height calculator). The height is on the element and nothing
1058
+ * here may add to it: no margin, no border, no padding — a box that is not
1059
+ * exactly N lines is a row the virtualizer was told the wrong size for.
1060
+ *
1061
+ * `object-fit: contain` with a top-left origin rather than a centred one: the
1062
+ * grid's origin is the top-left of the body cell, and a screenshot floating in
1063
+ * the middle of its box reads as a figure in a document instead of output on a
1064
+ * line. */
1065
+ .term-image {
1066
+ display: block;
1067
+ overflow: hidden;
1068
+ }
1069
+ .term-image > img {
1070
+ max-width: 100%;
1071
+ height: 100%;
1072
+ object-fit: contain;
1073
+ object-position: left top;
1074
+ }
1075
+ /* The wash is the placeholder's whole visual: it says "something is reserved
1076
+ here" for as long as the bytes are in flight, and it says it at the same
1077
+ height the picture will occupy. */
1078
+ .term-image[data-state='pending'],
1079
+ .term-image[data-state='failed'] {
1080
+ background: var(--term-band-bg);
1081
+ }