@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.
Files changed (69) hide show
  1. package/README.md +72 -0
  2. package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-B9CHoq8x.d.mts} +213 -27
  3. package/build/{SessionPanel-CKQa4i0Y.mjs → SessionPanel-DII9MmQ8.mjs} +5192 -2542
  4. package/build/SessionPanel-DII9MmQ8.mjs.map +1 -0
  5. package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
  6. package/build/format.d.mts +39 -4
  7. package/build/format.mjs +2 -118
  8. package/build/index.d.mts +494 -45
  9. package/build/index.mjs +182 -24
  10. package/build/index.mjs.map +1 -1
  11. package/build/status-Ydzi7n6j.mjs +143 -0
  12. package/build/status-Ydzi7n6j.mjs.map +1 -0
  13. package/build/workspace.d.mts +13 -1
  14. package/build/workspace.mjs +111 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +14 -7
  17. package/src/components/agent/Composer.tsx +251 -84
  18. package/src/components/agent/Conversation.tsx +12 -12
  19. package/src/components/agent/FileCard.tsx +0 -26
  20. package/src/components/agent/FileTree.tsx +9 -8
  21. package/src/components/agent/Loader.tsx +22 -72
  22. package/src/components/agent/Message.tsx +11 -43
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/QuestionPrompt.tsx +0 -122
  25. package/src/components/agent/Reasoning.tsx +5 -19
  26. package/src/components/agent/Response.tsx +1 -132
  27. package/src/components/agent/SessionBrowser.tsx +35 -25
  28. package/src/components/agent/SessionPanel.tsx +312 -63
  29. package/src/components/agent/SessionWorkspace.tsx +36 -0
  30. package/src/components/agent/StatusBar.tsx +70 -15
  31. package/src/components/agent/ToolCallCard.tsx +17 -111
  32. package/src/components/agent/Transcript.tsx +710 -203
  33. package/src/components/agent/UsageDialog.tsx +20 -106
  34. package/src/components/agent/UsageMeters.tsx +133 -0
  35. package/src/components/agent/pulse.tsx +3 -2
  36. package/src/components/agent/transcript-rows.ts +82 -0
  37. package/src/components/agent/transcript-variant.tsx +43 -51
  38. package/src/components/agent/use-height-epoch.ts +60 -0
  39. package/src/components/agent/use-path-links.ts +147 -0
  40. package/src/components/agent/use-transcript-jumps.ts +190 -0
  41. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  42. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  43. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  44. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  45. package/src/components/terminal/StatusLine.tsx +159 -0
  46. package/src/components/terminal/TerminalTranscript.tsx +147 -0
  47. package/src/components/terminal/affordances.tsx +118 -0
  48. package/src/components/terminal/diff.tsx +130 -0
  49. package/src/components/terminal/height.ts +727 -0
  50. package/src/components/terminal/items.tsx +449 -0
  51. package/src/components/terminal/markdown.tsx +191 -0
  52. package/src/components/terminal/press.tsx +120 -0
  53. package/src/components/terminal/prompt.tsx +343 -0
  54. package/src/components/terminal/result-preview.ts +72 -0
  55. package/src/components/terminal/row.tsx +132 -0
  56. package/src/components/terminal/scrubber.tsx +663 -0
  57. package/src/components/terminal/surface.tsx +80 -0
  58. package/src/components/terminal/tool-run.ts +91 -0
  59. package/src/components/ui/Badge.tsx +6 -1
  60. package/src/components/ui/Empty.tsx +56 -0
  61. package/src/components/ui/Splitter.tsx +14 -0
  62. package/src/index.ts +36 -0
  63. package/src/lib/status.ts +59 -3
  64. package/src/lib/tool-icon.ts +14 -0
  65. package/src/styles/terminal.css +1011 -0
  66. package/src/styles/theme.css +73 -0
  67. package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
  68. package/build/format.mjs.map +0 -1
  69. 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
+ }