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