@workerdeck/ui 0.15.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 +53 -0
- package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-B9CHoq8x.d.mts} +158 -21
- package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DII9MmQ8.mjs} +4254 -1661
- 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 +442 -45
- package/build/index.mjs +105 -2
- 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 +9 -1
- package/build/workspace.mjs +108 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +14 -7
- package/src/components/agent/Composer.tsx +189 -89
- 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 -46
- 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/SessionPanel.tsx +224 -28
- package/src/components/agent/SessionWorkspace.tsx +29 -0
- package/src/components/agent/StatusBar.tsx +20 -4
- 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 +29 -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/index.ts +34 -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 +41 -0
- package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- package/src/components/agent/line-prompt.tsx +0 -249
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import type { FilePatch, PatchHunk } from '@workerdeck/protocol'
|
|
2
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
3
|
+
import {
|
|
4
|
+
formatBytes,
|
|
5
|
+
formatCost,
|
|
6
|
+
formatDuration,
|
|
7
|
+
toolInputPreview,
|
|
8
|
+
} from '../../lib/format.ts'
|
|
9
|
+
import type { TerminalBlock, ToolCallItem } from './items.tsx'
|
|
10
|
+
import { collapsedResult } from './result-preview.ts'
|
|
11
|
+
import { runSummary } from './tool-run.ts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The terminal theme's row-height calculator.
|
|
15
|
+
*
|
|
16
|
+
* Computes, from a {@link TranscriptItem} and the cell metrics alone — no DOM —
|
|
17
|
+
* the pixel height the terminal renderer will draw it at. This exists because
|
|
18
|
+
* the theme makes it possible: one line height, one cell, monospace, ligatures
|
|
19
|
+
* off. It is a *terminal* capability, deliberately homed here rather than in a
|
|
20
|
+
* generic location — the cards variant has padding scales, borders and a
|
|
21
|
+
* proportional face, and nothing in this file transfers to it.
|
|
22
|
+
*
|
|
23
|
+
* The consumer is the virtualizer's `estimateSize` (see `agent/Transcript.tsx`):
|
|
24
|
+
* a computed height governs a row only until the row mounts and is measured, so
|
|
25
|
+
* the contract is "almost always exact, and honest when it cannot be". Honesty
|
|
26
|
+
* is the `exact` flag on {@link ComputedHeight}: `false` marks content whose
|
|
27
|
+
* advance this model cannot know — emoji and CJK render from fallback faces
|
|
28
|
+
* whose advances are not whole cells.
|
|
29
|
+
* Measured against the real renderer in the dev playground
|
|
30
|
+
* (`dev/height-audit.ts`): every unflagged row was pixel-exact across fixtures,
|
|
31
|
+
* widths and cell sizes — the audit is this file's regression gate.
|
|
32
|
+
*
|
|
33
|
+
* Two invariants keep the model small, and both are load-bearing:
|
|
34
|
+
*
|
|
35
|
+
* - **Only the default state is ever computed.** A tool row's expansion is
|
|
36
|
+
* component-local state that resets when the row unmounts, so an off-screen
|
|
37
|
+
* row is always collapsed — and an expanded row is by definition mounted,
|
|
38
|
+
* which means the virtualizer has its real measurement. There is no
|
|
39
|
+
* open/expanded branch here on purpose.
|
|
40
|
+
* - **Mutation is object replacement.** The transcript reducer never mutates an
|
|
41
|
+
* item in place — streaming text, a result arriving, a patch attaching each
|
|
42
|
+
* produce a new object — which is what lets {@link HeightEpoch}'s cache key on
|
|
43
|
+
* identity and never go stale.
|
|
44
|
+
*
|
|
45
|
+
* The wrap model mirrors `.term-body` (`white-space: pre-wrap` +
|
|
46
|
+
* `overflow-wrap: break-word` in a `minmax(0,1fr)` grid column): greedy fill,
|
|
47
|
+
* breaks at preserved spaces (which hang at the line end rather than wrapping),
|
|
48
|
+
* after hyphens/dashes/`?` not followed by a digit (verified against Chrome's
|
|
49
|
+
* actual break points in long URLs), and between CJK characters; a token wider
|
|
50
|
+
* than the whole column moves to its own line first and then breaks per cell —
|
|
51
|
+
* which is exactly where `break-word` differs from `anywhere`.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** The cell everything is measured in. `ch` is the advance of `0` in px —
|
|
55
|
+
* *measured* off the live surface via {@link measureCh}, never derived from the
|
|
56
|
+
* font size (7.83px at 13px JetBrains Mono, not 13 × 0.6). */
|
|
57
|
+
export type CellMetrics = {
|
|
58
|
+
/** Content-box width of the virtual row wrapper, px. */
|
|
59
|
+
width: number
|
|
60
|
+
/** Advance of `0`, px. */
|
|
61
|
+
ch: number
|
|
62
|
+
/** `--term-line`, px. */
|
|
63
|
+
line: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ComputedHeight = {
|
|
67
|
+
px: number
|
|
68
|
+
/** False when the content contains glyphs or structures whose rendered size
|
|
69
|
+
* this model cannot know — treat the row as an estimate, corrected the
|
|
70
|
+
* moment it mounts and measures. */
|
|
71
|
+
exact: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ── The epoch ─────────────────────────────────────────────────────────────
|
|
75
|
+
*
|
|
76
|
+
* One cache generation. **Owned by the transcript shell** (`TranscriptRows` in
|
|
77
|
+
* `agent/Transcript.tsx` holds exactly one, in React state): it is created from
|
|
78
|
+
* the first real measurement of the mounted surface and *replaced wholesale*
|
|
79
|
+
* whenever width, `ch` or line change — a computed height is only meaningful
|
|
80
|
+
* against the metrics it was computed for, so partial invalidation is a bug
|
|
81
|
+
* factory and a new WeakMap is free. Within an epoch the cache keys on item
|
|
82
|
+
* object identity, which the reducer's replace-on-mutation discipline turns
|
|
83
|
+
* into automatic invalidation: a streamed delta is a new object and simply
|
|
84
|
+
* misses. Run blocks are not cached — the folded array is rebuilt every
|
|
85
|
+
* render, so its identity is worthless as a key, and a collapsed run is one
|
|
86
|
+
* `wrapOne` over a short string (~2µs).
|
|
87
|
+
*/
|
|
88
|
+
export type HeightEpoch = CellMetrics & {
|
|
89
|
+
cache: WeakMap<TranscriptItem, ComputedHeight>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createHeightEpoch(width: number, ch: number, line: number): HeightEpoch {
|
|
93
|
+
return { width, ch, line, cache: new WeakMap() }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A virtual row's computed height under `epoch` — the `estimateSize` feed.
|
|
97
|
+
* The inter-row gap is the *pair's* business (`gapBefore`), not the row's, so
|
|
98
|
+
* it is added by the caller. */
|
|
99
|
+
export function estimateBlockPx(block: TerminalBlock, epoch: HeightEpoch): number {
|
|
100
|
+
if ('run' in block) return blockHeight(block, epoch).px
|
|
101
|
+
const hit = epoch.cache.get(block.item)
|
|
102
|
+
if (hit) return hit.px
|
|
103
|
+
const computed = itemHeight(block.item, epoch)
|
|
104
|
+
epoch.cache.set(block.item, computed)
|
|
105
|
+
return computed.px
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Measure the advance of `0` on the live surface. A DOM read — call it from
|
|
109
|
+
* the epoch's measurement pass (an effect / ResizeObserver callback), never
|
|
110
|
+
* from render. The probe is absolutely positioned, so it contributes no layout
|
|
111
|
+
* and cannot re-trigger the observer that called it. */
|
|
112
|
+
export function measureCh(surface: HTMLElement): number {
|
|
113
|
+
const probe = document.createElement('span')
|
|
114
|
+
probe.textContent = '0'.repeat(200)
|
|
115
|
+
probe.style.position = 'absolute'
|
|
116
|
+
probe.style.visibility = 'hidden'
|
|
117
|
+
probe.style.whiteSpace = 'pre'
|
|
118
|
+
surface.appendChild(probe)
|
|
119
|
+
const width = probe.getBoundingClientRect().width / 200
|
|
120
|
+
probe.remove()
|
|
121
|
+
return width
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ── Cell counting ─────────────────────────────────────────────────────────── */
|
|
125
|
+
|
|
126
|
+
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' })
|
|
127
|
+
|
|
128
|
+
const WIDE_RANGES: [number, number][] = [
|
|
129
|
+
[0x1100, 0x115f], // Hangul Jamo
|
|
130
|
+
[0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols/punctuation
|
|
131
|
+
[0x3041, 0x33ff], // Hiragana … CJK compatibility
|
|
132
|
+
[0x3400, 0x4dbf],
|
|
133
|
+
[0x4e00, 0x9fff],
|
|
134
|
+
[0xa000, 0xa4cf],
|
|
135
|
+
[0xac00, 0xd7a3], // Hangul syllables
|
|
136
|
+
[0xf900, 0xfaff],
|
|
137
|
+
[0xfe30, 0xfe4f],
|
|
138
|
+
[0xff00, 0xff60], // fullwidth forms
|
|
139
|
+
[0xffe0, 0xffe6],
|
|
140
|
+
[0x20000, 0x3fffd],
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
const isWide = (cp: number): boolean => WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)
|
|
144
|
+
|
|
145
|
+
const PICTOGRAPHIC = /\p{Extended_Pictographic}/u
|
|
146
|
+
|
|
147
|
+
/** One grapheme cluster's advance, in cells. Wide and pictographic clusters are
|
|
148
|
+
* counted as 2 but flagged: they render from a fallback face whose advance is
|
|
149
|
+
* not a whole number of cells, and the flag is what keeps the model honest. */
|
|
150
|
+
function clusterCells(cluster: string): { w: number; exact: boolean } {
|
|
151
|
+
if (PICTOGRAPHIC.test(cluster) || cluster.includes('') || cluster.includes('️'))
|
|
152
|
+
return { w: 2, exact: false }
|
|
153
|
+
const cp = cluster.codePointAt(0) ?? 0
|
|
154
|
+
if (isWide(cp)) return { w: 2, exact: false }
|
|
155
|
+
if (cp < 0x20) return { w: 0, exact: true }
|
|
156
|
+
return { w: 1, exact: true }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* ── The wrap model ────────────────────────────────────────────────────────── */
|
|
160
|
+
|
|
161
|
+
type Token = { kind: 'word' | 'space'; w: number; exact: boolean }
|
|
162
|
+
|
|
163
|
+
/** `tab-size: 2` on the surface. */
|
|
164
|
+
const TAB_SIZE = 2
|
|
165
|
+
|
|
166
|
+
/** Break *after* these when the next character is not a digit. The digit guard
|
|
167
|
+
* keeps `protocol-0.16.0` together; `?` is in the set because Chrome breaks
|
|
168
|
+
* long URLs after it (verified against real break rects). */
|
|
169
|
+
const BREAK_AFTER = new Set(['-', '–', '—', '?'])
|
|
170
|
+
|
|
171
|
+
/** Printable ASCII, tab excluded: every character is one cell and one grapheme,
|
|
172
|
+
* so the segmenter has nothing to tell us. This is nearly every line a
|
|
173
|
+
* transcript holds, and the segmenter was the calculator's whole CPU bill —
|
|
174
|
+
* ~1s of a 30s scroll sweep on the 4k-item perf fixture, all of it spent
|
|
175
|
+
* segmenting text that `charCodeAt` classifies for free. */
|
|
176
|
+
const PLAIN_ASCII = /^[\x20-\x7e]*$/
|
|
177
|
+
|
|
178
|
+
/** The ASCII half of {@link tokenize}: same tokens, same break rules ('-' and
|
|
179
|
+
* '?' break unless a digit follows; '–'/'—' are not ASCII), no segmenter and
|
|
180
|
+
* no per-grapheme allocation. Must stay semantically identical to the general
|
|
181
|
+
* path — the height audit runs both, via content that takes each. */
|
|
182
|
+
function tokenizeAscii(line: string): Token[] {
|
|
183
|
+
const tokens: Token[] = []
|
|
184
|
+
let wordW = 0
|
|
185
|
+
const flushWord = () => {
|
|
186
|
+
if (wordW > 0) tokens.push({ kind: 'word', w: wordW, exact: true })
|
|
187
|
+
wordW = 0
|
|
188
|
+
}
|
|
189
|
+
for (let i = 0; i < line.length; i++) {
|
|
190
|
+
const code = line.charCodeAt(i)
|
|
191
|
+
if (code === 0x20) {
|
|
192
|
+
flushWord()
|
|
193
|
+
const last = tokens[tokens.length - 1]
|
|
194
|
+
if (last?.kind === 'space') last.w += 1
|
|
195
|
+
else tokens.push({ kind: 'space', w: 1, exact: true })
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
wordW += 1
|
|
199
|
+
if (code === 0x2d /* - */ || code === 0x3f /* ? */) {
|
|
200
|
+
const next = line.charCodeAt(i + 1)
|
|
201
|
+
if (!(next >= 0x30 && next <= 0x39)) flushWord()
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
flushWord()
|
|
205
|
+
return tokens
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Tokenize one hard line into wrap units. Tabs advance to the next 2-cell stop
|
|
210
|
+
* measured from the hard line's start — position-dependent after a soft wrap in
|
|
211
|
+
* principle, but content with tabs beyond the first wrap point is vanishingly
|
|
212
|
+
* rare and the error is bounded by one tab stop.
|
|
213
|
+
*/
|
|
214
|
+
function tokenize(line: string): Token[] {
|
|
215
|
+
if (PLAIN_ASCII.test(line)) return tokenizeAscii(line)
|
|
216
|
+
const tokens: Token[] = []
|
|
217
|
+
let word = { w: 0, exact: true }
|
|
218
|
+
let col = 0
|
|
219
|
+
const flushWord = () => {
|
|
220
|
+
if (word.w > 0) tokens.push({ kind: 'word', w: word.w, exact: word.exact })
|
|
221
|
+
word = { w: 0, exact: true }
|
|
222
|
+
}
|
|
223
|
+
const segments = [...segmenter.segment(line)].map((s) => s.segment)
|
|
224
|
+
for (const [index, segment] of segments.entries()) {
|
|
225
|
+
if (segment === ' ') {
|
|
226
|
+
flushWord()
|
|
227
|
+
const last = tokens.at(-1)
|
|
228
|
+
if (last?.kind === 'space') last.w += 1
|
|
229
|
+
else tokens.push({ kind: 'space', w: 1, exact: true })
|
|
230
|
+
col += 1
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
if (segment === '\t') {
|
|
234
|
+
flushWord()
|
|
235
|
+
const advance = TAB_SIZE - (col % TAB_SIZE) || TAB_SIZE
|
|
236
|
+
const last = tokens.at(-1)
|
|
237
|
+
if (last?.kind === 'space') last.w += advance
|
|
238
|
+
else tokens.push({ kind: 'space', w: advance, exact: true })
|
|
239
|
+
col += advance
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
const { w, exact } = clusterCells(segment)
|
|
243
|
+
const cp = segment.codePointAt(0) ?? 0
|
|
244
|
+
if (isWide(cp) || PICTOGRAPHIC.test(segment)) {
|
|
245
|
+
// Each wide cluster is its own token — a break may fall between any two.
|
|
246
|
+
flushWord()
|
|
247
|
+
tokens.push({ kind: 'word', w, exact })
|
|
248
|
+
col += w
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
word.w += w
|
|
252
|
+
word.exact = word.exact && exact
|
|
253
|
+
col += w
|
|
254
|
+
if (BREAK_AFTER.has(segment) && !/^\d/.test(segments[index + 1] ?? '')) {
|
|
255
|
+
flushWord()
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
flushWord()
|
|
259
|
+
return tokens
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Visual lines one hard line occupies at `cols` columns. */
|
|
263
|
+
function wrapOne(line: string, cols: number): { lines: number; exact: boolean } {
|
|
264
|
+
if (cols <= 0) return { lines: 1, exact: false }
|
|
265
|
+
const tokens = tokenize(line)
|
|
266
|
+
let lines = 1
|
|
267
|
+
let pos = 0
|
|
268
|
+
let exact = true
|
|
269
|
+
for (const token of tokens) {
|
|
270
|
+
exact = exact && token.exact
|
|
271
|
+
if (token.kind === 'space') {
|
|
272
|
+
// Preserved spaces hang at the line end (CSS Text 3): they may overflow
|
|
273
|
+
// without forcing a wrap, and the next word starts the next line.
|
|
274
|
+
pos += token.w
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
if (pos + token.w <= cols) {
|
|
278
|
+
pos += token.w
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
if (token.w <= cols) {
|
|
282
|
+
lines += 1
|
|
283
|
+
pos = token.w
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
// break-word: the token first moves to its own line, then fills whole lines.
|
|
287
|
+
if (pos > 0) lines += 1
|
|
288
|
+
const full = Math.ceil(token.w / cols)
|
|
289
|
+
lines += full - 1
|
|
290
|
+
pos = token.w - (full - 1) * cols
|
|
291
|
+
}
|
|
292
|
+
return { lines, exact }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Visual lines of a (possibly multi-hard-line) text at `cols` columns.
|
|
296
|
+
* Exported for the dev audit; also the one genuinely pure piece a unit test
|
|
297
|
+
* could pin, should `ui` ever grow a runner. */
|
|
298
|
+
export function textLines(text: string, cols: number): { lines: number; exact: boolean } {
|
|
299
|
+
let lines = 0
|
|
300
|
+
let exact = true
|
|
301
|
+
for (const hard of text.split('\n')) {
|
|
302
|
+
const r = wrapOne(hard, cols)
|
|
303
|
+
lines += Math.max(1, r.lines)
|
|
304
|
+
exact = exact && r.exact
|
|
305
|
+
}
|
|
306
|
+
return { lines: Math.max(1, lines), exact }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* ── Rows ──────────────────────────────────────────────────────────────────── */
|
|
310
|
+
|
|
311
|
+
/** Layout rounds to 1/64px; the epsilon keeps a body width that is exactly
|
|
312
|
+
* N × ch from flooring to N−1. */
|
|
313
|
+
const EPS = 1e-4
|
|
314
|
+
|
|
315
|
+
type Acc = { px: number; exact: boolean }
|
|
316
|
+
|
|
317
|
+
const add = (a: Acc, b: Acc): Acc => ({ px: a.px + b.px, exact: a.exact && b.exact })
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* One `Row`: `indentCells` of padding (already resolved against the row's own
|
|
321
|
+
* `--term-cell` — a `columns={3} indent={1}` row loses 3ch to the indent *and*
|
|
322
|
+
* 3ch to the gutter, because the indent padding reads the same variable the
|
|
323
|
+
* override sets), `gutterCells` of gutter, the body wrapping in what is left.
|
|
324
|
+
* `extraPx` is non-cell chrome around the row (the nested-run border+padding).
|
|
325
|
+
*/
|
|
326
|
+
function rowH(
|
|
327
|
+
text: string,
|
|
328
|
+
m: CellMetrics,
|
|
329
|
+
{ indentCells = 0, gutterCells = 2, extraPx = 0 } = {},
|
|
330
|
+
): Acc {
|
|
331
|
+
const bodyPx = m.width - extraPx - (indentCells + gutterCells) * m.ch
|
|
332
|
+
const cols = Math.floor(bodyPx / m.ch + EPS)
|
|
333
|
+
const { lines, exact } = textLines(text, cols)
|
|
334
|
+
return { px: lines * m.line, exact }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* ── Markdown ──────────────────────────────────────────────────────────────── */
|
|
338
|
+
|
|
339
|
+
/** Rendered text of an inline run — markers stripped the way the component map
|
|
340
|
+
* renders them (`**bold**` is 4 chars narrower on screen than in source).
|
|
341
|
+
* Approximate: reference links, raw HTML and nested emphasis edge cases are
|
|
342
|
+
* not modelled. */
|
|
343
|
+
function stripInline(s: string): string {
|
|
344
|
+
return s
|
|
345
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
346
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
347
|
+
.replace(/``([^`]+)``/g, '$1')
|
|
348
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
349
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
350
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
351
|
+
.replace(/\*([^*\s][^*]*)\*/g, '$1')
|
|
352
|
+
.replace(/(^|\W)_([^_]+)_(?=\W|$)/g, '$1$2')
|
|
353
|
+
.replace(/\\([\\`*_{}[\]()#+.!-])/g, '$1')
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Text blocks carry **rendered lines**, resolved by CommonMark's own break
|
|
358
|
+
* rule: a source line ending in two spaces (or a backslash) is a hard break
|
|
359
|
+
* and renders as `<br>`; any other newline is soft and collapses to a space
|
|
360
|
+
* under `white-space: normal`. Both halves are load-bearing and each was the
|
|
361
|
+
* whole model once. Join-always missed the hard breaks — models write poems
|
|
362
|
+
* with trailing double-spaces, and a 350-line one estimated ~115 lines short
|
|
363
|
+
* (a phantom scrollbar tail, scrubber marks resizing as rows mounted).
|
|
364
|
+
* Break-always missed the soft ones and overestimated the same stanza written
|
|
365
|
+
* without them by four lines. The `markdown` fixture carries one poem of each
|
|
366
|
+
* as the audit's guard.
|
|
367
|
+
*/
|
|
368
|
+
type MdBlock =
|
|
369
|
+
| { t: 'p'; lines: string[] }
|
|
370
|
+
| { t: 'h'; text: string }
|
|
371
|
+
| { t: 'fence'; code: string }
|
|
372
|
+
| { t: 'list'; items: { lines: string[]; gutter: number; indent: number }[] }
|
|
373
|
+
| { t: 'quote'; paras: string[][] }
|
|
374
|
+
/** One line per row, unconditionally — see the `table` case in
|
|
375
|
+
* {@link markdownHeight}. */
|
|
376
|
+
| { t: 'table'; rows: number }
|
|
377
|
+
| { t: 'hr' }
|
|
378
|
+
|
|
379
|
+
/** Source lines of one paragraph-ish run → the lines the renderer draws: hard
|
|
380
|
+
* breaks (trailing double space or backslash) split, soft newlines join with a
|
|
381
|
+
* space. The break marker itself never renders and is stripped. */
|
|
382
|
+
function hardLines(source: string[]): string[] {
|
|
383
|
+
const out: string[] = []
|
|
384
|
+
let current: string[] = []
|
|
385
|
+
for (const raw of source) {
|
|
386
|
+
const hard = /(?:\s{2}|\\)$/.test(raw)
|
|
387
|
+
current.push(stripInline(raw.replace(/(?:\s+|\\)$/, '')))
|
|
388
|
+
if (hard) {
|
|
389
|
+
out.push(current.join(' '))
|
|
390
|
+
current = []
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (current.length) out.push(current.join(' '))
|
|
394
|
+
return out
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** A line-based CommonMark-ish block parser — just enough structure to mirror
|
|
398
|
+
* what Streamdown + the terminal component map emit for transcript content. */
|
|
399
|
+
function parseBlocks(md: string): MdBlock[] {
|
|
400
|
+
const lines = md.split('\n')
|
|
401
|
+
const blocks: MdBlock[] = []
|
|
402
|
+
let i = 0
|
|
403
|
+
const isBlank = (s: string | undefined) => s !== undefined && s.trim() === ''
|
|
404
|
+
while (i < lines.length) {
|
|
405
|
+
const line = lines[i]!
|
|
406
|
+
if (line.trim() === '') {
|
|
407
|
+
i += 1
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
if (line.startsWith('```')) {
|
|
411
|
+
const code: string[] = []
|
|
412
|
+
i += 1
|
|
413
|
+
while (i < lines.length && !lines[i]!.startsWith('```')) {
|
|
414
|
+
code.push(lines[i]!)
|
|
415
|
+
i += 1
|
|
416
|
+
}
|
|
417
|
+
i += 1 // closing fence (or EOF, mid-stream)
|
|
418
|
+
blocks.push({ t: 'fence', code: code.join('\n') })
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
421
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
422
|
+
if (h) {
|
|
423
|
+
blocks.push({ t: 'h', text: stripInline(h[2]!) })
|
|
424
|
+
i += 1
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
428
|
+
blocks.push({ t: 'hr' })
|
|
429
|
+
i += 1
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
if (/^>\s?/.test(line)) {
|
|
433
|
+
const raw: string[] = []
|
|
434
|
+
while (i < lines.length && /^>\s?/.test(lines[i]!)) {
|
|
435
|
+
raw.push(lines[i]!.replace(/^>\s?/, ''))
|
|
436
|
+
i += 1
|
|
437
|
+
}
|
|
438
|
+
const paras: string[][] = []
|
|
439
|
+
let current: string[] = []
|
|
440
|
+
for (const l of raw) {
|
|
441
|
+
if (l.trim() === '') {
|
|
442
|
+
if (current.length) paras.push(hardLines(current))
|
|
443
|
+
current = []
|
|
444
|
+
} else current.push(l)
|
|
445
|
+
}
|
|
446
|
+
if (current.length) paras.push(hardLines(current))
|
|
447
|
+
blocks.push({ t: 'quote', paras })
|
|
448
|
+
continue
|
|
449
|
+
}
|
|
450
|
+
const listMarker = (s: string) => {
|
|
451
|
+
const m = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(s)
|
|
452
|
+
if (!m) return undefined
|
|
453
|
+
const depth = Math.floor(m[1]!.length / 2)
|
|
454
|
+
const ordered = /\d/.test(m[2]!)
|
|
455
|
+
return { depth, ordered, text: m[3]! }
|
|
456
|
+
}
|
|
457
|
+
if (listMarker(line)) {
|
|
458
|
+
const items: { lines: string[]; gutter: number; indent: number }[] = []
|
|
459
|
+
// Marker cells per level, outermost first — a nested item's indent is the
|
|
460
|
+
// sum of its ancestors' gutters (each nested list sits in its parent li's
|
|
461
|
+
// body column).
|
|
462
|
+
const gutters: number[] = []
|
|
463
|
+
let listOrdered: boolean | undefined
|
|
464
|
+
while (i < lines.length) {
|
|
465
|
+
const m = listMarker(lines[i]!)
|
|
466
|
+
if (!m) {
|
|
467
|
+
if (isBlank(lines[i]) && listMarker(lines[i + 1] ?? '')) {
|
|
468
|
+
i += 1
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
break
|
|
472
|
+
}
|
|
473
|
+
// A different marker type at the top level starts a *new* list — one
|
|
474
|
+
// more block, one more inter-block margin.
|
|
475
|
+
if (m.depth === 0) {
|
|
476
|
+
if (listOrdered !== undefined && m.ordered !== listOrdered) break
|
|
477
|
+
listOrdered = m.ordered
|
|
478
|
+
}
|
|
479
|
+
gutters[m.depth] = m.ordered ? 3 : 2
|
|
480
|
+
const indent = gutters.slice(0, m.depth).reduce((a, b) => a + (b ?? 2), 0)
|
|
481
|
+
const source = [m.text]
|
|
482
|
+
i += 1
|
|
483
|
+
// Lazy continuation lines belong to the item; whether each newline
|
|
484
|
+
// renders is `hardLines`' call, same as a paragraph's.
|
|
485
|
+
while (i < lines.length && lines[i]!.trim() !== '' && !listMarker(lines[i]!)) {
|
|
486
|
+
// trimStart only: a trailing double space is the hard-break marker,
|
|
487
|
+
// and `hardLines` needs to see it.
|
|
488
|
+
source.push(lines[i]!.trimStart())
|
|
489
|
+
i += 1
|
|
490
|
+
}
|
|
491
|
+
items.push({ lines: hardLines(source), gutter: gutters[m.depth]!, indent })
|
|
492
|
+
}
|
|
493
|
+
blocks.push({ t: 'list', items })
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
if (line.trimStart().startsWith('|')) {
|
|
497
|
+
// Only the row count. A table lays out at `max-content` and scrolls rather
|
|
498
|
+
// than compressing, so a row is a line whatever the cells hold — the
|
|
499
|
+
// per-column width measuring this used to do fed a wrap model that no
|
|
500
|
+
// longer exists, and it was real work on every table.
|
|
501
|
+
let rows = 0
|
|
502
|
+
while (i < lines.length && lines[i]!.trimStart().startsWith('|')) {
|
|
503
|
+
const cells = lines[i]!
|
|
504
|
+
.trim()
|
|
505
|
+
.replace(/^\||\|$/g, '')
|
|
506
|
+
.split('|')
|
|
507
|
+
.map((cell) => cell.trim())
|
|
508
|
+
// The `|---|:--:|` delimiter row is structure, not a rendered line.
|
|
509
|
+
if (!cells.every((cell) => /^:?-+:?$/.test(cell))) rows += 1
|
|
510
|
+
i += 1
|
|
511
|
+
}
|
|
512
|
+
blocks.push({ t: 'table', rows: Math.max(1, rows) })
|
|
513
|
+
continue
|
|
514
|
+
}
|
|
515
|
+
// Paragraph: gather until a blank line or another block opener.
|
|
516
|
+
const para: string[] = [line]
|
|
517
|
+
i += 1
|
|
518
|
+
while (
|
|
519
|
+
i < lines.length &&
|
|
520
|
+
lines[i]!.trim() !== '' &&
|
|
521
|
+
!lines[i]!.startsWith('```') &&
|
|
522
|
+
!/^(#{1,6})\s+/.test(lines[i]!) &&
|
|
523
|
+
!/^>\s?/.test(lines[i]!) &&
|
|
524
|
+
!listMarker(lines[i]!) &&
|
|
525
|
+
!lines[i]!.trimStart().startsWith('|') &&
|
|
526
|
+
!/^(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i]!)
|
|
527
|
+
) {
|
|
528
|
+
para.push(lines[i]!)
|
|
529
|
+
i += 1
|
|
530
|
+
}
|
|
531
|
+
blocks.push({ t: 'p', lines: hardLines(para) })
|
|
532
|
+
}
|
|
533
|
+
return blocks
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** The markdown body's height: blocks, one line between consecutive ones
|
|
537
|
+
* (`.term-md .term-block + .term-block`). Exported for the dev audit only. */
|
|
538
|
+
export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
|
|
539
|
+
const bodyPx = m.width - extraPx - 2 * m.ch // the outer Row's gutter
|
|
540
|
+
const cols = Math.floor(bodyPx / m.ch + EPS)
|
|
541
|
+
const blocks = parseBlocks(md)
|
|
542
|
+
let acc: Acc = { px: 0, exact: true }
|
|
543
|
+
for (const [index, block] of blocks.entries()) {
|
|
544
|
+
if (index > 0) acc = add(acc, { px: m.line, exact: true })
|
|
545
|
+
switch (block.t) {
|
|
546
|
+
case 'p': {
|
|
547
|
+
for (const line of block.lines) {
|
|
548
|
+
const r = textLines(line, cols)
|
|
549
|
+
acc = add(acc, { px: r.lines * m.line, exact: r.exact })
|
|
550
|
+
}
|
|
551
|
+
break
|
|
552
|
+
}
|
|
553
|
+
case 'h': {
|
|
554
|
+
const r = textLines(block.text, cols)
|
|
555
|
+
acc = add(acc, { px: r.lines * m.line, exact: r.exact })
|
|
556
|
+
break
|
|
557
|
+
}
|
|
558
|
+
case 'fence': {
|
|
559
|
+
let px = 0
|
|
560
|
+
let exact = true
|
|
561
|
+
for (const codeLine of block.code.split('\n')) {
|
|
562
|
+
const r = wrapOne(codeLine, cols)
|
|
563
|
+
px += Math.max(1, r.lines) * m.line
|
|
564
|
+
exact = exact && r.exact
|
|
565
|
+
}
|
|
566
|
+
acc = add(acc, { px: Math.max(px, m.line), exact })
|
|
567
|
+
break
|
|
568
|
+
}
|
|
569
|
+
case 'list': {
|
|
570
|
+
let px = 0
|
|
571
|
+
let exact = true
|
|
572
|
+
for (const item of block.items) {
|
|
573
|
+
for (const line of item.lines) {
|
|
574
|
+
const r = textLines(line, cols - item.indent - item.gutter)
|
|
575
|
+
px += r.lines * m.line
|
|
576
|
+
exact = exact && r.exact
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
acc = add(acc, { px, exact })
|
|
580
|
+
break
|
|
581
|
+
}
|
|
582
|
+
case 'quote': {
|
|
583
|
+
let px = 0
|
|
584
|
+
let exact = true
|
|
585
|
+
for (const [pi, para] of block.paras.entries()) {
|
|
586
|
+
if (pi > 0) px += m.line
|
|
587
|
+
for (const line of para) {
|
|
588
|
+
const r = textLines(line, cols - 2)
|
|
589
|
+
px += r.lines * m.line
|
|
590
|
+
exact = exact && r.exact
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
acc = add(acc, { px, exact })
|
|
594
|
+
break
|
|
595
|
+
}
|
|
596
|
+
case 'table':
|
|
597
|
+
// One line per row, always. A table is laid out at `max-content` and
|
|
598
|
+
// carries no `max-width`, so a wide one scrolls inside
|
|
599
|
+
// `.term-table-wrap` rather than compressing — which means the auto
|
|
600
|
+
// table layout never wraps a cell and there is nothing here to model.
|
|
601
|
+
// This used to branch on whether the table fit, and flag the wide case:
|
|
602
|
+
// that branch was the theme's own `max-width: 100%` leaking into the
|
|
603
|
+
// calculator, and both went at once. See `terminal.css` §Markdown.
|
|
604
|
+
acc = add(acc, { px: block.rows * m.line, exact: true })
|
|
605
|
+
break
|
|
606
|
+
case 'hr':
|
|
607
|
+
acc = add(acc, { px: m.line, exact: true })
|
|
608
|
+
break
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return { px: Math.max(acc.px, m.line), exact: acc.exact }
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/* ── Items ─────────────────────────────────────────────────────────────────── */
|
|
615
|
+
|
|
616
|
+
function diffHeight(patch: FilePatch, m: CellMetrics, extraPx = 0): Acc {
|
|
617
|
+
const walk = (hunk: PatchHunk) => {
|
|
618
|
+
let newLine = hunk.newStart
|
|
619
|
+
let oldLine = hunk.oldStart
|
|
620
|
+
return hunk.lines.map((line) => {
|
|
621
|
+
const kind = line.startsWith('+') ? 'add' : line.startsWith('-') ? 'remove' : 'context'
|
|
622
|
+
const text = line.slice(1)
|
|
623
|
+
const number = kind === 'add' ? newLine++ : kind === 'remove' ? oldLine++ : newLine
|
|
624
|
+
if (kind === 'context') {
|
|
625
|
+
oldLine++
|
|
626
|
+
newLine++
|
|
627
|
+
}
|
|
628
|
+
return { number, text }
|
|
629
|
+
})
|
|
630
|
+
}
|
|
631
|
+
const hunks = patch.hunks.map(walk)
|
|
632
|
+
const numbered = patch.hunks.some((hunk) => hunk.newStart > 0)
|
|
633
|
+
const width = numbered
|
|
634
|
+
? String(Math.max(...hunks.flat().map((row) => row.number), 1)).length
|
|
635
|
+
: 0
|
|
636
|
+
const columns = numbered ? width + 3 : 2
|
|
637
|
+
let acc: Acc = { px: 0, exact: true }
|
|
638
|
+
for (const [index, hunk] of hunks.entries()) {
|
|
639
|
+
if (index > 0) acc = add(acc, { px: m.line, exact: true }) // the ⋮ separator row
|
|
640
|
+
for (const row of hunk) {
|
|
641
|
+
acc = add(acc, rowH(row.text || ' ', m, { indentCells: 2, gutterCells: columns, extraPx }))
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (patch.truncated) acc = add(acc, { px: m.line, exact: true })
|
|
645
|
+
return acc
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** A collapsed tool row: the header, then the diff (when the call carries a
|
|
649
|
+
* patch) or the shared collapsed preview plus its trailing "there is more" row.
|
|
650
|
+
* No expanded branch — see the module comment's first invariant. */
|
|
651
|
+
function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc {
|
|
652
|
+
const preview = toolInputPreview(item.input)
|
|
653
|
+
const backend = item.backend && item.backend !== 'server' ? ` · ${item.backend}` : ''
|
|
654
|
+
let acc = rowH(`${item.name}(${preview})${backend}`, m, { gutterCells: 2, extraPx })
|
|
655
|
+
|
|
656
|
+
if (item.patch) return add(acc, diffHeight(item.patch, m, extraPx))
|
|
657
|
+
const text = item.result?.text ?? ''
|
|
658
|
+
if (!text) return acc
|
|
659
|
+
// The renderer's own budget, not a copy of it: `collapsedResult` returns both
|
|
660
|
+
// the lines and the exact trailing label, so this cannot drift from what
|
|
661
|
+
// `items.tsx` draws — which it previously could, and which its own comment
|
|
662
|
+
// said only the dev audit was catching.
|
|
663
|
+
const { shown, more } = collapsedResult(text.trimEnd().split('\n'))
|
|
664
|
+
for (const line of shown) {
|
|
665
|
+
// indent=1 with columns=3 resolves the indent against the row's own
|
|
666
|
+
// --term-cell: 3ch of padding + 3ch of gutter.
|
|
667
|
+
acc = add(acc, rowH(line || ' ', m, { indentCells: 3, gutterCells: 3, extraPx }))
|
|
668
|
+
}
|
|
669
|
+
if (more) acc = add(acc, rowH(more, m, { indentCells: 3, gutterCells: 3, extraPx }))
|
|
670
|
+
return acc
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Rows produced inside a subagent are stepped in behind a rule —
|
|
674
|
+
* `border-l-2` (2px) + `pl-3` (12px) on the wrapper in `agent/Transcript.tsx`. */
|
|
675
|
+
const nestedExtraPx = (item: TranscriptItem): number =>
|
|
676
|
+
'parentToolUseId' in item && item.parentToolUseId != null ? 14 : 0
|
|
677
|
+
|
|
678
|
+
/** One transcript item's height, in its default (collapsed, settled-or-not)
|
|
679
|
+
* presentation. */
|
|
680
|
+
export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight {
|
|
681
|
+
const extraPx = nestedExtraPx(item)
|
|
682
|
+
switch (item.kind) {
|
|
683
|
+
case 'user': {
|
|
684
|
+
let acc: Acc = { px: 0, exact: true }
|
|
685
|
+
if (item.attachments?.length)
|
|
686
|
+
acc = add(acc, rowH(item.attachments.map((a) => a.name).join(', '), m, { extraPx }))
|
|
687
|
+
if (item.text)
|
|
688
|
+
for (const line of item.text.split('\n')) acc = add(acc, rowH(line || ' ', m, { extraPx }))
|
|
689
|
+
return acc
|
|
690
|
+
}
|
|
691
|
+
case 'assistant_text':
|
|
692
|
+
return markdownHeight(item.text, m, extraPx)
|
|
693
|
+
case 'thinking':
|
|
694
|
+
return rowH(item.text, m, { extraPx })
|
|
695
|
+
case 'tool_call':
|
|
696
|
+
return toolRowHeight(item, m, extraPx)
|
|
697
|
+
case 'turn_result': {
|
|
698
|
+
let acc = rowH(
|
|
699
|
+
`${item.isError ? item.subtype : 'done'} · ${formatDuration(item.durationMs)} · ${formatCost(item.totalCostUsd)}`,
|
|
700
|
+
m,
|
|
701
|
+
{ extraPx },
|
|
702
|
+
)
|
|
703
|
+
for (const message of item.errors ?? []) acc = add(acc, rowH(message, m, { extraPx }))
|
|
704
|
+
return acc
|
|
705
|
+
}
|
|
706
|
+
case 'notice':
|
|
707
|
+
return rowH(item.text, m, { extraPx })
|
|
708
|
+
case 'file_delivered': {
|
|
709
|
+
const text =
|
|
710
|
+
`${item.path} · ${formatBytes(item.bytes)}` +
|
|
711
|
+
(item.description ? ` · ${item.description}` : '')
|
|
712
|
+
return rowH(text, m, { extraPx })
|
|
713
|
+
}
|
|
714
|
+
default:
|
|
715
|
+
return { px: 0, exact: false }
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** A virtual row's height: an item, or a folded tool run (collapsed = its one
|
|
720
|
+
* summary line, built by the same function the row draws). */
|
|
721
|
+
export function blockHeight(block: TerminalBlock, m: CellMetrics): ComputedHeight {
|
|
722
|
+
if ('run' in block) {
|
|
723
|
+
const busy = block.run.some((item) => item.status === 'running' || item.status === 'pending')
|
|
724
|
+
return rowH(runSummary(block.run, busy), m)
|
|
725
|
+
}
|
|
726
|
+
return itemHeight(block.item, m)
|
|
727
|
+
}
|