@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
@@ -1,6 +1,5 @@
1
1
  import type { HTMLAttributes } from 'react'
2
2
  import { cn } from '../../lib/utils.ts'
3
- import { LINE_TEXT, LineGlyph, useLines } from './transcript-variant.tsx'
4
3
 
5
4
  export interface MessageProps extends HTMLAttributes<HTMLDivElement> {
6
5
  from: 'user' | 'assistant'
@@ -16,67 +15,33 @@ export interface MessageProps extends HTMLAttributes<HTMLDivElement> {
16
15
  * column separates a prompt from the reply it produced. The bubble alone is
17
16
  * enough to say who spoke.
18
17
  *
19
- * `lines`: both are left-aligned full-width line items behind a gutter glyph
20
- * `❯` for what was typed, `●` for what the model said. No bubble: a prompt is
21
- * already distinguishable by its marker, and the bubble's padding is vertical
22
- * space the terminal treatment refuses to spend.
18
+ * The terminal theme draws none of this: it is its own renderer
19
+ * (`components/terminal/`) and mounts instead of these rows.
23
20
  */
24
21
  export function Message({ from, className, children, ...props }: MessageProps) {
25
- const lines = useLines()
26
22
  return (
27
23
  <div
28
24
  data-slot='message'
29
25
  data-from={from}
30
- className={cn(
31
- 'flex w-full',
32
- lines
33
- ? cn(
34
- 'flex-row gap-2',
35
- // What YOU said, on a band of its own — the CLI's own treatment.
36
- // Full-bleed (the negative margin cancels the row's padding) so
37
- // the band lines up with the hover highlight rather than sitting
38
- // inside it, and square-ish so it reads as a strip, not a bubble.
39
- from === 'user' && '-mx-1 rounded-sm bg-surface px-1',
40
- )
41
- : 'flex-col items-start gap-1',
42
- className,
43
- )}
26
+ className={cn('flex w-full flex-col items-start gap-1', className)}
44
27
  {...props}>
45
- {lines ? (
46
- <>
47
- {/* The prompt marker is accent, the same blue VS Code's own chat and
48
- the iOS app give it: in a column of grey rows it is the one thing
49
- that says "you said this", and it is what the eye scrolls back to. */}
50
- <LineGlyph className={from === 'user' ? 'text-accent' : 'text-fg-3'}>
51
- {from === 'user' ? '❯' : '●'}
52
- </LineGlyph>
53
- <div className='flex min-w-0 flex-1 flex-col gap-1'>{children}</div>
54
- </>
55
- ) : (
56
- children
57
- )}
28
+ {children}
58
29
  </div>
59
30
  )
60
31
  }
61
32
 
62
33
  export function MessageContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
63
- const lines = useLines()
64
34
  return (
65
35
  <div
66
36
  data-slot='message-content'
67
37
  className={cn(
68
- 'min-w-0',
69
- lines
70
- ? cn('w-full text-fg-1', LINE_TEXT, 'in-data-[from=user]:whitespace-pre-wrap')
71
- : cn(
72
- 'text-body-sm leading-6 text-fg-1',
73
- // Bubble treatment only within a user message row.
74
- // The tail points bottom-*left* now that the bubble is left-aligned.
75
- 'in-data-[from=user]:max-w-[85%] in-data-[from=user]:rounded-lg in-data-[from=user]:rounded-bl-sm',
76
- 'in-data-[from=user]:bg-accent-bg in-data-[from=user]:px-3 in-data-[from=user]:py-2',
77
- 'in-data-[from=user]:whitespace-pre-wrap',
78
- 'in-data-[from=assistant]:w-full',
79
- ),
38
+ 'min-w-0 text-body-sm leading-6 text-fg-1',
39
+ // Bubble treatment only within a user message row.
40
+ // The tail points bottom-*left* now that the bubble is left-aligned.
41
+ 'in-data-[from=user]:max-w-[85%] in-data-[from=user]:rounded-lg in-data-[from=user]:rounded-bl-sm',
42
+ 'in-data-[from=user]:bg-accent-bg in-data-[from=user]:px-3 in-data-[from=user]:py-2',
43
+ 'in-data-[from=user]:whitespace-pre-wrap',
44
+ 'in-data-[from=assistant]:w-full',
80
45
  className,
81
46
  )}
82
47
  {...props}
@@ -6,14 +6,6 @@ import { Input } from '../ui/Input.tsx'
6
6
  import { cn } from '../../lib/utils.ts'
7
7
  import { toolInputPreview } from '../../lib/format.ts'
8
8
  import { toolIcon } from '../../lib/tool-icon.ts'
9
- import {
10
- LINE_INDENT,
11
- LineHint,
12
- LineInput,
13
- LineOptionList,
14
- LinePayload,
15
- } from './line-prompt.tsx'
16
- import { LineGlyph, useLines } from './transcript-variant.tsx'
17
9
 
18
10
  export interface PermissionPromptProps {
19
11
  request: PermissionRequest
@@ -37,11 +29,9 @@ export interface PermissionPromptProps {
37
29
  * already written the sentence that says so.
38
30
  */
39
31
  export function PermissionPrompt({ request, onApprove, onDeny, className }: PermissionPromptProps) {
40
- const lines = useLines()
41
32
  const [showInput, setShowInput] = useState(false)
42
33
  const [denying, setDenying] = useState(false)
43
34
  const [reason, setReason] = useState('')
44
- const [focused, setFocused] = useState(0)
45
35
 
46
36
  const deny = (interrupt: boolean) => {
47
37
  const message = reason.trim()
@@ -52,88 +42,6 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
52
42
 
53
43
  const summary = toolInputPreview(request.input)
54
44
  const ToolIcon = toolIcon(request.toolName)
55
- const heading = request.title ?? request.displayName ?? 'Permission needed'
56
-
57
- if (lines) {
58
- // The same three outcomes as the card, as rows. "Deny" opens the reason
59
- // field rather than acting, because a denial the agent can learn from is the
60
- // one worth defaulting to — an empty field still just denies.
61
- const options = [
62
- { key: 'allow', label: 'Allow' },
63
- {
64
- key: 'deny',
65
- label: 'Deny',
66
- description: 'the turn continues',
67
- detail: denying ? (
68
- <LineInput
69
- value={reason}
70
- onChange={setReason}
71
- onSubmit={() => deny(false)}
72
- onCancel={() => setDenying(false)}
73
- placeholder='Reason (optional) — the agent reads this and can try something else'
74
- />
75
- ) : undefined,
76
- },
77
- { key: 'stop', label: 'Deny & stop', description: 'interrupt the turn', danger: true },
78
- ]
79
-
80
- return (
81
- <div
82
- data-slot='permission-prompt'
83
- className={cn('w-full', className)}
84
- onKeyDown={(event) => {
85
- if (event.key === 'Escape') {
86
- event.preventDefault()
87
- deny(false)
88
- }
89
- }}>
90
- <div className='flex items-baseline gap-2'>
91
- <LineGlyph className='text-warning'>!</LineGlyph>
92
- <span className='min-w-0 flex-1 text-body-sm leading-5 font-medium text-fg-1'>
93
- {heading}
94
- </span>
95
- </div>
96
- {request.description ? (
97
- <div className={cn(LINE_INDENT, 'text-label leading-5 text-fg-3')}>
98
- {request.description}
99
- </div>
100
- ) : null}
101
- {request.decisionReason ? (
102
- <div className={cn(LINE_INDENT, 'text-label leading-5 text-fg-4')}>
103
- {request.decisionReason}
104
- </div>
105
- ) : null}
106
- <button
107
- type='button'
108
- onClick={() => setShowInput((v) => !v)}
109
- className='flex w-full items-baseline gap-2 text-left outline-none hover:bg-surface-hover/60'>
110
- <LineGlyph className='text-fg-4'>⎿</LineGlyph>
111
- <span className='min-w-0 flex-1 truncate text-label leading-5 text-fg-4'>
112
- <span className='font-medium text-fg-2'>{request.toolName}</span>
113
- {summary ? `(${summary})` : null}
114
- </span>
115
- </button>
116
- {showInput ? (
117
- <div className={LINE_INDENT}>
118
- <LinePayload code={JSON.stringify(request.input, null, 2)} language='json' label='Input' />
119
- </div>
120
- ) : null}
121
- <LineOptionList
122
- label={heading}
123
- options={options}
124
- focused={focused}
125
- onFocus={setFocused}
126
- active={!denying}
127
- onChoose={(index) => {
128
- if (index === 0) onApprove(request.id)
129
- else if (index === 1) setDenying(true)
130
- else deny(true)
131
- }}
132
- />
133
- <LineHint>↑↓ move · 1–3 choose · esc deny</LineHint>
134
- </div>
135
- )
136
- }
137
45
 
138
46
  return (
139
47
  <div
@@ -0,0 +1,119 @@
1
+ import type { ProjectIcon as ProjectIconSpec } from '@workerdeck/protocol'
2
+ import {
3
+ Anchor, Atom, Beaker, Bike, Binary, Blocks, Bot, Box, Brain, Briefcase, Brush, Bug,
4
+ Building2, Cable, Camera, Car, Cat, CircuitBoard, Cloud, Code, Coffee, Cog, Compass,
5
+ Container, Cpu, CreditCard, Database, Diamond, Dog, Feather, Film, Flame, FlaskConical,
6
+ Folder, Gamepad2, Gauge, Gem, Ghost, Gift, GitBranch, Globe, GraduationCap, Hammer,
7
+ Hexagon, House, Image, Key, Landmark, Laptop, Layers, Leaf, Library, Lightbulb, Lock,
8
+ Mail, Map, MessageCircle, Mic, Monitor, Moon, Mountain, Music, Network, Newspaper,
9
+ Notebook, Orbit, Package, Palette, PenTool, Phone, PiggyBank, Plane, Plug, Puzzle,
10
+ Radar, Radio, Receipt, Rocket, Ruler, Satellite, Scale, Scissors, Server, Shield, Ship,
11
+ ShoppingCart, Signal, Smartphone, Sparkles, Sprout, Square, Star, Sun, Table, Tag,
12
+ Target, Telescope, Terminal, TestTube, TreePine, Trophy, Truck, Umbrella, Users, Wallet,
13
+ Wand, Waves, Webhook, Wifi, Wrench, Zap,
14
+ } from 'lucide-react'
15
+ import type { ComponentType } from 'react'
16
+ import { cn } from '../../lib/utils.ts'
17
+
18
+ /**
19
+ * A project's icon — the render side of protocol's `ProjectIcon`, for a list
20
+ * row or a group header.
21
+ *
22
+ * **The glyph arm is a curated set, and that is the design rather than a
23
+ * shortcut.** The gateway validates a glyph name by *shape* only (lucide's
24
+ * lowercase-kebab convention) and explicitly declines to grow an icon catalog,
25
+ * so the contract already says a client must fall back on a name it does not
26
+ * know.
27
+ *
28
+ * The alternative was measured rather than argued, against the VS Code
29
+ * sidebar's own bundle: `sidebar.js` is **31 KB** with one glyph, **77 KB**
30
+ * with the 110 below (~418 bytes each), and **927 KB** with a
31
+ * `import * as` over lucide's ~1,600 — which is what it costs, because a
32
+ * namespace import defeats tree-shaking by construction. (`DynamicIcon` is the
33
+ * third option and worse in a different currency: ~1,600 chunk files shipped
34
+ * inside the `.vsix`.) Twelve times the bundle to render a name nobody has
35
+ * declared yet is not a trade worth making; 46 KB to cover the names people
36
+ * actually use is.
37
+ *
38
+ * The set is chosen to cover what people call a project; an unlisted-but-valid
39
+ * name draws {@link Folder}, which is what the row drew before this feature
40
+ * existed. **Grow it freely** — each addition is ~400 bytes and the fallback
41
+ * means a missing one is a shrug, not a hole.
42
+ *
43
+ * **The image arm draws nothing until its bytes arrive**, and takes them as a
44
+ * resolved `src` rather than fetching: the wire carries only an address
45
+ * (media type, size, content hash), and *who* fetches it differs per client —
46
+ * a VS Code webview cannot reach a gateway at all and must be handed a data
47
+ * URL by its extension host, while a browser can fetch and `createObjectURL`.
48
+ * A component that fetched would be wrong on one of them. Absent `src` renders
49
+ * nothing rather than a placeholder box: an icon is decoration beside a name
50
+ * that is already there, and a box that becomes a picture a beat later is more
51
+ * movement than the picture is worth.
52
+ *
53
+ * One thing an image icon cannot do, and a glyph can: **take the row's
54
+ * colour.** An `<img>`-embedded SVG is its own document, so `currentColor` does
55
+ * not reach it and its own `prefers-color-scheme` resolves against the *OS*
56
+ * rather than the host's theme (measured: it does resolve, which is the
57
+ * surprise — so a monochrome mark tuned for both schemes is right on a dark OS
58
+ * in a dark editor and wrong on a light OS in a dark one). A repo that wants a
59
+ * mark that always matches its row should declare a glyph; one that wants its
60
+ * brand should declare the image and accept that it is a picture.
61
+ */
62
+ const GLYPHS: Record<string, ComponentType<{ className?: string }>> = {
63
+ anchor: Anchor, atom: Atom, beaker: Beaker, bike: Bike, binary: Binary, blocks: Blocks,
64
+ bot: Bot, box: Box, brain: Brain, briefcase: Briefcase, brush: Brush, bug: Bug,
65
+ 'building-2': Building2, cable: Cable, camera: Camera, car: Car, cat: Cat,
66
+ 'circuit-board': CircuitBoard, cloud: Cloud, code: Code, coffee: Coffee, cog: Cog,
67
+ compass: Compass, container: Container, cpu: Cpu, 'credit-card': CreditCard,
68
+ database: Database, diamond: Diamond, dog: Dog, feather: Feather, film: Film,
69
+ flame: Flame, 'flask-conical': FlaskConical, folder: Folder, 'gamepad-2': Gamepad2,
70
+ gauge: Gauge, gem: Gem, ghost: Ghost, gift: Gift, 'git-branch': GitBranch, globe: Globe,
71
+ 'graduation-cap': GraduationCap, hammer: Hammer, hexagon: Hexagon, house: House,
72
+ image: Image, key: Key, landmark: Landmark, laptop: Laptop, layers: Layers, leaf: Leaf,
73
+ library: Library, lightbulb: Lightbulb, lock: Lock, mail: Mail, map: Map,
74
+ 'message-circle': MessageCircle, mic: Mic, monitor: Monitor, moon: Moon,
75
+ mountain: Mountain, music: Music, network: Network, newspaper: Newspaper,
76
+ notebook: Notebook, orbit: Orbit, package: Package, palette: Palette, 'pen-tool': PenTool,
77
+ phone: Phone, 'piggy-bank': PiggyBank, plane: Plane, plug: Plug, puzzle: Puzzle,
78
+ radar: Radar, radio: Radio, receipt: Receipt, rocket: Rocket, ruler: Ruler,
79
+ satellite: Satellite, scale: Scale, scissors: Scissors, server: Server, shield: Shield,
80
+ ship: Ship, 'shopping-cart': ShoppingCart, signal: Signal, smartphone: Smartphone,
81
+ sparkles: Sparkles, sprout: Sprout, square: Square, star: Star, sun: Sun, table: Table,
82
+ tag: Tag, target: Target, telescope: Telescope, terminal: Terminal, 'test-tube': TestTube,
83
+ 'tree-pine': TreePine, trophy: Trophy, truck: Truck, umbrella: Umbrella, users: Users,
84
+ wallet: Wallet, wand: Wand, waves: Waves, webhook: Webhook, wifi: Wifi, wrench: Wrench,
85
+ zap: Zap,
86
+ }
87
+
88
+ export function ProjectIcon({
89
+ icon,
90
+ src,
91
+ name,
92
+ className,
93
+ }: {
94
+ icon: ProjectIconSpec | undefined
95
+ /** Resolved bytes for the `image` arm — an object URL or a data URL. The
96
+ * caller fetches (see the note above); absent draws nothing. */
97
+ src?: string
98
+ /** The project's name, for the alt text. */
99
+ name?: string
100
+ className?: string
101
+ }) {
102
+ if (!icon) return null
103
+ if (icon.type === 'image') {
104
+ if (!src) return null
105
+ return (
106
+ <img
107
+ src={src}
108
+ alt=''
109
+ aria-hidden
110
+ title={name}
111
+ // `object-contain` because a declared icon is whatever aspect the repo
112
+ // checked in, and a squashed logo reads worse than a letterboxed one.
113
+ className={cn('inline-block size-3 shrink-0 object-contain', className)}
114
+ />
115
+ )
116
+ }
117
+ const Glyph = GLYPHS[icon.name] ?? Folder
118
+ return <Glyph className={cn('inline-block size-3 shrink-0', className)} />
119
+ }
@@ -10,8 +10,6 @@ import { Badge } from '../ui/Badge.tsx'
10
10
  import { Button } from '../ui/Button.tsx'
11
11
  import { Input } from '../ui/Input.tsx'
12
12
  import { cn } from '../../lib/utils.ts'
13
- import { LINE_INDENT, LineHint, LineInput, LineOptionList, LinePayload } from './line-prompt.tsx'
14
- import { LineGlyph, useLines } from './transcript-variant.tsx'
15
13
 
16
14
  export type QuestionBehaviorMeta = {
17
15
  value: QuestionBehavior
@@ -49,9 +47,6 @@ export function parseUserQuestions(input: Record<string, unknown>): UserQuestion
49
47
 
50
48
  type Selection = { labels: string[]; other: string; otherActive: boolean }
51
49
 
52
- /** Whether the key hint should say "toggle" rather than "choose". */
53
- const multiSelectAnywhere = (questions: UserQuestion[]) => questions.some((q) => q.multiSelect === true)
54
-
55
50
  const EMPTY_SELECTION: Selection = { labels: [], other: '', otherActive: false }
56
51
 
57
52
  /** A question's answer string: chosen label(s) (multi-select comma-joined), with any
@@ -77,16 +72,10 @@ export interface QuestionPromptProps {
77
72
  * and the focused option's preview. Falls back to nothing renderable → the caller
78
73
  * should show a generic PermissionPrompt if `parseUserQuestions` finds no questions. */
79
74
  export function QuestionPrompt({ request, onAnswer, onDismiss, className }: QuestionPromptProps) {
80
- const lines = useLines()
81
75
  const questions = parseUserQuestions(request.input)
82
76
  const [selections, setSelections] = useState<Selection[]>(() =>
83
77
  questions.map(() => EMPTY_SELECTION),
84
78
  )
85
- // `lines` only: the roving cursor per question, and which question's list owns
86
- // the DOM focus — two lists both chasing their own index would tear the caret
87
- // between them on every render.
88
- const [cursors, setCursors] = useState<number[]>(() => questions.map(() => 0))
89
- const [activeQuestion, setActiveQuestion] = useState(0)
90
79
 
91
80
  const update = (index: number, patch: Partial<Selection>) => {
92
81
  setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
@@ -115,117 +104,6 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
115
104
  onAnswer(request.id, { ...request.input, answers })
116
105
  }
117
106
 
118
- if (lines) {
119
- const dismiss = () => onDismiss(request.id, 'Question dismissed by user')
120
- return (
121
- <div
122
- data-slot='question-prompt'
123
- className={cn('flex w-full flex-col gap-2', className)}
124
- onKeyDown={(event) => {
125
- if (event.key === 'Escape') {
126
- event.preventDefault()
127
- dismiss()
128
- return
129
- }
130
- // Enter belongs to the focused row (it is a button), so committing the
131
- // whole form needs the modifier — the composer's own convention.
132
- if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && complete) {
133
- event.preventDefault()
134
- submit()
135
- }
136
- }}>
137
- {questions.map((q, index) => {
138
- const selection = selections[index] ?? EMPTY_SELECTION
139
- const cursor = cursors[index] ?? 0
140
- const multiSelect = q.multiSelect === true
141
- const options = [
142
- ...q.options.map((option, optionIndex) => {
143
- const selected = selection.labels.includes(option.label)
144
- return {
145
- key: option.label,
146
- label: option.label,
147
- description: option.description,
148
- // A preview shows on focus, not only on selection: in a list you
149
- // walk with the arrow keys, "what does this one look like" is the
150
- // question the cursor is asking.
151
- detail:
152
- option.preview && (selected || cursor === optionIndex) ? (
153
- <LinePayload code={option.preview} label='Preview' />
154
- ) : undefined,
155
- // Both kinds draw their state: without a marker a chosen
156
- // one-of is invisible the moment the cursor moves off it.
157
- checked: selected,
158
- marker: multiSelect ? ('check' as const) : ('radio' as const),
159
- }
160
- }),
161
- {
162
- key: '__other',
163
- label: 'Other…',
164
- checked: selection.otherActive,
165
- marker: multiSelect ? ('check' as const) : ('radio' as const),
166
- detail: selection.otherActive ? (
167
- <LineInput
168
- value={selection.other}
169
- onChange={(value) => update(index, { other: value })}
170
- onSubmit={() => {
171
- // The typed text is already in state, so `complete` is
172
- // current: Enter finishes the form when this was the last
173
- // question outstanding, and is a no-op when it wasn't.
174
- if (complete) submit()
175
- }}
176
- onCancel={() => update(index, { otherActive: false, other: '' })}
177
- placeholder='Type your own answer'
178
- />
179
- ) : undefined,
180
- },
181
- ]
182
- return (
183
- <div key={index} className='flex flex-col'>
184
- <div className='flex items-baseline gap-2'>
185
- <LineGlyph className='text-fg-3'>?</LineGlyph>
186
- <span className='min-w-0 flex-1 text-body-sm leading-5 text-fg-1'>
187
- {q.header ? <span className='text-fg-4'>{q.header} · </span> : null}
188
- <span className='font-medium'>{q.question}</span>
189
- </span>
190
- </div>
191
- <LineOptionList
192
- label={q.question}
193
- options={options}
194
- focused={cursor}
195
- active={activeQuestion === index && !selection.otherActive}
196
- onFocus={(next) => {
197
- setActiveQuestion(index)
198
- setCursors((prev) => prev.map((c, i) => (i === index ? next : c)))
199
- }}
200
- onChoose={(next) => {
201
- if (next === q.options.length) {
202
- update(index, { otherActive: !selection.otherActive })
203
- return
204
- }
205
- const option = q.options[next]
206
- if (option) toggle(index, option.label, multiSelect)
207
- }}
208
- />
209
- </div>
210
- )
211
- })}
212
- <div className={cn(LINE_INDENT, 'flex flex-col')}>
213
- <button
214
- type='button'
215
- disabled={!complete}
216
- onClick={submit}
217
- className='w-fit text-body-sm leading-5 text-accent underline-offset-2 outline-none hover:underline disabled:text-fg-4 disabled:no-underline'>
218
- ⏎ Answer
219
- </button>
220
- </div>
221
- <LineHint>
222
- ↑↓ move · {multiSelectAnywhere(questions) ? 'space/1–9 toggle' : '1–9 choose'} · ⌘⏎ answer
223
- · esc dismiss
224
- </LineHint>
225
- </div>
226
- )
227
- }
228
-
229
107
  return (
230
108
  <div
231
109
  data-slot='question-prompt'
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'
2
2
  import { Brain, ChevronDown } from 'lucide-react'
3
3
  import { cn } from '../../lib/utils.ts'
4
4
  import { Response } from './Response.tsx'
5
- import { LineGlyph, useLines } from './transcript-variant.tsx'
6
5
 
7
6
  export interface ReasoningProps {
8
7
  children: string
@@ -15,7 +14,6 @@ export interface ReasoningProps {
15
14
  /** Collapsible extended-thinking block: open while the model is thinking, tucks itself
16
15
  * away once the thought is finished (unless the user toggled it manually). */
17
16
  export function Reasoning({ children, isStreaming = false, defaultOpen, className }: ReasoningProps) {
18
- const lines = useLines()
19
17
  const [open, setOpen] = useState(defaultOpen ?? isStreaming)
20
18
  const userToggled = useRef(false)
21
19
 
@@ -43,28 +41,16 @@ export function Reasoning({ children, isStreaming = false, defaultOpen, classNam
43
41
  userToggled.current = true
44
42
  setOpen((v) => !v)
45
43
  }}
46
- className={cn(
47
- 'flex items-baseline text-label text-fg-3 transition-colors outline-none disabled:cursor-default hover:text-fg-1 disabled:hover:text-fg-3',
48
- lines ? 'w-full gap-2' : 'items-center gap-1.5',
49
- )}>
50
- {lines ? (
51
- <LineGlyph className='text-fg-4'>✻</LineGlyph>
52
- ) : (
53
- <Brain className='size-3.5' />
54
- )}
55
- <span className={cn(lines && 'leading-5')}>
56
- {isStreaming ? 'Thinking…' : 'Thought process'}
57
- </span>
58
- {hasText && !lines ? (
44
+ className='flex items-center gap-1.5 text-label text-fg-3 transition-colors outline-none disabled:cursor-default hover:text-fg-1 disabled:hover:text-fg-3'>
45
+ <Brain className='size-3.5' />
46
+ <span>{isStreaming ? 'Thinking…' : 'Thought process'}</span>
47
+ {hasText ? (
59
48
  <ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
60
49
  ) : null}
61
50
  </button>
62
51
  {open && hasText ? (
63
52
  <div
64
- className={cn(
65
- 'border-l border-border text-body-sm text-fg-3 [&_*]:text-fg-3',
66
- lines ? 'ml-[0.4375rem] mt-0.5 pl-2 leading-5' : 'mt-2 border-l-2 pl-3',
67
- )}>
53
+ className='mt-2 border-l-2 border-border pl-3 text-body-sm text-fg-3 [&_*]:text-fg-3'>
68
54
  <Response streaming={isStreaming}>{children}</Response>
69
55
  </div>
70
56
  ) : null}