@workerdeck/ui 0.13.0 → 0.16.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 +72 -0
- package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-B9CHoq8x.d.mts} +213 -27
- package/build/{SessionPanel-CKQa4i0Y.mjs → SessionPanel-DII9MmQ8.mjs} +5192 -2542
- package/build/SessionPanel-DII9MmQ8.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +494 -45
- package/build/index.mjs +182 -24
- package/build/index.mjs.map +1 -1
- package/build/status-Ydzi7n6j.mjs +143 -0
- package/build/status-Ydzi7n6j.mjs.map +1 -0
- package/build/workspace.d.mts +13 -1
- package/build/workspace.mjs +111 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +14 -7
- package/src/components/agent/Composer.tsx +251 -84
- package/src/components/agent/Conversation.tsx +12 -12
- package/src/components/agent/FileCard.tsx +0 -26
- package/src/components/agent/FileTree.tsx +9 -8
- package/src/components/agent/Loader.tsx +22 -72
- package/src/components/agent/Message.tsx +11 -43
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionBrowser.tsx +35 -25
- package/src/components/agent/SessionPanel.tsx +312 -63
- package/src/components/agent/SessionWorkspace.tsx +36 -0
- package/src/components/agent/StatusBar.tsx +70 -15
- package/src/components/agent/ToolCallCard.tsx +17 -111
- package/src/components/agent/Transcript.tsx +710 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/transcript-rows.ts +82 -0
- package/src/components/agent/transcript-variant.tsx +43 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +147 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +727 -0
- package/src/components/terminal/items.tsx +449 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +72 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +663 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +91 -0
- package/src/components/ui/Badge.tsx +6 -1
- package/src/components/ui/Empty.tsx +56 -0
- package/src/components/ui/Splitter.tsx +14 -0
- package/src/index.ts +36 -0
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1011 -0
- package/src/styles/theme.css +73 -0
- package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- package/src/components/agent/line-prompt.tsx +0 -249
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { useStickToBottomContext } from 'use-stick-to-bottom'
|
|
3
|
+
import type { PermissionRequest } from '@workerdeck/protocol'
|
|
4
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
5
|
+
import { formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
|
|
6
|
+
import { TerminalSurface } from './surface.tsx'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The overview ruler — VS Code's strip beside the minimap, with this
|
|
10
|
+
* transcript's own semantics.
|
|
11
|
+
*
|
|
12
|
+
* A 12px rail over the scroller's right edge, two lanes of 6px: **left** is
|
|
13
|
+
* what you typed (blue), **right** is each turn's final response and its
|
|
14
|
+
* `turn_result` as *one* merged mark (white; red when the turn failed — the
|
|
15
|
+
* turn boundary is the response's address, so two ticks would answer no
|
|
16
|
+
* question the peek doesn't). Those two are the conversation, and they are what
|
|
17
|
+
* you navigate by.
|
|
18
|
+
*
|
|
19
|
+
* Everything else spans the **full 12px** and is an annotation rather than a
|
|
20
|
+
* step: an error, the pending approval (pinned at the foot, pulsing), a
|
|
21
|
+
* bookmark (magenta — paint only; the store is the client's, the way watermarks
|
|
22
|
+
* are) and the catch-up seam (dashed). A mark is its row's extent at rail
|
|
23
|
+
* scale, floored at 2px, drawn as a solid 2px head with a 25% tail; marks
|
|
24
|
+
* merge when closer than a pixel, loudest colour winning.
|
|
25
|
+
*
|
|
26
|
+
* Positions are **pixel space**, not index space: mark y = the row's
|
|
27
|
+
* virtualizer offset over `getTotalSize()`. That is only honest because the
|
|
28
|
+
* height calculator (`height.ts`) feeds `estimateSize`, so an unmounted row's
|
|
29
|
+
* offset is computed rather than guessed — the rail is the calculator's
|
|
30
|
+
* payoff, and it is why the rail can be a *real scrollbar*: drag scrubs
|
|
31
|
+
* `scrollTop` directly, and the native scrollbar is hidden while an
|
|
32
|
+
* interactive rail is mounted.
|
|
33
|
+
*
|
|
34
|
+
* The peek renders from `items`, never the DOM — the row it describes is
|
|
35
|
+
* usually unmounted, so there is nothing to clone. A click is a jump through
|
|
36
|
+
* the transcript's re-aim closure (`onJumpToRow`), which starts with
|
|
37
|
+
* `stopScroll()`: the rail is a third writer of `scrollTop` beside the follow
|
|
38
|
+
* spring and the virtualizer's corrections, and that call is the library's
|
|
39
|
+
* own "the user is leaving the bottom" switch.
|
|
40
|
+
*
|
|
41
|
+
* Under `affordances={false}` the rail is passive paint: `pointer-events:
|
|
42
|
+
* none`, no peek, no drag, no click — and the native scrollbar stays, working
|
|
43
|
+
* straight through the paint.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
type Lane = 'l' | 'r' | 'f'
|
|
47
|
+
type MarkKind =
|
|
48
|
+
| 'user'
|
|
49
|
+
| 'turn'
|
|
50
|
+
| 'turnFailed'
|
|
51
|
+
| 'toolFailed'
|
|
52
|
+
| 'error'
|
|
53
|
+
| 'approval'
|
|
54
|
+
| 'recap'
|
|
55
|
+
| 'bookmark'
|
|
56
|
+
|
|
57
|
+
type Mark = {
|
|
58
|
+
kind: MarkKind
|
|
59
|
+
/** The jump anchor (for a turn mark: the paired response). −1 for recap. */
|
|
60
|
+
itemIndex: number
|
|
61
|
+
rowIndex: number
|
|
62
|
+
/** The `turn_result` behind a center mark — the peek shows its done-line. */
|
|
63
|
+
turnIndex?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Members keep their own y: a dense transcript chain-merges a lane into one
|
|
67
|
+
* tall bar (600 prompts over a 300px rail IS a solid stripe, exactly as VS
|
|
68
|
+
* Code draws dense decorations), and the bar answers the pointer by its
|
|
69
|
+
* *nearest member* — a click or peek at the middle of the bar must not act on
|
|
70
|
+
* the first mark that happened to found the cluster. */
|
|
71
|
+
type Cluster = { lane: Lane; kind: MarkKind; y: number; h: number; marks: { mark: Mark; y: number }[] }
|
|
72
|
+
|
|
73
|
+
/** The member closest to a rail-space y — what a press or peek on a merged
|
|
74
|
+
* cluster resolves to. */
|
|
75
|
+
function nearestMember(cluster: Cluster, y: number): Mark | undefined {
|
|
76
|
+
let best: { mark: Mark; y: number } | undefined
|
|
77
|
+
for (const member of cluster.marks)
|
|
78
|
+
if (!best || Math.abs(member.y - y) < Math.abs(best.y - y)) best = member
|
|
79
|
+
return best?.mark
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
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.
|
|
89
|
+
*
|
|
90
|
+
* It also buys the marks their width back: three lanes in a 16px rail is 5px a
|
|
91
|
+
* lane, which is a hard target to hit and a hard colour to see.
|
|
92
|
+
*/
|
|
93
|
+
const LANE: Record<MarkKind, Lane> = {
|
|
94
|
+
user: 'l',
|
|
95
|
+
turn: 'r',
|
|
96
|
+
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',
|
|
103
|
+
approval: 'f',
|
|
104
|
+
recap: 'f',
|
|
105
|
+
bookmark: 'f',
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Who wins the colour when marks merge. */
|
|
109
|
+
const LOUDNESS: Record<MarkKind, number> = {
|
|
110
|
+
approval: 7,
|
|
111
|
+
error: 6,
|
|
112
|
+
turnFailed: 5,
|
|
113
|
+
// Under `error`, which is the rank that actually does work: both are lane `f`,
|
|
114
|
+
// so a session error and a tool failure a pixel apart merge and the error must
|
|
115
|
+
// keep the cluster. (It cannot merge with `turnFailed` — that is lane `r`, and
|
|
116
|
+
// merging is per lane.) A failed tool call the model recovered from is routine
|
|
117
|
+
// 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.
|
|
120
|
+
toolFailed: 4,
|
|
121
|
+
user: 3,
|
|
122
|
+
turn: 2,
|
|
123
|
+
bookmark: 1,
|
|
124
|
+
recap: 0,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const KIND_NAME: Record<MarkKind, string> = {
|
|
128
|
+
user: 'you',
|
|
129
|
+
turn: 'response · turn end',
|
|
130
|
+
turnFailed: 'turn failed',
|
|
131
|
+
toolFailed: 'tool failed',
|
|
132
|
+
error: 'error',
|
|
133
|
+
approval: 'pending approval',
|
|
134
|
+
recap: 'catch-up boundary',
|
|
135
|
+
bookmark: 'bookmark',
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The floor, not the height: a mark spans its row's actual extent at rail
|
|
139
|
+
// scale, so a one-line prompt is a tick and a hundred-line response is a bar —
|
|
140
|
+
// the rail is a map, and on a map a long answer looks long. 2px keeps a tick
|
|
141
|
+
// findable while the CSS draws only the first 2px solid (the rest is a 25%
|
|
142
|
+
// tail); the pointer's real target is the 6px-wide lane, and a press resolves
|
|
143
|
+
// through `nearestMember`, so hit reliability does not ride on mark height.
|
|
144
|
+
const MIN_MARK = 2
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Rail pixels per content pixel — the one scale both the marks and the viewport
|
|
148
|
+
* band are drawn at, so they cannot disagree about where a row sits.
|
|
149
|
+
*
|
|
150
|
+
* The denominator is `max(totalSize, viewportH)` and never `totalSize` alone.
|
|
151
|
+
* A transcript **shorter than its viewport** is the case that forces it: with
|
|
152
|
+
* 90px of content in a 906px window, `railH / totalSize` is ~10, and the band
|
|
153
|
+
* — `viewportH * scale` — comes out at 9120px inside a 906px rail. The rail is
|
|
154
|
+
* absolutely positioned *within the scroller*, so that overflow becomes real
|
|
155
|
+
* scrollable height: a short session grew ~8000px of empty space below it, and
|
|
156
|
+
* the reader could scroll away from the only three rows there were.
|
|
157
|
+
*
|
|
158
|
+
* Clamping the denominator says the thing that is actually true: when
|
|
159
|
+
* everything fits, the rail represents the **viewport**, not the content. The
|
|
160
|
+
* band then fills it exactly (`viewportH * railH / viewportH === railH`), which
|
|
161
|
+
* is what "you are looking at all of it" should look like. It also makes the
|
|
162
|
+
* overflow structurally impossible rather than merely unlikely — `bandH` can
|
|
163
|
+
* never exceed `railH` again, for any content, because `viewportH` can never
|
|
164
|
+
* exceed the denominator.
|
|
165
|
+
*/
|
|
166
|
+
function railScale(railH: number, totalSize: number, viewportH: number): number {
|
|
167
|
+
return totalSize > 0 ? railH / Math.max(totalSize, viewportH) : 0
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The right lane is anchored on **the answer, not the turn end**.
|
|
172
|
+
*
|
|
173
|
+
* It used to be built from `turn_result` items alone, which made it silently
|
|
174
|
+
* history-blind: `#backfillHistory` maps only `user` and `assistant` entries, so
|
|
175
|
+
* a session replayed after a gateway restart — or any resumed session — carried
|
|
176
|
+
* no turn rows at all and the whole white lane came back empty. The blue lane
|
|
177
|
+
* survived, which is what made it look like a rendering bug rather than a
|
|
178
|
+
* missing input.
|
|
179
|
+
*
|
|
180
|
+
* So a turn's mark is emitted for the last top-level assistant message of each
|
|
181
|
+
* segment, and a `turn_result` (when there is one) *decorates* it rather than
|
|
182
|
+
* conjuring it — contributing the failed colour and the `turnIndex` its peek
|
|
183
|
+
* shows the done-line from. Live behaviour is unchanged by construction: the
|
|
184
|
+
* item this lands on is exactly the one `pairedResponse` used to find, because
|
|
185
|
+
* a settled `assistant_text` always precedes the `turn_result` that ends it.
|
|
186
|
+
*/
|
|
187
|
+
type Segment = { response?: number; turn?: number; failed?: boolean }
|
|
188
|
+
|
|
189
|
+
const doneLine = (turn: Extract<TranscriptItem, { kind: 'turn_result' }>): string =>
|
|
190
|
+
`${turn.isError ? turn.subtype : 'done'} · ${formatDuration(turn.durationMs)} · ${formatCost(turn.totalCostUsd)}`
|
|
191
|
+
|
|
192
|
+
function excerpt(item: TranscriptItem): string {
|
|
193
|
+
switch (item.kind) {
|
|
194
|
+
case 'user':
|
|
195
|
+
case 'assistant_text':
|
|
196
|
+
case 'thinking':
|
|
197
|
+
case 'notice':
|
|
198
|
+
return item.text
|
|
199
|
+
case 'tool_call':
|
|
200
|
+
return `${item.name}(${toolInputPreview(item.input)})`
|
|
201
|
+
case 'turn_result':
|
|
202
|
+
return doneLine(item)
|
|
203
|
+
case 'file_delivered':
|
|
204
|
+
return item.path
|
|
205
|
+
default:
|
|
206
|
+
return ''
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface TerminalScrubberProps {
|
|
211
|
+
items: readonly TranscriptItem[]
|
|
212
|
+
pendingApprovals: readonly PermissionRequest[]
|
|
213
|
+
/** The catch-up boundary's virtual row, when the recap is spliced in. */
|
|
214
|
+
recapRow?: { rowIndex: number; label: string }
|
|
215
|
+
/** Bookmarked item indices. Paint only — no store, no set affordance. */
|
|
216
|
+
bookmarks: readonly number[]
|
|
217
|
+
/** Item index → virtual row index (the off-by-a-fold mapping; see
|
|
218
|
+
* `rowIndexForItem` in `agent/Transcript.tsx`). */
|
|
219
|
+
rowIndexFor: (itemIndex: number) => number
|
|
220
|
+
/** A virtual row's offset in content space — the virtualizer's measurements,
|
|
221
|
+
* which the height calculator keeps honest for unmounted rows. */
|
|
222
|
+
offsetOfRow: (rowIndex: number) => number
|
|
223
|
+
/** A virtual row's height in content space, same source — what a mark's own
|
|
224
|
+
* height is scaled from. */
|
|
225
|
+
sizeOfRow: (rowIndex: number) => number
|
|
226
|
+
totalSize: number
|
|
227
|
+
scrollOffset: number
|
|
228
|
+
viewportH: number
|
|
229
|
+
/** The transcript's re-aim jump (stopScroll + aim + exact finish). */
|
|
230
|
+
onJumpToRow: (rowIndex: number) => void
|
|
231
|
+
/** False renders passive paint: no pointer events at all. */
|
|
232
|
+
interactive: boolean
|
|
233
|
+
fontSize?: number
|
|
234
|
+
lineHeight?: number
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildClusters(
|
|
238
|
+
props: TerminalScrubberProps,
|
|
239
|
+
railH: number,
|
|
240
|
+
): Cluster[] {
|
|
241
|
+
const {
|
|
242
|
+
items,
|
|
243
|
+
bookmarks,
|
|
244
|
+
recapRow,
|
|
245
|
+
pendingApprovals,
|
|
246
|
+
rowIndexFor,
|
|
247
|
+
offsetOfRow,
|
|
248
|
+
sizeOfRow,
|
|
249
|
+
totalSize,
|
|
250
|
+
viewportH,
|
|
251
|
+
} = props
|
|
252
|
+
const marks: Mark[] = []
|
|
253
|
+
// One right-lane mark per segment, emitted when the segment closes. A segment
|
|
254
|
+
// is closed by the next prompt, by its own turn end, or by running out of
|
|
255
|
+
// items — that last one is what a replayed history is made of.
|
|
256
|
+
let segment: Segment = {}
|
|
257
|
+
const closeSegment = () => {
|
|
258
|
+
const anchor = segment.response ?? segment.turn
|
|
259
|
+
if (anchor !== undefined) {
|
|
260
|
+
marks.push({
|
|
261
|
+
kind: segment.failed ? 'turnFailed' : 'turn',
|
|
262
|
+
itemIndex: anchor,
|
|
263
|
+
rowIndex: rowIndexFor(anchor),
|
|
264
|
+
turnIndex: segment.turn,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
segment = {}
|
|
268
|
+
}
|
|
269
|
+
items.forEach((item, index) => {
|
|
270
|
+
if (item.kind === 'user') {
|
|
271
|
+
closeSegment()
|
|
272
|
+
marks.push({ kind: 'user', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
273
|
+
} else if (item.kind === 'turn_result') {
|
|
274
|
+
segment.turn = index
|
|
275
|
+
segment.failed = item.isError
|
|
276
|
+
closeSegment()
|
|
277
|
+
} else if (item.kind === 'notice' && item.level === 'error') {
|
|
278
|
+
marks.push({ kind: 'error', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
279
|
+
} 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.
|
|
285
|
+
item.kind === 'tool_call' &&
|
|
286
|
+
(item.status === 'failed' || item.result?.isError === true)
|
|
287
|
+
) {
|
|
288
|
+
marks.push({ kind: 'toolFailed', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
289
|
+
} else if (item.kind === 'assistant_text' && item.parentToolUseId == null) {
|
|
290
|
+
// The live one included, deliberately: a turn in flight has no turn end
|
|
291
|
+
// yet, which left a two-minute answer unrepresented on the rail for the
|
|
292
|
+
// whole two minutes it was the only thing worth navigating to. The mark's
|
|
293
|
+
// height is its row's, so it grows as the answer does with no extra
|
|
294
|
+
// bookkeeping, and it cannot double up — the reducer settles this item in
|
|
295
|
+
// the same action that appends the `turn_result` that closes the segment.
|
|
296
|
+
segment.response = index
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
// A history that ends mid-segment still has an answer in it.
|
|
300
|
+
closeSegment()
|
|
301
|
+
for (const index of bookmarks)
|
|
302
|
+
if (index >= 0 && index < items.length)
|
|
303
|
+
marks.push({ kind: 'bookmark', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
304
|
+
if (recapRow) marks.push({ kind: 'recap', itemIndex: -1, rowIndex: recapRow.rowIndex })
|
|
305
|
+
|
|
306
|
+
const scale = railScale(railH, totalSize, viewportH)
|
|
307
|
+
const lanes = new Map<Lane, { mark: Mark; y: number; h: number }[]>()
|
|
308
|
+
for (const mark of marks) {
|
|
309
|
+
// A mark's height is its row's, at rail scale, floored at the hit target —
|
|
310
|
+
// the row the mark *anchors* (for a turn, the final response), which is
|
|
311
|
+
// 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))
|
|
314
|
+
const lane = LANE[mark.kind]
|
|
315
|
+
const list = lanes.get(lane) ?? []
|
|
316
|
+
list.push({ mark, y, h })
|
|
317
|
+
lanes.set(lane, list)
|
|
318
|
+
}
|
|
319
|
+
const clusters: Cluster[] = []
|
|
320
|
+
for (const [lane, list] of lanes) {
|
|
321
|
+
list.sort((a, b) => a.y - b.y)
|
|
322
|
+
let current: Cluster | null = null
|
|
323
|
+
for (const { mark, y, h } of list) {
|
|
324
|
+
// Merge when the gap is under a pixel; the merged mark grows and takes
|
|
325
|
+
// the loudest member's colour.
|
|
326
|
+
if (current && y <= current.y + current.h + 1) {
|
|
327
|
+
current.h = Math.max(current.h, y + h - current.y)
|
|
328
|
+
if (LOUDNESS[mark.kind] > LOUDNESS[current.kind]) current.kind = mark.kind
|
|
329
|
+
current.marks.push({ mark, y })
|
|
330
|
+
} else {
|
|
331
|
+
current = { lane, kind: mark.kind, y, h, marks: [{ mark, y }] }
|
|
332
|
+
clusters.push(current)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// The approval is not an item — the prompt renders below the transcript —
|
|
337
|
+
// so its mark pins at the rail's foot, where the prompt is.
|
|
338
|
+
if (pendingApprovals.length > 0)
|
|
339
|
+
clusters.push({
|
|
340
|
+
// `LANE.approval`, not a literal: this cluster is built by hand because it
|
|
341
|
+
// has no item to derive a position from, and a hardcoded lane here is how
|
|
342
|
+
// it silently kept the old three-lane layout after the map moved on.
|
|
343
|
+
lane: LANE.approval,
|
|
344
|
+
kind: 'approval',
|
|
345
|
+
y: Math.max(0, railH - MIN_MARK),
|
|
346
|
+
h: MIN_MARK,
|
|
347
|
+
marks: [],
|
|
348
|
+
})
|
|
349
|
+
return clusters
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function peekContent(
|
|
353
|
+
cluster: Cluster,
|
|
354
|
+
/** The member the pointer resolved to — see {@link nearestMember}. */
|
|
355
|
+
first: Mark | undefined,
|
|
356
|
+
{ items, pendingApprovals, recapRow }: TerminalScrubberProps,
|
|
357
|
+
): ReactNode {
|
|
358
|
+
const more = cluster.marks.length > 1 ? ` · ${cluster.marks.length} marks` : ''
|
|
359
|
+
let body: ReactNode = null
|
|
360
|
+
if (cluster.kind === 'approval') {
|
|
361
|
+
const request = pendingApprovals[0]
|
|
362
|
+
body = request ? (
|
|
363
|
+
<>
|
|
364
|
+
<div data-tone='bright'>{request.title ?? 'Permission required'}</div>
|
|
365
|
+
<div className='term-scrub-ex' data-tone='fg'>
|
|
366
|
+
{`${request.displayName ?? request.toolName}(${toolInputPreview(request.input)})`}
|
|
367
|
+
</div>
|
|
368
|
+
</>
|
|
369
|
+
) : null
|
|
370
|
+
} else if (cluster.kind === 'recap' && !first) {
|
|
371
|
+
body = <div data-tone='faint'>※ {recapRow?.label}</div>
|
|
372
|
+
} else if (first) {
|
|
373
|
+
const item = items[first.itemIndex]
|
|
374
|
+
if (first.kind === 'recap') {
|
|
375
|
+
body = <div data-tone='faint'>※ {recapRow?.label}</div>
|
|
376
|
+
} else if (first.kind === 'turn' || first.kind === 'turnFailed') {
|
|
377
|
+
// The merged mark's peek carries both halves: the message the turn ended
|
|
378
|
+
// on, and the done-line (with its reasons, when it failed).
|
|
379
|
+
const turn = first.turnIndex === undefined ? undefined : items[first.turnIndex]
|
|
380
|
+
body = (
|
|
381
|
+
<>
|
|
382
|
+
{item?.kind === 'assistant_text' ? (
|
|
383
|
+
<div className='term-scrub-ex' data-tone='fg'>
|
|
384
|
+
<span data-tone='dim'>● </span>
|
|
385
|
+
{item.text}
|
|
386
|
+
</div>
|
|
387
|
+
) : null}
|
|
388
|
+
{turn?.kind === 'turn_result' ? (
|
|
389
|
+
<>
|
|
390
|
+
<div data-tone={turn.isError ? 'red' : 'faint'}>{doneLine(turn)}</div>
|
|
391
|
+
{turn.errors?.map((message, index) => (
|
|
392
|
+
<div key={index} data-tone='red'>
|
|
393
|
+
{message}
|
|
394
|
+
</div>
|
|
395
|
+
))}
|
|
396
|
+
</>
|
|
397
|
+
) : null}
|
|
398
|
+
</>
|
|
399
|
+
)
|
|
400
|
+
} else if (item) {
|
|
401
|
+
const failure =
|
|
402
|
+
first.kind === 'toolFailed' && item.kind === 'tool_call'
|
|
403
|
+
? item.result?.text.split('\n').find((line) => line.trim() !== '')
|
|
404
|
+
: undefined
|
|
405
|
+
body = (
|
|
406
|
+
<>
|
|
407
|
+
<div
|
|
408
|
+
className='term-scrub-ex'
|
|
409
|
+
data-tone={first.kind === 'error' || first.kind === 'toolFailed' ? 'red' : 'fg'}>
|
|
410
|
+
{first.kind === 'user' ? <span data-tone='dim'>{'❯ '}</span> : null}
|
|
411
|
+
{excerpt(item)}
|
|
412
|
+
</div>
|
|
413
|
+
{/* Which tool failed is rarely the question — `Bash(pnpm test)` is
|
|
414
|
+
what you already expected to see. The first non-blank line of what
|
|
415
|
+
it said back is the thing worth peeking at. */}
|
|
416
|
+
{failure ? (
|
|
417
|
+
<div className='term-scrub-ex' data-tone='red'>
|
|
418
|
+
{failure}
|
|
419
|
+
</div>
|
|
420
|
+
) : null}
|
|
421
|
+
</>
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return (
|
|
426
|
+
<>
|
|
427
|
+
<div data-tone='faint'>
|
|
428
|
+
{KIND_NAME[first?.kind ?? cluster.kind]}
|
|
429
|
+
{more}
|
|
430
|
+
</div>
|
|
431
|
+
{body}
|
|
432
|
+
</>
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function TerminalScrubber(props: TerminalScrubberProps) {
|
|
437
|
+
const {
|
|
438
|
+
scrollOffset,
|
|
439
|
+
viewportH,
|
|
440
|
+
totalSize,
|
|
441
|
+
onJumpToRow,
|
|
442
|
+
interactive,
|
|
443
|
+
fontSize,
|
|
444
|
+
lineHeight,
|
|
445
|
+
} = props
|
|
446
|
+
const stick = useStickToBottomContext()
|
|
447
|
+
const bodyRef = useRef<HTMLDivElement | null>(null)
|
|
448
|
+
const peekRef = useRef<HTMLDivElement | null>(null)
|
|
449
|
+
const [railH, setRailH] = useState(0)
|
|
450
|
+
const [peek, setPeek] = useState<{ cluster: Cluster; mark: Mark | undefined; y: number } | null>(
|
|
451
|
+
null,
|
|
452
|
+
)
|
|
453
|
+
const drag = useRef<{ y: number; moved: boolean; target: EventTarget | null } | null>(null)
|
|
454
|
+
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
const element = bodyRef.current
|
|
457
|
+
if (!element) return
|
|
458
|
+
const observer = new ResizeObserver(() => setRailH(element.clientHeight))
|
|
459
|
+
observer.observe(element)
|
|
460
|
+
setRailH(element.clientHeight)
|
|
461
|
+
return () => observer.disconnect()
|
|
462
|
+
}, [])
|
|
463
|
+
|
|
464
|
+
// The band tracks the scroller's own scroll events, not the `scrollOffset`
|
|
465
|
+
// prop: the virtualizer notifies React only when the virtual row *range*
|
|
466
|
+
// changes, and scrolling inside one tall row changes none — the prop then
|
|
467
|
+
// refreshes only when `isScrolling` flips at the end, which reads as the
|
|
468
|
+
// band lagging the drag and snapping into place. The prop still seeds the
|
|
469
|
+
// first paint, before this listener's first event.
|
|
470
|
+
const [liveOffset, setLiveOffset] = useState(scrollOffset)
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
const scroller = stick.scrollRef.current
|
|
473
|
+
if (!scroller) return
|
|
474
|
+
const onScroll = () => setLiveOffset(scroller.scrollTop)
|
|
475
|
+
scroller.addEventListener('scroll', onScroll, { passive: true })
|
|
476
|
+
setLiveOffset(scroller.scrollTop)
|
|
477
|
+
return () => scroller.removeEventListener('scroll', onScroll)
|
|
478
|
+
}, [stick.scrollRef])
|
|
479
|
+
|
|
480
|
+
// A peek is a snapshot of the cluster it was opened on; if the transcript
|
|
481
|
+
// changes underneath (a fixture/session swap, a burst of new items), drop it
|
|
482
|
+
// rather than describe rows that no longer exist.
|
|
483
|
+
useEffect(() => {
|
|
484
|
+
setPeek(null)
|
|
485
|
+
}, [props.items])
|
|
486
|
+
|
|
487
|
+
// Wheel over the rail scrolls the transcript. Manual listener because it
|
|
488
|
+
// must preventDefault (React's root wheel listeners are passive).
|
|
489
|
+
useEffect(() => {
|
|
490
|
+
if (!interactive) return
|
|
491
|
+
const element = bodyRef.current
|
|
492
|
+
if (!element) return
|
|
493
|
+
const onWheel = (event: WheelEvent) => {
|
|
494
|
+
const scroller = stick.scrollRef.current
|
|
495
|
+
if (!scroller) return
|
|
496
|
+
scroller.scrollTop += event.deltaY
|
|
497
|
+
event.preventDefault()
|
|
498
|
+
}
|
|
499
|
+
element.addEventListener('wheel', onWheel, { passive: false })
|
|
500
|
+
return () => element.removeEventListener('wheel', onWheel)
|
|
501
|
+
}, [interactive, stick.scrollRef])
|
|
502
|
+
|
|
503
|
+
// The peek can be taller than the space beside its mark — clamp it into the
|
|
504
|
+
// rail after it has a measured height.
|
|
505
|
+
useLayoutEffect(() => {
|
|
506
|
+
const element = peekRef.current
|
|
507
|
+
if (!element || !peek) return
|
|
508
|
+
const height = element.offsetHeight
|
|
509
|
+
const railHeight = bodyRef.current?.clientHeight ?? 0
|
|
510
|
+
element.style.top = `${Math.max(4, Math.min(railHeight - height - 4, peek.y - height / 2))}px`
|
|
511
|
+
}, [peek])
|
|
512
|
+
|
|
513
|
+
// Memoized against content and geometry, NOT recomputed per render: the live
|
|
514
|
+
// scroll offset re-renders this component on every scroll event, and
|
|
515
|
+
// rebuilding the clusters there walks every item — O(session) work per
|
|
516
|
+
// wheel tick for output that only changes when content or measurements do.
|
|
517
|
+
// `rowIndexFor`/`offsetOfRow` are closures rebuilt every parent render and
|
|
518
|
+
// deliberately not dependencies; `totalSize` stands in for the measurements
|
|
519
|
+
// behind them — row heights cannot move a mark without moving the total.
|
|
520
|
+
const clusters = useMemo(
|
|
521
|
+
() => (railH > 0 ? buildClusters(props, railH) : []),
|
|
522
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
523
|
+
// `viewportH` rides here because it is the scale's other term whenever the
|
|
524
|
+
// transcript is shorter than the window — without it a resize in that
|
|
525
|
+
// regime leaves every mark at the old scale.
|
|
526
|
+
[
|
|
527
|
+
props.items,
|
|
528
|
+
props.bookmarks,
|
|
529
|
+
props.recapRow,
|
|
530
|
+
props.pendingApprovals,
|
|
531
|
+
totalSize,
|
|
532
|
+
railH,
|
|
533
|
+
viewportH,
|
|
534
|
+
],
|
|
535
|
+
)
|
|
536
|
+
const scale = railScale(railH, totalSize, viewportH)
|
|
537
|
+
const bandH = Math.max(2, Math.min(railH, Math.round(viewportH * scale)))
|
|
538
|
+
// Clamped against the rail's foot as well as its head: an overscroll bounce
|
|
539
|
+
// drives `liveOffset` past `totalSize - viewportH` for a frame or two, and the
|
|
540
|
+
// band is the one child whose top is not already bounded by its own height.
|
|
541
|
+
const bandTop = Math.max(0, Math.min(railH - bandH, Math.round(liveOffset * scale)))
|
|
542
|
+
|
|
543
|
+
const scrub = (clientY: number) => {
|
|
544
|
+
const rail = bodyRef.current
|
|
545
|
+
const scroller = stick.scrollRef.current
|
|
546
|
+
if (!rail || !scroller) return
|
|
547
|
+
const rect = rail.getBoundingClientRect()
|
|
548
|
+
const fraction = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height))
|
|
549
|
+
scroller.scrollTop = fraction * scroller.scrollHeight - scroller.clientHeight / 2
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** A pointer's y in rail space. */
|
|
553
|
+
const railY = (clientY: number): number =>
|
|
554
|
+
clientY - (bodyRef.current?.getBoundingClientRect().top ?? 0)
|
|
555
|
+
|
|
556
|
+
const activate = (cluster: Cluster, clientY: number) => {
|
|
557
|
+
if (cluster.kind === 'approval' && cluster.marks.length === 0) {
|
|
558
|
+
void stick.scrollToBottom()
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
const mark = nearestMember(cluster, railY(clientY))
|
|
562
|
+
if (mark) onJumpToRow(mark.rowIndex)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const showPeek = (cluster: Cluster, clientY: number) => {
|
|
566
|
+
const y = railY(clientY)
|
|
567
|
+
const mark = nearestMember(cluster, y)
|
|
568
|
+
setPeek((previous) =>
|
|
569
|
+
previous && previous.cluster === cluster && previous.mark === mark
|
|
570
|
+
? previous
|
|
571
|
+
: { cluster, mark, y: Math.min(Math.max(y, cluster.y), cluster.y + cluster.h) },
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// The rail is a scrollbar to assistive tech when it acts like one, and
|
|
576
|
+
// invisible when it is passive paint (a decorative copy of information the
|
|
577
|
+
// transcript itself carries).
|
|
578
|
+
const maxOffset = Math.max(1, totalSize - viewportH)
|
|
579
|
+
return (
|
|
580
|
+
<TerminalSurface
|
|
581
|
+
fontSize={fontSize}
|
|
582
|
+
lineHeight={lineHeight}
|
|
583
|
+
className='term-scrubber'
|
|
584
|
+
data-interactive={interactive || undefined}
|
|
585
|
+
{...(interactive
|
|
586
|
+
? {
|
|
587
|
+
role: 'scrollbar',
|
|
588
|
+
'aria-orientation': 'vertical' as const,
|
|
589
|
+
'aria-label': 'Transcript overview',
|
|
590
|
+
'aria-valuemin': 0,
|
|
591
|
+
'aria-valuemax': 100,
|
|
592
|
+
'aria-valuenow': Math.min(100, Math.max(0, Math.round((liveOffset / maxOffset) * 100))),
|
|
593
|
+
}
|
|
594
|
+
: { 'aria-hidden': true })}>
|
|
595
|
+
<div
|
|
596
|
+
ref={bodyRef}
|
|
597
|
+
className='term-scrubber-body'
|
|
598
|
+
{...(interactive
|
|
599
|
+
? {
|
|
600
|
+
onPointerDown: (event) => {
|
|
601
|
+
// The rail is about to write scrollTop; this is the follow
|
|
602
|
+
// spring's own off switch, same as every other jump.
|
|
603
|
+
stick.stopScroll()
|
|
604
|
+
drag.current = { y: event.clientY, moved: false, target: event.target }
|
|
605
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
606
|
+
},
|
|
607
|
+
onPointerMove: (event) => {
|
|
608
|
+
const state = drag.current
|
|
609
|
+
if (!state) return
|
|
610
|
+
if (!state.moved && Math.abs(event.clientY - state.y) < 3) return
|
|
611
|
+
state.moved = true
|
|
612
|
+
setPeek(null)
|
|
613
|
+
scrub(event.clientY)
|
|
614
|
+
},
|
|
615
|
+
onPointerUp: (event) => {
|
|
616
|
+
const state = drag.current
|
|
617
|
+
drag.current = null
|
|
618
|
+
if (!state || state.moved) return
|
|
619
|
+
// A clean press: on a mark it is a jump; on the ground it is a
|
|
620
|
+
// scrub to that spot — scrollbar semantics.
|
|
621
|
+
const mark = (state.target as HTMLElement | null)?.closest?.('[data-ci]')
|
|
622
|
+
const index = mark ? Number((mark as HTMLElement).dataset.ci) : Number.NaN
|
|
623
|
+
if (Number.isInteger(index) && clusters[index])
|
|
624
|
+
activate(clusters[index]!, event.clientY)
|
|
625
|
+
else scrub(event.clientY)
|
|
626
|
+
},
|
|
627
|
+
}
|
|
628
|
+
: null)}>
|
|
629
|
+
{/* The band is the whole "where am I" answer. It used to carry a 2px
|
|
630
|
+
blue line on its top edge as well; with the band already outlined,
|
|
631
|
+
that was a second indicator of one fact, and the loudest colour on
|
|
632
|
+
the rail spent on it. */}
|
|
633
|
+
<div className='term-scrub-band' style={{ top: bandTop, height: bandH }} />
|
|
634
|
+
{clusters.map((cluster, index) => (
|
|
635
|
+
<div
|
|
636
|
+
key={index}
|
|
637
|
+
data-ci={index}
|
|
638
|
+
className='term-scrub-mark'
|
|
639
|
+
data-lane={cluster.lane}
|
|
640
|
+
data-kind={cluster.kind}
|
|
641
|
+
style={{ top: cluster.y, height: cluster.h }}
|
|
642
|
+
{...(interactive
|
|
643
|
+
? {
|
|
644
|
+
onPointerEnter: (event) => showPeek(cluster, event.clientY),
|
|
645
|
+
// A chain-merged bar can span the rail; sliding along it
|
|
646
|
+
// retargets the peek to the member under the pointer.
|
|
647
|
+
onPointerMove: (event) => {
|
|
648
|
+
if (!drag.current) showPeek(cluster, event.clientY)
|
|
649
|
+
},
|
|
650
|
+
onPointerLeave: () => setPeek(null),
|
|
651
|
+
}
|
|
652
|
+
: null)}
|
|
653
|
+
/>
|
|
654
|
+
))}
|
|
655
|
+
{peek ? (
|
|
656
|
+
<div ref={peekRef} className='term-scrub-peek' style={{ top: peek.y }}>
|
|
657
|
+
{peekContent(peek.cluster, peek.mark, props)}
|
|
658
|
+
</div>
|
|
659
|
+
) : null}
|
|
660
|
+
</div>
|
|
661
|
+
</TerminalSurface>
|
|
662
|
+
)
|
|
663
|
+
}
|