@workerdeck/ui 0.9.0 → 0.12.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 +81 -3
  2. package/build/SessionPanel-Dy9lQrOV.d.mts +319 -0
  3. package/build/SessionPanel-NQ8ksCfj.mjs +8474 -0
  4. package/build/SessionPanel-NQ8ksCfj.mjs.map +1 -0
  5. package/build/format-DqR56Y8l.mjs +162 -0
  6. package/build/format-DqR56Y8l.mjs.map +1 -0
  7. package/build/format-ljc3lKpA.d.mts +59 -0
  8. package/build/format.d.mts +66 -0
  9. package/build/format.mjs +119 -0
  10. package/build/format.mjs.map +1 -0
  11. package/build/index.d.mts +671 -88
  12. package/build/index.mjs +387 -5160
  13. package/build/index.mjs.map +1 -1
  14. package/build/workspace.d.mts +226 -0
  15. package/build/workspace.mjs +861 -0
  16. package/build/workspace.mjs.map +1 -0
  17. package/package.json +22 -4
  18. package/src/components/agent/CodeEditor.tsx +300 -0
  19. package/src/components/agent/Composer.tsx +522 -87
  20. package/src/components/agent/ContextDialog.tsx +99 -0
  21. package/src/components/agent/Conversation.tsx +11 -3
  22. package/src/components/agent/EditorTabs.tsx +165 -0
  23. package/src/components/agent/FileCard.tsx +26 -0
  24. package/src/components/agent/FileTree.tsx +287 -0
  25. package/src/components/agent/FileViewer.tsx +148 -0
  26. package/src/components/agent/HostFilesDialog.tsx +218 -0
  27. package/src/components/agent/Loader.tsx +82 -14
  28. package/src/components/agent/McpDialog.tsx +363 -0
  29. package/src/components/agent/Message.tsx +51 -17
  30. package/src/components/agent/ModelSelect.tsx +34 -6
  31. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  32. package/src/components/agent/PermissionPrompt.tsx +164 -6
  33. package/src/components/agent/PromptTokenText.tsx +39 -0
  34. package/src/components/agent/QuestionPrompt.tsx +122 -0
  35. package/src/components/agent/Reasoning.tsx +20 -5
  36. package/src/components/agent/Response.tsx +128 -0
  37. package/src/components/agent/SessionBrowser.tsx +428 -0
  38. package/src/components/agent/SessionEmptyState.tsx +65 -0
  39. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  40. package/src/components/agent/SessionPanel.tsx +783 -91
  41. package/src/components/agent/SessionWorkspace.tsx +317 -0
  42. package/src/components/agent/SkillsDialog.tsx +195 -0
  43. package/src/components/agent/StatusBar.tsx +85 -18
  44. package/src/components/agent/ToolCallCard.tsx +252 -30
  45. package/src/components/agent/Transcript.tsx +513 -30
  46. package/src/components/agent/UsageDialog.tsx +168 -0
  47. package/src/components/agent/line-prompt.tsx +249 -0
  48. package/src/components/agent/pulse.tsx +60 -0
  49. package/src/components/agent/transcript-variant.tsx +123 -0
  50. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  51. package/src/components/prompt-area/types.ts +15 -0
  52. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  53. package/src/components/ui/CodeBlock.tsx +40 -2
  54. package/src/components/ui/CopyButton.tsx +28 -3
  55. package/src/components/ui/Dialog.tsx +92 -0
  56. package/src/components/ui/Menu.tsx +55 -0
  57. package/src/components/ui/Splitter.tsx +133 -0
  58. package/src/components/ui/Tooltip.tsx +22 -5
  59. package/src/format.ts +11 -0
  60. package/src/index.ts +67 -2
  61. package/src/lib/clipboard.ts +56 -0
  62. package/src/lib/format.ts +114 -0
  63. package/src/lib/status.ts +124 -0
  64. package/src/lib/tool-icon.ts +96 -0
  65. package/src/workspace.ts +28 -0
@@ -0,0 +1,168 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { ProfileEngine, RateLimitInfo } from '@workerdeck/protocol'
3
+ import { RotateCcw } from 'lucide-react'
4
+ import { Badge } from '../ui/Badge.tsx'
5
+ 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'
14
+
15
+ export interface UsageDialogProps {
16
+ /** Windows in reading order — session, weekly, then per-model weeklies. */
17
+ rateLimits: Array<{ key: string; info: RateLimitInfo }>
18
+ /** claude.ai plan behind the windows ('max', 'pro', …), when there is one. */
19
+ subscriptionType?: string
20
+ engine: ProfileEngine
21
+ 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. */
24
+ updatedAt?: number
25
+ open: boolean
26
+ onOpenChange: (open: boolean) => void
27
+ }
28
+
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
+ /**
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.
54
+ */
55
+ export function UsageDialog({
56
+ rateLimits,
57
+ subscriptionType,
58
+ engine,
59
+ totalCostUsd,
60
+ updatedAt,
61
+ open,
62
+ onOpenChange,
63
+ }: UsageDialogProps) {
64
+ const now = useMinuteClock(open)
65
+ return (
66
+ <Dialog open={open} onOpenChange={onOpenChange}>
67
+ <DialogContent>
68
+ <DialogHeader
69
+ title='Usage'
70
+ description={engine === 'claude' ? 'Claude Code' : engine}
71
+ actions={
72
+ // The CLI reports a tier ('max'), never the multiplier a
73
+ // subscription page shows — so this says "Max" and stops there.
74
+ subscriptionType ? (
75
+ <Badge variant='accent' className='mt-0.5 shrink-0 capitalize'>
76
+ {subscriptionType}
77
+ </Badge>
78
+ ) : null
79
+ }
80
+ />
81
+ <DialogBody>
82
+ {rateLimits.length === 0 ? (
83
+ <p className='py-6 text-center text-body-sm text-fg-4'>
84
+ {engine === 'claude'
85
+ ? 'This session reports no plan windows — API-key sessions have none, and a subscription session reports them once a turn has run.'
86
+ : `Plan windows are a claude.ai subscription thing; this session runs on the ${engine} engine.`}
87
+ </p>
88
+ ) : (
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>
94
+ )}
95
+ <div className='mt-5 flex items-baseline justify-between gap-4 border-t border-border pt-3'>
96
+ <span className='text-label text-fg-3'>This session has cost</span>
97
+ <span className='font-mono text-body-sm text-fg-1'>{formatCost(totalCostUsd)}</span>
98
+ </div>
99
+ {updatedAt ? (
100
+ <p className='mt-2 text-label text-fg-4'>Updated {formatAgoPrecise(updatedAt, now)}</p>
101
+ ) : null}
102
+ </DialogBody>
103
+ </DialogContent>
104
+ </Dialog>
105
+ )
106
+ }
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,249 @@
1
+ import { useEffect, useRef, type ReactNode } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+ import { Response } from './Response.tsx'
4
+ import { LineGlyph } from './transcript-variant.tsx'
5
+
6
+ /**
7
+ * The parts a terminal-shaped prompt is built from.
8
+ *
9
+ * The `lines` transcript answers a question the `cards` one does not have to:
10
+ * an approval and a question are *interactive*, so "no boxes" has to be paid for
11
+ * by something else carrying the affordance. That something is the keyboard —
12
+ * a roving `❯` marker, numbered rows, and a hint line — which is what a terminal
13
+ * would do anyway, and what makes the prompt answerable without reaching for the
14
+ * mouse in a dock where the rows are two lines tall.
15
+ *
16
+ * Everything here indents to the same gutter as every other line item, so an
17
+ * approval reads as one more row in the run rather than a dialog dropped on top
18
+ * of it.
19
+ */
20
+
21
+ /** The left inset that lines up body text with a `LineGlyph`'d row above it. */
22
+ export const LINE_INDENT = 'pl-[calc(0.875rem+0.5rem)]'
23
+
24
+ export type LineChoice = {
25
+ key: string
26
+ label: string
27
+ description?: string
28
+ /**
29
+ * Rendered under the row, outside the button — a preview, an input. The caller
30
+ * decides when it exists (focused, checked); a button may not contain one.
31
+ */
32
+ detail?: ReactNode
33
+ /**
34
+ * Present → the row carries a selection state and draws it. `marker` says in
35
+ * which idiom: a checkbox for a multi-select, a radio for a one-of. Absent →
36
+ * the row is an action (allow, deny), which has no state to show.
37
+ */
38
+ checked?: boolean
39
+ marker?: 'check' | 'radio'
40
+ danger?: boolean
41
+ }
42
+
43
+ /** The two-state glyph pairs, in the character forms a terminal would use. */
44
+ const MARKERS = {
45
+ check: ['[ ]', '[x]'],
46
+ radio: ['( )', '(•)'],
47
+ } as const
48
+
49
+ export interface LineOptionListProps {
50
+ options: LineChoice[]
51
+ /** Roving index: the one row that is tab-reachable and wears the marker. */
52
+ focused: number
53
+ onFocus: (index: number) => void
54
+ onChoose: (index: number) => void
55
+ /**
56
+ * Own the DOM focus, moving it with the roving index. False while something
57
+ * else inside the prompt holds it (a reason input, another question's list) —
58
+ * two lists both chasing `focused` would tear the caret back and forth.
59
+ */
60
+ active?: boolean
61
+ label: string
62
+ className?: string
63
+ }
64
+
65
+ /**
66
+ * A keyboard-first list of choices as line items: `↑`/`↓` move, `1`–`9` pick
67
+ * directly, `Enter`/`Space` take the focused one (the button does that itself).
68
+ */
69
+ export function LineOptionList({
70
+ options,
71
+ focused,
72
+ onFocus,
73
+ onChoose,
74
+ active = true,
75
+ label,
76
+ className,
77
+ }: LineOptionListProps) {
78
+ const refs = useRef<Array<HTMLButtonElement | null>>([])
79
+
80
+ useEffect(() => {
81
+ if (active) refs.current[focused]?.focus()
82
+ }, [active, focused])
83
+
84
+ const move = (delta: number) => {
85
+ if (options.length > 0) onFocus((focused + delta + options.length) % options.length)
86
+ }
87
+
88
+ return (
89
+ <div
90
+ role='group'
91
+ aria-label={label}
92
+ className={cn('flex flex-col', className)}
93
+ onKeyDown={(event) => {
94
+ if (event.key === 'ArrowDown') {
95
+ move(1)
96
+ event.preventDefault()
97
+ return
98
+ }
99
+ if (event.key === 'ArrowUp') {
100
+ move(-1)
101
+ event.preventDefault()
102
+ return
103
+ }
104
+ // Digits are the whole point of numbering the rows — but only up to the
105
+ // rows that exist, so `9` on a three-option prompt stays a no-op rather
106
+ // than a silent miss.
107
+ const digit = Number(event.key)
108
+ if (Number.isInteger(digit) && digit >= 1 && digit <= Math.min(options.length, 9)) {
109
+ onFocus(digit - 1)
110
+ onChoose(digit - 1)
111
+ event.preventDefault()
112
+ }
113
+ }}>
114
+ {options.map((option, index) => {
115
+ const isFocused = index === focused
116
+ return (
117
+ <div key={option.key} className='flex flex-col'>
118
+ <button
119
+ ref={(element) => {
120
+ refs.current[index] = element
121
+ }}
122
+ type='button'
123
+ tabIndex={isFocused ? 0 : -1}
124
+ aria-pressed={option.checked}
125
+ onFocus={() => onFocus(index)}
126
+ onClick={() => onChoose(index)}
127
+ className={cn(
128
+ 'flex w-full items-baseline gap-2 text-left outline-none',
129
+ isFocused ? 'bg-surface-hover' : 'hover:bg-surface-hover/60',
130
+ )}>
131
+ <LineGlyph className={isFocused ? 'text-accent' : undefined}>
132
+ {isFocused ? '❯' : ' '}
133
+ </LineGlyph>
134
+ <span className='shrink-0 font-mono text-label leading-5 text-fg-4'>{index + 1}</span>
135
+ {option.checked !== undefined ? (
136
+ <span
137
+ className={cn(
138
+ 'shrink-0 font-mono text-label leading-5',
139
+ option.checked ? 'text-accent' : 'text-fg-4',
140
+ )}>
141
+ {MARKERS[option.marker ?? 'check'][option.checked ? 1 : 0]}
142
+ </span>
143
+ ) : null}
144
+ <span
145
+ className={cn(
146
+ 'min-w-0 flex-1 text-body-sm leading-5',
147
+ option.danger ? 'text-danger' : 'text-fg-1',
148
+ )}>
149
+ {option.label}
150
+ {option.description ? (
151
+ <span className='text-fg-4'> — {option.description}</span>
152
+ ) : null}
153
+ </span>
154
+ </button>
155
+ {option.detail ? <div className={LINE_INDENT}>{option.detail}</div> : null}
156
+ </div>
157
+ )
158
+ })}
159
+ </div>
160
+ )
161
+ }
162
+
163
+ /** The dim key legend under a prompt. Separated by `·`, like a status line. */
164
+ export function LineHint({ children }: { children: ReactNode }) {
165
+ return (
166
+ <div className={cn(LINE_INDENT, 'text-label leading-5 text-fg-4')}>{children}</div>
167
+ )
168
+ }
169
+
170
+ /** A single-line text field in the terminal idiom: a caret glyph and a rule,
171
+ * no box. `Enter` commits, `Escape` backs out — the caller says what those mean. */
172
+ export function LineInput({
173
+ value,
174
+ onChange,
175
+ onSubmit,
176
+ onCancel,
177
+ placeholder,
178
+ }: {
179
+ value: string
180
+ onChange: (value: string) => void
181
+ onSubmit: () => void
182
+ onCancel: () => void
183
+ placeholder?: string
184
+ }) {
185
+ return (
186
+ <div className='flex items-baseline gap-2'>
187
+ <LineGlyph className='text-accent'>›</LineGlyph>
188
+ <input
189
+ autoFocus
190
+ value={value}
191
+ placeholder={placeholder}
192
+ onChange={(event) => onChange(event.target.value)}
193
+ onKeyDown={(event) => {
194
+ if (event.key === 'Enter') {
195
+ event.preventDefault()
196
+ onSubmit()
197
+ }
198
+ if (event.key === 'Escape') {
199
+ // The prompt's own Escape means "deny"/"dismiss"; inside the field it
200
+ // only closes the field, so it must not travel further.
201
+ event.stopPropagation()
202
+ onCancel()
203
+ }
204
+ }}
205
+ className='min-w-0 flex-1 border-b border-border bg-transparent text-body-sm leading-5 text-fg-1 outline-none placeholder:text-fg-4 focus:border-accent'
206
+ />
207
+ </div>
208
+ )
209
+ }
210
+
211
+ /**
212
+ * A payload in the terminal idiom: a dim label line over a **highlighted** band.
213
+ *
214
+ * Highlighting goes through `Response` — the markdown renderer already carries
215
+ * shiki, so wrapping the text in a fence costs no new dependency, no second
216
+ * highlighter and no second theme to keep in sync, and it inherits the terminal
217
+ * flattening plus the hover copy/download the renderer draws for every code
218
+ * block. Text with no language still goes through it, as an unlabelled fence:
219
+ * same band, same grid, no grammar guessed.
220
+ */
221
+ export function LinePayload({
222
+ code,
223
+ label,
224
+ language,
225
+ className,
226
+ }: {
227
+ code: string
228
+ label: string
229
+ language?: string
230
+ className?: string
231
+ }) {
232
+ return (
233
+ <div className={cn('min-w-0', className)}>
234
+ <span className='block truncate text-label leading-5 text-fg-4'>{label}</span>
235
+ <Response>{fence(code, language)}</Response>
236
+ </div>
237
+ )
238
+ }
239
+
240
+ /**
241
+ * Wrap text as a markdown code fence, with a fence long enough to survive the
242
+ * content: a payload containing ``` would otherwise close the block early and
243
+ * spill the rest of it into the transcript as markdown.
244
+ */
245
+ export function fence(code: string, language?: string): string {
246
+ const longest = Math.max(0, ...[...code.matchAll(/`+/g)].map((match) => match[0].length))
247
+ const ticks = '`'.repeat(Math.max(3, longest + 1))
248
+ return `${ticks}${language ?? ''}\n${code}\n${ticks}`
249
+ }
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /**
4
+ * The brand mark's pulse, as characters — the working marker every surface in the
5
+ * transcript animates.
6
+ *
7
+ * These are the mark's own four states (`docs/assets/BRAND.md`, "The loading
8
+ * state"): a dot, an outline, a semi and a full diamond, built in the SVG from
9
+ * two shapes rather than four drawings. 150ms each, so one cycle is the 0.6s
10
+ * clock the marker pulses on in `icon-loading.svg` — the same rhythm, in the
11
+ * medium a transcript row actually has.
12
+ *
13
+ * BRAND.md's caveat applies and is satisfied here: `U+25C6/7/8` are East-Asian
14
+ * *ambiguous width*, so they can render double-width in a terminal under an
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.
18
+ */
19
+ export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
20
+ export const PULSE_MS = 150
21
+
22
+ /**
23
+ * The resting state. Stopping the animation lands on the complete mark rather
24
+ * than on a half-drawn frame — the same property that makes the SVG's
25
+ * `prefers-reduced-motion` free (see BRAND.md: `translateY(0)` *is* the mark).
26
+ */
27
+ export const PULSE_REST = PULSE_FRAMES[PULSE_FRAMES.length - 1]
28
+
29
+ /** The OS-level "stop moving things" setting. A spinner is decoration — the word
30
+ * beside it carries the meaning — so honouring this costs nothing. */
31
+ export function usePrefersReducedMotion(): boolean {
32
+ const [reduced, setReduced] = useState(false)
33
+ useEffect(() => {
34
+ const query = window.matchMedia?.('(prefers-reduced-motion: reduce)')
35
+ if (!query) return
36
+ setReduced(query.matches)
37
+ const onChange = () => setReduced(query.matches)
38
+ query.addEventListener('change', onChange)
39
+ return () => query.removeEventListener('change', onChange)
40
+ }, [])
41
+ return reduced
42
+ }
43
+
44
+ /**
45
+ * The current pulse frame, ticking while `animated`. Callers mount this only
46
+ * while something is actually in flight, so nothing here runs on an idle
47
+ * session; with reduced motion, or when not animating, it holds at rest.
48
+ */
49
+ export function usePulse(animated: boolean): string {
50
+ const reduced = usePrefersReducedMotion()
51
+ const running = animated && !reduced
52
+ const [frame, setFrame] = useState(0)
53
+ useEffect(() => {
54
+ if (!running) return
55
+ const timer = setInterval(() => setFrame((f) => f + 1), PULSE_MS)
56
+ return () => clearInterval(timer)
57
+ }, [running])
58
+ if (!running) return PULSE_REST
59
+ return PULSE_FRAMES[frame % PULSE_FRAMES.length]!
60
+ }
@@ -0,0 +1,123 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+
4
+ /**
5
+ * How the transcript draws a turn.
6
+ *
7
+ * - `cards` — the chat convention: bubbles, bordered tool cards, generous gaps.
8
+ * 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.
13
+ *
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.
18
+ */
19
+ export type TranscriptVariant = 'cards' | 'lines'
20
+
21
+ const VariantContext = createContext<TranscriptVariant>('cards')
22
+
23
+ export function TranscriptVariantProvider({
24
+ value,
25
+ children,
26
+ }: {
27
+ value: TranscriptVariant
28
+ children: ReactNode
29
+ }) {
30
+ return <VariantContext.Provider value={value}>{children}</VariantContext.Provider>
31
+ }
32
+
33
+ export function useTranscriptVariant(): TranscriptVariant {
34
+ return useContext(VariantContext)
35
+ }
36
+
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
+ /**
43
+ * How much room the transcript gives each row.
44
+ *
45
+ * - `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.
48
+ * - `compact` — rows tight against each other, for a dock where every line of
49
+ * vertical space is contested.
50
+ *
51
+ * 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.
56
+ */
57
+ export type TranscriptDensity = 'comfortable' | 'compact'
58
+
59
+ const DensityContext = createContext<TranscriptDensity>('comfortable')
60
+
61
+ export function TranscriptDensityProvider({
62
+ value,
63
+ children,
64
+ }: {
65
+ value: TranscriptDensity
66
+ children: ReactNode
67
+ }) {
68
+ return <DensityContext.Provider value={value}>{children}</DensityContext.Provider>
69
+ }
70
+
71
+ export function useTranscriptDensity(): TranscriptDensity {
72
+ return useContext(DensityContext)
73
+ }
74
+
75
+ /**
76
+ * The gap between two rows, per variant and density — the whole of the density
77
+ * feature, since it is the only vertical spacing between rows that exists.
78
+ *
79
+ * `className` goes on the **measured** wrapper (see `Transcript`), so the gap is
80
+ * part of each row's measured height and no pixel constant is load-bearing.
81
+ * `px` is fed to `estimateSize` alone, where being approximate is the contract:
82
+ * it sets the scrollbar's length before rows mount and is replaced by a real
83
+ * measurement the moment one does.
84
+ *
85
+ * `lines` + `compact` is the only combination with no gap at all: there the
86
+ * row's own `py-0.5` is the entire separation, which is what makes it compact.
87
+ */
88
+ export const ROW_GAP: Record<
89
+ TranscriptVariant,
90
+ Record<TranscriptDensity, { className?: string; px: number }>
91
+ > = {
92
+ cards: {
93
+ comfortable: { className: 'pt-4', px: 16 },
94
+ compact: { className: 'pt-2', px: 8 },
95
+ },
96
+ lines: {
97
+ // 16px on top of the row's own 4px of `py-0.5` is one 20px line — the blank
98
+ // line the CLI leaves, arrived at from the line height rather than picked.
99
+ comfortable: { className: 'pt-4', px: 16 },
100
+ compact: { px: 0 },
101
+ },
102
+ }
103
+
104
+ /**
105
+ * The left gutter of a line item: one glyph, fixed width, so every row's text
106
+ * starts on the same column no matter which kind of event it is. Decorative —
107
+ * the row's own text says what it is.
108
+ */
109
+ export function LineGlyph({ children, className }: { children: ReactNode; className?: string }) {
110
+ return (
111
+ <span
112
+ aria-hidden
113
+ className={cn(
114
+ 'w-3.5 shrink-0 select-none text-center font-mono text-label leading-5 text-fg-4',
115
+ className,
116
+ )}>
117
+ {children}
118
+ </span>
119
+ )
120
+ }
121
+
122
+ /** Body text metrics for a line item — tighter than the card variant's. */
123
+ export const LINE_TEXT = 'text-body-sm leading-5'
@@ -288,6 +288,59 @@ export function resolveChip(
288
288
  return { segments: merged, cursorOffset }
289
289
  }
290
290
 
291
+ /**
292
+ * Replaces the active trigger's range with **plain text** instead of a chip.
293
+ *
294
+ * The sibling of {@link resolveChip}, for suggestions that are a typing aid
295
+ * rather than a token: what lands in the document is ordinary editable text the
296
+ * user is expected to finish and change, and it must not look or behave like a
297
+ * resolved chip — no immutability, no trigger character, nothing for a consumer
298
+ * to parse back out. The caret is left at the end of the inserted text so typing
299
+ * continues from there.
300
+ */
301
+ export function resolveText(
302
+ segments: Segment[],
303
+ activeTrigger: ActiveTrigger,
304
+ text: string,
305
+ ): { segments: Segment[]; cursorOffset: number } {
306
+ const triggerStart = activeTrigger.startOffset
307
+ const triggerEnd = triggerStart + 1 + activeTrigger.query.length // +1 for trigger char
308
+
309
+ const newSegments: Segment[] = []
310
+ let offset = 0
311
+ let cursorOffset = triggerStart + text.length
312
+
313
+ for (const seg of segments) {
314
+ if (seg.type === 'chip') {
315
+ const chipEnd = offset + `${seg.trigger}${seg.displayText}`.length
316
+ // A trigger range can never overlap a chip — chips are atomic — so a chip
317
+ // is either wholly before or wholly after, and is kept either way.
318
+ if (chipEnd <= triggerStart || offset >= triggerEnd) newSegments.push(seg)
319
+ offset = chipEnd
320
+ continue
321
+ }
322
+ const textStart = offset
323
+ const textEnd = offset + seg.text.length
324
+ if (textEnd <= triggerStart || textStart >= triggerEnd) {
325
+ newSegments.push(seg)
326
+ } else {
327
+ const before = seg.text.slice(0, Math.max(0, triggerStart - textStart))
328
+ const after = seg.text.slice(Math.min(seg.text.length, triggerEnd - textStart))
329
+ if (before) newSegments.push({ type: 'text', text: before })
330
+ newSegments.push({ type: 'text', text })
331
+ if (after) newSegments.push({ type: 'text', text: after })
332
+ }
333
+ offset = textEnd
334
+ }
335
+
336
+ const merged = mergeAdjacentTextSegments(newSegments)
337
+ // Clamp: a trigger detected against a stale document could otherwise leave the
338
+ // caret past the end, which renders as no caret at all.
339
+ const total = segmentsToPlainText(merged).length
340
+ if (cursorOffset > total) cursorOffset = total
341
+ return { segments: merged, cursorOffset }
342
+ }
343
+
291
344
  // ---------------------------------------------------------------------------
292
345
  // Chip removal
293
346
  // ---------------------------------------------------------------------------
@@ -103,6 +103,21 @@ export type TriggerConfig = {
103
103
  * Return the display text for the chip, or void to use `suggestion.label`.
104
104
  */
105
105
  onSelect?: (suggestion: TriggerSuggestion) => string | void
106
+ /**
107
+ * For 'dropdown' mode: opt a suggestion out of becoming a chip.
108
+ *
109
+ * Return a string and the trigger's range is replaced with that **plain,
110
+ * editable text** — the trigger character included — with the caret left at
111
+ * its end. Return undefined and the suggestion resolves to a chip as usual,
112
+ * so one dropdown can mix both kinds.
113
+ *
114
+ * For suggestions that are a typing aid rather than a token: something the
115
+ * user is meant to finish and edit, where a chip would falsely promise the
116
+ * host parses it back out. Takes precedence over `onSelect`, which is not
117
+ * called for a text-resolved suggestion (there is no chip to label), and
118
+ * `onChipAdd` does not fire either.
119
+ */
120
+ insertAsText?: (suggestion: TriggerSuggestion) => string | undefined
106
121
  /**
107
122
  * For 'callback' and 'launch' modes: called when the trigger is activated.
108
123
  * Receives the full input text and cursor position. For 'launch' it fires on