@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,1011 @@
1
+ /*
2
+ * The terminal theme's geometry and palette.
3
+ *
4
+ * Plain CSS classes rather than Tailwind utilities, and deliberately. This is a
5
+ * *renderer*: every row it draws lands on the same character cell, and a grid
6
+ * that has to be reassembled from `gap-2 pl-[calc(...)] -mx-1` at each call site
7
+ * is a grid that drifts the first time someone edits one row. The components in
8
+ * `components/terminal/` are therefore thin — they choose a class and a tone,
9
+ * and the geometry lives here in one place where it can be read as a whole.
10
+ *
11
+ * It also means an embedder gets the geometry without configuring Tailwind's
12
+ * `@source` scanning: these class names exist whether or not the scanner found
13
+ * the components that use them.
14
+ *
15
+ * ── The two rules everything else follows ────────────────────────────────────
16
+ *
17
+ * 1. **Horizontal measures are `ch`, never px or rem.** `1ch` in a monospace
18
+ * face is exactly one cell, so a gutter is `2ch` and an indent level is
19
+ * `2ch`, and text lands on a column rather than near one. A wrapped line
20
+ * hangs under its own first character because the body is its own grid
21
+ * column — not because a padding and a negative text-indent were tuned
22
+ * against each other.
23
+ * 2. **Vertical measures are whole multiples of `--term-line`, never padding.**
24
+ * Blank space between blocks is a *blank line* (an empty row), the way it is
25
+ * in a terminal. Nothing has margins, so no two blocks can disagree about
26
+ * the rhythm, and a row's height is always an integer number of lines — the
27
+ * thing that keeps text off half-pixels and out of the blur.
28
+ *
29
+ * `--term-font-size` and `--term-line` must both be whole pixels. A line height
30
+ * of `1.5 × 13px` is 19.5px, and every second row of a long transcript then
31
+ * renders on a half-pixel: the text visibly softens and hard-edged backgrounds
32
+ * (the diff bands) show a seam. Integers are not a preference here.
33
+ */
34
+
35
+ [data-terminal] {
36
+ /* Metrics. Overridable per host — VS Code hands its editor font down, and a
37
+ phone wants a larger cell — but always in whole pixels. */
38
+ --term-font-size: 13px;
39
+ --term-line: 18px;
40
+ /* One indent level, and the width of a gutter. The same value on purpose:
41
+ a child row's marker sits exactly where its parent's text began. */
42
+ --term-cell: 2ch;
43
+
44
+ font-family: var(--cw-font-mono);
45
+ font-size: var(--term-font-size);
46
+ line-height: var(--term-line);
47
+ color: var(--term-fg);
48
+ /* Ligatures turn `=>` and `!=` into glyphs that no longer occupy the cells
49
+ they were measured as. A grid renderer cannot have that. */
50
+ font-variant-ligatures: none;
51
+ font-feature-settings: 'liga' 0, 'calt' 0;
52
+ /* `ch` is the advance of `0`, so tabular figures keep digit columns — the
53
+ diff's line numbers — genuinely aligned. */
54
+ font-variant-numeric: tabular-nums;
55
+ -webkit-font-smoothing: antialiased;
56
+ tab-size: 2;
57
+ }
58
+
59
+ /* ── Palette ─────────────────────────────────────────────────────────────────
60
+ *
61
+ * The terminal's own colours, not the app's. `--accent` is VS Code blue because
62
+ * WorkerDeck's chrome shares an editor's visual language; a terminal shares a
63
+ * terminal's, and the two must not be forced to agree. Anything reading a
64
+ * `--term-*` token therefore keeps its colour when the surrounding app is
65
+ * re-themed, which is the point: the transcript looks like the CLI wherever it
66
+ * is embedded.
67
+ *
68
+ * Values are read off the Claude Code CLI on a dark terminal: body text is a
69
+ * light grey rather than white (white is reserved for emphasis, so bold has
70
+ * somewhere to go), meta lines are one step down again, and the working mark is
71
+ * the product's coral.
72
+ */
73
+ [data-terminal] {
74
+ --term-fg: #d4d4d4;
75
+ --term-bright: #ffffff;
76
+ --term-dim: #8a8a8a;
77
+ --term-faint: #6a6a6a;
78
+
79
+ --term-mark: #d97757;
80
+ --term-blue: #afb9fe;
81
+ --term-green: #4ec9a0;
82
+ --term-red: #f14c4c;
83
+ --term-yellow: #d7ba7d;
84
+ --term-magenta: #c586c0;
85
+
86
+ /* Diff bands. Full-bleed backgrounds, so they are tuned to sit under body
87
+ text rather than to be looked at: the CLI's added block is a dark green
88
+ wash the text stays legible on, not a highlight. */
89
+ --term-add-fg: #b5e8a9;
90
+ --term-add-bg: #16301a;
91
+ --term-del-fg: #f0a6a6;
92
+ --term-del-bg: #3a1518;
93
+ /* The unchanged context inside a diff, and the line-number column. */
94
+ --term-diff-fg: #9a9a9a;
95
+ --term-diff-num: #5a5a5a;
96
+
97
+ /* A band of code or output. Barely there — one step off the canvas. */
98
+ --term-band-bg: rgb(255 255 255 / 0.04);
99
+ /* The user's own prompt row. */
100
+ --term-user-bg: rgb(255 255 255 / 0.05);
101
+ --term-row-hover: rgb(255 255 255 / 0.05);
102
+ }
103
+
104
+ [data-theme='light'] [data-terminal],
105
+ [data-terminal][data-theme='light'] {
106
+ --term-fg: #2f2f2f;
107
+ --term-bright: #000000;
108
+ --term-dim: #6b6b6b;
109
+ --term-faint: #8d8d8d;
110
+
111
+ --term-mark: #bf5b3d;
112
+ --term-blue: #0a66c2;
113
+ --term-green: #16794a;
114
+ --term-red: #c62828;
115
+ --term-yellow: #8a6d00;
116
+ --term-magenta: #8b3a8b;
117
+
118
+ --term-add-fg: #12492a;
119
+ --term-add-bg: #e2f6e5;
120
+ --term-del-fg: #7a1f24;
121
+ --term-del-bg: #fbe6e7;
122
+ --term-diff-fg: #4a4a4a;
123
+ --term-diff-num: #9a9a9a;
124
+
125
+ --term-band-bg: rgb(0 0 0 / 0.04);
126
+ --term-user-bg: rgb(0 0 0 / 0.05);
127
+ --term-row-hover: rgb(0 0 0 / 0.04);
128
+ }
129
+
130
+ /* ── Rows ────────────────────────────────────────────────────────────────────
131
+ *
132
+ * One row = a gutter cell and a body cell. The gutter holds the marker (`●`,
133
+ * `⎿`, `>`) or nothing at all; the body holds the text and wraps inside its own
134
+ * column, which is what gives every row its hanging indent for free.
135
+ */
136
+ .term-row {
137
+ display: grid;
138
+ grid-template-columns: var(--term-cell) minmax(0, 1fr);
139
+ align-items: start;
140
+ min-height: var(--term-line);
141
+ }
142
+
143
+ /* Indent levels. A nested row steps one cell per level — the same 2 columns a
144
+ marker occupies, so a child's marker sits under its parent's first letter. */
145
+ .term-row[data-indent='1'] { padding-left: var(--term-cell); }
146
+ .term-row[data-indent='2'] { padding-left: calc(2 * var(--term-cell)); }
147
+ .term-row[data-indent='3'] { padding-left: calc(3 * var(--term-cell)); }
148
+
149
+ .term-gutter {
150
+ /* `pre` so a space-only gutter keeps its cell — an unmarked row must still
151
+ start its text on the same column as a marked one. */
152
+ white-space: pre;
153
+ user-select: none;
154
+ color: var(--term-dim);
155
+ }
156
+
157
+ .term-body {
158
+ min-width: 0;
159
+ /* Preserve the run's own spacing, wrap at the viewport edge. `break-word`
160
+ rather than `anywhere`: a long path should wrap, a sentence should not be
161
+ chopped mid-word when a shorter break exists. */
162
+ white-space: pre-wrap;
163
+ overflow-wrap: break-word;
164
+ }
165
+
166
+ /* A blank line — the only vertical spacing in the theme. Exactly one row tall
167
+ and holding nothing, which is what a terminal's blank line is. */
168
+ .term-blank {
169
+ height: var(--term-line);
170
+ }
171
+
172
+ /* A row `scrollIntoView`ed lands one line below the top edge rather than flush
173
+ against it, so the target reads as the start of something rather than as
174
+ content cut off by the frame. Only the recap's `'center'` jump still goes
175
+ through scrollIntoView — the scrubber's `'start'` jumps compute their own
176
+ offset (a sticky row's rect is wherever it is pinned) and land the line at
177
+ exactly the top edge. */
178
+ [data-terminal] [data-slot='transcript-rows'] [data-index] {
179
+ scroll-margin-block-start: var(--term-line);
180
+ }
181
+
182
+ /* The same blank line, as padding on a virtualized row's measured wrapper.
183
+ Identical height, and it must be padding there rather than a `<Blank/>`: the
184
+ virtualizer measures one element per item, so space between two items has to
185
+ be part of one of them or it is not measured at all. */
186
+ [data-terminal] .term-row-gap {
187
+ padding-top: var(--term-line);
188
+ }
189
+
190
+ /* ── Bands ───────────────────────────────────────────────────────────────────
191
+ *
192
+ * A run of rows carrying a background: a code block, a command's output, a diff
193
+ * hunk. Full-bleed — the wash reaches the scroller's edges rather than stopping
194
+ * at a content gutter, because in a terminal the line *is* the full width.
195
+ * `--term-bleed` is how far out that is; the transcript sets it to its own
196
+ * padding and the band cancels it with matched negative margin and padding.
197
+ */
198
+ .term-band {
199
+ background: var(--term-band-bg);
200
+ margin-inline: calc(-1 * var(--term-bleed, 0px));
201
+ padding-inline: var(--term-bleed, 0px);
202
+ }
203
+
204
+ /* ── Tones ───────────────────────────────────────────────────────────────────
205
+ *
206
+ * Colour as an attribute rather than a class per palette entry: a row component
207
+ * takes `tone` and passes it straight through, so adding a colour is one line
208
+ * here and nothing in the components.
209
+ */
210
+ [data-terminal] [data-tone='fg'] { color: var(--term-fg); }
211
+ [data-terminal] [data-tone='bright'] { color: var(--term-bright); }
212
+ [data-terminal] [data-tone='dim'] { color: var(--term-dim); }
213
+ [data-terminal] [data-tone='faint'] { color: var(--term-faint); }
214
+ [data-terminal] [data-tone='mark'] { color: var(--term-mark); }
215
+ [data-terminal] [data-tone='blue'] { color: var(--term-blue); }
216
+ [data-terminal] [data-tone='green'] { color: var(--term-green); }
217
+ [data-terminal] [data-tone='red'] { color: var(--term-red); }
218
+ [data-terminal] [data-tone='yellow'] { color: var(--term-yellow); }
219
+ [data-terminal] [data-tone='magenta'] { color: var(--term-magenta); }
220
+
221
+ /* Weight is the only other axis. A terminal has one type size, so emphasis has
222
+ to be carried by weight and colour — never by a larger glyph. */
223
+ [data-terminal] [data-weight='bold'] { font-weight: 600; }
224
+
225
+ /* The scroller. Its horizontal padding IS `--term-bleed` — that is the whole
226
+ contract a full-bleed band relies on — and its vertical padding is a line, so
227
+ the first row starts one blank line down like every other block. */
228
+ [data-terminal].term-transcript {
229
+ padding-inline: var(--term-bleed, 0px);
230
+ padding-block: var(--term-line);
231
+ }
232
+
233
+ /* ── Interaction ─────────────────────────────────────────────────────────────
234
+ *
235
+ * A terminal has no buttons, but a transcript does have things you can open. The
236
+ * affordance is the hover fill on the row itself — nothing gains a border, a
237
+ * radius or a focus box that would take it off the grid.
238
+ */
239
+ [data-terminal] .term-press {
240
+ display: block;
241
+ width: 100%;
242
+ margin: 0;
243
+ padding: 0;
244
+ border: 0;
245
+ background: none;
246
+ font: inherit;
247
+ color: inherit;
248
+ text-align: left;
249
+ cursor: pointer;
250
+ /* A transcript is read far more often than it is opened, so a pressable row
251
+ still has to be text you can drag a selection out of. The other half of
252
+ this is in `press.tsx`: the click that ends such a drag is refused. */
253
+ user-select: text;
254
+ }
255
+ /* The hover fill lives with the other affordances below — it is one, and it is
256
+ switchable for the same reason. */
257
+ [data-terminal] .term-press:focus-visible {
258
+ outline: none;
259
+ }
260
+
261
+ /* ── Affordances ─────────────────────────────────────────────────────────────
262
+ *
263
+ * What a real terminal cannot do. Every one of these costs **no layout** — a
264
+ * fill is a background, an action is an absolutely positioned overlay one line
265
+ * tall — so switching them off leaves every glyph on the cell it was already on.
266
+ * See `components/terminal/affordances.tsx`.
267
+ */
268
+
269
+ /* The hover fill, gated on the surface's declaration. Without `hover` in the
270
+ list the selector never matches and a pressable row is inert to the pointer,
271
+ which is what a host projecting to a real terminal wants. */
272
+ [data-terminal][data-affordances~='hover'] .term-press:hover > .term-row,
273
+ [data-terminal][data-affordances~='hover'] .term-press:focus-visible > .term-row {
274
+ background: var(--term-row-hover);
275
+ margin-inline: calc(-1 * var(--term-bleed, 0px));
276
+ padding-inline: var(--term-bleed, 0px);
277
+ }
278
+
279
+ /* An opened block keeps the fill the pointer gave it, for the whole of what it
280
+ opened. Eighty lines of output pushes its own first line off the top of the
281
+ screen, and without a wash there is nothing to say where the thing you
282
+ expanded began — or that these rows are one block at all. Full-bleed, like
283
+ every other wash; the hover fill inside it reaches the same edge, so the two
284
+ stack without a step. (The other half is `useRevealOnOpen`, which brings that
285
+ first line back when it has gone above the fold.) */
286
+ [data-terminal] .term-open {
287
+ background: var(--term-row-hover);
288
+ margin-inline: calc(-1 * var(--term-bleed, 0px));
289
+ padding-inline: var(--term-bleed, 0px);
290
+ }
291
+
292
+ .term-hoverable {
293
+ position: relative;
294
+ }
295
+ /* Pinned to the block's first line at its right edge. Absolute, so the rows
296
+ below are exactly where they would have been without it. */
297
+ [data-terminal] .term-actions {
298
+ position: absolute;
299
+ top: 0;
300
+ right: 0;
301
+ height: var(--term-line);
302
+ display: flex;
303
+ gap: 1ch;
304
+ padding-inline: 1ch;
305
+ background: var(--bg, transparent);
306
+ opacity: 0;
307
+ transition: opacity var(--motion-fast, 120ms);
308
+ }
309
+ [data-terminal] .term-hoverable:hover .term-actions,
310
+ [data-terminal] .term-hoverable:focus-within .term-actions {
311
+ opacity: 1;
312
+ }
313
+ @media (hover: none) {
314
+ /* No pointer to hover with: showing them always is the only way they exist at
315
+ all, and a phone has the room. */
316
+ [data-terminal] .term-actions {
317
+ opacity: 1;
318
+ }
319
+ }
320
+ [data-terminal] .term-action {
321
+ font: inherit;
322
+ line-height: var(--term-line);
323
+ color: var(--term-dim);
324
+ background: none;
325
+ border: 0;
326
+ padding: 0;
327
+ cursor: pointer;
328
+ }
329
+ [data-terminal] .term-action:hover {
330
+ color: var(--term-fg);
331
+ }
332
+
333
+ /* What you typed, on a band of its own — the CLI's own treatment. */
334
+ [data-terminal] .term-user {
335
+ background: var(--term-user-bg);
336
+ margin-inline: calc(-1 * var(--term-bleed, 0px));
337
+ padding-inline: var(--term-bleed, 0px);
338
+ }
339
+
340
+ [data-terminal] .term-link {
341
+ text-decoration: underline;
342
+ text-underline-offset: 2px;
343
+ }
344
+
345
+ /* A file path, while the modifier that would open it is held.
346
+ *
347
+ * Only then, and that is the point: a path *is* clickable but only under
348
+ * Cmd/Ctrl, so it should only look clickable then — an always-underlined path
349
+ * promises a plain click that does nothing. Applied by `usePathLinks` using the
350
+ * same test the click itself makes, so nothing lights up that a click ignores.
351
+ * Outside `[data-terminal]` too, since a cards transcript names files as well. */
352
+ .wd-path-link {
353
+ text-decoration: underline;
354
+ text-underline-offset: 2px;
355
+ cursor: pointer;
356
+ }
357
+
358
+ /* A status-line reading that leads somewhere. Inline and unstyled until hovered
359
+ — the line is meant to be quiet, and five underlined segments would make it
360
+ the loudest row on screen. */
361
+ [data-terminal] .term-reading {
362
+ font: inherit;
363
+ color: inherit;
364
+ background: none;
365
+ border: 0;
366
+ padding: 0;
367
+ cursor: pointer;
368
+ }
369
+ [data-terminal][data-affordances~='hover'] .term-reading:hover {
370
+ text-decoration: underline;
371
+ text-underline-offset: 2px;
372
+ }
373
+
374
+ /* ── Prompts ─────────────────────────────────────────────────────────────────
375
+ *
376
+ * The affordances a form needs, drawn without a single box or border that would
377
+ * take a row off the grid. Strokes are backgrounds (zero layout), the focused
378
+ * row is a fill, and the only "control" is a row you can press a number on.
379
+ */
380
+
381
+ /* One line tall with the stroke through its middle, full-bleed — the boundary
382
+ between the run and the decision. */
383
+ [data-terminal] .term-rule-row {
384
+ height: var(--term-line);
385
+ margin-inline: calc(-1 * var(--term-bleed, 0px));
386
+ background: linear-gradient(var(--term-dim), var(--term-dim)) left center / 100% 1px no-repeat;
387
+ }
388
+ /* Dashed, for a boundary *within* a prompt. A repeating gradient rather than a
389
+ dashed border, for the same no-layout reason. */
390
+ [data-terminal] .term-rule-dashed {
391
+ background: repeating-linear-gradient(
392
+ to right,
393
+ var(--term-faint) 0 4px,
394
+ transparent 4px 8px
395
+ )
396
+ left center / 100% 1px no-repeat;
397
+ }
398
+
399
+ /* The focused option. A fill, never an outline: an outline is 2px of layout the
400
+ row does not have, and the browser's own focus ring would land between cells. */
401
+ [data-terminal] .term-row[data-focused] {
402
+ background: var(--term-row-hover);
403
+ margin-inline: calc(-1 * var(--term-bleed, 0px));
404
+ padding-inline: var(--term-bleed, 0px);
405
+ }
406
+
407
+ /* An option's expanded detail (a preview, a reason field) lines up with the
408
+ option's own text — one gutter in, so it reads as belonging to that row. */
409
+ [data-terminal] .term-detail {
410
+ padding-left: 5ch;
411
+ }
412
+
413
+ /* A framed payload: four 1px strokes as backgrounds, so the frame costs no
414
+ layout and the text inside stays on the cell it would have been on. */
415
+ [data-terminal] .term-box {
416
+ padding-inline: var(--term-cell);
417
+ /* Sized to its content, never to the viewport: a preview is a sample, and a
418
+ frame stretched across a wide screen reads as a panel the reader is meant
419
+ to work in. */
420
+ width: max-content;
421
+ max-width: 100%;
422
+ background:
423
+ linear-gradient(var(--term-faint), var(--term-faint)) top left / 100% 1px no-repeat,
424
+ linear-gradient(var(--term-faint), var(--term-faint)) bottom left / 100% 1px no-repeat,
425
+ linear-gradient(var(--term-faint), var(--term-faint)) top left / 1px 100% no-repeat,
426
+ linear-gradient(var(--term-faint), var(--term-faint)) top right / 1px 100% no-repeat;
427
+ }
428
+
429
+ /* A question chip. Inline so the strip is one row; the active one is filled the
430
+ way the CLI fills it. */
431
+ [data-terminal] .term-tab {
432
+ font: inherit;
433
+ color: inherit;
434
+ background: none;
435
+ border: 0;
436
+ padding: 0 1ch;
437
+ margin: 0;
438
+ cursor: pointer;
439
+ }
440
+ [data-terminal] .term-tab-active {
441
+ background: var(--term-blue);
442
+ color: var(--bg, #000);
443
+ }
444
+
445
+ /* The bare text field — a caret and a rule, no box. */
446
+ [data-terminal] .term-input {
447
+ width: 100%;
448
+ min-width: 0;
449
+ font: inherit;
450
+ color: var(--term-fg);
451
+ background: none;
452
+ border: 0;
453
+ padding: 0;
454
+ outline: none;
455
+ /* The terminal's own caret: a filled cell, not a hairline. It is the last
456
+ thing in the theme still drawn the way the web draws it, and it is the one
457
+ the eye is always on. `caret-animation: manual` hands the blink to the
458
+ author and we then decline to animate it, so the block sits steady — a
459
+ blinking block is a strobe at this size, where a blinking hairline was
460
+ merely a caret. Both are ignored where unsupported, which is exactly the
461
+ right failure: a thin blinking caret is a plain caret, not a broken one. */
462
+ caret-shape: block;
463
+ caret-animation: manual;
464
+ background: linear-gradient(var(--term-faint), var(--term-faint)) bottom left / 100% 1px no-repeat;
465
+ }
466
+ [data-terminal] .term-input:focus {
467
+ background-image: linear-gradient(var(--term-blue), var(--term-blue));
468
+ }
469
+ [data-terminal] .term-input::placeholder {
470
+ color: var(--term-faint);
471
+ }
472
+
473
+ /* ── Sticky prompt ───────────────────────────────────────────────────────────
474
+ *
475
+ * The pinned prompt is the prompt's **first line**, and the pin is the
476
+ * browser's, not script's. `TranscriptRows` renders each prompt inside a lane
477
+ * spanning its turn; a one-line `sticky` **head** — the same row rendered
478
+ * again, clipped by height — leads the lane with its flow footprint cancelled
479
+ * (the negative bottom margin), so in flow it lies exactly over the real
480
+ * row's first line and is invisible. The compositor pins it (no per-scroll
481
+ * JS, no frame lag) and the lane's bottom edge pushes it off when the next
482
+ * prompt arrives. One line and not the row, because a pasted twenty-line
483
+ * prompt pinned whole covers the very answer being read.
484
+ *
485
+ * The head's visible line docks flush to the top edge of the scroller. Its
486
+ * inter-row gap is padding on the head (matching the row it overlays), so a
487
+ * gap-carrying head is one line taller and pins one line negative: the
488
+ * padding parks above the viewport edge and the line starts at zero — which
489
+ * is also what makes the pin engage exactly when the line itself reaches the
490
+ * top, not a line early.
491
+ *
492
+ * The lane spans the whole turn and the head is an overlay, so both are
493
+ * transparent to the pointer — the real row beneath owns interaction, and
494
+ * the head is `aria-hidden` in the markup. The z-index is on the lane —
495
+ * every row wrapper is its own stacking context (they are transformed), so
496
+ * the head's own z-index could never beat a later sibling.
497
+ *
498
+ * `--bg` is the panel's own ground, the same token the hover-revealed row
499
+ * actions cover themselves with — the user band is translucent by design, so
500
+ * without a ground the rows passing beneath would show straight through the
501
+ * pinned head. Opaque always, which is invisible in flow: the ground behind
502
+ * it is the same `--bg`, and the row it covers is its own content.
503
+ */
504
+ [data-terminal] [data-slot='transcript-rows'] > [data-sticky-lane] {
505
+ z-index: 3;
506
+ pointer-events: none;
507
+ }
508
+
509
+ /* The head's own strip: the full lane, out of flow, so the head needs no
510
+ footprint-cancelling margin — sticky confinement clamps the margin box, and
511
+ a negative margin shrinks it to nothing, letting the head overshoot the
512
+ lane's end by its own height (two pinned prompts on screen at once). */
513
+ [data-terminal] [data-sticky-lane] > [data-sticky-headlane] {
514
+ position: absolute;
515
+ inset: 0;
516
+ pointer-events: none;
517
+ }
518
+
519
+ /* Hidden until actually stuck (`data-stuck`, set by the lane's sentinel
520
+ observer): an overlay visible in flow sits on the real row's first line and
521
+ swallows its selection highlight — the line reads as unselectable. */
522
+ [data-terminal] [data-sticky-headlane] > [data-sticky-head] {
523
+ position: sticky;
524
+ top: 0;
525
+ height: var(--term-line);
526
+ overflow: hidden;
527
+ background: var(--bg, transparent);
528
+ user-select: none;
529
+ visibility: hidden;
530
+ }
531
+
532
+ [data-terminal] [data-sticky-headlane] > [data-sticky-head][data-stuck] {
533
+ visibility: visible;
534
+ }
535
+
536
+ [data-terminal] [data-sticky-headlane] > [data-sticky-head].term-row-gap {
537
+ top: calc(-1 * var(--term-line));
538
+ height: calc(2 * var(--term-line));
539
+ }
540
+
541
+ /* ── Composer ────────────────────────────────────────────────────────────────
542
+ *
543
+ * The foot of the panel, and a *row* like every other: `>` in the gutter cell,
544
+ * the caret on the body column. That is the whole design — a typed line and a
545
+ * rendered one start on the same character, which no chat composer does and
546
+ * every terminal does for free.
547
+ *
548
+ * Docked rather than floating: no radius, no shadow, and a rule along the top
549
+ * *and* the bottom, both turning accent while anything inside has focus. Those
550
+ * two rules are the CLI's own frame for its prompt, and they are the only
551
+ * affordance — which is what the transcript above (no boxes anywhere) asks for.
552
+ * They also do the work a box would: the field reads as its own strip of the
553
+ * panel rather than as the last row of the transcript, without a border on the
554
+ * sides that would take the `❯` off the column the rows start on.
555
+ *
556
+ * The bottom rule is why the status bar under it draws none of its own — see
557
+ * `StatusBar.tsx`. Two adjacent 1px rules is a 2px rule with a seam in it.
558
+ */
559
+ [data-terminal].term-composer {
560
+ border-top: 1px solid var(--term-faint);
561
+ border-bottom: 1px solid var(--term-faint);
562
+ transition: border-color 120ms;
563
+ }
564
+ /* The bar spans the panel; its contents sit in the transcript's own column, so
565
+ the `>` lands on the column the rows above it start on. Two elements because
566
+ those are two different widths — a rule that stopped at the content column
567
+ would read as a card, which is the one thing this theme has none of. */
568
+ [data-terminal] .term-composer-body {
569
+ margin-inline: auto;
570
+ width: 100%;
571
+ max-width: var(--wd-content-max-w, 48rem);
572
+ padding-inline: var(--term-bleed, 0px);
573
+ /* Air on each side of the prompt, and a plain pixel value rather than a
574
+ fraction of `--term-line`: this is chrome between two rules, not a
575
+ transcript row, and the whole-multiple rule governs rows — a computed
576
+ half-line would be exactly the fractional metric that rule exists to
577
+ forbid. */
578
+ padding-block: 8px;
579
+ }
580
+ [data-terminal].term-composer:focus-within,
581
+ [data-terminal].term-composer[data-dragging] {
582
+ border-color: var(--term-blue);
583
+ }
584
+
585
+ /* The contentEditable field. `font: inherit` is what makes it the cell — the
586
+ surface above already carries the size and the line height, and anything that
587
+ restates them here can disagree with the rows. */
588
+ [data-terminal] .term-composer-field {
589
+ font: inherit;
590
+ color: var(--term-fg);
591
+ outline: none;
592
+ /* The steady block caret, same as the prompt fields. See `.term-input`. */
593
+ caret-shape: block;
594
+ caret-animation: manual;
595
+ }
596
+
597
+ /* Staged attachments, above the prompt.
598
+ *
599
+ * The card form wraps each thumbnail in a rounded box and hangs a round `✕`
600
+ * badge outside its top-right corner, over a strip with its own bottom rule.
601
+ * None of that survives here: this theme has no rounded corners and no badges
602
+ * floating off an edge, and the strip sits *inside* the composer's frame, which
603
+ * already draws the only rule there is — a second one directly above the field
604
+ * reads as the box this theme exists not to have.
605
+ *
606
+ * So the thumbnail is a plain 1px cell, and the remove control is tucked into
607
+ * its top-right corner with two rules of its own, which is how a terminal draws
608
+ * a control inside a frame rather than on top of one. `1px` rather than `0`
609
+ * puts it just inside the thumbnail's border instead of straddling it.
610
+ *
611
+ * The strip's only spacing is the 8px the composer already uses above and below
612
+ * its prompt — a plain pixel value, and deliberately, for the reason given
613
+ * there: this is chrome between rules, not a transcript row, so the
614
+ * whole-multiple-of-`--term-line` rule does not govern it. No side padding: the
615
+ * strip is already inside the body column, so padding here would step it in
616
+ * from the column every row above it starts on. */
617
+ [data-terminal] .term-attachments {
618
+ padding: 0 0 8px 0;
619
+ }
620
+ [data-terminal] .term-attachment {
621
+ border: 1px solid var(--term-faint);
622
+ }
623
+ /* A refused upload, in the terminal's own red. This needs to be said here
624
+ rather than left to the card form's `border-danger/50`: the rule above sets
625
+ the `border` shorthand, colour included, and outranks a utility class — so
626
+ without this the one state that must stand out is the one that looks
627
+ ordinary. */
628
+ [data-terminal] .term-attachment[data-failed] {
629
+ border-color: var(--term-red);
630
+ }
631
+ [data-terminal] .term-attachment-remove {
632
+ top: 1px;
633
+ right: 1px;
634
+ border-left: 1px solid var(--term-faint);
635
+ border-bottom: 1px solid var(--term-faint);
636
+ }
637
+
638
+ /* A composer action as a character: one cell wide, one line tall, so pressing
639
+ it costs no width and removing it would move nothing. Dim until you reach for
640
+ it — the same restraint the row actions show. */
641
+ [data-terminal] .term-glyph {
642
+ flex: none;
643
+ width: var(--term-cell);
644
+ height: var(--term-line);
645
+ font: inherit;
646
+ line-height: var(--term-line);
647
+ text-align: center;
648
+ background: none;
649
+ border: 0;
650
+ padding: 0;
651
+ outline: none;
652
+ user-select: none;
653
+ cursor: pointer;
654
+ }
655
+ /* A glyph standing in the **gutter cell** is a marker, so it aligns like one.
656
+ The cell is 2ch and a character is 1ch, so `text-align: center` — right for a
657
+ standalone control at the row's trailing edge — lands the glyph half a cell
658
+ (≈3.9px at 13px/JetBrains Mono) to the right of the column every `❯` and `●`
659
+ in the transcript starts on. `start` is what `.term-gutter` uses, and on this
660
+ grid "nearly the same column" is just the wrong column. */
661
+ [data-terminal] .term-glyph[data-gutter] {
662
+ text-align: start;
663
+ }
664
+
665
+ /* `:not([data-tone])` because the tone rules and this one have the same
666
+ specificity and this one is further down the file: without it, a toned glyph
667
+ (the lit `↵`) would silently lose its colour to the default. */
668
+ [data-terminal] .term-glyph:not([data-tone]) {
669
+ color: var(--term-dim);
670
+ }
671
+ [data-terminal] .term-glyph:hover:not(:disabled),
672
+ [data-terminal] .term-glyph:focus-visible:not(:disabled) {
673
+ color: var(--term-bright);
674
+ }
675
+ [data-terminal] .term-glyph:disabled {
676
+ opacity: 0.4;
677
+ cursor: default;
678
+ }
679
+
680
+ /* ── Diff ────────────────────────────────────────────────────────────────────
681
+ *
682
+ * An added or removed line is a **band**, not coloured text: the wash runs the
683
+ * full width of the screen, the way it does in a terminal, so the shape of a
684
+ * change is readable from the scrollbar without reading a word of it. The line
685
+ * numbers ride inside the wash — they are part of the changed line.
686
+ */
687
+ /* A diff belongs to the call above it, so it steps in one cell like any other
688
+ tool output. */
689
+ [data-terminal] .term-diff {
690
+ padding-left: var(--term-cell);
691
+ }
692
+
693
+ /* The wash bleeds **right** only. Left it stops at the diff's own edge, because
694
+ that edge is the line-number column and a band running past it would swallow
695
+ the indent that says which tool call this diff belongs to. Right there is
696
+ nothing to preserve, and stopping at the longest line would make the shape of
697
+ the change depend on its text. */
698
+ [data-terminal] .term-diff .term-row[data-diff='add'],
699
+ [data-terminal] .term-diff .term-row[data-diff='remove'] {
700
+ margin-right: calc(-1 * var(--term-bleed, 0px));
701
+ padding-right: var(--term-bleed, 0px);
702
+ }
703
+ [data-terminal] .term-diff .term-row[data-diff='add'] {
704
+ background: var(--term-add-bg);
705
+ color: var(--term-add-fg);
706
+ }
707
+ [data-terminal] .term-diff .term-row[data-diff='remove'] {
708
+ background: var(--term-del-bg);
709
+ color: var(--term-del-fg);
710
+ }
711
+ [data-terminal] .term-diff .term-row[data-diff='context'] {
712
+ color: var(--term-diff-fg);
713
+ }
714
+ /* The number column is dimmer than the line it belongs to in every case — it is
715
+ an address, not content. `.term-gutter`'s own colour would flatten all three
716
+ to one grey, so each state dims its own. */
717
+ [data-terminal] .term-diff .term-row > .term-gutter {
718
+ color: var(--term-diff-num);
719
+ }
720
+ [data-terminal] .term-diff .term-row[data-diff='add'] > .term-gutter {
721
+ color: color-mix(in srgb, var(--term-add-fg) 65%, transparent);
722
+ }
723
+ [data-terminal] .term-diff .term-row[data-diff='remove'] > .term-gutter {
724
+ color: color-mix(in srgb, var(--term-del-fg) 65%, transparent);
725
+ }
726
+
727
+ /* ── Markdown ────────────────────────────────────────────────────────────────
728
+ *
729
+ * The element map is in `components/terminal/markdown.tsx`; this is the little
730
+ * CSS it needs. Nothing here is an override — the renderer is told what to emit,
731
+ * so there is no default to undo and no `!important` anywhere in the theme.
732
+ */
733
+
734
+ /* react-markdown emits the source's newlines as text nodes between blocks. The
735
+ surrounding rows are `pre-wrap` (a plain line of output must keep its own
736
+ spacing), which would render every one of those as a visible blank line — so
737
+ markdown bodies opt back out, and only a fenced block opts back in. */
738
+ .term-md {
739
+ white-space: normal;
740
+ }
741
+
742
+ /* And it has to reach `.term-body` explicitly, because a list item *is* a Row
743
+ and Rows carry `pre-wrap` (a plain line of tool output must keep its own
744
+ spacing). Inherited, that turns every newline react-markdown emits between an
745
+ item's block children into a visible empty line — a nested list drew k+2
746
+ phantom blanks, so a two-line nested item was 72px of 18px rows. Models nest
747
+ lists constantly, and the grid audit cannot see it: the phantoms are whole
748
+ lines, so every row still lands on the grid. Fenced code opts back in for
749
+ itself (`.term-pre`), which is the only markdown that wants its own
750
+ whitespace. */
751
+ .term-md .term-body {
752
+ white-space: normal;
753
+ }
754
+
755
+ /* The one vertical rule: consecutive blocks are one blank line apart. A margin
756
+ rather than an inserted row, because this row list belongs to the markdown
757
+ renderer — but it is exactly one line, so the grid holds. */
758
+ .term-md .term-block + .term-block {
759
+ margin-top: var(--term-line);
760
+ }
761
+
762
+ .term-md .term-list {
763
+ list-style: none;
764
+ margin: 0;
765
+ padding: 0;
766
+ }
767
+ .term-md .term-list-ordered {
768
+ counter-reset: term-ol;
769
+ }
770
+ /* Markers live in the row's gutter cell, so they cost columns rather than a
771
+ typographic indent: `- ` is two, `1. ` is three. */
772
+ .term-md .term-li > .term-gutter::before {
773
+ content: '-';
774
+ }
775
+ .term-md .term-list-ordered > .term-li {
776
+ --term-cell: 3ch;
777
+ counter-increment: term-ol;
778
+ }
779
+ .term-md .term-list-ordered > .term-li > .term-gutter::before {
780
+ content: counter(term-ol) '.';
781
+ }
782
+
783
+ /* A quote's rule is a background, not a border: a 1px border would push the
784
+ text 1px off the column it shares with every other line. */
785
+ .term-md .term-quote {
786
+ padding-left: var(--term-cell);
787
+ background: linear-gradient(var(--term-faint), var(--term-faint)) left / 1px 100% no-repeat;
788
+ }
789
+
790
+ /* A horizontal rule is one line tall with the rule through its middle — the row
791
+ it occupies is a whole line either way. */
792
+ .term-md .term-rule {
793
+ height: var(--term-line);
794
+ background: linear-gradient(var(--term-faint), var(--term-faint)) left center / 100% 1px no-repeat;
795
+ }
796
+
797
+ .term-md .term-pre {
798
+ margin: 0;
799
+ font: inherit;
800
+ white-space: pre-wrap;
801
+ overflow-wrap: break-word;
802
+ color: var(--term-dim);
803
+ }
804
+
805
+ .term-md .term-inline-code {
806
+ font: inherit;
807
+ background: none;
808
+ padding: 0;
809
+ }
810
+
811
+ .term-md .term-em {
812
+ font-style: italic;
813
+ }
814
+
815
+ .term-md .term-table-wrap {
816
+ overflow-x: auto;
817
+ }
818
+ .term-md .term-table {
819
+ border-collapse: collapse;
820
+ /* `max-content` and **no** `max-width`. A cap of `100%` looks like the
821
+ defensive choice and is the opposite: it clamps a wide table below its
822
+ natural width, so the cells wrap and a 14-row table becomes 19 lines tall —
823
+ which breaks the theme's premise (a row is a line) and with it the height
824
+ calculator, whose table model counts rows. It also silently disables the
825
+ wrapper's own `overflow-x`, since a table that shrinks never overflows.
826
+ Letting it reach `max-content` puts every row back on one line and hands the
827
+ scrolling to `.term-table-wrap`, which exists for exactly that. */
828
+ width: max-content;
829
+ }
830
+ .term-md .term-table :is(th, td) {
831
+ /* Columns are cells, rows are lines. No vertical padding, ever. */
832
+ padding: 0 var(--term-cell) 0 0;
833
+ text-align: left;
834
+ vertical-align: top;
835
+ }
836
+ .term-md .term-thead {
837
+ /* The header rule, again as a shadow so it costs no layout. */
838
+ box-shadow: inset 0 -1px var(--term-diff-num);
839
+ }
840
+
841
+ /* ── Scrubber ────────────────────────────────────────────────────────────────
842
+ *
843
+ * The overview ruler (`components/terminal/scrubber.tsx`): a 12px rail of marks
844
+ * over the scroller's right edge — VS Code's strip beside the minimap, with
845
+ * this transcript's semantics. It is *chrome*, not rows: absolutely positioned
846
+ * over the scroll container so the transcript's grid is untouched, and drawn
847
+ * entirely from --term-* tokens so it follows the theme wherever the panel is
848
+ * embedded. Its width is the one deliberate exception to the theme's `ch` rule
849
+ * — see the note on `.term-scrubber` below.
850
+ *
851
+ * Interactivity is an affordance. Without `data-interactive` the rail is
852
+ * passive paint with pointer events off — and the native scrollbar, which
853
+ * stays in that mode, keeps working straight through it.
854
+ */
855
+ [data-terminal].term-scrubber {
856
+ position: absolute;
857
+ top: 0;
858
+ right: 0;
859
+ bottom: 0;
860
+ /* Exactly 12px, and this is the one place the theme's `ch` rule is set aside
861
+ on purpose: the rail is chrome beside the grid rather than part of it, and
862
+ its width is a *hit target* (two 6px lanes) rather than a column of text.
863
+ A `ch` measure drifts with the font, which would make the lanes different
864
+ again in the VS Code dock. */
865
+ width: 12px;
866
+ z-index: 10;
867
+ pointer-events: none;
868
+ }
869
+ [data-terminal].term-scrubber[data-interactive] {
870
+ pointer-events: auto;
871
+ }
872
+ .term-scrubber .term-scrubber-body {
873
+ position: absolute;
874
+ inset: 0;
875
+ touch-action: none;
876
+ }
877
+
878
+ /* The viewport band — where you are: a faint wash with a hairline frame,
879
+ floated over the marks (z-index) so the framed region reads as a window
880
+ onto the rail rather than a stripe behind it. `--term-faint` at 25% rather
881
+ than a literal, so the light palette re-tints it. */
882
+ .term-scrubber .term-scrub-band {
883
+ position: absolute;
884
+ left: 0;
885
+ right: 0;
886
+ background: color-mix(in srgb, var(--term-faint) 25%, transparent);
887
+ box-shadow: 0 0 0 1px var(--term-faint) inset;
888
+ pointer-events: none;
889
+ z-index: 1;
890
+ }
891
+
892
+ /* There is no separate cursor line. The band above answers "where am I" on its
893
+ own — it is outlined, so its top edge already reads as an edge — and a 2px
894
+ blue rule laid over it was a second indicator of the same fact, in the
895
+ loudest colour the rail has. */
896
+
897
+ .term-scrubber .term-scrub-mark {
898
+ position: absolute;
899
+ min-height: 2px;
900
+ }
901
+ .term-scrubber[data-interactive] .term-scrub-mark {
902
+ cursor: pointer;
903
+ }
904
+ .term-scrubber[data-interactive] .term-scrub-mark:hover {
905
+ outline: 1px solid color-mix(in srgb, var(--term-bright) 40%, transparent);
906
+ }
907
+
908
+ /* Two lanes of 8px — what you asked, and what came back — and 'f', the
909
+ full-width lane for annotations *about* the run (an error, an approval, a
910
+ bookmark, the catch-up seam) rather than steps through it. Pixels, not
911
+ percentages: the whole point of the 16px rail is that a lane is a reliable
912
+ 8px target, and a percentage would reintroduce the drift the fixed width was
913
+ chosen to remove. */
914
+ .term-scrubber .term-scrub-mark[data-lane='l'] { left: 0; width: 6px; }
915
+ .term-scrubber .term-scrub-mark[data-lane='r'] { left: 6px; width: 6px; }
916
+ .term-scrubber .term-scrub-mark[data-lane='f'] { left: 0; width: 12px; }
917
+
918
+ /* Lane marks are two-tone: the first 2px — the mark's own anchor — at full
919
+ strength, the rest of the row's extent a 25% tail, so a long answer reads
920
+ as long without a tall solid bar shouting over the rail. A minimum-height
921
+ mark shows only the solid segment, so short and long marks need no separate
922
+ rules; the *lane* stays the pointer's target, so a 2px mark is still
923
+ findable. Full-width annotations (error, approval) stay solid: they are
924
+ alarms, not extents. */
925
+ .term-scrubber .term-scrub-mark[data-kind='user'] {
926
+ background: linear-gradient(
927
+ to bottom,
928
+ var(--term-blue) 0 2px,
929
+ color-mix(in srgb, var(--term-blue) 25%, transparent) 2px
930
+ );
931
+ }
932
+ .term-scrubber .term-scrub-mark[data-kind='turn'] {
933
+ background: linear-gradient(
934
+ to bottom,
935
+ var(--term-fg) 0 2px,
936
+ color-mix(in srgb, var(--term-fg) 25%, transparent) 2px
937
+ );
938
+ }
939
+ .term-scrubber .term-scrub-mark[data-kind='turnFailed'] {
940
+ background: linear-gradient(
941
+ to bottom,
942
+ var(--term-red) 0 2px,
943
+ color-mix(in srgb, var(--term-red) 25%, transparent) 2px
944
+ );
945
+ }
946
+ .term-scrubber .term-scrub-mark[data-kind='error'] { background: var(--term-red); }
947
+ /* A failed tool call is an alarm, so it is full-width and solid like the rest of
948
+ them — but at 55%, which is the one thing keeping the rail readable. A session
949
+ error is rare and a turn failure rarer; a tool that failed and was recovered
950
+ from is routine (a grep that matched nothing, a build fixed on the second go),
951
+ and at full strength a normal working session paints the rail solid red and
952
+ the two errors that actually ended something stop standing out. Dimmer reads
953
+ as the same class of thing, one rank quieter — which is exactly its rank in
954
+ LOUDNESS. */
955
+ .term-scrubber .term-scrub-mark[data-kind='toolFailed'] {
956
+ background: color-mix(in srgb, var(--term-red) 55%, transparent);
957
+ }
958
+ .term-scrubber .term-scrub-mark[data-kind='bookmark'] { background: var(--term-magenta); }
959
+ .term-scrubber .term-scrub-mark[data-kind='approval'] {
960
+ background: var(--term-yellow);
961
+ animation: term-scrub-pulse 1.2s infinite;
962
+ }
963
+ /* The catch-up boundary: a dashed hairline centered in its 3px hit box. */
964
+ .term-scrubber .term-scrub-mark[data-kind='recap'] {
965
+ background: repeating-linear-gradient(to right, var(--term-faint) 0 3px, transparent 3px 6px)
966
+ left center / 100% 1px no-repeat;
967
+ }
968
+ @keyframes term-scrub-pulse {
969
+ 50% { opacity: 0.3; }
970
+ }
971
+ @media (prefers-reduced-motion: reduce) {
972
+ .term-scrubber .term-scrub-mark[data-kind='approval'] {
973
+ animation: none;
974
+ }
975
+ }
976
+
977
+ /* The peek. Rendered from transcript state — the row it describes is usually
978
+ unmounted — and framed the theme's way: strokes as backgrounds, no border.
979
+ pointer-events: none, so it can never trap the pointer that revealed it. */
980
+ .term-scrubber .term-scrub-peek {
981
+ position: absolute;
982
+ right: calc(100% + 1ch);
983
+ width: 44ch;
984
+ max-width: 60vw;
985
+ padding: var(--term-line) 2ch;
986
+ background:
987
+ linear-gradient(var(--term-faint), var(--term-faint)) top left / 100% 1px no-repeat,
988
+ linear-gradient(var(--term-faint), var(--term-faint)) bottom left / 100% 1px no-repeat,
989
+ linear-gradient(var(--term-faint), var(--term-faint)) top left / 1px 100% no-repeat,
990
+ linear-gradient(var(--term-faint), var(--term-faint)) top right / 1px 100% no-repeat,
991
+ var(--bg-elevated, #1f1f1f);
992
+ pointer-events: none;
993
+ z-index: 11;
994
+ }
995
+ .term-scrubber .term-scrub-ex {
996
+ display: -webkit-box;
997
+ -webkit-line-clamp: 4;
998
+ -webkit-box-orient: vertical;
999
+ overflow: hidden;
1000
+ }
1001
+
1002
+ /* While an interactive scrubber is mounted it IS the scrollbar; the transcript
1003
+ stamps this attribute on the scroll element. A passive rail leaves the
1004
+ native scrollbar alone — pointer-events: none above is what keeps that
1005
+ scrollbar reachable through the paint. */
1006
+ [data-term-scrubber-host] {
1007
+ scrollbar-width: none;
1008
+ }
1009
+ [data-term-scrubber-host]::-webkit-scrollbar {
1010
+ display: none;
1011
+ }