@workerdeck/ui 0.9.0 → 0.11.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 (61) hide show
  1. package/README.md +81 -3
  2. package/build/SessionPanel-CyhygZx_.d.mts +277 -0
  3. package/build/SessionPanel-_U8tjX29.mjs +8409 -0
  4. package/build/SessionPanel-_U8tjX29.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 +2 -0
  9. package/build/format.mjs +2 -0
  10. package/build/index.d.mts +610 -87
  11. package/build/index.mjs +6 -5104
  12. package/build/index.mjs.map +1 -1
  13. package/build/workspace.d.mts +199 -0
  14. package/build/workspace.mjs +849 -0
  15. package/build/workspace.mjs.map +1 -0
  16. package/package.json +22 -4
  17. package/src/components/agent/CodeEditor.tsx +300 -0
  18. package/src/components/agent/Composer.tsx +522 -87
  19. package/src/components/agent/ContextDialog.tsx +99 -0
  20. package/src/components/agent/Conversation.tsx +11 -3
  21. package/src/components/agent/EditorTabs.tsx +165 -0
  22. package/src/components/agent/FileCard.tsx +26 -0
  23. package/src/components/agent/FileTree.tsx +287 -0
  24. package/src/components/agent/FileViewer.tsx +148 -0
  25. package/src/components/agent/HostFilesDialog.tsx +218 -0
  26. package/src/components/agent/Loader.tsx +120 -14
  27. package/src/components/agent/McpDialog.tsx +363 -0
  28. package/src/components/agent/Message.tsx +51 -17
  29. package/src/components/agent/ModelSelect.tsx +34 -6
  30. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  31. package/src/components/agent/PermissionPrompt.tsx +164 -6
  32. package/src/components/agent/PromptTokenText.tsx +39 -0
  33. package/src/components/agent/QuestionPrompt.tsx +122 -0
  34. package/src/components/agent/Reasoning.tsx +20 -5
  35. package/src/components/agent/Response.tsx +128 -0
  36. package/src/components/agent/SessionEmptyState.tsx +65 -0
  37. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  38. package/src/components/agent/SessionPanel.tsx +756 -91
  39. package/src/components/agent/SessionWorkspace.tsx +282 -0
  40. package/src/components/agent/SkillsDialog.tsx +195 -0
  41. package/src/components/agent/StatusBar.tsx +85 -18
  42. package/src/components/agent/ToolCallCard.tsx +243 -30
  43. package/src/components/agent/Transcript.tsx +495 -30
  44. package/src/components/agent/UsageDialog.tsx +168 -0
  45. package/src/components/agent/line-prompt.tsx +249 -0
  46. package/src/components/agent/transcript-variant.tsx +61 -0
  47. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  48. package/src/components/prompt-area/types.ts +15 -0
  49. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  50. package/src/components/ui/CodeBlock.tsx +40 -2
  51. package/src/components/ui/CopyButton.tsx +28 -3
  52. package/src/components/ui/Dialog.tsx +92 -0
  53. package/src/components/ui/Menu.tsx +55 -0
  54. package/src/components/ui/Splitter.tsx +133 -0
  55. package/src/components/ui/Tooltip.tsx +22 -5
  56. package/src/format.ts +10 -0
  57. package/src/index.ts +63 -2
  58. package/src/lib/clipboard.ts +56 -0
  59. package/src/lib/format.ts +114 -0
  60. package/src/lib/tool-icon.ts +96 -0
  61. package/src/workspace.ts +28 -0
@@ -1,4 +1,6 @@
1
+ import type { LucideIcon } from 'lucide-react'
1
2
  import type { PermissionMode } from '@workerdeck/protocol'
3
+ import { ClipboardList, Code, Hand, ShieldCheck, TriangleAlert, Zap } from 'lucide-react'
2
4
  import {
3
5
  Select,
4
6
  SelectContent,
@@ -11,34 +13,128 @@ import { cn } from '../../lib/utils.ts'
11
13
 
12
14
  export type PermissionModeMeta = {
13
15
  value: PermissionMode
16
+ /** The name Claude Code itself uses. */
14
17
  label: string
18
+ /** The chip form, for bars where the label shares a line with three other
19
+ * things and "Bypass permissions" would eat half of it. */
20
+ shortLabel: string
21
+ /** What the mode actually does — the CLI's own one-liners. */
15
22
  description: string
23
+ icon: LucideIcon
16
24
  dangerous?: boolean
17
25
  }
18
26
 
19
- /** The modes surfaced across UI surfaces (session creation, in-session switcher). */
27
+ /**
28
+ * The modes surfaced across UI surfaces (session creation, in-session switcher),
29
+ * ordered by how much of the approval gate they give away.
30
+ *
31
+ * Notably `default` is **"Manual"**: the wire value is `default`, but calling it
32
+ * that in the UI conflates a real mode (ask me every time) with "whatever the
33
+ * server picked", which is the one confusion a mode chip exists to avoid. The
34
+ * naming, the icons and the summaries are shared with the iOS app on purpose —
35
+ * the two surfaces should read as the same list.
36
+ */
20
37
  export const PERMISSION_MODES: PermissionModeMeta[] = [
21
- { value: 'default', label: 'default', description: 'ask for approval' },
22
- { value: 'acceptEdits', label: 'acceptEdits', description: 'auto-approve file edits' },
23
- { value: 'plan', label: 'plan', description: 'read-only planning' },
24
- { value: 'auto', label: 'auto', description: 'model decides when to ask' },
25
- { value: 'dontAsk', label: 'dontAsk', description: 'never ask deny unapproved' },
38
+ {
39
+ value: 'default',
40
+ label: 'Manual',
41
+ shortLabel: 'Manual',
42
+ description: 'Always ask before making changes',
43
+ icon: Hand,
44
+ },
45
+ {
46
+ value: 'acceptEdits',
47
+ label: 'Accept edits',
48
+ shortLabel: 'Edits',
49
+ description: 'Automatically accept all file edits',
50
+ icon: Code,
51
+ },
52
+ {
53
+ value: 'plan',
54
+ label: 'Plan',
55
+ shortLabel: 'Plan',
56
+ description: 'Create a plan before making changes',
57
+ icon: ClipboardList,
58
+ },
59
+ {
60
+ value: 'auto',
61
+ label: 'Auto',
62
+ shortLabel: 'Auto',
63
+ description: 'Claude handles permission decisions',
64
+ icon: Zap,
65
+ },
66
+ {
67
+ value: 'dontAsk',
68
+ label: "Don't ask",
69
+ shortLabel: "Don't ask",
70
+ // The CLI's own definition, and the opposite of bypass: it never prompts,
71
+ // and anything not already permitted is denied rather than allowed.
72
+ description: 'Never ask — deny anything not pre-approved',
73
+ icon: ShieldCheck,
74
+ },
26
75
  {
27
76
  value: 'bypassPermissions',
28
- label: 'bypassPermissions',
29
- description: 'no prompts (danger)',
77
+ label: 'Bypass permissions',
78
+ shortLabel: 'Bypass',
79
+ description: 'Skip every approval — the agent is unsupervised',
80
+ icon: TriangleAlert,
30
81
  dangerous: true,
31
82
  },
32
83
  ]
33
84
 
85
+ export const permissionModeMeta = (mode: PermissionMode): PermissionModeMeta | undefined =>
86
+ PERMISSION_MODES.find((m) => m.value === mode)
87
+
88
+ /** One offered mode as plain data — no icon, no React. What a host chrome
89
+ * outside the panel (a VS Code QuickPick) needs to draw the same list. */
90
+ export type PermissionModeChoice = {
91
+ value: PermissionMode
92
+ label: string
93
+ description: string
94
+ dangerous?: boolean
95
+ /** Offered but unreachable: a session not started for bypass can never gain
96
+ * it, and saying so beats omitting the row. */
97
+ disabled?: boolean
98
+ }
99
+
100
+ /**
101
+ * The modes this session may actually be switched into, with the reasons baked
102
+ * in — the same filtering {@link PermissionModeSelect} applies, so an embedder
103
+ * rendering its own picker cannot drift from the panel's.
104
+ */
105
+ export function permissionModeChoices(
106
+ modes?: readonly PermissionMode[],
107
+ canBypass?: boolean,
108
+ ): PermissionModeChoice[] {
109
+ const offered = modes ? PERMISSION_MODES.filter((m) => modes.includes(m.value)) : PERMISSION_MODES
110
+ return offered.map((m) => ({
111
+ value: m.value,
112
+ label: m.label,
113
+ description:
114
+ m.value === 'bypassPermissions' && canBypass === false
115
+ ? 'Only available to a session started in this mode'
116
+ : m.description,
117
+ dangerous: m.dangerous,
118
+ disabled: m.value === 'bypassPermissions' && canBypass === false,
119
+ }))
120
+ }
121
+
34
122
  export interface PermissionModeSelectProps {
35
123
  /** The session's current mode (TranscriptState.permissionMode). */
36
124
  mode?: PermissionMode
37
125
  onModeChange: (mode: PermissionMode) => void
38
126
  /** Restrict what is offered — most of {@link PERMISSION_MODES} is Claude Code
39
- * vocabulary the provider engine has no meaning for. Defaults to all of them;
40
- * pass `PROVIDER_PERMISSION_MODES` for a provider session. */
127
+ * vocabulary the other engines have no meaning for. Defaults to all of them;
128
+ * pass the session's `capabilities.permissionModes`. */
41
129
  modes?: readonly PermissionMode[]
130
+ /**
131
+ * Whether this session may be switched into `bypassPermissions` at all. The
132
+ * CLI refuses unless the process was spawned for it, so a session that didn't
133
+ * ask up front can never gain it. The row is shown disabled rather than hidden
134
+ * — "you can't have this here" is a more useful answer than a row that
135
+ * silently isn't there. `undefined` (an older server) offers it.
136
+ */
137
+ canBypass?: boolean
42
138
  /** 'toolbar' (default) is the composer's compact borderless trigger;
43
139
  * 'form' is a standard field-sized Select for create/settings forms. */
44
140
  variant?: 'toolbar' | 'form'
@@ -51,12 +147,15 @@ export function PermissionModeSelect({
51
147
  mode,
52
148
  onModeChange,
53
149
  modes,
150
+ canBypass,
54
151
  variant = 'toolbar',
55
152
  disabled,
56
153
  className,
57
154
  }: PermissionModeSelectProps) {
58
155
  const dangerous = mode === 'bypassPermissions'
59
156
  const offered = modes ? PERMISSION_MODES.filter((m) => modes.includes(m.value)) : PERMISSION_MODES
157
+ const unavailable = (meta: PermissionModeMeta) =>
158
+ meta.value === 'bypassPermissions' && canBypass === false
60
159
  return (
61
160
  <Select
62
161
  items={offered.map((m) => ({ value: m.value, label: m.label }))}
@@ -73,21 +172,33 @@ export function PermissionModeSelect({
73
172
  dangerous ? 'text-danger' : variant === 'toolbar' ? 'text-fg-3' : undefined,
74
173
  className,
75
174
  )}>
76
- <span className={cn('truncate', variant === 'toolbar' && 'font-mono text-label')}>
175
+ <span className={cn('truncate', variant === 'toolbar' && 'text-label')}>
77
176
  <SelectValue placeholder='permissions' />
78
177
  </span>
79
178
  </SelectTrigger>
80
- <SelectContent className='min-w-56'>
81
- {offered.map((m) => (
82
- <SelectItem key={m.value} value={m.value}>
83
- <SelectItemText>
84
- <span className={cn('font-medium', m.dangerous && 'text-danger')}>{m.label}</span>
85
- </SelectItemText>
86
- <span className={cn('text-label', m.dangerous ? 'text-danger/80' : 'text-fg-4')}>
87
- {m.description}
88
- </span>
89
- </SelectItem>
90
- ))}
179
+ <SelectContent className='min-w-64'>
180
+ {offered.map((m) => {
181
+ const blocked = unavailable(m)
182
+ return (
183
+ <SelectItem key={m.value} value={m.value} disabled={blocked}>
184
+ <SelectItemText>
185
+ <span className='flex items-center gap-2'>
186
+ <m.icon
187
+ className={cn('size-3.5 shrink-0', m.dangerous ? 'text-danger' : 'text-fg-3')}
188
+ />
189
+ <span className={cn('font-medium', m.dangerous && 'text-danger')}>{m.label}</span>
190
+ </span>
191
+ </SelectItemText>
192
+ <span
193
+ className={cn(
194
+ 'pl-5.5 text-label',
195
+ blocked ? 'text-fg-4' : m.dangerous ? 'text-danger/80' : 'text-fg-4',
196
+ )}>
197
+ {blocked ? 'Only available to a session started in this mode' : m.description}
198
+ </span>
199
+ </SelectItem>
200
+ )
201
+ })}
91
202
  </SelectContent>
92
203
  </Select>
93
204
  )
@@ -1,31 +1,165 @@
1
1
  import { useState } from 'react'
2
2
  import type { PermissionRequest } from '@workerdeck/protocol'
3
- import { ShieldAlert } from 'lucide-react'
3
+ import { Hand } from 'lucide-react'
4
4
  import { Button } from '../ui/Button.tsx'
5
+ import { Input } from '../ui/Input.tsx'
5
6
  import { cn } from '../../lib/utils.ts'
7
+ import { toolInputPreview } from '../../lib/format.ts'
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'
6
17
 
7
18
  export interface PermissionPromptProps {
8
19
  request: PermissionRequest
9
20
  onApprove: (requestId: string) => void
10
- onDeny: (requestId: string, message?: string) => void
21
+ /** `message` is fed back to the agent, which can then try something else;
22
+ * `interrupt` also stops the turn. */
23
+ onDeny: (requestId: string, message?: string, interrupt?: boolean) => void
11
24
  className?: string
12
25
  }
13
26
 
27
+ /**
28
+ * Generic allow/deny prompt for a pending permission request.
29
+ *
30
+ * Three outcomes, not two: denying usually means "not that, try something else",
31
+ * so plain Deny lets the turn continue (with an optional reason the agent reads)
32
+ * while "Deny & stop" also interrupts.
33
+ *
34
+ * The heading is whatever the engine authored — `title`, else `displayName`.
35
+ * Composing "wants to use {tool}" instead would be wrong for codex, where an
36
+ * approval is usually an *escalation after a sandbox refusal* and the runner has
37
+ * already written the sentence that says so.
38
+ */
14
39
  export function PermissionPrompt({ request, onApprove, onDeny, className }: PermissionPromptProps) {
40
+ const lines = useLines()
15
41
  const [showInput, setShowInput] = useState(false)
42
+ const [denying, setDenying] = useState(false)
43
+ const [reason, setReason] = useState('')
44
+ const [focused, setFocused] = useState(0)
45
+
46
+ const deny = (interrupt: boolean) => {
47
+ const message = reason.trim()
48
+ onDeny(request.id, message || undefined, interrupt || undefined)
49
+ setReason('')
50
+ setDenying(false)
51
+ }
52
+
53
+ const summary = toolInputPreview(request.input)
54
+ 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
+
16
138
  return (
17
139
  <div
18
140
  data-slot='permission-prompt'
19
141
  className={cn('rounded-lg border border-warning/40 bg-warning-bg p-3', className)}>
20
142
  <div className='flex items-start gap-2.5'>
21
- <ShieldAlert className='mt-0.5 size-4 shrink-0 text-warning' />
143
+ <Hand className='mt-0.5 size-4 shrink-0 text-warning' />
22
144
  <div className='min-w-0 flex-1'>
23
145
  <div className='text-body-sm font-medium text-fg-1'>
24
- {request.title ?? `Claude wants to use ${request.toolName}`}
146
+ {request.title ?? request.displayName ?? 'Permission needed'}
25
147
  </div>
26
148
  {request.description ? (
27
149
  <div className='mt-0.5 text-label text-fg-3'>{request.description}</div>
28
150
  ) : null}
151
+ {request.decisionReason ? (
152
+ <div className='mt-0.5 text-label text-fg-4'>{request.decisionReason}</div>
153
+ ) : null}
154
+ <div className='mt-1.5 flex min-w-0 items-center gap-2'>
155
+ <ToolIcon className='size-3 shrink-0 text-fg-3' />
156
+ <span className='shrink-0 font-mono text-label font-medium text-fg-2'>
157
+ {request.toolName}
158
+ </span>
159
+ {summary ? (
160
+ <span className='min-w-0 truncate font-mono text-label text-fg-4'>{summary}</span>
161
+ ) : null}
162
+ </div>
29
163
  <button
30
164
  type='button'
31
165
  className='mt-1 font-mono text-label text-fg-3 underline-offset-2 hover:underline'
@@ -38,15 +172,39 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
38
172
  </pre>
39
173
  ) : null}
40
174
  </div>
41
- <div className='flex shrink-0 gap-1.5'>
175
+ <div className='flex shrink-0 flex-wrap justify-end gap-1.5'>
42
176
  <Button size='sm' onClick={() => onApprove(request.id)}>
43
177
  Allow
44
178
  </Button>
45
- <Button size='sm' variant='outline' onClick={() => onDeny(request.id, 'Denied by user')}>
179
+ <Button size='sm' variant='outline' onClick={() => setDenying((v) => !v)}>
46
180
  Deny
47
181
  </Button>
182
+ <Button size='sm' variant='outline' onClick={() => deny(true)}>
183
+ Deny &amp; stop
184
+ </Button>
48
185
  </div>
49
186
  </div>
187
+ {denying ? (
188
+ <div className='mt-2.5 flex items-center gap-2 border-t border-warning/30 pt-2.5'>
189
+ <Input
190
+ autoFocus
191
+ value={reason}
192
+ onChange={(e) => setReason(e.target.value)}
193
+ onKeyDown={(e) => {
194
+ if (e.key === 'Enter') deny(false)
195
+ if (e.key === 'Escape') setDenying(false)
196
+ }}
197
+ placeholder='Reason (optional) — the agent reads this and can try something else'
198
+ className='h-7 flex-1'
199
+ />
200
+ <Button size='sm' variant='outline' onClick={() => setDenying(false)}>
201
+ Cancel
202
+ </Button>
203
+ <Button size='sm' variant='destructive' onClick={() => deny(false)}>
204
+ Deny
205
+ </Button>
206
+ </div>
207
+ ) : null}
50
208
  </div>
51
209
  )
52
210
  }
@@ -0,0 +1,39 @@
1
+ import { Fragment } from 'react'
2
+ import { scanPromptTokens } from '@workerdeck/react'
3
+ import { cn } from '../../lib/utils.ts'
4
+
5
+ /**
6
+ * A sent message, with its `@file` and `/command` tokens styled the way the CLI
7
+ * writes them: monospace and tinted, no background — the bubble already has one,
8
+ * and a second fill inside it reads as a button.
9
+ *
10
+ * Literal text, never markdown: what was typed is what was sent. The one pass
11
+ * over it is this, so a message reads the same after sending as it did in the
12
+ * composer. Two tokens, two meanings — a file is a reference, a command is an
13
+ * action — so they are told apart by hue rather than by shape alone.
14
+ */
15
+ export function PromptTokenText({ text, className }: { text: string; className?: string }) {
16
+ const tokens = scanPromptTokens(text)
17
+ if (tokens.length === 0) return <span className={className}>{text}</span>
18
+ const parts: React.ReactNode[] = []
19
+ let cursor = 0
20
+ for (const [index, token] of tokens.entries()) {
21
+ if (token.start > cursor) parts.push(text.slice(cursor, token.start))
22
+ parts.push(
23
+ <span
24
+ key={`${token.start}-${index}`}
25
+ className={cn('font-mono', token.kind === 'file' ? 'text-accent' : 'text-info')}>
26
+ {token.text}
27
+ </span>,
28
+ )
29
+ cursor = token.end
30
+ }
31
+ if (cursor < text.length) parts.push(text.slice(cursor))
32
+ return (
33
+ <span className={className}>
34
+ {parts.map((part, index) => (
35
+ <Fragment key={index}>{part}</Fragment>
36
+ ))}
37
+ </span>
38
+ )
39
+ }
@@ -10,6 +10,8 @@ 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'
13
15
 
14
16
  export type QuestionBehaviorMeta = {
15
17
  value: QuestionBehavior
@@ -47,6 +49,9 @@ export function parseUserQuestions(input: Record<string, unknown>): UserQuestion
47
49
 
48
50
  type Selection = { labels: string[]; other: string; otherActive: boolean }
49
51
 
52
+ /** Whether the key hint should say "toggle" rather than "choose". */
53
+ const multiSelectAnywhere = (questions: UserQuestion[]) => questions.some((q) => q.multiSelect === true)
54
+
50
55
  const EMPTY_SELECTION: Selection = { labels: [], other: '', otherActive: false }
51
56
 
52
57
  /** A question's answer string: chosen label(s) (multi-select comma-joined), with any
@@ -72,10 +77,16 @@ export interface QuestionPromptProps {
72
77
  * and the focused option's preview. Falls back to nothing renderable → the caller
73
78
  * should show a generic PermissionPrompt if `parseUserQuestions` finds no questions. */
74
79
  export function QuestionPrompt({ request, onAnswer, onDismiss, className }: QuestionPromptProps) {
80
+ const lines = useLines()
75
81
  const questions = parseUserQuestions(request.input)
76
82
  const [selections, setSelections] = useState<Selection[]>(() =>
77
83
  questions.map(() => EMPTY_SELECTION),
78
84
  )
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)
79
90
 
80
91
  const update = (index: number, patch: Partial<Selection>) => {
81
92
  setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
@@ -104,6 +115,117 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
104
115
  onAnswer(request.id, { ...request.input, answers })
105
116
  }
106
117
 
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-info'>?</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
+
107
229
  return (
108
230
  <div
109
231
  data-slot='question-prompt'
@@ -2,6 +2,7 @@ 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'
5
6
 
6
7
  export interface ReasoningProps {
7
8
  children: string
@@ -14,6 +15,7 @@ export interface ReasoningProps {
14
15
  /** Collapsible extended-thinking block: open while the model is thinking, tucks itself
15
16
  * away once the thought is finished (unless the user toggled it manually). */
16
17
  export function Reasoning({ children, isStreaming = false, defaultOpen, className }: ReasoningProps) {
18
+ const lines = useLines()
17
19
  const [open, setOpen] = useState(defaultOpen ?? isStreaming)
18
20
  const userToggled = useRef(false)
19
21
 
@@ -41,15 +43,28 @@ export function Reasoning({ children, isStreaming = false, defaultOpen, classNam
41
43
  userToggled.current = true
42
44
  setOpen((v) => !v)
43
45
  }}
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 ? (
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 ? (
48
59
  <ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
49
60
  ) : null}
50
61
  </button>
51
62
  {open && hasText ? (
52
- <div className='mt-2 border-l-2 border-border pl-3 text-body-sm text-fg-3 [&_*]:text-fg-3'>
63
+ <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
68
  <Response streaming={isStreaming}>{children}</Response>
54
69
  </div>
55
70
  ) : null}