@workerdeck/ui 2.15.0 → 3.1.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 (75) hide show
  1. package/README.md +6 -5
  2. package/build/{SessionPanel-BVKqPs_3.d.mts → SessionPanel-BNNc8grK.d.mts} +18 -16
  3. package/build/index.d.mts +194 -49
  4. package/build/index.mjs +562 -260
  5. package/build/index.mjs.map +1 -1
  6. package/build/scoped.css +170 -21
  7. package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-C6Fh5zdx.mjs} +3312 -2758
  8. package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
  9. package/build/workspace.d.mts +3 -3
  10. package/build/workspace.mjs +3 -3
  11. package/build/workspace.mjs.map +1 -1
  12. package/package.json +4 -4
  13. package/src/components/agent/ApprovalPrompts.tsx +87 -0
  14. package/src/components/agent/Composer.tsx +25 -47
  15. package/src/components/agent/ContextDialog.tsx +75 -67
  16. package/src/components/agent/FrameStrip.tsx +74 -0
  17. package/src/components/agent/HostFilesDialog.tsx +3 -2
  18. package/src/components/agent/McpDialog.tsx +112 -78
  19. package/src/components/agent/PermissionPrompt.tsx +22 -17
  20. package/src/components/agent/QuestionPrompt.tsx +6 -41
  21. package/src/components/agent/Response.tsx +8 -1
  22. package/src/components/agent/Scrubber.tsx +6 -75
  23. package/src/components/agent/SessionBrowser.tsx +131 -214
  24. package/src/components/agent/SessionFilters.tsx +266 -0
  25. package/src/components/agent/SessionInfoDialog.tsx +47 -63
  26. package/src/components/agent/SessionItem.tsx +29 -6
  27. package/src/components/agent/SessionPanel.tsx +249 -498
  28. package/src/components/agent/SessionSteps.tsx +98 -14
  29. package/src/components/agent/SessionWorkspace.tsx +3 -3
  30. package/src/components/agent/ShellStrip.tsx +43 -36
  31. package/src/components/agent/ShellTerminal.tsx +1 -1
  32. package/src/components/agent/SubagentStrip.tsx +13 -30
  33. package/src/components/agent/ToolCallCard.tsx +18 -41
  34. package/src/components/agent/Transcript.tsx +111 -93
  35. package/src/components/agent/TranscriptItemView.tsx +80 -67
  36. package/src/components/agent/TranscriptRows.tsx +4 -2
  37. package/src/components/agent/composer-commands.ts +80 -1
  38. package/src/components/agent/image-viewer.tsx +326 -0
  39. package/src/components/agent/markdown-image.tsx +139 -0
  40. package/src/components/agent/scrubber-marks.ts +33 -11
  41. package/src/components/agent/scrubber-peek.tsx +122 -0
  42. package/src/components/agent/session-panel-providers.tsx +41 -0
  43. package/src/components/agent/shell-actions.tsx +38 -1
  44. package/src/components/agent/tool-result-image.tsx +40 -0
  45. package/src/components/agent/transcript-variant.tsx +3 -21
  46. package/src/components/agent/use-catch-up.ts +20 -0
  47. package/src/components/agent/use-frame.ts +88 -0
  48. package/src/components/agent/use-host-image.ts +81 -0
  49. package/src/components/agent/use-shell-frame.ts +12 -70
  50. package/src/components/agent/use-subagent-frame.ts +19 -75
  51. package/src/components/prompt-area/prompt-area-engine.ts +21 -20
  52. package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
  53. package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
  54. package/src/components/terminal/PermissionPrompt.tsx +31 -28
  55. package/src/components/terminal/QuestionPrompt.tsx +10 -46
  56. package/src/components/terminal/TerminalTranscript.tsx +23 -89
  57. package/src/components/terminal/affordances.tsx +100 -74
  58. package/src/components/terminal/height.ts +48 -32
  59. package/src/components/terminal/image-box.ts +31 -0
  60. package/src/components/terminal/items.tsx +70 -30
  61. package/src/components/terminal/markdown.tsx +4 -1
  62. package/src/components/terminal/scrubber.tsx +25 -272
  63. package/src/components/terminal/shell-row.ts +17 -3
  64. package/src/components/ui/Popover.tsx +29 -0
  65. package/src/components/ui/Select.tsx +25 -0
  66. package/src/index.ts +65 -12
  67. package/src/lib/file-link.ts +3 -16
  68. package/src/lib/permission-prompt.ts +57 -0
  69. package/src/lib/question-answers.ts +65 -0
  70. package/src/lib/shell-request.ts +115 -0
  71. package/src/lib/tool-icon.ts +13 -1
  72. package/src/styles/terminal.css +129 -22
  73. package/src/styles/theme.css +4 -0
  74. package/build/use-path-links-BgWXmfAS.mjs.map +0 -1
  75. package/src/components/prompt-area/use-markdown-mode.ts +0 -103
@@ -14,81 +14,89 @@ export interface ContextDialogProps {
14
14
  className?: string
15
15
  }
16
16
 
17
+ export interface ContextPanelProps {
18
+ usage?: ContextUsage
19
+ engine?: ProfileEngine
20
+ }
21
+
17
22
  export function ContextDialog({ usage, engine, open, onOpenChange, className }: ContextDialogProps) {
18
- const note = contextNote(engine)
19
23
  return (
20
24
  <Dialog open={open} onOpenChange={onOpenChange}>
21
25
  <DialogContent className={className}>
22
26
  <DialogHeader title="Context" description={usage?.model} />
23
27
  <DialogBody>
24
- {!usage ? (
25
- <p className="py-6 text-center text-body-sm text-fg-4">
26
- No reading yet - the context window is measured after a turn completes.
27
- </p>
28
- ) : (
29
- <>
30
- <div className="flex items-baseline justify-between gap-4">
31
- <span className="text-label text-fg-3">Used</span>
32
- <span className="font-mono text-body-sm text-fg-1">
33
- {formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} · {usage.percentage.toFixed(0)}%
34
- </span>
35
- </div>
36
- <div className="mt-2 h-2 overflow-hidden rounded-full bg-border">
37
- <div
38
- className={cn('h-full rounded-full', meterTintClass(usage.percentage))}
39
- style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
40
- />
41
- </div>
42
- {usage.categories.length > 0 ? (
43
- <div className="mt-5">
44
- <h3 className="text-label font-medium text-fg-3">Breakdown</h3>
45
- <div className="mt-2 flex flex-col gap-3">
46
- {usage.categories.map((category) => {
47
- const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
48
- const color = cssColor(category.color)
49
- return (
50
- <div key={category.name}>
51
- <div className="flex items-baseline justify-between gap-3">
52
- <span className="flex min-w-0 items-center gap-2">
53
- <span
54
- className="size-2 shrink-0 rounded-full bg-fg-4"
55
- style={color ? { backgroundColor: color } : undefined}
56
- />
57
- <span className="truncate text-body-sm text-fg-1">{category.name}</span>
58
- </span>
59
- <span className="shrink-0 font-mono text-label text-fg-3">{formatTokens(category.tokens)}</span>
60
- </div>
61
- <div className="mt-1 h-1 overflow-hidden rounded-full bg-border">
62
- <div
63
- className="h-full rounded-full bg-fg-4"
64
- style={{
65
- width: `${Math.min(100, share)}%`,
66
- backgroundColor: color,
67
- }}
68
- />
69
- </div>
70
- </div>
71
- )
72
- })}
73
- </div>
74
- </div>
75
- ) : null}
76
- </>
77
- )}
78
- {note ? (
79
- <div className="mt-5 border-t border-border pt-4">
80
- <h3 className="text-label font-medium text-fg-3">Why this window is 272K</h3>
81
- <p className="mt-2 text-body-sm text-fg-2">{note.summary}</p>
82
- {note.detail.map((line) => (
83
- <p key={line} className="mt-2 text-body-sm text-fg-3">
84
- {line}
85
- </p>
86
- ))}
87
- <p className="mt-2 text-body-sm text-warning">{note.caveat}</p>
88
- </div>
89
- ) : null}
28
+ <ContextPanel usage={usage} engine={engine} />
90
29
  </DialogBody>
91
30
  </DialogContent>
92
31
  </Dialog>
93
32
  )
94
33
  }
34
+
35
+ export function ContextPanel({ usage, engine }: ContextPanelProps) {
36
+ const note = contextNote(engine)
37
+ return (
38
+ <>
39
+ {!usage ? (
40
+ <p className="py-6 text-center text-body-sm text-fg-4">No reading yet - the context window is measured after a turn completes.</p>
41
+ ) : (
42
+ <>
43
+ <div className="flex items-baseline justify-between gap-4">
44
+ <span className="text-label text-fg-3">Used</span>
45
+ <span className="font-mono text-body-sm text-fg-1">
46
+ {formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} · {usage.percentage.toFixed(0)}%
47
+ </span>
48
+ </div>
49
+ <div className="mt-2 h-2 overflow-hidden rounded-full bg-border">
50
+ <div
51
+ className={cn('h-full rounded-full', meterTintClass(usage.percentage))}
52
+ style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
53
+ />
54
+ </div>
55
+ {usage.categories.length > 0 ? (
56
+ <div className="mt-5">
57
+ <h3 className="text-label font-medium text-fg-3">Breakdown</h3>
58
+ <div className="mt-2 flex flex-col gap-3">
59
+ {usage.categories.map((category) => {
60
+ const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
61
+ const color = cssColor(category.color)
62
+ return (
63
+ <div key={category.name}>
64
+ <div className="flex items-baseline justify-between gap-3">
65
+ <span className="flex min-w-0 items-center gap-2">
66
+ <span className="size-2 shrink-0 rounded-full bg-fg-4" style={color ? { backgroundColor: color } : undefined} />
67
+ <span className="truncate text-body-sm text-fg-1">{category.name}</span>
68
+ </span>
69
+ <span className="shrink-0 font-mono text-label text-fg-3">{formatTokens(category.tokens)}</span>
70
+ </div>
71
+ <div className="mt-1 h-1 overflow-hidden rounded-full bg-border">
72
+ <div
73
+ className="h-full rounded-full bg-fg-4"
74
+ style={{
75
+ width: `${Math.min(100, share)}%`,
76
+ backgroundColor: color,
77
+ }}
78
+ />
79
+ </div>
80
+ </div>
81
+ )
82
+ })}
83
+ </div>
84
+ </div>
85
+ ) : null}
86
+ </>
87
+ )}
88
+ {note ? (
89
+ <div className="mt-5 border-t border-border pt-4">
90
+ <h3 className="text-label font-medium text-fg-3">Why this window is 272K</h3>
91
+ <p className="mt-2 text-body-sm text-fg-2">{note.summary}</p>
92
+ {note.detail.map((line) => (
93
+ <p key={line} className="mt-2 text-body-sm text-fg-3">
94
+ {line}
95
+ </p>
96
+ ))}
97
+ <p className="mt-2 text-body-sm text-warning">{note.caveat}</p>
98
+ </div>
99
+ ) : null}
100
+ </>
101
+ )
102
+ }
@@ -0,0 +1,74 @@
1
+ import type { ReactNode } from 'react'
2
+ import { ArrowLeft } from 'lucide-react'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { Button } from '../ui/Button.tsx'
5
+ import { Ink, Row } from '../terminal/row.tsx'
6
+ import { TerminalSurface } from '../terminal/surface.tsx'
7
+ import { WithActions } from '../terminal/affordances.tsx'
8
+
9
+ export type FrameKind = 'shell' | 'subagent'
10
+
11
+ export interface FrameStripProps {
12
+ kind: FrameKind
13
+ name: string
14
+ status?: string
15
+ detail?: string
16
+ failed: boolean
17
+ live: boolean
18
+ onBack: () => void
19
+ // Drawn at the strip's trailing edge; a terminal strip then hangs them inline, which is why only the name stays the Back target.
20
+ actions?: ReactNode
21
+ terminal: boolean
22
+ fontSize?: number
23
+ lineHeight?: number
24
+ }
25
+
26
+ const TERMINAL_TONE = { shell: { name: 'magenta', live: 'magenta' }, subagent: { name: 'green', live: 'mark' } } as const
27
+ const CARDS_LIVE_CLASS: Record<FrameKind, string> = { shell: 'text-[var(--wd-shell-accent)]', subagent: 'text-accent' }
28
+
29
+ export function FrameStrip({ kind, name, status, detail, failed, live, onBack, actions, terminal, fontSize, lineHeight }: FrameStripProps) {
30
+ if (terminal) {
31
+ const tones = TERMINAL_TONE[kind]
32
+ const line = (label: ReactNode) => (
33
+ <Row glyph="←" glyphTone="dim" indent={1} tone={failed ? 'red' : tones.name}>
34
+ {label}
35
+ {status ? <Ink tone={failed ? 'red' : live ? tones.live : 'dim'}> · {status}</Ink> : null}
36
+ {detail ? <Ink tone="faint"> · {detail}</Ink> : null}
37
+ </Row>
38
+ )
39
+ return (
40
+ <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} className="shrink-0">
41
+ {actions === undefined ? (
42
+ <button type="button" onClick={onBack} aria-label="Back to the session" className="block w-full cursor-pointer text-left">
43
+ {line(name)}
44
+ </button>
45
+ ) : (
46
+ <WithActions placement="inline" actions={actions}>
47
+ {line(
48
+ <button type="button" onClick={onBack} aria-label="Back to the session" className="cursor-pointer text-left">
49
+ {name}
50
+ </button>,
51
+ )}
52
+ </WithActions>
53
+ )}
54
+ </TerminalSurface>
55
+ )
56
+ }
57
+
58
+ return (
59
+ <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
60
+ <Button variant="ghost" size="sm" onClick={onBack} className="h-6 gap-1 px-1.5">
61
+ <ArrowLeft className="size-3.5" />
62
+ Back
63
+ </Button>
64
+ <span className={cn('min-w-0 flex-1 truncate text-body-sm', kind === 'shell' && 'font-mono', failed ? 'text-danger' : 'text-fg-2')}>
65
+ {name}
66
+ </span>
67
+ {status ? (
68
+ <span className={cn('shrink-0 text-label', failed ? 'text-danger' : live ? CARDS_LIVE_CLASS[kind] : 'text-fg-3')}>{status}</span>
69
+ ) : null}
70
+ {detail ? <span className="shrink-0 text-label text-fg-4">{detail}</span> : null}
71
+ {actions}
72
+ </div>
73
+ )
74
+ }
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from '@workerdeck/protocol'
1
2
  import { useCallback, useEffect, useState } from 'react'
2
3
  import type { WorkerDeckClient } from '@workerdeck/client'
3
4
  import type { HostDirEntry, HostFileMatch } from '@workerdeck/protocol'
@@ -37,7 +38,7 @@ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDi
37
38
  setEntries(response.entries)
38
39
  setTruncated(response.truncated ?? false)
39
40
  } catch (e) {
40
- setError(e instanceof Error ? e.message : 'Could not read that directory')
41
+ setError(errorMessage(e, 'Could not read that directory'))
41
42
  } finally {
42
43
  setLoading(false)
43
44
  }
@@ -87,7 +88,7 @@ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDi
87
88
  content: response.encoding === 'utf8' ? response.content : '(binary file - not shown)',
88
89
  })
89
90
  } catch (e) {
90
- setError(e instanceof Error ? e.message : 'Could not read that file')
91
+ setError(errorMessage(e, 'Could not read that file'))
91
92
  } finally {
92
93
  setLoading(false)
93
94
  }
@@ -1,6 +1,7 @@
1
- import { useCallback, useEffect, useState } from 'react'
1
+ import { useEffect, useState } from 'react'
2
2
  import type { WorkerDeckClient } from '@workerdeck/client'
3
- import type { McpServerActionRequest, McpServerStatusInfo, McpServerToolInfo } from '@workerdeck/protocol'
3
+ import { useAsync } from '@workerdeck/react'
4
+ import { errorMessage, type McpServerActionRequest, type McpServerStatusInfo, type McpServerToolInfo } from '@workerdeck/protocol'
4
5
  import { ChevronLeft, ChevronRight, Power, PowerOff, RotateCw } from 'lucide-react'
5
6
  import { Badge, type BadgeProps } from '../ui/Badge.tsx'
6
7
  import { Button } from '../ui/Button.tsx'
@@ -17,6 +18,27 @@ export interface McpDialogProps {
17
18
  className?: string
18
19
  }
19
20
 
21
+ export interface McpPanelModel {
22
+ servers: McpServerStatusInfo[] | undefined
23
+ server: McpServerStatusInfo | undefined
24
+ tool: McpServerToolInfo | undefined
25
+ loading: boolean
26
+ busyServer: string | undefined
27
+ error: string | undefined
28
+ title: string
29
+ description: string | undefined
30
+ selectServer: (name: string | undefined) => void
31
+ selectTool: (name: string | undefined) => void
32
+ back: (() => void) | undefined
33
+ reload: () => void
34
+ act: (name: string, action: McpServerActionRequest['action']) => Promise<void>
35
+ }
36
+
37
+ export interface McpPanelProps {
38
+ model: McpPanelModel
39
+ canManageServers?: boolean
40
+ }
41
+
20
42
  const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
21
43
  connected: 'success',
22
44
  failed: 'danger',
@@ -26,94 +48,106 @@ const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
26
48
  }
27
49
 
28
50
  export function McpDialog({ client, sessionId, open, onOpenChange, canManageServers = true, className }: McpDialogProps) {
29
- const [servers, setServers] = useState<McpServerStatusInfo[] | undefined>()
30
- const [error, setError] = useState<string | undefined>()
31
- const [loading, setLoading] = useState(false)
51
+ const mcp = useMcpPanel(client, sessionId, open)
52
+ return (
53
+ <Dialog open={open} onOpenChange={onOpenChange}>
54
+ <DialogContent className={className}>
55
+ <DialogHeader title={mcp.title} description={mcp.description} actions={<McpPanelActions model={mcp} />} />
56
+ <DialogBody>
57
+ <McpPanel model={mcp} canManageServers={canManageServers} />
58
+ </DialogBody>
59
+ </DialogContent>
60
+ </Dialog>
61
+ )
62
+ }
63
+
64
+ export function useMcpPanel(client: WorkerDeckClient | undefined, sessionId: string | undefined, active = true): McpPanelModel {
65
+ const listed = useAsync(() => client!.listMcpServers(sessionId!), [client, sessionId], { enabled: active && !!client && !!sessionId })
66
+ const [actError, setActError] = useState<string | undefined>()
32
67
  const [busyServer, setBusyServer] = useState<string | undefined>()
33
68
  const [selectedServer, setSelectedServer] = useState<string | undefined>()
34
69
  const [selectedTool, setSelectedTool] = useState<string | undefined>()
35
70
 
36
- const load = useCallback(async () => {
37
- if (!sessionId) {
38
- return
39
- }
40
- setLoading(true)
41
- setError(undefined)
42
- try {
43
- setServers(await client.listMcpServers(sessionId))
44
- } catch (e) {
45
- setError(e instanceof Error ? e.message : 'Could not read MCP status')
46
- } finally {
47
- setLoading(false)
48
- }
49
- }, [client, sessionId])
50
-
51
71
  useEffect(() => {
52
- if (!open) {
53
- return
72
+ if (active) {
73
+ setSelectedServer(undefined)
74
+ setSelectedTool(undefined)
75
+ setActError(undefined)
54
76
  }
55
- setSelectedServer(undefined)
56
- setSelectedTool(undefined)
57
- void load()
58
- }, [open, load])
59
-
60
- const act = async (name: string, action: McpServerActionRequest['action']) => {
61
- if (!sessionId) {
62
- return
63
- }
64
- setBusyServer(name)
65
- setError(undefined)
66
- try {
67
- setServers(await client.mcpServerAction(sessionId, name, action))
68
- } catch (e) {
69
- setError(e instanceof Error ? e.message : `Could not ${action} ${name}`)
70
- } finally {
71
- setBusyServer(undefined)
72
- }
73
- }
77
+ }, [active, client, sessionId])
74
78
 
79
+ const servers = listed.data
75
80
  const server = servers?.find((s) => s.name === selectedServer)
76
81
  const tool = server?.tools?.find((t) => t.name === selectedTool)
77
- const title = tool?.name ?? server?.name ?? 'MCP servers'
82
+ return {
83
+ servers,
84
+ server,
85
+ tool,
86
+ loading: listed.loading,
87
+ busyServer,
88
+ error: actError ?? (listed.error === undefined ? undefined : errorMessage(listed.error, 'Could not read MCP status')),
89
+ title: tool?.name ?? server?.name ?? 'MCP servers',
90
+ description: tool ? `${server?.name} tool` : server ? server.serverInfo?.name : undefined,
91
+ selectServer: setSelectedServer,
92
+ selectTool: setSelectedTool,
93
+ back: selectedServer ? () => (selectedTool ? setSelectedTool(undefined) : setSelectedServer(undefined)) : undefined,
94
+ reload: () => {
95
+ setActError(undefined)
96
+ void listed.reload()
97
+ },
98
+ act: async (name, action) => {
99
+ if (!client || !sessionId) {
100
+ return
101
+ }
102
+ setBusyServer(name)
103
+ setActError(undefined)
104
+ try {
105
+ listed.setData(await client.mcpServerAction(sessionId, name, action))
106
+ } catch (e) {
107
+ setActError(errorMessage(e, `Could not ${action} ${name}`))
108
+ } finally {
109
+ setBusyServer(undefined)
110
+ }
111
+ },
112
+ }
113
+ }
78
114
 
115
+ export function McpPanelActions({ model }: { model: McpPanelModel }) {
116
+ if (model.back) {
117
+ return (
118
+ <Button variant="ghost" size="xs" onClick={model.back}>
119
+ <ChevronLeft className="size-3.5" />
120
+ Back
121
+ </Button>
122
+ )
123
+ }
79
124
  return (
80
- <Dialog open={open} onOpenChange={onOpenChange}>
81
- <DialogContent className={className}>
82
- <DialogHeader
83
- title={title}
84
- description={tool ? `${server?.name} tool` : server ? server.serverInfo?.name : undefined}
85
- actions={
86
- selectedServer ? (
87
- <Button variant="ghost" size="xs" onClick={() => (selectedTool ? setSelectedTool(undefined) : setSelectedServer(undefined))}>
88
- <ChevronLeft className="size-3.5" />
89
- Back
90
- </Button>
91
- ) : (
92
- <Button variant="ghost" size="xs" onClick={() => void load()} disabled={loading}>
93
- {loading ? <Spinner className="size-3 text-current" /> : <RotateCw className="size-3" />}
94
- Refresh
95
- </Button>
96
- )
97
- }
125
+ <Button variant="ghost" size="xs" onClick={model.reload} disabled={model.loading}>
126
+ {model.loading ? <Spinner className="size-3 text-current" /> : <RotateCw className="size-3" />}
127
+ Refresh
128
+ </Button>
129
+ )
130
+ }
131
+
132
+ export function McpPanel({ model, canManageServers = true }: McpPanelProps) {
133
+ const { error, servers, server, tool } = model
134
+ return (
135
+ <>
136
+ {error ? <div className="mb-3 rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger">{error}</div> : null}
137
+ {tool ? (
138
+ <ToolView tool={tool} />
139
+ ) : server ? (
140
+ <ServerView
141
+ server={server}
142
+ busy={model.busyServer === server.name}
143
+ canManage={canManageServers}
144
+ onAct={(action) => void model.act(server.name, action)}
145
+ onSelectTool={model.selectTool}
98
146
  />
99
- <DialogBody>
100
- {error ? <div className="mb-3 rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger">{error}</div> : null}
101
- {tool ? (
102
- <ToolView tool={tool} />
103
- ) : server ? (
104
- <ServerView
105
- server={server}
106
- busy={busyServer === server.name}
107
- canManage={canManageServers}
108
- onAct={(action) => void act(server.name, action)}
109
- onSelectTool={setSelectedTool}
110
- />
111
- ) : error && !servers ? null : (
112
- <ServerList servers={servers} loading={loading} onSelect={setSelectedServer} />
113
- )}
114
- </DialogBody>
115
- </DialogContent>
116
- </Dialog>
147
+ ) : error && !servers ? null : (
148
+ <ServerList servers={servers} loading={model.loading} onSelect={model.selectServer} />
149
+ )}
150
+ </>
117
151
  )
118
152
  }
119
153
 
@@ -5,7 +5,8 @@ import { Button } from '../ui/Button.tsx'
5
5
  import { Input } from '../ui/Input.tsx'
6
6
  import { cn } from '../../lib/utils.ts'
7
7
  import { toolInputPreview } from '../../lib/format.ts'
8
- import { planFromRequest } from '../../lib/plan-request.ts'
8
+ import { permissionPromptModel } from '../../lib/permission-prompt.ts'
9
+ import { shellRequestLines } from '../../lib/shell-request.ts'
9
10
  import { toolIcon } from '../../lib/tool-icon.ts'
10
11
  import { Response } from './Response.tsx'
11
12
 
@@ -28,7 +29,9 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
28
29
  setDenying(false)
29
30
  }
30
31
 
31
- const plan = planFromRequest(request)
32
+ const model = permissionPromptModel(request)
33
+ const { plan, shell } = model
34
+ const [allow, keep, stop] = model.choices
32
35
  const summary = toolInputPreview(request.input)
33
36
  const ToolIcon = toolIcon(request.toolName)
34
37
 
@@ -37,21 +40,27 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
37
40
  <div className="flex items-start gap-2.5">
38
41
  <Hand className="mt-0.5 size-4 shrink-0 text-warning" />
39
42
  <div className="min-w-0 flex-1">
40
- <div className="text-body-sm font-medium text-fg-1">
41
- {plan ? 'Plan ready for review' : (request.title ?? request.displayName ?? 'Permission needed')}
42
- </div>
43
- {request.description ? <div className="mt-0.5 text-label text-fg-3">{request.description}</div> : null}
44
- {request.decisionReason ? <div className="mt-0.5 text-label text-fg-4">{request.decisionReason}</div> : null}
43
+ <div className="text-body-sm font-medium text-fg-1">{model.heading}</div>
44
+ {model.description ? <div className="mt-0.5 text-label text-fg-3">{model.description}</div> : null}
45
+ {model.decisionReason ? <div className="mt-0.5 text-label text-fg-4">{model.decisionReason}</div> : null}
45
46
  {plan ? (
46
47
  <div className="mt-1.5 max-h-72 overflow-y-auto rounded-md bg-code-bg px-2.5 py-2 text-body-sm text-fg-2">
47
48
  <Response>{plan}</Response>
48
49
  </div>
49
50
  ) : (
50
51
  <>
52
+ {shell ? (
53
+ <pre
54
+ data-slot="shell-request"
55
+ className="mt-1.5 max-h-48 overflow-auto rounded-md bg-code-bg px-2.5 py-1.5 font-mono text-body-sm whitespace-pre-wrap text-fg-1"
56
+ >
57
+ {shellRequestLines(shell).join('\n')}
58
+ </pre>
59
+ ) : null}
51
60
  <div className="mt-1.5 flex min-w-0 items-center gap-2">
52
61
  <ToolIcon className="size-3 shrink-0 text-fg-3" />
53
62
  <span className="shrink-0 font-mono text-label font-medium text-fg-2">{request.toolName}</span>
54
- {summary ? <span className="min-w-0 truncate font-mono text-label text-fg-4">{summary}</span> : null}
63
+ {summary && !shell ? <span className="min-w-0 truncate font-mono text-label text-fg-4">{summary}</span> : null}
55
64
  </div>
56
65
  <button
57
66
  type="button"
@@ -70,13 +79,13 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
70
79
  </div>
71
80
  <div className="flex shrink-0 flex-wrap justify-end gap-1.5">
72
81
  <Button size="sm" onClick={() => onApprove(request.id)}>
73
- {plan ? 'Approve plan' : 'Allow'}
82
+ {allow.button}
74
83
  </Button>
75
84
  <Button size="sm" variant="outline" onClick={() => setDenying((v) => !v)}>
76
- {plan ? 'Keep planning' : 'Deny'}
85
+ {keep.button}
77
86
  </Button>
78
87
  <Button size="sm" variant="outline" onClick={() => deny(true)}>
79
- {plan ? 'Stop the turn' : 'Deny & stop'}
88
+ {stop.button}
80
89
  </Button>
81
90
  </div>
82
91
  </div>
@@ -94,18 +103,14 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
94
103
  setDenying(false)
95
104
  }
96
105
  }}
97
- placeholder={
98
- plan
99
- ? 'What should change? (optional) - the agent keeps planning and reads this'
100
- : 'Reason (optional) - the agent reads this and can try something else'
101
- }
106
+ placeholder={model.denyPlaceholder}
102
107
  className="h-7 flex-1"
103
108
  />
104
109
  <Button size="sm" variant="outline" onClick={() => setDenying(false)}>
105
110
  Cancel
106
111
  </Button>
107
112
  <Button size="sm" variant="destructive" onClick={() => deny(false)}>
108
- {plan ? 'Keep planning' : 'Deny'}
113
+ {keep.button}
109
114
  </Button>
110
115
  </div>
111
116
  ) : null}
@@ -1,4 +1,3 @@
1
- import { useState } from 'react'
2
1
  import { parseUserQuestions, type PermissionRequest, type QuestionBehavior } from '@workerdeck/protocol'
3
2
 
4
3
  // Re-exported, not redefined: the parse moved to protocol so the CLI's Live Activity builder could
@@ -7,6 +6,7 @@ import { MessageCircleQuestion, X } from 'lucide-react'
7
6
  import { Badge } from '../ui/Badge.tsx'
8
7
  import { Button } from '../ui/Button.tsx'
9
8
  import { Textarea } from '../ui/Textarea.tsx'
9
+ import { useQuestionAnswers } from '../../lib/question-answers.ts'
10
10
  import { cn } from '../../lib/utils.ts'
11
11
 
12
12
  export { parseUserQuestions }
@@ -23,18 +23,6 @@ export const QUESTION_BEHAVIORS: QuestionBehaviorMeta[] = [
23
23
  { value: 'deny', label: 'Disabled', description: 'the agent is told to decide on its own' },
24
24
  ]
25
25
 
26
- type Selection = { labels: string[]; other: string; otherActive: boolean }
27
-
28
- const EMPTY_SELECTION: Selection = { labels: [], other: '', otherActive: false }
29
-
30
- function answerFor(selection: Selection): string {
31
- const parts = [...selection.labels]
32
- if (selection.otherActive && selection.other.trim()) {
33
- parts.push(selection.other.trim())
34
- }
35
- return parts.join(', ')
36
- }
37
-
38
26
  export interface QuestionPromptProps {
39
27
  request: PermissionRequest
40
28
  onAnswer: (requestId: string, updatedInput: Record<string, unknown>) => void
@@ -44,32 +32,9 @@ export interface QuestionPromptProps {
44
32
 
45
33
  export function QuestionPrompt({ request, onAnswer, onDismiss, className }: QuestionPromptProps) {
46
34
  const questions = parseUserQuestions(request.input)
47
- const [selections, setSelections] = useState<Selection[]>(() => questions.map(() => EMPTY_SELECTION))
48
-
49
- const update = (index: number, patch: Partial<Selection>) => {
50
- setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
51
- }
52
-
53
- const toggle = (index: number, label: string, multiSelect: boolean) => {
54
- const current = selections[index] ?? EMPTY_SELECTION
55
- if (multiSelect) {
56
- update(index, {
57
- labels: current.labels.includes(label) ? current.labels.filter((l) => l !== label) : [...current.labels, label],
58
- })
59
- } else {
60
- update(index, { labels: current.labels[0] === label ? [] : [label], otherActive: false })
61
- }
62
- }
63
-
64
- const complete = questions.every((_, i) => answerFor(selections[i] ?? EMPTY_SELECTION) !== '')
35
+ const { selectionFor, update, toggle, toggleOther, complete, answers } = useQuestionAnswers(questions)
65
36
 
66
- const submit = () => {
67
- const answers: Record<string, string> = {}
68
- questions.forEach((q, i) => {
69
- answers[q.question] = answerFor(selections[i] ?? EMPTY_SELECTION)
70
- })
71
- onAnswer(request.id, { ...request.input, answers })
72
- }
37
+ const submit = () => onAnswer(request.id, { ...request.input, answers: answers() })
73
38
 
74
39
  return (
75
40
  <div data-slot="question-prompt" className={cn('rounded-lg border border-info/40 bg-info-bg p-3', className)}>
@@ -77,7 +42,7 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
77
42
  <MessageCircleQuestion className="mt-0.5 size-4 shrink-0 text-info" />
78
43
  <div className="flex min-w-0 flex-1 flex-col gap-3">
79
44
  {questions.map((q, index) => {
80
- const selection = selections[index] ?? EMPTY_SELECTION
45
+ const selection = selectionFor(index)
81
46
  return (
82
47
  <div key={index} className="flex flex-col gap-1.5">
83
48
  <div className="flex items-center gap-2">
@@ -91,7 +56,7 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
91
56
  <button
92
57
  key={option.label}
93
58
  type="button"
94
- onClick={() => toggle(index, option.label, q.multiSelect === true)}
59
+ onClick={() => toggle(index, option.label)}
95
60
  className={cn(
96
61
  'rounded-md border px-2.5 py-1.5 text-left transition-colors',
97
62
  selected ? 'border-info bg-bg' : 'border-border bg-bg/50 hover:border-border-strong hover:bg-bg',
@@ -110,7 +75,7 @@ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: Ques
110
75
  <div className="flex items-center gap-2">
111
76
  <button
112
77
  type="button"
113
- onClick={() => update(index, { otherActive: !selection.otherActive })}
78
+ onClick={() => toggleOther(index)}
114
79
  className={cn(
115
80
  'shrink-0 rounded-md border px-2.5 py-1.5 text-body-sm font-medium transition-colors',
116
81
  selection.otherActive