@workerdeck/ui 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +53 -0
  2. package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-B9CHoq8x.d.mts} +158 -21
  3. package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DII9MmQ8.mjs} +4254 -1661
  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 +442 -45
  9. package/build/index.mjs +105 -2
  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 +14 -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/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/SessionPanel.tsx +224 -28
  28. package/src/components/agent/SessionWorkspace.tsx +29 -0
  29. package/src/components/agent/StatusBar.tsx +20 -4
  30. package/src/components/agent/ToolCallCard.tsx +17 -111
  31. package/src/components/agent/Transcript.tsx +710 -203
  32. package/src/components/agent/UsageDialog.tsx +20 -106
  33. package/src/components/agent/UsageMeters.tsx +133 -0
  34. package/src/components/agent/pulse.tsx +3 -2
  35. package/src/components/agent/transcript-rows.ts +82 -0
  36. package/src/components/agent/transcript-variant.tsx +29 -51
  37. package/src/components/agent/use-height-epoch.ts +60 -0
  38. package/src/components/agent/use-path-links.ts +147 -0
  39. package/src/components/agent/use-transcript-jumps.ts +190 -0
  40. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  41. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  42. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  43. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  44. package/src/components/terminal/StatusLine.tsx +159 -0
  45. package/src/components/terminal/TerminalTranscript.tsx +147 -0
  46. package/src/components/terminal/affordances.tsx +118 -0
  47. package/src/components/terminal/diff.tsx +130 -0
  48. package/src/components/terminal/height.ts +727 -0
  49. package/src/components/terminal/items.tsx +449 -0
  50. package/src/components/terminal/markdown.tsx +191 -0
  51. package/src/components/terminal/press.tsx +120 -0
  52. package/src/components/terminal/prompt.tsx +343 -0
  53. package/src/components/terminal/result-preview.ts +72 -0
  54. package/src/components/terminal/row.tsx +132 -0
  55. package/src/components/terminal/scrubber.tsx +663 -0
  56. package/src/components/terminal/surface.tsx +80 -0
  57. package/src/components/terminal/tool-run.ts +91 -0
  58. package/src/index.ts +34 -0
  59. package/src/lib/status.ts +59 -3
  60. package/src/lib/tool-icon.ts +14 -0
  61. package/src/styles/terminal.css +1011 -0
  62. package/src/styles/theme.css +41 -0
  63. package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
  64. package/build/format.mjs.map +0 -1
  65. package/src/components/agent/line-prompt.tsx +0 -249
@@ -1,56 +1,36 @@
1
- import { useEffect, useState } from 'react'
2
- import type { ProfileEngine, RateLimitInfo } from '@workerdeck/protocol'
3
- import { RotateCcw } from 'lucide-react'
1
+ import type { ProfileEngine, UsageWindowRow } from '@workerdeck/protocol'
4
2
  import { Badge } from '../ui/Badge.tsx'
5
3
  import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
6
- import { cn } from '../../lib/utils.ts'
7
- import {
8
- formatAgoPrecise,
9
- formatCost,
10
- formatCountdown,
11
- formatRateLimitWindowLong,
12
- rateLimitWindowSeconds,
13
- } from '../../lib/format.ts'
4
+ import { formatAgoPrecise, formatCost } from '../../lib/format.ts'
5
+ import { UsageMeters, useMinuteClock } from './UsageMeters.tsx'
14
6
 
15
7
  export interface UsageDialogProps {
16
- /** Windows in reading order — session, weekly, then per-model weeklies. */
17
- rateLimits: Array<{ key: string; info: RateLimitInfo }>
8
+ /**
9
+ * Windows in reading order session, weekly, then per-model weeklies.
10
+ *
11
+ * `updatedAt`/`inferredReset` ride each window because the readings no longer
12
+ * share one clock: the panel merges the gateway's per-profile state over this
13
+ * session's, so a `five_hour` learned from a sibling session two minutes ago
14
+ * can sit beside a `seven_day` this session last heard about yesterday.
15
+ * `inferredReset` marks the ones the *gateway* zeroed because their own reset
16
+ * time passed with nothing newer — a floor, not a report.
17
+ */
18
+ rateLimits: UsageWindowRow[]
18
19
  /** claude.ai plan behind the windows ('max', 'pro', …), when there is one. */
19
20
  subscriptionType?: string
20
21
  engine: ProfileEngine
21
22
  totalCostUsd: number
22
- /** Local receipt time of the last window update. `rate_limit` events are one
23
- * per turn at best, so a stale reading is normal and worth saying out loud. */
23
+ /** When the *freshest* of the windows was read, for the footer line. Readings
24
+ * arrive one per turn at best (and for an idle session, not at all), so a
25
+ * stale one is normal and worth saying out loud. */
24
26
  updatedAt?: number
25
27
  open: boolean
26
28
  onOpenChange: (open: boolean) => void
27
29
  }
28
30
 
29
- /** Ticking clock — the countdowns and the pace markers both move with it, and a
30
- * minute is the finest resolution either of them prints. */
31
- function useMinuteClock(open: boolean): number {
32
- const [now, setNow] = useState(() => Date.now())
33
- useEffect(() => {
34
- if (!open) return
35
- setNow(Date.now())
36
- const timer = setInterval(() => setNow(Date.now()), 60_000)
37
- return () => clearInterval(timer)
38
- }, [open])
39
- return now
40
- }
41
-
42
- const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
43
-
44
31
  /**
45
- * The plan's rate-limit windows, spelled out: how much of each is used, how that
46
- * compares to the pace that would spend the window exactly, and when it resets.
47
- *
48
- * The pace marker is the point. A bar alone says "17% used", which is only
49
- * alarming or reassuring once you know how far into the week you are — so every
50
- * window draws a tick at the elapsed share of its duration. Left of the tick is
51
- * under budget, right of it is ahead of it. The duration comes from the window
52
- * key (5h, 7d) because the CLI reports a reset time and a percentage and never a
53
- * duration; a window whose key doesn't say gets no marker rather than a guessed one.
32
+ * The session's view of the plan: every window as a meter (see
33
+ * {@link UsageMeters}), the plan tier, and what this session itself has cost.
54
34
  */
55
35
  export function UsageDialog({
56
36
  rateLimits,
@@ -86,11 +66,7 @@ export function UsageDialog({
86
66
  : `Plan windows are a claude.ai subscription thing; this session runs on the ${engine} engine.`}
87
67
  </p>
88
68
  ) : (
89
- <div className='flex flex-col gap-5'>
90
- {rateLimits.map(({ key, info }) => (
91
- <UsageWindow key={key} windowKey={key} info={info} now={now} />
92
- ))}
93
- </div>
69
+ <UsageMeters windows={rateLimits} now={now} />
94
70
  )}
95
71
  <div className='mt-5 flex items-baseline justify-between gap-4 border-t border-border pt-3'>
96
72
  <span className='text-label text-fg-3'>This session has cost</span>
@@ -104,65 +80,3 @@ export function UsageDialog({
104
80
  </Dialog>
105
81
  )
106
82
  }
107
-
108
- function UsageWindow({
109
- windowKey,
110
- info,
111
- now,
112
- }: {
113
- windowKey: string
114
- info: RateLimitInfo
115
- now: number
116
- }) {
117
- const utilization = info.utilization ?? 0
118
- const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
119
- // Share of the window already elapsed — where usage *would* be if it were
120
- // spent evenly. Needs both a duration (from the key) and a reset time.
121
- const duration = rateLimitWindowSeconds(windowKey)
122
- const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
123
- const pace =
124
- duration !== undefined && remaining !== undefined && remaining > 0 && remaining < duration
125
- ? (duration - remaining) / duration
126
- : undefined
127
- return (
128
- <div>
129
- <div className='flex items-baseline justify-between gap-3'>
130
- <span className='truncate text-body-sm text-fg-1'>
131
- {formatRateLimitWindowLong(windowKey)}
132
- </span>
133
- <span
134
- className={cn(
135
- 'shrink-0 font-mono text-body-sm font-medium',
136
- info.status === 'rejected' ? 'text-danger' : 'text-fg-1',
137
- )}>
138
- {utilization.toFixed(0)}% used
139
- </span>
140
- </div>
141
- <div className='relative mt-2 h-2 rounded-full bg-border'>
142
- <div
143
- className={cn('h-full rounded-full', usageTint(utilization))}
144
- // A floor, so a barely-touched window still shows a mark instead of
145
- // reading as missing data.
146
- style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
147
- />
148
- {pace !== undefined ? (
149
- <span
150
- aria-hidden
151
- title='Spent evenly, usage would be here by now'
152
- className='absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1'
153
- style={{ left: `${Math.min(100, Math.max(0, pace * 100))}%` }}
154
- />
155
- ) : null}
156
- </div>
157
- <div className='mt-1.5 flex items-center gap-3 text-label text-fg-4'>
158
- {resetsAtMs !== undefined ? (
159
- <span className='inline-flex items-center gap-1'>
160
- <RotateCcw className='size-3' /> Resets in {formatCountdown(resetsAtMs, now)}
161
- </span>
162
- ) : null}
163
- {info.isUsingOverage ? <span className='text-warning'>overage</span> : null}
164
- {info.status === 'rejected' ? <span className='text-danger'>limit reached</span> : null}
165
- </div>
166
- </div>
167
- )
168
- }
@@ -0,0 +1,133 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { UsageWindowRow } from '@workerdeck/protocol'
3
+ import { RotateCcw } from 'lucide-react'
4
+ import { cn } from '../../lib/utils.ts'
5
+ import {
6
+ formatAgoPrecise,
7
+ formatCountdown,
8
+ formatRateLimitWindowLong,
9
+ rateLimitWindowSeconds,
10
+ } from '../../lib/format.ts'
11
+
12
+ /**
13
+ * Ticking clock — the countdowns and the pace markers both move with it, and a
14
+ * minute is the finest resolution either of them prints.
15
+ *
16
+ * `active` is what a dialog passes its `open`: a closed panel's clock is a timer
17
+ * re-rendering something nobody can see.
18
+ */
19
+ export function useMinuteClock(active = true): number {
20
+ const [now, setNow] = useState(() => Date.now())
21
+ useEffect(() => {
22
+ if (!active) return
23
+ setNow(Date.now())
24
+ const timer = setInterval(() => setNow(Date.now()), 60_000)
25
+ return () => clearInterval(timer)
26
+ }, [active])
27
+ return now
28
+ }
29
+
30
+ const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
31
+
32
+ /**
33
+ * The plan's rate-limit windows, spelled out: how much of each is used, how that
34
+ * compares to the pace that would spend the window exactly, and when it resets.
35
+ *
36
+ * The pace marker is the point. A bar alone says "17% used", which is only
37
+ * alarming or reassuring once you know how far into the week you are — so every
38
+ * window draws a tick at the elapsed share of its duration. Left of the tick is
39
+ * under budget, right of it is ahead of it. The duration comes from the window
40
+ * key (5h, 7d) because the engine reports a reset time and a percentage and
41
+ * never a duration; a window whose key doesn't say gets no marker rather than a
42
+ * guessed one.
43
+ *
44
+ * Its own component rather than the Usage dialog's private helper because two
45
+ * surfaces draw these now — the session panel's Usage panel, from the session's
46
+ * readings merged with the gateway's, and the dashboard's profile page, straight
47
+ * off `ProfileInfo.usage` with no session in sight. Same account, same bars.
48
+ */
49
+ export function UsageMeters({
50
+ windows,
51
+ now,
52
+ className,
53
+ }: {
54
+ /** In reading order — protocol's `orderUsageWindows`. */
55
+ windows: UsageWindowRow[]
56
+ /** A shared clock, when the caller already ticks one. Omitted, this ticks its
57
+ * own for as long as it is mounted. */
58
+ now?: number
59
+ className?: string
60
+ }) {
61
+ const ownClock = useMinuteClock(now === undefined)
62
+ const clock = now ?? ownClock
63
+ return (
64
+ <div className={cn('flex flex-col gap-5', className)}>
65
+ {windows.map((window) => (
66
+ <UsageMeter key={window.key} window={window} now={clock} />
67
+ ))}
68
+ </div>
69
+ )
70
+ }
71
+
72
+ function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
73
+ const { key: windowKey, info, updatedAt, inferredReset } = window
74
+ const utilization = info.utilization ?? 0
75
+ const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
76
+ // Share of the window already elapsed — where usage *would* be if it were
77
+ // spent evenly. Needs both a duration (from the key) and a reset time.
78
+ const duration = rateLimitWindowSeconds(windowKey)
79
+ const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
80
+ const pace =
81
+ duration !== undefined && remaining !== undefined && remaining > 0 && remaining < duration
82
+ ? (duration - remaining) / duration
83
+ : undefined
84
+ return (
85
+ <div>
86
+ <div className='flex items-baseline justify-between gap-3'>
87
+ <span className='truncate text-body-sm text-fg-1'>
88
+ {formatRateLimitWindowLong(windowKey)}
89
+ </span>
90
+ <span
91
+ className={cn(
92
+ 'shrink-0 font-mono text-body-sm font-medium',
93
+ info.status === 'rejected' ? 'text-danger' : 'text-fg-1',
94
+ )}>
95
+ {utilization.toFixed(0)}% used
96
+ </span>
97
+ </div>
98
+ <div className='relative mt-2 h-2 rounded-full bg-border'>
99
+ <div
100
+ className={cn('h-full rounded-full', usageTint(utilization))}
101
+ // A floor, so a barely-touched window still shows a mark instead of
102
+ // reading as missing data.
103
+ style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
104
+ />
105
+ {pace !== undefined ? (
106
+ <span
107
+ aria-hidden
108
+ title='Spent evenly, usage would be here by now'
109
+ className='absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded-full bg-fg-1'
110
+ style={{ left: `${Math.min(100, Math.max(0, pace * 100))}%` }}
111
+ />
112
+ ) : null}
113
+ </div>
114
+ <div className='mt-1.5 flex items-center gap-3 text-label text-fg-4'>
115
+ {resetsAtMs !== undefined ? (
116
+ <span className='inline-flex items-center gap-1'>
117
+ <RotateCcw className='size-3' /> Resets in {formatCountdown(resetsAtMs, now)}
118
+ </span>
119
+ ) : null}
120
+ {info.isUsingOverage ? <span className='text-warning'>overage</span> : null}
121
+ {info.status === 'rejected' ? <span className='text-danger'>limit reached</span> : null}
122
+ {/* Said plainly rather than drawn as a fact: the gateway zeroed this
123
+ because the reading's own reset time passed with nothing newer, so 0
124
+ is a floor — the account may have been spent elsewhere since. */}
125
+ {inferredReset ? <span>window reset · nothing reported since</span> : null}
126
+ {/* Per window, because they no longer share one clock: a reading learned
127
+ from a sibling session minutes ago can sit beside one this session
128
+ last heard about yesterday. */}
129
+ {updatedAt && !inferredReset ? <span>{formatAgoPrecise(updatedAt, now)}</span> : null}
130
+ </div>
131
+ </div>
132
+ )
133
+ }
@@ -13,8 +13,9 @@ import { useEffect, useState } from 'react'
13
13
  * BRAND.md's caveat applies and is satisfied here: `U+25C6/7/8` are East-Asian
14
14
  * *ambiguous width*, so they can render double-width in a terminal under an
15
15
  * East-Asian locale and shift every line with them. They are safe wherever the
16
- * glyph is centred in a fixed-width box, which is what `LineGlyph` is. Anything
17
- * writing to a real terminal must use the ASCII set instead.
16
+ * glyph is centred in a fixed-width box, which is what the terminal theme's
17
+ * gutter cell (`.term-gutter`, one `--term-cell` wide) is. Anything writing to a
18
+ * real terminal must use the ASCII set instead.
18
19
  */
19
20
  export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
20
21
  export const PULSE_MS = 150
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The virtual row model: what one row of the transcript's virtual list *is*,
3
+ * and the two rules everything positional must go through — which item a row
4
+ * is spaced as, and how an item index maps onto a row index. Pure; shared by
5
+ * the transcript shell and its hooks.
6
+ */
7
+ import type { TranscriptItem } from '@workerdeck/react'
8
+ import { needsBlank, type TerminalBlock } from '../terminal/items.tsx'
9
+
10
+ /** One row of the virtual list: a {@link TerminalBlock} (a transcript item, or
11
+ * — under the terminal theme — a folded run of tool calls), or the recap
12
+ * boundary line spliced in at `catchUp.from`. One flat array so the virtualizer
13
+ * sees stable indices, and each row carries the key the item was already
14
+ * React-keyed by — measurements are cached per key, so a row keeps its measured
15
+ * height when the recap splice shifts every index after it. */
16
+ export type TranscriptRow = TerminalBlock | { key: 'recap'; line: string }
17
+
18
+ /** The item a row is spaced *as*. A run stands for the calls it folded, so a
19
+ * run and a lone tool call below it still read as one block. */
20
+ export function rowItem(row: TranscriptRow | undefined): TranscriptItem | undefined {
21
+ if (!row) return undefined
22
+ if ('item' in row) return row.item
23
+ if ('run' in row) return row.run[0]
24
+ return undefined
25
+ }
26
+
27
+ /**
28
+ * Does a blank line go above this row, in the terminal theme?
29
+ *
30
+ * The recap row always earns one — it is a boundary, and a boundary flush
31
+ * against the row above reads as part of it. Otherwise the pair decides
32
+ * (`needsBlank`): consecutive tool calls are one block in the CLI and get none.
33
+ */
34
+ export function gapBefore(rows: TranscriptRow[], index: number): boolean {
35
+ const before = rowItem(rows[index - 1])
36
+ const after = rowItem(rows[index])
37
+ if (!before || !after) return true
38
+ return needsBlank(before, after)
39
+ }
40
+
41
+ /**
42
+ * Transcript-item index → virtual-row index — **the off-by-a-fold trap.**
43
+ *
44
+ * The virtualizer's rows are {@link TerminalBlock}s, not items: a folded tool
45
+ * run occupies ONE row for `run.length` consecutive items, and the recap
46
+ * boundary is a row with *no* item index at all, shifting every row after it
47
+ * by one. `virtualizer.scrollToIndex(itemIndex)` is therefore wrong by
48
+ * construction on any folded or spliced transcript — every jump that starts
49
+ * from an item (the scrubber's marks, a future bookmark) must come through
50
+ * here first.
51
+ *
52
+ * The rule: the **last non-recap row whose first item index is ≤ the target**.
53
+ * Rows are ordered by `index` (a run's row covers
54
+ * `[index, index + run.length)`), so this is a binary search; the recap row
55
+ * is skipped by giving it its successor's start for navigation (both qualify
56
+ * at the boundary, and "last wins" lands on the real row) while never letting
57
+ * it be the answer. Exhaustively checked against a linear reference — every
58
+ * fixture × every item index × several splice positions — by
59
+ * `__wdCheckMapping` in `dev/App.tsx`.
60
+ */
61
+ export function rowIndexForItem(rows: readonly TranscriptRow[], itemIndex: number): number {
62
+ let lo = 0
63
+ let hi = rows.length - 1
64
+ let best = 0
65
+ while (lo <= hi) {
66
+ const mid = (lo + hi) >> 1
67
+ const row = rows[mid]!
68
+ let start: number
69
+ if ('index' in row) start = row.index
70
+ else {
71
+ const next = rows[mid + 1]
72
+ start = next && 'index' in next ? next.index : Number.MAX_SAFE_INTEGER
73
+ }
74
+ if (start <= itemIndex) {
75
+ if ('index' in row) best = mid
76
+ lo = mid + 1
77
+ } else {
78
+ hi = mid - 1
79
+ }
80
+ }
81
+ return best
82
+ }
@@ -1,22 +1,25 @@
1
1
  import { createContext, useContext, type ReactNode } from 'react'
2
- import { cn } from '../../lib/utils.ts'
3
2
 
4
3
  /**
5
4
  * How the transcript draws a turn.
6
5
  *
7
6
  * - `cards` — the chat convention: bubbles, bordered tool cards, generous gaps.
8
7
  * Right for a wide dashboard where the transcript is the page.
9
- * - `lines` — one full-width line item per event, transparent, hover-highlit,
10
- * with a glyph in a fixed left gutter. Right where vertical space is the
11
- * scarce resource (a VS Code dock) and the terminal is the reference UX:
12
- * nothing is boxed, the content and its marker carry the comprehension.
8
+ * - `terminal` — the CLI's own form: every row on a character cell, no boxes
9
+ * anywhere, diffs as full-width bands. Right where the transcript sits beside
10
+ * a terminal (a VS Code dock) and where vertical space is scarce.
13
11
  *
14
- * A context rather than a prop chain because every row component needs it and
15
- * only the transcript root knows it and because `Message`/`ToolCallCard` are
16
- * exported on their own, so an embedder composing them by hand gets the right
17
- * treatment for free.
12
+ * `terminal` is **not** a second set of branches in the components under
13
+ * `components/agent/`: it is its own renderer (`components/terminal/`) that the
14
+ * shell mounts *instead* of them. So a row component here never asks which
15
+ * variant it is in — if it is drawing at all, it is drawing cards.
16
+ *
17
+ * A context rather than a prop chain because the pieces that DO need it sit
18
+ * outside the transcript (the composer, the pending prompts) and because
19
+ * `Message`/`ToolCallCard` are exported on their own, so an embedder composing
20
+ * them by hand gets the right treatment for free.
18
21
  */
19
- export type TranscriptVariant = 'cards' | 'lines'
22
+ export type TranscriptVariant = 'cards' | 'terminal'
20
23
 
21
24
  const VariantContext = createContext<TranscriptVariant>('cards')
22
25
 
@@ -34,25 +37,21 @@ export function useTranscriptVariant(): TranscriptVariant {
34
37
  return useContext(VariantContext)
35
38
  }
36
39
 
37
- /** True in `lines`, for the many `cond ? a : b` reads in the row components. */
38
- export function useLines(): boolean {
39
- return useTranscriptVariant() === 'lines'
40
- }
41
-
42
40
  /**
43
41
  * How much room the transcript gives each row.
44
42
  *
45
43
  * - `comfortable` — a blank line between messages, which is what the Claude Code
46
- * CLI does and what the `lines` variant is trying to read like. The default:
47
- * a transcript is prose before it is a table.
44
+ * CLI does. The default: a transcript is prose before it is a table.
48
45
  * - `compact` — rows tight against each other, for a dock where every line of
49
46
  * vertical space is contested.
50
47
  *
51
48
  * Separate from the variant, and deliberately: they answer different questions.
52
- * The variant decides *how a row is drawn* (boxed or not) and follows from the
53
- * surface; density decides *how much air is around it* and is a preference the
54
- * reader holds. Coupling them would mean a dock could not be roomy and a
55
- * dashboard could not be dense.
49
+ * The variant decides *how a row is drawn* and follows from the surface; density
50
+ * decides *how much air is around it* and is a preference the reader holds.
51
+ *
52
+ * Reaches `cards` only. The terminal theme's spacing is a blank *line*, decided
53
+ * per pair of blocks by `needsBlank` — a terminal has one line height, which is
54
+ * the whole premise — so there is nothing there for this to turn.
56
55
  */
57
56
  export type TranscriptDensity = 'comfortable' | 'compact'
58
57
 
@@ -95,43 +94,22 @@ export type TranscriptFont = 'sans' | 'mono'
95
94
  * `px` is fed to `estimateSize` alone, where being approximate is the contract:
96
95
  * it sets the scrollbar's length before rows mount and is replaced by a real
97
96
  * measurement the moment one does.
98
- *
99
- * `lines` + `compact` is the only combination with no gap at all: there the
100
- * row's own `py-0.5` is the entire separation, which is what makes it compact.
101
97
  */
102
98
  export const ROW_GAP: Record<
103
99
  TranscriptVariant,
104
100
  Record<TranscriptDensity, { className?: string; px: number }>
105
101
  > = {
102
+ // The terminal theme has no gap scale: the space between blocks is a **blank
103
+ // line**, decided per pair by `needsBlank` (a tool call and its output get
104
+ // none), so it is a class the shell applies conditionally rather than a
105
+ // constant it applies to every row. Density does not reach it — a terminal
106
+ // has one line height, which is the whole premise.
107
+ terminal: {
108
+ comfortable: { className: 'term-row-gap', px: 18 },
109
+ compact: { className: 'term-row-gap', px: 18 },
110
+ },
106
111
  cards: {
107
112
  comfortable: { className: 'pt-4', px: 16 },
108
113
  compact: { className: 'pt-2', px: 8 },
109
114
  },
110
- lines: {
111
- // 16px on top of the row's own 4px of `py-0.5` is one 20px line — the blank
112
- // line the CLI leaves, arrived at from the line height rather than picked.
113
- comfortable: { className: 'pt-4', px: 16 },
114
- compact: { px: 0 },
115
- },
116
- }
117
-
118
- /**
119
- * The left gutter of a line item: one glyph, fixed width, so every row's text
120
- * starts on the same column no matter which kind of event it is. Decorative —
121
- * the row's own text says what it is.
122
- */
123
- export function LineGlyph({ children, className }: { children: ReactNode; className?: string }) {
124
- return (
125
- <span
126
- aria-hidden
127
- className={cn(
128
- 'w-3.5 shrink-0 select-none text-center font-mono text-label leading-5 text-fg-4',
129
- className,
130
- )}>
131
- {children}
132
- </span>
133
- )
134
115
  }
135
-
136
- /** Body text metrics for a line item — tighter than the card variant's. */
137
- export const LINE_TEXT = 'text-body-sm leading-5'
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The height epoch: one cache generation of computed row heights (terminal
3
+ * theme only — cards have no calculator and keep the flat estimate). Owned by
4
+ * the transcript shell because it owns the virtualizer the heights feed; the
5
+ * WeakMap inside self-invalidates through the reducer's replace-on-mutation,
6
+ * and the epoch itself is replaced wholesale when the wrap width or the cell
7
+ * changes. Measured off the rows container: it *is* the width rows wrap in
8
+ * (the scroller can resize without it moving — `ConversationContent` caps at
9
+ * 48rem — and the window never hears about a splitter drag), and it inherits
10
+ * the surface's font, which is what makes the `ch` probe honest. All DOM
11
+ * reads happen in the effect, debounced; render never touches layout.
12
+ */
13
+ import { useEffect, useState, type RefObject } from 'react'
14
+ import { createHeightEpoch, measureCh, type HeightEpoch } from '../terminal/height.ts'
15
+
16
+ export function useHeightEpoch(options: {
17
+ terminal: boolean
18
+ /** The terminal cell, when the host set one — only read as a signal that the
19
+ * epoch must re-measure; the epoch's numbers come from the DOM. */
20
+ fontSize?: number
21
+ lineHeight?: number
22
+ rowsRef: RefObject<HTMLDivElement | null>
23
+ }): HeightEpoch | null {
24
+ const { terminal, fontSize, lineHeight, rowsRef } = options
25
+ const [epoch, setEpoch] = useState<HeightEpoch | null>(null)
26
+ useEffect(() => {
27
+ if (!terminal) return
28
+ const element = rowsRef.current
29
+ if (!element) return
30
+ let timer: ReturnType<typeof setTimeout> | undefined
31
+ const measure = () => {
32
+ const line = Number.parseFloat(
33
+ getComputedStyle(element).getPropertyValue('--term-line'),
34
+ )
35
+ const width = element.clientWidth
36
+ const ch = measureCh(element)
37
+ if (!line || !width || !ch) return
38
+ setEpoch((previous) =>
39
+ previous && previous.width === width && previous.ch === ch && previous.line === line
40
+ ? previous
41
+ : createHeightEpoch(width, ch, line),
42
+ )
43
+ }
44
+ const observer = new ResizeObserver(() => {
45
+ clearTimeout(timer)
46
+ timer = setTimeout(measure, 150)
47
+ })
48
+ observer.observe(element)
49
+ measure()
50
+ return () => {
51
+ observer.disconnect()
52
+ clearTimeout(timer)
53
+ }
54
+ // fontSize/lineHeight: a cell change re-renders every row, which usually
55
+ // moves the container's size and fires the observer — but a transcript
56
+ // whose height happens to survive the change would keep a stale `ch`, so
57
+ // the props re-arm the measurement directly.
58
+ }, [terminal, fontSize, lineHeight, rowsRef])
59
+ return epoch
60
+ }