@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.
- package/README.md +81 -3
- package/build/SessionPanel-CyhygZx_.d.mts +277 -0
- package/build/SessionPanel-_U8tjX29.mjs +8409 -0
- package/build/SessionPanel-_U8tjX29.mjs.map +1 -0
- package/build/format-DqR56Y8l.mjs +162 -0
- package/build/format-DqR56Y8l.mjs.map +1 -0
- package/build/format-ljc3lKpA.d.mts +59 -0
- package/build/format.d.mts +2 -0
- package/build/format.mjs +2 -0
- package/build/index.d.mts +610 -87
- package/build/index.mjs +6 -5104
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +199 -0
- package/build/workspace.mjs +849 -0
- package/build/workspace.mjs.map +1 -0
- package/package.json +22 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +522 -87
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/Conversation.tsx +11 -3
- package/src/components/agent/EditorTabs.tsx +165 -0
- package/src/components/agent/FileCard.tsx +26 -0
- package/src/components/agent/FileTree.tsx +287 -0
- package/src/components/agent/FileViewer.tsx +148 -0
- package/src/components/agent/HostFilesDialog.tsx +218 -0
- package/src/components/agent/Loader.tsx +120 -14
- package/src/components/agent/McpDialog.tsx +363 -0
- package/src/components/agent/Message.tsx +51 -17
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +133 -22
- package/src/components/agent/PermissionPrompt.tsx +164 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/QuestionPrompt.tsx +122 -0
- package/src/components/agent/Reasoning.tsx +20 -5
- package/src/components/agent/Response.tsx +128 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +756 -91
- package/src/components/agent/SessionWorkspace.tsx +282 -0
- package/src/components/agent/SkillsDialog.tsx +195 -0
- package/src/components/agent/StatusBar.tsx +85 -18
- package/src/components/agent/ToolCallCard.tsx +243 -30
- package/src/components/agent/Transcript.tsx +495 -30
- package/src/components/agent/UsageDialog.tsx +168 -0
- package/src/components/agent/line-prompt.tsx +249 -0
- package/src/components/agent/transcript-variant.tsx +61 -0
- package/src/components/prompt-area/prompt-area-engine.ts +53 -0
- package/src/components/prompt-area/types.ts +15 -0
- package/src/components/prompt-area/use-prompt-area.ts +20 -0
- package/src/components/ui/CodeBlock.tsx +40 -2
- package/src/components/ui/CopyButton.tsx +28 -3
- package/src/components/ui/Dialog.tsx +92 -0
- package/src/components/ui/Menu.tsx +55 -0
- package/src/components/ui/Splitter.tsx +133 -0
- package/src/components/ui/Tooltip.tsx +22 -5
- package/src/format.ts +10 -0
- package/src/index.ts +63 -2
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +114 -0
- package/src/lib/tool-icon.ts +96 -0
- 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
|
-
/**
|
|
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
|
-
{
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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: '
|
|
29
|
-
|
|
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
|
|
40
|
-
* pass
|
|
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' && '
|
|
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-
|
|
81
|
-
{offered.map((m) =>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
<
|
|
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 ??
|
|
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={() =>
|
|
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 & 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=
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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}
|