@workerdeck/ui 0.23.0 → 1.0.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/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +15 -23
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +5 -126
- package/build/workspace.mjs +11 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +3 -9
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +315 -996
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +104 -219
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +96 -32
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-BobynUo4.d.mts +0 -669
- package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -1,111 +1,23 @@
|
|
|
1
1
|
import type { FilePatch, PatchHunk } from '@workerdeck/protocol'
|
|
2
2
|
import type { TranscriptItem } from '@workerdeck/react'
|
|
3
|
-
import {
|
|
4
|
-
formatBytes,
|
|
5
|
-
formatCost,
|
|
6
|
-
formatDuration,
|
|
7
|
-
toolInputPreview,
|
|
8
|
-
} from '../../lib/format.ts'
|
|
3
|
+
import { formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
|
|
9
4
|
import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
|
|
10
5
|
import { IMAGE_BOX_LINES } from './image-box.ts'
|
|
11
6
|
import { collapsedResult } from './result-preview.ts'
|
|
7
|
+
import { todoLine, todoPreview } from './todos.ts'
|
|
12
8
|
import { runSummary, taskSummary } from './tool-run.ts'
|
|
13
9
|
|
|
14
|
-
/**
|
|
15
|
-
* The terminal theme's row-height calculator.
|
|
16
|
-
*
|
|
17
|
-
* Computes, from a {@link TranscriptItem} and the cell metrics alone — no DOM —
|
|
18
|
-
* the pixel height the terminal renderer will draw it at. This exists because
|
|
19
|
-
* the theme makes it possible: one line height, one cell, monospace, ligatures
|
|
20
|
-
* off. It is a *terminal* capability, deliberately homed here rather than in a
|
|
21
|
-
* generic location — the cards variant has padding scales, borders and a
|
|
22
|
-
* proportional face, and nothing in this file transfers to it.
|
|
23
|
-
*
|
|
24
|
-
* The consumer is the virtualizer's `estimateSize` (see `agent/Transcript.tsx`):
|
|
25
|
-
* a computed height governs a row only until the row mounts and is measured, so
|
|
26
|
-
* the contract is "almost always exact, and honest when it cannot be". Honesty
|
|
27
|
-
* is the `exact` flag on {@link ComputedHeight}: `false` marks content whose
|
|
28
|
-
* advance this model cannot know — emoji and CJK render from fallback faces
|
|
29
|
-
* whose advances are not whole cells.
|
|
30
|
-
* Measured against the real renderer in the dev playground
|
|
31
|
-
* (`dev/height-audit.ts`): every unflagged row was pixel-exact across fixtures,
|
|
32
|
-
* widths and cell sizes — the audit is this file's regression gate.
|
|
33
|
-
*
|
|
34
|
-
* Two invariants keep the model small, and both are load-bearing:
|
|
35
|
-
*
|
|
36
|
-
* - **Only the default state is ever computed.** A tool row's expansion is
|
|
37
|
-
* component-local state that resets when the row unmounts, so an off-screen
|
|
38
|
-
* row is always collapsed — and an expanded row is by definition mounted,
|
|
39
|
-
* which means the virtualizer has its real measurement. There is no
|
|
40
|
-
* open/expanded branch here on purpose.
|
|
41
|
-
*
|
|
42
|
-
* **This is a decided, permanent divergence from iOS**, not a gap anyone
|
|
43
|
-
* should close. There (`apps/ios/WorkerDeckKit/.../TerminalExpansion.swift`)
|
|
44
|
-
* nothing self-measures — a `UICollectionViewLayout` takes every frame from
|
|
45
|
-
* the height book — so expansion has to be an *input to the planner*, and a
|
|
46
|
-
* height the book does not know about is a frame the layout gets wrong. That
|
|
47
|
-
* inversion is what lets the iOS scrubber be expansion-aware (a band over the
|
|
48
|
-
* region you opened; a failed member of an *open* run marking on its own
|
|
49
|
-
* line) and it is the one thing this client's rail cannot do. Lifting
|
|
50
|
-
* expansion to shared state here to match would buy one rail feature and cost
|
|
51
|
-
* this invariant — the central simplification of the whole calculator — so
|
|
52
|
-
* the two clients share the *rule* and differ in how much of it each can see.
|
|
53
|
-
*
|
|
54
|
-
* And the gap is entailed by **where the state can live**, not by how it is
|
|
55
|
-
* plumbed: a paint-only registry would preserve this invariant, but web
|
|
56
|
-
* expansion exists only while a row is *mounted*, so the rail it fed could
|
|
57
|
-
* only ever band the rows currently on screen — the one place an overview
|
|
58
|
-
* rail is useless, because the yellow wash is already visible there.
|
|
59
|
-
* Said here as well as in the Swift, because this is the file where someone
|
|
60
|
-
* would go looking to "fix" it.
|
|
61
|
-
* - **Mutation is object replacement.** The transcript reducer never mutates an
|
|
62
|
-
* item in place — streaming text, a result arriving, a patch attaching each
|
|
63
|
-
* produce a new object — which is what lets {@link HeightEpoch}'s cache key on
|
|
64
|
-
* identity and never go stale.
|
|
65
|
-
*
|
|
66
|
-
* The wrap model mirrors `.term-body` (`white-space: pre-wrap` +
|
|
67
|
-
* `overflow-wrap: break-word` in a `minmax(0,1fr)` grid column): greedy fill,
|
|
68
|
-
* breaks at preserved spaces (which hang at the line end rather than wrapping),
|
|
69
|
-
* after hyphens/dashes/`?` not followed by a digit (verified against Chrome's
|
|
70
|
-
* actual break points in long URLs), and between CJK characters; a token wider
|
|
71
|
-
* than the whole column moves to its own line first and then breaks per cell —
|
|
72
|
-
* which is exactly where `break-word` differs from `anywhere`.
|
|
73
|
-
*/
|
|
74
|
-
|
|
75
|
-
/** The cell everything is measured in. `ch` is the advance of `0` in px —
|
|
76
|
-
* *measured* off the live surface via {@link measureCh}, never derived from the
|
|
77
|
-
* font size (7.83px at 13px JetBrains Mono, not 13 × 0.6). */
|
|
78
10
|
export type CellMetrics = {
|
|
79
|
-
/** Content-box width of the virtual row wrapper, px. */
|
|
80
11
|
width: number
|
|
81
|
-
/** Advance of `0`, px. */
|
|
82
12
|
ch: number
|
|
83
|
-
/** `--term-line`, px. */
|
|
84
13
|
line: number
|
|
85
14
|
}
|
|
86
15
|
|
|
87
16
|
export type ComputedHeight = {
|
|
88
17
|
px: number
|
|
89
|
-
/** False when the content contains glyphs or structures whose rendered size
|
|
90
|
-
* this model cannot know — treat the row as an estimate, corrected the
|
|
91
|
-
* moment it mounts and measures. */
|
|
92
18
|
exact: boolean
|
|
93
19
|
}
|
|
94
20
|
|
|
95
|
-
/* ── The epoch ─────────────────────────────────────────────────────────────
|
|
96
|
-
*
|
|
97
|
-
* One cache generation. **Owned by the transcript shell** (`TranscriptRows` in
|
|
98
|
-
* `agent/Transcript.tsx` holds exactly one, in React state): it is created from
|
|
99
|
-
* the first real measurement of the mounted surface and *replaced wholesale*
|
|
100
|
-
* whenever width, `ch` or line change — a computed height is only meaningful
|
|
101
|
-
* against the metrics it was computed for, so partial invalidation is a bug
|
|
102
|
-
* factory and a new WeakMap is free. Within an epoch the cache keys on item
|
|
103
|
-
* object identity, which the reducer's replace-on-mutation discipline turns
|
|
104
|
-
* into automatic invalidation: a streamed delta is a new object and simply
|
|
105
|
-
* misses. Run blocks are not cached — the folded array is rebuilt every
|
|
106
|
-
* render, so its identity is worthless as a key, and a collapsed run is one
|
|
107
|
-
* `wrapOne` over a short string (~2µs).
|
|
108
|
-
*/
|
|
109
21
|
export type HeightEpoch = CellMetrics & {
|
|
110
22
|
cache: WeakMap<TranscriptItem, ComputedHeight>
|
|
111
23
|
}
|
|
@@ -114,52 +26,27 @@ export function createHeightEpoch(width: number, ch: number, line: number): Heig
|
|
|
114
26
|
return { width, ch, line, cache: new WeakMap() }
|
|
115
27
|
}
|
|
116
28
|
|
|
117
|
-
/** A virtual row's computed height under `epoch` — the `estimateSize` feed.
|
|
118
|
-
* The inter-row gap is the *pair's* business (`gapBefore`), not the row's, so
|
|
119
|
-
* it is added by the caller. */
|
|
120
29
|
export function estimateBlockPx(block: TerminalBlock, epoch: HeightEpoch): number {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// reducer replaces the *child*), so a height cached against the task would
|
|
125
|
-
// survive exactly the mutation that changes the summary line. Both are one
|
|
126
|
-
// `wrapOne` over a short string (~2µs), so neither needs the cache.
|
|
127
|
-
if (!('item' in block)) return blockHeight(block, epoch).px
|
|
30
|
+
if (!('item' in block)) {
|
|
31
|
+
return blockHeight(block, epoch).px
|
|
32
|
+
}
|
|
128
33
|
const hit = epoch.cache.get(block.item)
|
|
129
|
-
if (hit)
|
|
34
|
+
if (hit) {
|
|
35
|
+
return hit.px
|
|
36
|
+
}
|
|
130
37
|
const computed = itemHeight(block.item, epoch)
|
|
131
38
|
epoch.cache.set(block.item, computed)
|
|
132
39
|
return computed.px
|
|
133
40
|
}
|
|
134
41
|
|
|
135
|
-
/**
|
|
136
|
-
* How many lines of a sub-agent's brief the collapsed row shows.
|
|
137
|
-
*
|
|
138
|
-
* Four, and the number is a judgement rather than a measurement: a brief runs
|
|
139
|
-
* to thousands of characters, and an uncapped one buries the work it was asking
|
|
140
|
-
* for under its own instructions. Four is enough to recognise the task and
|
|
141
|
-
* short enough that the agent's first line stays on screen beside it.
|
|
142
|
-
*/
|
|
143
42
|
export const BRIEF_LINES = 4
|
|
144
43
|
|
|
145
|
-
/**
|
|
146
|
-
* The collapsed brief row's height — the frame's first row, which is synthetic
|
|
147
|
-
* and so cannot go through {@link estimateBlockPx}.
|
|
148
|
-
*
|
|
149
|
-
* Clipped, so it is `min(wrapped, BRIEF_LINES)` lines plus the row the header
|
|
150
|
-
* occupies. Expanding is local state on a *mounted* row, which the virtualizer
|
|
151
|
-
* re-measures; what has to be right before it mounts is the collapsed height,
|
|
152
|
-
* exactly as a task row is always collapsed when unmounted for the same reason.
|
|
153
|
-
*/
|
|
154
44
|
export function briefPx(text: string, m: CellMetrics): number {
|
|
155
45
|
const cols = Math.max(1, Math.floor(m.width / m.ch + EPS))
|
|
156
46
|
return (Math.min(textLines(text, cols).lines, BRIEF_LINES) + 1) * m.line
|
|
157
47
|
}
|
|
158
48
|
|
|
159
|
-
|
|
160
|
-
* the epoch's measurement pass (an effect / ResizeObserver callback), never
|
|
161
|
-
* from render. The probe is absolutely positioned, so it contributes no layout
|
|
162
|
-
* and cannot re-trigger the observer that called it. */
|
|
49
|
+
// Absolutely positioned so the probe adds no layout and cannot re-trigger the ResizeObserver that called it.
|
|
163
50
|
export function measureCh(surface: HTMLElement): number {
|
|
164
51
|
const probe = document.createElement('span')
|
|
165
52
|
probe.textContent = '0'.repeat(200)
|
|
@@ -172,69 +59,61 @@ export function measureCh(surface: HTMLElement): number {
|
|
|
172
59
|
return width
|
|
173
60
|
}
|
|
174
61
|
|
|
175
|
-
/* ── Cell counting ─────────────────────────────────────────────────────────── */
|
|
176
|
-
|
|
177
62
|
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' })
|
|
178
63
|
|
|
179
64
|
const WIDE_RANGES: [number, number][] = [
|
|
180
|
-
[0x1100, 0x115f],
|
|
181
|
-
[0x2e80, 0x303e],
|
|
182
|
-
[0x3041, 0x33ff],
|
|
65
|
+
[0x1100, 0x115f],
|
|
66
|
+
[0x2e80, 0x303e],
|
|
67
|
+
[0x3041, 0x33ff],
|
|
183
68
|
[0x3400, 0x4dbf],
|
|
184
69
|
[0x4e00, 0x9fff],
|
|
185
70
|
[0xa000, 0xa4cf],
|
|
186
|
-
[0xac00, 0xd7a3],
|
|
71
|
+
[0xac00, 0xd7a3],
|
|
187
72
|
[0xf900, 0xfaff],
|
|
188
73
|
[0xfe30, 0xfe4f],
|
|
189
|
-
[0xff00, 0xff60],
|
|
74
|
+
[0xff00, 0xff60],
|
|
190
75
|
[0xffe0, 0xffe6],
|
|
191
76
|
[0x20000, 0x3fffd],
|
|
192
77
|
]
|
|
193
78
|
|
|
194
|
-
|
|
79
|
+
function isWide(cp: number): boolean {
|
|
80
|
+
return WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)
|
|
81
|
+
}
|
|
195
82
|
|
|
196
83
|
const PICTOGRAPHIC = /\p{Extended_Pictographic}/u
|
|
197
84
|
|
|
198
|
-
/** One grapheme cluster's advance, in cells. Wide and pictographic clusters are
|
|
199
|
-
* counted as 2 but flagged: they render from a fallback face whose advance is
|
|
200
|
-
* not a whole number of cells, and the flag is what keeps the model honest. */
|
|
201
85
|
function clusterCells(cluster: string): { w: number; exact: boolean } {
|
|
202
|
-
if (PICTOGRAPHIC.test(cluster) || cluster.includes('') || cluster.includes('️'))
|
|
86
|
+
if (PICTOGRAPHIC.test(cluster) || cluster.includes('') || cluster.includes('️')) {
|
|
203
87
|
return { w: 2, exact: false }
|
|
88
|
+
}
|
|
204
89
|
const cp = cluster.codePointAt(0) ?? 0
|
|
205
|
-
if (isWide(cp))
|
|
206
|
-
|
|
90
|
+
if (isWide(cp)) {
|
|
91
|
+
return { w: 2, exact: false }
|
|
92
|
+
}
|
|
93
|
+
if (cp < 0x20) {
|
|
94
|
+
return { w: 0, exact: true }
|
|
95
|
+
}
|
|
207
96
|
return { w: 1, exact: true }
|
|
208
97
|
}
|
|
209
98
|
|
|
210
|
-
/* ── The wrap model ────────────────────────────────────────────────────────── */
|
|
211
|
-
|
|
212
99
|
type Token = { kind: 'word' | 'space'; w: number; exact: boolean }
|
|
213
100
|
|
|
214
|
-
|
|
101
|
+
// Must match `tab-size: 2` on the surface.
|
|
215
102
|
const TAB_SIZE = 2
|
|
216
103
|
|
|
217
|
-
|
|
218
|
-
* keeps `protocol-0.16.0` together; `?` is in the set because Chrome breaks
|
|
219
|
-
* long URLs after it (verified against real break rects). */
|
|
104
|
+
// Break after these unless a digit follows; `?` is here because Chrome breaks long URLs after it (verified against real break rects).
|
|
220
105
|
const BREAK_AFTER = new Set(['-', '–', '—', '?'])
|
|
221
106
|
|
|
222
|
-
/** Printable ASCII, tab excluded: every character is one cell and one grapheme,
|
|
223
|
-
* so the segmenter has nothing to tell us. This is nearly every line a
|
|
224
|
-
* transcript holds, and the segmenter was the calculator's whole CPU bill —
|
|
225
|
-
* ~1s of a 30s scroll sweep on the 4k-item perf fixture, all of it spent
|
|
226
|
-
* segmenting text that `charCodeAt` classifies for free. */
|
|
227
107
|
const PLAIN_ASCII = /^[\x20-\x7e]*$/
|
|
228
108
|
|
|
229
|
-
|
|
230
|
-
* '?' break unless a digit follows; '–'/'—' are not ASCII), no segmenter and
|
|
231
|
-
* no per-grapheme allocation. Must stay semantically identical to the general
|
|
232
|
-
* path — the height audit runs both, via content that takes each. */
|
|
109
|
+
// Must stay semantically identical to `tokenize`; the height audit runs content that takes each path.
|
|
233
110
|
function tokenizeAscii(line: string): Token[] {
|
|
234
111
|
const tokens: Token[] = []
|
|
235
112
|
let wordW = 0
|
|
236
113
|
const flushWord = () => {
|
|
237
|
-
if (wordW > 0)
|
|
114
|
+
if (wordW > 0) {
|
|
115
|
+
tokens.push({ kind: 'word', w: wordW, exact: true })
|
|
116
|
+
}
|
|
238
117
|
wordW = 0
|
|
239
118
|
}
|
|
240
119
|
for (let i = 0; i < line.length; i++) {
|
|
@@ -242,33 +121,36 @@ function tokenizeAscii(line: string): Token[] {
|
|
|
242
121
|
if (code === 0x20) {
|
|
243
122
|
flushWord()
|
|
244
123
|
const last = tokens[tokens.length - 1]
|
|
245
|
-
if (last?.kind === 'space')
|
|
246
|
-
|
|
124
|
+
if (last?.kind === 'space') {
|
|
125
|
+
last.w += 1
|
|
126
|
+
} else {
|
|
127
|
+
tokens.push({ kind: 'space', w: 1, exact: true })
|
|
128
|
+
}
|
|
247
129
|
continue
|
|
248
130
|
}
|
|
249
131
|
wordW += 1
|
|
250
|
-
if (code === 0x2d
|
|
132
|
+
if (code === 0x2d || code === 0x3f) {
|
|
251
133
|
const next = line.charCodeAt(i + 1)
|
|
252
|
-
if (!(next >= 0x30 && next <= 0x39))
|
|
134
|
+
if (!(next >= 0x30 && next <= 0x39)) {
|
|
135
|
+
flushWord()
|
|
136
|
+
}
|
|
253
137
|
}
|
|
254
138
|
}
|
|
255
139
|
flushWord()
|
|
256
140
|
return tokens
|
|
257
141
|
}
|
|
258
142
|
|
|
259
|
-
/**
|
|
260
|
-
* Tokenize one hard line into wrap units. Tabs advance to the next 2-cell stop
|
|
261
|
-
* measured from the hard line's start — position-dependent after a soft wrap in
|
|
262
|
-
* principle, but content with tabs beyond the first wrap point is vanishingly
|
|
263
|
-
* rare and the error is bounded by one tab stop.
|
|
264
|
-
*/
|
|
265
143
|
function tokenize(line: string): Token[] {
|
|
266
|
-
if (PLAIN_ASCII.test(line))
|
|
144
|
+
if (PLAIN_ASCII.test(line)) {
|
|
145
|
+
return tokenizeAscii(line)
|
|
146
|
+
}
|
|
267
147
|
const tokens: Token[] = []
|
|
268
148
|
let word = { w: 0, exact: true }
|
|
269
149
|
let col = 0
|
|
270
150
|
const flushWord = () => {
|
|
271
|
-
if (word.w > 0)
|
|
151
|
+
if (word.w > 0) {
|
|
152
|
+
tokens.push({ kind: 'word', w: word.w, exact: word.exact })
|
|
153
|
+
}
|
|
272
154
|
word = { w: 0, exact: true }
|
|
273
155
|
}
|
|
274
156
|
const segments = [...segmenter.segment(line)].map((s) => s.segment)
|
|
@@ -276,8 +158,11 @@ function tokenize(line: string): Token[] {
|
|
|
276
158
|
if (segment === ' ') {
|
|
277
159
|
flushWord()
|
|
278
160
|
const last = tokens.at(-1)
|
|
279
|
-
if (last?.kind === 'space')
|
|
280
|
-
|
|
161
|
+
if (last?.kind === 'space') {
|
|
162
|
+
last.w += 1
|
|
163
|
+
} else {
|
|
164
|
+
tokens.push({ kind: 'space', w: 1, exact: true })
|
|
165
|
+
}
|
|
281
166
|
col += 1
|
|
282
167
|
continue
|
|
283
168
|
}
|
|
@@ -285,8 +170,11 @@ function tokenize(line: string): Token[] {
|
|
|
285
170
|
flushWord()
|
|
286
171
|
const advance = TAB_SIZE - (col % TAB_SIZE) || TAB_SIZE
|
|
287
172
|
const last = tokens.at(-1)
|
|
288
|
-
if (last?.kind === 'space')
|
|
289
|
-
|
|
173
|
+
if (last?.kind === 'space') {
|
|
174
|
+
last.w += advance
|
|
175
|
+
} else {
|
|
176
|
+
tokens.push({ kind: 'space', w: advance, exact: true })
|
|
177
|
+
}
|
|
290
178
|
col += advance
|
|
291
179
|
continue
|
|
292
180
|
}
|
|
@@ -310,9 +198,10 @@ function tokenize(line: string): Token[] {
|
|
|
310
198
|
return tokens
|
|
311
199
|
}
|
|
312
200
|
|
|
313
|
-
/** Visual lines one hard line occupies at `cols` columns. */
|
|
314
201
|
function wrapOne(line: string, cols: number): { lines: number; exact: boolean } {
|
|
315
|
-
if (cols <= 0)
|
|
202
|
+
if (cols <= 0) {
|
|
203
|
+
return { lines: 1, exact: false }
|
|
204
|
+
}
|
|
316
205
|
const tokens = tokenize(line)
|
|
317
206
|
let lines = 1
|
|
318
207
|
let pos = 0
|
|
@@ -320,8 +209,7 @@ function wrapOne(line: string, cols: number): { lines: number; exact: boolean }
|
|
|
320
209
|
for (const token of tokens) {
|
|
321
210
|
exact = exact && token.exact
|
|
322
211
|
if (token.kind === 'space') {
|
|
323
|
-
// Preserved spaces hang at the line end (CSS Text 3): they
|
|
324
|
-
// without forcing a wrap, and the next word starts the next line.
|
|
212
|
+
// Preserved spaces hang at the line end (CSS Text 3): they overflow without forcing a wrap.
|
|
325
213
|
pos += token.w
|
|
326
214
|
continue
|
|
327
215
|
}
|
|
@@ -335,7 +223,9 @@ function wrapOne(line: string, cols: number): { lines: number; exact: boolean }
|
|
|
335
223
|
continue
|
|
336
224
|
}
|
|
337
225
|
// break-word: the token first moves to its own line, then fills whole lines.
|
|
338
|
-
if (pos > 0)
|
|
226
|
+
if (pos > 0) {
|
|
227
|
+
lines += 1
|
|
228
|
+
}
|
|
339
229
|
const full = Math.ceil(token.w / cols)
|
|
340
230
|
lines += full - 1
|
|
341
231
|
pos = token.w - (full - 1) * cols
|
|
@@ -343,9 +233,6 @@ function wrapOne(line: string, cols: number): { lines: number; exact: boolean }
|
|
|
343
233
|
return { lines, exact }
|
|
344
234
|
}
|
|
345
235
|
|
|
346
|
-
/** Visual lines of a (possibly multi-hard-line) text at `cols` columns.
|
|
347
|
-
* Exported for the dev audit; also the one genuinely pure piece a unit test
|
|
348
|
-
* could pin, should `ui` ever grow a runner. */
|
|
349
236
|
export function textLines(text: string, cols: number): { lines: number; exact: boolean } {
|
|
350
237
|
let lines = 0
|
|
351
238
|
let exact = true
|
|
@@ -357,40 +244,23 @@ export function textLines(text: string, cols: number): { lines: number; exact: b
|
|
|
357
244
|
return { lines: Math.max(1, lines), exact }
|
|
358
245
|
}
|
|
359
246
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
/** Layout rounds to 1/64px; the epsilon keeps a body width that is exactly
|
|
363
|
-
* N × ch from flooring to N−1. */
|
|
247
|
+
// Layout rounds to 1/64px; the epsilon keeps a body width of exactly N × ch from flooring to N−1.
|
|
364
248
|
const EPS = 1e-4
|
|
365
249
|
|
|
366
250
|
type Acc = { px: number; exact: boolean }
|
|
367
251
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
* override sets), `gutterCells` of gutter, the body wrapping in what is left.
|
|
375
|
-
* `extraPx` is non-cell chrome around the row (the nested-run border+padding).
|
|
376
|
-
*/
|
|
377
|
-
function rowH(
|
|
378
|
-
text: string,
|
|
379
|
-
m: CellMetrics,
|
|
380
|
-
{ indentCells = 0, gutterCells = 2, extraPx = 0 } = {},
|
|
381
|
-
): Acc {
|
|
252
|
+
function add(a: Acc, b: Acc): Acc {
|
|
253
|
+
return { px: a.px + b.px, exact: a.exact && b.exact }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// `indent` resolves against the row's own `--term-cell`, so `columns={3} indent={1}` loses 3ch to the indent and 3ch to the gutter.
|
|
257
|
+
function rowH(text: string, m: CellMetrics, { indentCells = 0, gutterCells = 2, extraPx = 0 } = {}): Acc {
|
|
382
258
|
const bodyPx = m.width - extraPx - (indentCells + gutterCells) * m.ch
|
|
383
259
|
const cols = Math.floor(bodyPx / m.ch + EPS)
|
|
384
260
|
const { lines, exact } = textLines(text, cols)
|
|
385
261
|
return { px: lines * m.line, exact }
|
|
386
262
|
}
|
|
387
263
|
|
|
388
|
-
/* ── Markdown ──────────────────────────────────────────────────────────────── */
|
|
389
|
-
|
|
390
|
-
/** Rendered text of an inline run — markers stripped the way the component map
|
|
391
|
-
* renders them (`**bold**` is 4 chars narrower on screen than in source).
|
|
392
|
-
* Approximate: reference links, raw HTML and nested emphasis edge cases are
|
|
393
|
-
* not modelled. */
|
|
394
264
|
function stripInline(s: string): string {
|
|
395
265
|
return s
|
|
396
266
|
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
@@ -404,32 +274,16 @@ function stripInline(s: string): string {
|
|
|
404
274
|
.replace(/\\([\\`*_{}[\]()#+.!-])/g, '$1')
|
|
405
275
|
}
|
|
406
276
|
|
|
407
|
-
/**
|
|
408
|
-
* Text blocks carry **rendered lines**, resolved by CommonMark's own break
|
|
409
|
-
* rule: a source line ending in two spaces (or a backslash) is a hard break
|
|
410
|
-
* and renders as `<br>`; any other newline is soft and collapses to a space
|
|
411
|
-
* under `white-space: normal`. Both halves are load-bearing and each was the
|
|
412
|
-
* whole model once. Join-always missed the hard breaks — models write poems
|
|
413
|
-
* with trailing double-spaces, and a 350-line one estimated ~115 lines short
|
|
414
|
-
* (a phantom scrollbar tail, scrubber marks resizing as rows mounted).
|
|
415
|
-
* Break-always missed the soft ones and overestimated the same stanza written
|
|
416
|
-
* without them by four lines. The `markdown` fixture carries one poem of each
|
|
417
|
-
* as the audit's guard.
|
|
418
|
-
*/
|
|
419
277
|
type MdBlock =
|
|
420
278
|
| { t: 'p'; lines: string[] }
|
|
421
279
|
| { t: 'h'; text: string }
|
|
422
280
|
| { t: 'fence'; code: string }
|
|
423
281
|
| { t: 'list'; items: { lines: string[]; gutter: number; indent: number }[] }
|
|
424
282
|
| { t: 'quote'; paras: string[][] }
|
|
425
|
-
/** One line per row, unconditionally — see the `table` case in
|
|
426
|
-
* {@link markdownHeight}. */
|
|
427
283
|
| { t: 'table'; rows: number }
|
|
428
284
|
| { t: 'hr' }
|
|
429
285
|
|
|
430
|
-
|
|
431
|
-
* breaks (trailing double space or backslash) split, soft newlines join with a
|
|
432
|
-
* space. The break marker itself never renders and is stripped. */
|
|
286
|
+
// CommonMark: a trailing double space or backslash is a hard break; any other newline is soft and joins with a space.
|
|
433
287
|
function hardLines(source: string[]): string[] {
|
|
434
288
|
const out: string[] = []
|
|
435
289
|
let current: string[] = []
|
|
@@ -441,12 +295,12 @@ function hardLines(source: string[]): string[] {
|
|
|
441
295
|
current = []
|
|
442
296
|
}
|
|
443
297
|
}
|
|
444
|
-
if (current.length)
|
|
298
|
+
if (current.length) {
|
|
299
|
+
out.push(current.join(' '))
|
|
300
|
+
}
|
|
445
301
|
return out
|
|
446
302
|
}
|
|
447
303
|
|
|
448
|
-
/** A line-based CommonMark-ish block parser — just enough structure to mirror
|
|
449
|
-
* what Streamdown + the terminal component map emit for transcript content. */
|
|
450
304
|
function parseBlocks(md: string): MdBlock[] {
|
|
451
305
|
const lines = md.split('\n')
|
|
452
306
|
const blocks: MdBlock[] = []
|
|
@@ -465,7 +319,7 @@ function parseBlocks(md: string): MdBlock[] {
|
|
|
465
319
|
code.push(lines[i]!)
|
|
466
320
|
i += 1
|
|
467
321
|
}
|
|
468
|
-
i += 1
|
|
322
|
+
i += 1
|
|
469
323
|
blocks.push({ t: 'fence', code: code.join('\n') })
|
|
470
324
|
continue
|
|
471
325
|
}
|
|
@@ -490,26 +344,31 @@ function parseBlocks(md: string): MdBlock[] {
|
|
|
490
344
|
let current: string[] = []
|
|
491
345
|
for (const l of raw) {
|
|
492
346
|
if (l.trim() === '') {
|
|
493
|
-
if (current.length)
|
|
347
|
+
if (current.length) {
|
|
348
|
+
paras.push(hardLines(current))
|
|
349
|
+
}
|
|
494
350
|
current = []
|
|
495
|
-
} else
|
|
351
|
+
} else {
|
|
352
|
+
current.push(l)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (current.length) {
|
|
356
|
+
paras.push(hardLines(current))
|
|
496
357
|
}
|
|
497
|
-
if (current.length) paras.push(hardLines(current))
|
|
498
358
|
blocks.push({ t: 'quote', paras })
|
|
499
359
|
continue
|
|
500
360
|
}
|
|
501
361
|
const listMarker = (s: string) => {
|
|
502
362
|
const m = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(s)
|
|
503
|
-
if (!m)
|
|
363
|
+
if (!m) {
|
|
364
|
+
return undefined
|
|
365
|
+
}
|
|
504
366
|
const depth = Math.floor(m[1]!.length / 2)
|
|
505
367
|
const ordered = /\d/.test(m[2]!)
|
|
506
368
|
return { depth, ordered, text: m[3]! }
|
|
507
369
|
}
|
|
508
370
|
if (listMarker(line)) {
|
|
509
371
|
const items: { lines: string[]; gutter: number; indent: number }[] = []
|
|
510
|
-
// Marker cells per level, outermost first — a nested item's indent is the
|
|
511
|
-
// sum of its ancestors' gutters (each nested list sits in its parent li's
|
|
512
|
-
// body column).
|
|
513
372
|
const gutters: number[] = []
|
|
514
373
|
let listOrdered: boolean | undefined
|
|
515
374
|
while (i < lines.length) {
|
|
@@ -521,21 +380,18 @@ function parseBlocks(md: string): MdBlock[] {
|
|
|
521
380
|
}
|
|
522
381
|
break
|
|
523
382
|
}
|
|
524
|
-
// A different marker type at the top level starts a *new* list — one
|
|
525
|
-
// more block, one more inter-block margin.
|
|
526
383
|
if (m.depth === 0) {
|
|
527
|
-
if (listOrdered !== undefined && m.ordered !== listOrdered)
|
|
384
|
+
if (listOrdered !== undefined && m.ordered !== listOrdered) {
|
|
385
|
+
break
|
|
386
|
+
}
|
|
528
387
|
listOrdered = m.ordered
|
|
529
388
|
}
|
|
530
389
|
gutters[m.depth] = m.ordered ? 3 : 2
|
|
531
390
|
const indent = gutters.slice(0, m.depth).reduce((a, b) => a + (b ?? 2), 0)
|
|
532
391
|
const source = [m.text]
|
|
533
392
|
i += 1
|
|
534
|
-
// Lazy continuation lines belong to the item; whether each newline
|
|
535
|
-
// renders is `hardLines`' call, same as a paragraph's.
|
|
536
393
|
while (i < lines.length && lines[i]!.trim() !== '' && !listMarker(lines[i]!)) {
|
|
537
|
-
// trimStart only: a trailing double space is the hard-break marker
|
|
538
|
-
// and `hardLines` needs to see it.
|
|
394
|
+
// trimStart only: a trailing double space is the hard-break marker `hardLines` needs to see.
|
|
539
395
|
source.push(lines[i]!.trimStart())
|
|
540
396
|
i += 1
|
|
541
397
|
}
|
|
@@ -545,25 +401,20 @@ function parseBlocks(md: string): MdBlock[] {
|
|
|
545
401
|
continue
|
|
546
402
|
}
|
|
547
403
|
if (line.trimStart().startsWith('|')) {
|
|
548
|
-
// Only the row count. A table lays out at `max-content` and scrolls rather
|
|
549
|
-
// than compressing, so a row is a line whatever the cells hold — the
|
|
550
|
-
// per-column width measuring this used to do fed a wrap model that no
|
|
551
|
-
// longer exists, and it was real work on every table.
|
|
552
404
|
let rows = 0
|
|
553
405
|
while (i < lines.length && lines[i]!.trimStart().startsWith('|')) {
|
|
554
|
-
const cells = lines[i]
|
|
555
|
-
.trim()
|
|
406
|
+
const cells = lines[i]!.trim()
|
|
556
407
|
.replace(/^\||\|$/g, '')
|
|
557
408
|
.split('|')
|
|
558
409
|
.map((cell) => cell.trim())
|
|
559
|
-
|
|
560
|
-
|
|
410
|
+
if (!cells.every((cell) => /^:?-+:?$/.test(cell))) {
|
|
411
|
+
rows += 1
|
|
412
|
+
}
|
|
561
413
|
i += 1
|
|
562
414
|
}
|
|
563
415
|
blocks.push({ t: 'table', rows: Math.max(1, rows) })
|
|
564
416
|
continue
|
|
565
417
|
}
|
|
566
|
-
// Paragraph: gather until a blank line or another block opener.
|
|
567
418
|
const para: string[] = [line]
|
|
568
419
|
i += 1
|
|
569
420
|
while (
|
|
@@ -584,15 +435,15 @@ function parseBlocks(md: string): MdBlock[] {
|
|
|
584
435
|
return blocks
|
|
585
436
|
}
|
|
586
437
|
|
|
587
|
-
/** The markdown body's height: blocks, one line between consecutive ones
|
|
588
|
-
* (`.term-md .term-block + .term-block`). Exported for the dev audit only. */
|
|
589
438
|
export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
|
|
590
|
-
const bodyPx = m.width - extraPx - 2 * m.ch
|
|
439
|
+
const bodyPx = m.width - extraPx - 2 * m.ch
|
|
591
440
|
const cols = Math.floor(bodyPx / m.ch + EPS)
|
|
592
441
|
const blocks = parseBlocks(md)
|
|
593
442
|
let acc: Acc = { px: 0, exact: true }
|
|
594
443
|
for (const [index, block] of blocks.entries()) {
|
|
595
|
-
if (index > 0)
|
|
444
|
+
if (index > 0) {
|
|
445
|
+
acc = add(acc, { px: m.line, exact: true })
|
|
446
|
+
}
|
|
596
447
|
switch (block.t) {
|
|
597
448
|
case 'p': {
|
|
598
449
|
for (const line of block.lines) {
|
|
@@ -634,7 +485,9 @@ export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
|
|
|
634
485
|
let px = 0
|
|
635
486
|
let exact = true
|
|
636
487
|
for (const [pi, para] of block.paras.entries()) {
|
|
637
|
-
if (pi > 0)
|
|
488
|
+
if (pi > 0) {
|
|
489
|
+
px += m.line
|
|
490
|
+
}
|
|
638
491
|
for (const line of para) {
|
|
639
492
|
const r = textLines(line, cols - 2)
|
|
640
493
|
px += r.lines * m.line
|
|
@@ -644,26 +497,19 @@ export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
|
|
|
644
497
|
acc = add(acc, { px, exact })
|
|
645
498
|
break
|
|
646
499
|
}
|
|
647
|
-
case 'table':
|
|
648
|
-
// One line per row, always. A table is laid out at `max-content` and
|
|
649
|
-
// carries no `max-width`, so a wide one scrolls inside
|
|
650
|
-
// `.term-table-wrap` rather than compressing — which means the auto
|
|
651
|
-
// table layout never wraps a cell and there is nothing here to model.
|
|
652
|
-
// This used to branch on whether the table fit, and flag the wide case:
|
|
653
|
-
// that branch was the theme's own `max-width: 100%` leaking into the
|
|
654
|
-
// calculator, and both went at once. See `terminal.css` §Markdown.
|
|
500
|
+
case 'table': {
|
|
655
501
|
acc = add(acc, { px: block.rows * m.line, exact: true })
|
|
656
502
|
break
|
|
657
|
-
|
|
503
|
+
}
|
|
504
|
+
case 'hr': {
|
|
658
505
|
acc = add(acc, { px: m.line, exact: true })
|
|
659
506
|
break
|
|
507
|
+
}
|
|
660
508
|
}
|
|
661
509
|
}
|
|
662
510
|
return { px: Math.max(acc.px, m.line), exact: acc.exact }
|
|
663
511
|
}
|
|
664
512
|
|
|
665
|
-
/* ── Items ─────────────────────────────────────────────────────────────────── */
|
|
666
|
-
|
|
667
513
|
function diffHeight(patch: FilePatch, m: CellMetrics, extraPx = 0): Acc {
|
|
668
514
|
const walk = (hunk: PatchHunk) => {
|
|
669
515
|
let newLine = hunk.newStart
|
|
@@ -681,107 +527,111 @@ function diffHeight(patch: FilePatch, m: CellMetrics, extraPx = 0): Acc {
|
|
|
681
527
|
}
|
|
682
528
|
const hunks = patch.hunks.map(walk)
|
|
683
529
|
const numbered = patch.hunks.some((hunk) => hunk.newStart > 0)
|
|
684
|
-
const width = numbered
|
|
685
|
-
? String(Math.max(...hunks.flat().map((row) => row.number), 1)).length
|
|
686
|
-
: 0
|
|
530
|
+
const width = numbered ? String(Math.max(...hunks.flat().map((row) => row.number), 1)).length : 0
|
|
687
531
|
const columns = numbered ? width + 3 : 2
|
|
688
532
|
let acc: Acc = { px: 0, exact: true }
|
|
689
533
|
for (const [index, hunk] of hunks.entries()) {
|
|
690
|
-
if (index > 0)
|
|
534
|
+
if (index > 0) {
|
|
535
|
+
acc = add(acc, { px: m.line, exact: true })
|
|
536
|
+
} // the ⋮ separator row
|
|
691
537
|
for (const row of hunk) {
|
|
692
538
|
acc = add(acc, rowH(row.text || ' ', m, { indentCells: 2, gutterCells: columns, extraPx }))
|
|
693
539
|
}
|
|
694
540
|
}
|
|
695
|
-
if (patch.truncated)
|
|
541
|
+
if (patch.truncated) {
|
|
542
|
+
acc = add(acc, { px: m.line, exact: true })
|
|
543
|
+
}
|
|
696
544
|
return acc
|
|
697
545
|
}
|
|
698
546
|
|
|
699
|
-
/** A collapsed tool row: the header, then the diff (when the call carries a
|
|
700
|
-
* patch) or the shared collapsed preview plus its trailing "there is more" row.
|
|
701
|
-
* No expanded branch — see the module comment's first invariant. */
|
|
702
547
|
function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc {
|
|
703
|
-
const
|
|
548
|
+
const todos = todoPreview(item.name, item.input)
|
|
549
|
+
const preview = todos ? todos.summary : toolInputPreview(item.input)
|
|
704
550
|
const backend = item.backend && item.backend !== 'server' ? ` · ${item.backend}` : ''
|
|
705
551
|
let acc = rowH(`${item.name}(${preview})${backend}`, m, { gutterCells: 2, extraPx })
|
|
706
552
|
|
|
707
|
-
// Each replayed image part draws a box of whole lines, and it draws it in
|
|
708
|
-
// every state — placeholder, picture, failure — so the height is settled
|
|
709
|
-
// before the first byte is asked for and the load can never reflow the list.
|
|
710
|
-
// No wrap and no `exact: false`: the box is the constant, not the image (see
|
|
711
|
-
// `image-box.ts`).
|
|
712
553
|
const images = item.result?.images
|
|
713
|
-
if (images?.length)
|
|
554
|
+
if (images?.length) {
|
|
714
555
|
acc = add(acc, { px: images.length * IMAGE_BOX_LINES * m.line, exact: true })
|
|
556
|
+
}
|
|
715
557
|
|
|
716
|
-
if (item.patch)
|
|
558
|
+
if (item.patch) {
|
|
559
|
+
return add(acc, diffHeight(item.patch, m, extraPx))
|
|
560
|
+
}
|
|
561
|
+
if (todos) {
|
|
562
|
+
for (const todo of todos.shown) {
|
|
563
|
+
acc = add(acc, rowH(todoLine(todo), m, { indentCells: 3, gutterCells: 3, extraPx }))
|
|
564
|
+
}
|
|
565
|
+
if (todos.more) {
|
|
566
|
+
acc = add(acc, rowH(todos.more, m, { indentCells: 3, gutterCells: 3, extraPx }))
|
|
567
|
+
}
|
|
568
|
+
return acc
|
|
569
|
+
}
|
|
717
570
|
const text = item.result?.text ?? ''
|
|
718
|
-
if (!text)
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
// `items.tsx` draws — which it previously could, and which its own comment
|
|
722
|
-
// said only the dev audit was catching.
|
|
571
|
+
if (!text) {
|
|
572
|
+
return acc
|
|
573
|
+
}
|
|
723
574
|
const { shown, more } = collapsedResult(text.trimEnd().split('\n'), item.result?.totalChars)
|
|
724
575
|
for (const line of shown) {
|
|
725
|
-
// indent=1 with columns=3 resolves the indent against the row's own
|
|
726
|
-
// --term-cell: 3ch of padding + 3ch of gutter.
|
|
727
576
|
acc = add(acc, rowH(line || ' ', m, { indentCells: 3, gutterCells: 3, extraPx }))
|
|
728
577
|
}
|
|
729
|
-
if (more)
|
|
578
|
+
if (more) {
|
|
579
|
+
acc = add(acc, rowH(more, m, { indentCells: 3, gutterCells: 3, extraPx }))
|
|
580
|
+
}
|
|
730
581
|
return acc
|
|
731
582
|
}
|
|
732
583
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
584
|
+
// Nested rows are stepped in behind a rule: `border-l-2` (2px) + `pl-3` (12px) on the wrapper in `agent/Transcript.tsx`.
|
|
585
|
+
function nestedExtraPx(item: TranscriptItem): number {
|
|
586
|
+
return 'parentToolUseId' in item && item.parentToolUseId != null ? 14 : 0
|
|
587
|
+
}
|
|
737
588
|
|
|
738
|
-
/** One transcript item's height, in its default (collapsed, settled-or-not)
|
|
739
|
-
* presentation. */
|
|
740
589
|
export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight {
|
|
741
590
|
const extraPx = nestedExtraPx(item)
|
|
742
591
|
switch (item.kind) {
|
|
743
592
|
case 'user': {
|
|
744
593
|
let acc: Acc = { px: 0, exact: true }
|
|
745
|
-
if (item.attachments?.length)
|
|
594
|
+
if (item.attachments?.length) {
|
|
746
595
|
acc = add(acc, rowH(item.attachments.map((a) => a.name).join(', '), m, { extraPx }))
|
|
747
|
-
|
|
748
|
-
|
|
596
|
+
}
|
|
597
|
+
if (item.text) {
|
|
598
|
+
for (const line of item.text.split('\n')) {
|
|
599
|
+
acc = add(acc, rowH(line || ' ', m, { extraPx }))
|
|
600
|
+
}
|
|
601
|
+
}
|
|
749
602
|
return acc
|
|
750
603
|
}
|
|
751
|
-
case 'assistant_text':
|
|
604
|
+
case 'assistant_text': {
|
|
752
605
|
return markdownHeight(item.text, m, extraPx)
|
|
753
|
-
|
|
606
|
+
}
|
|
607
|
+
case 'thinking': {
|
|
754
608
|
return rowH(item.text, m, { extraPx })
|
|
755
|
-
|
|
609
|
+
}
|
|
610
|
+
case 'tool_call': {
|
|
756
611
|
return toolRowHeight(item, m, extraPx)
|
|
612
|
+
}
|
|
757
613
|
case 'turn_result': {
|
|
758
|
-
let acc = rowH(
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
614
|
+
let acc = rowH(`${item.isError ? item.subtype : 'done'} · ${formatDuration(item.durationMs)} · ${formatCost(item.totalCostUsd)}`, m, {
|
|
615
|
+
extraPx,
|
|
616
|
+
})
|
|
617
|
+
for (const message of item.errors ?? []) {
|
|
618
|
+
acc = add(acc, rowH(message, m, { extraPx }))
|
|
619
|
+
}
|
|
764
620
|
return acc
|
|
765
621
|
}
|
|
766
|
-
case 'notice':
|
|
622
|
+
case 'notice': {
|
|
767
623
|
return rowH(item.text, m, { extraPx })
|
|
624
|
+
}
|
|
768
625
|
case 'file_delivered': {
|
|
769
|
-
const text =
|
|
770
|
-
`${item.path} · ${formatBytes(item.bytes)}` +
|
|
771
|
-
(item.description ? ` · ${item.description}` : '')
|
|
626
|
+
const text = `${item.path} · ${formatBytes(item.bytes)}` + (item.description ? ` · ${item.description}` : '')
|
|
772
627
|
return rowH(text, m, { extraPx })
|
|
773
628
|
}
|
|
774
|
-
default:
|
|
629
|
+
default: {
|
|
775
630
|
return { px: 0, exact: false }
|
|
631
|
+
}
|
|
776
632
|
}
|
|
777
633
|
}
|
|
778
634
|
|
|
779
|
-
/** A virtual row's height: an item, a folded tool run, or a task block —
|
|
780
|
-
* either fold collapsed is its one summary line, built by the same function
|
|
781
|
-
* the row draws (`runSummary` / `taskSummary`), rendered as one standard
|
|
782
|
-
* 2-cell-gutter `Row`. No expanded branch for the task block either: it is
|
|
783
|
-
* always collapsed by default, which is the invariant that keeps every
|
|
784
|
-
* unmounted row's estimate exact. */
|
|
785
635
|
export function blockHeight(block: TerminalBlock, m: CellMetrics): ComputedHeight {
|
|
786
636
|
if ('task' in block) {
|
|
787
637
|
return rowH(taskSummary(block.task, taskChildItems(block)), m)
|