@workerdeck/ui 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnNYEX80.d.mts} +13 -1
- package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DMPhsNlW.mjs} +1015 -332
- package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
- package/build/index.d.mts +54 -3
- package/build/index.mjs +240 -17
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +1 -1
- package/build/workspace.mjs +1 -1
- package/package.json +8 -6
- package/src/components/agent/ProjectIcon.tsx +119 -0
- package/src/components/agent/SessionBrowser.tsx +84 -3
- package/src/components/agent/SessionPanel.tsx +25 -0
- package/src/components/agent/ToolCallCard.tsx +72 -5
- package/src/components/agent/Transcript.tsx +77 -7
- package/src/components/agent/tool-result-fetch.tsx +36 -0
- package/src/components/agent/tool-result-image.tsx +209 -0
- package/src/components/agent/transcript-rows.ts +113 -22
- package/src/components/terminal/TerminalTranscript.tsx +80 -2
- package/src/components/terminal/blocks.ts +232 -0
- package/src/components/terminal/height.ts +49 -6
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +115 -78
- package/src/components/terminal/result-preview.ts +20 -6
- package/src/components/terminal/scrubber.tsx +146 -25
- package/src/components/terminal/tool-run.ts +133 -0
- package/src/index.ts +2 -1
- package/src/styles/terminal.css +75 -5
- package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* a
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
//
|
|
119
|
-
// failure
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
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
|
-
|
|
313
|
-
|
|
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,
|
package/src/styles/terminal.css
CHANGED
|
@@ -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-
|
|
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.
|
|
924
|
-
|
|
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
|
|
948
|
-
|
|
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
|
+
}
|