@workerdeck/ui 0.23.0 → 1.0.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 (145) hide show
  1. package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +15 -23
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +5 -126
  14. package/build/workspace.mjs +11 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +3 -9
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +315 -996
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +104 -219
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +96 -32
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-BobynUo4.d.mts +0 -669
  143. package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -5,26 +5,9 @@ export interface MessageProps extends HTMLAttributes<HTMLDivElement> {
5
5
  from: 'user' | 'assistant'
6
6
  }
7
7
 
8
- /**
9
- * One chat turn row.
10
- *
11
- * `cards`: user messages sit in a bubble, assistant content is flat and
12
- * full-width. Both are **left-aligned**: the transcript is a log read top to
13
- * bottom, and an editor-shaped host (a full-width session view beside a sessions
14
- * rail) has no right edge to anchor to — a bubble drifting right in a 1600px
15
- * column separates a prompt from the reply it produced. The bubble alone is
16
- * enough to say who spoke.
17
- *
18
- * The terminal theme draws none of this: it is its own renderer
19
- * (`components/terminal/`) and mounts instead of these rows.
20
- */
21
8
  export function Message({ from, className, children, ...props }: MessageProps) {
22
9
  return (
23
- <div
24
- data-slot='message'
25
- data-from={from}
26
- className={cn('flex w-full flex-col items-start gap-1', className)}
27
- {...props}>
10
+ <div data-slot="message" data-from={from} className={cn('flex w-full flex-col items-start gap-1', className)} {...props}>
28
11
  {children}
29
12
  </div>
30
13
  )
@@ -33,11 +16,9 @@ export function Message({ from, className, children, ...props }: MessageProps) {
33
16
  export function MessageContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
34
17
  return (
35
18
  <div
36
- data-slot='message-content'
19
+ data-slot="message-content"
37
20
  className={cn(
38
21
  'min-w-0 text-body-sm leading-6 text-fg-1',
39
- // Bubble treatment only within a user message row.
40
- // The tail points bottom-*left* now that the bubble is left-aligned.
41
22
  'in-data-[from=user]:max-w-[85%] in-data-[from=user]:rounded-lg in-data-[from=user]:rounded-bl-sm',
42
23
  'in-data-[from=user]:bg-accent-bg in-data-[from=user]:px-3 in-data-[from=user]:py-2',
43
24
  'in-data-[from=user]:whitespace-pre-wrap',
@@ -1,112 +1,83 @@
1
1
  import type { ModelOption } from '@workerdeck/protocol'
2
- import {
3
- Select,
4
- SelectContent,
5
- SelectItem,
6
- SelectItemText,
7
- SelectTrigger,
8
- SelectValue,
9
- } from '../ui/Select.tsx'
2
+ import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
10
3
  import { cn } from '../../lib/utils.ts'
11
4
 
12
5
  export interface ModelSelectProps {
13
- /** Models the session can switch to (TranscriptState.models). */
14
6
  models: ModelOption[]
15
- /** The session's current model id (TranscriptState.model), possibly decorated
16
- * (e.g. "claude-fable-5[1m]") — matched leniently against the options.
17
- * `undefined` selects the list's default row, if it has one. */
18
7
  model?: string
19
- /** `undefined` = back to the CLI's default model. */
20
8
  onModelChange: (model?: string) => void
21
- /** 'toolbar' (default) is the composer's compact borderless trigger;
22
- * 'form' is a standard field-sized Select for create/settings forms. */
23
9
  variant?: 'toolbar' | 'form'
24
10
  disabled?: boolean
25
11
  className?: string
26
12
  }
27
13
 
28
- /** The CLI's supportedModels list leads with a "Default (recommended)" row whose value
29
- * is a sentinel, not a model id — selecting it means "clear the override". */
30
- const isDefaultOption = (value: string) => value === 'default'
14
+ function isDefaultOption(value: string) {
15
+ return value === 'default'
16
+ }
31
17
 
32
- /** Everything before a '[1m]'-style context-window suffix. */
33
- const dropVariant = (id: string) => id.replace(/\[.*\]$/, '')
18
+ function dropVariant(id: string) {
19
+ return id.replace(/\[.*\]$/, '')
20
+ }
34
21
 
35
- /** 'claude-opus-4-8' → "opus", 'sonnet' → "sonnet". The vendor prefix and the
36
- * version tail are dropped; what is left is the name a person would say. */
37
22
  function family(id: string): string {
38
23
  const parts = id.toLowerCase().split('-')
39
- if (parts[0] === 'claude') parts.shift()
24
+ if (parts[0] === 'claude') {
25
+ parts.shift()
26
+ }
40
27
  return parts[0] ?? ''
41
28
  }
42
29
 
43
- /**
44
- * Whether a row is the one naming `model`.
45
- *
46
- * Three passes, narrowest first, because the rows and the id a session *reports*
47
- * are written differently: the rows are aliases ('opus[1m]', 'sonnet',
48
- * 'claude-fable-5[1m]') and a session reports a resolved wire id
49
- * ('claude-opus-5[1m]'). `resolvedModel` is the server's own answer to this and
50
- * wins when present; the family fallback covers a server that doesn't send it,
51
- * which is the difference between the chip reading "Opus 5" and reading
52
- * `claude-opus-5[1m]`. Kept identical to the iOS client's `ModelOption.matches`.
53
- */
54
30
  function optionMatches(option: ModelOption, model: string): boolean {
55
- if (model === option.value || model === option.resolvedModel) return true
31
+ if (model === option.value || model === option.resolvedModel) {
32
+ return true
33
+ }
56
34
  const stripped = dropVariant(model)
57
- // A row that declares what it resolves to is *authoritative*, including when
58
- // it disagrees: two rows of the same family ("Opus 5" and "Opus 4.8") differ
59
- // only here, so falling through to the family would match both.
60
- if (option.resolvedModel) return stripped === dropVariant(option.resolvedModel)
35
+ if (option.resolvedModel) {
36
+ return stripped === dropVariant(option.resolvedModel)
37
+ }
61
38
  const token = family(stripped)
62
39
  return token !== '' && token === family(dropVariant(option.value))
63
40
  }
64
41
 
65
- /** Find the option matching a (possibly decorated/aliased) session model id. */
66
42
  function matchModel(models: ModelOption[], model?: string): ModelOption | undefined {
67
- if (!model) return undefined
43
+ if (!model) {
44
+ return undefined
45
+ }
68
46
  return models.filter((m) => !isDefaultOption(m.value)).find((m) => optionMatches(m, model))
69
47
  }
70
48
 
71
- /** Compact model switcher for the composer toolbar; fed by the `capabilities` event.
72
- * Rows render CLI-style: bold display name with the model's description beneath. */
73
- export function ModelSelect({
74
- models,
75
- model,
76
- onModelChange,
77
- variant = 'toolbar',
78
- disabled,
79
- className,
80
- }: ModelSelectProps) {
81
- const selected =
82
- matchModel(models, model) ?? (model ? undefined : models.find((m) => isDefaultOption(m.value)))
49
+ export function ModelSelect({ models, model, onModelChange, variant = 'toolbar', disabled, className }: ModelSelectProps) {
50
+ const selected = matchModel(models, model) ?? (model ? undefined : models.find((m) => isDefaultOption(m.value)))
83
51
  return (
84
52
  <Select
85
53
  items={models.map((m) => ({ value: m.value, label: m.displayName }))}
86
54
  value={selected?.value ?? null}
87
55
  onValueChange={(value) => {
88
- if (typeof value !== 'string' || value === selected?.value) return
56
+ if (typeof value !== 'string' || value === selected?.value) {
57
+ return
58
+ }
89
59
  onModelChange(isDefaultOption(value) ? undefined : value)
90
60
  }}
91
- disabled={disabled}>
61
+ disabled={disabled}
62
+ >
92
63
  <SelectTrigger
93
- aria-label='Model'
64
+ aria-label="Model"
94
65
  className={cn(
95
- variant === 'toolbar' &&
96
- 'h-6 max-w-56 border-transparent bg-transparent text-fg-3 hover:bg-surface-hover',
66
+ variant === 'toolbar' && 'h-6 max-w-56 border-transparent bg-transparent text-fg-3 hover:bg-surface-hover',
97
67
  className,
98
- )}>
68
+ )}
69
+ >
99
70
  <span className={cn('truncate', variant === 'toolbar' && 'font-mono text-label')}>
100
71
  <SelectValue placeholder={model ?? 'model'} />
101
72
  </span>
102
73
  </SelectTrigger>
103
- <SelectContent className='min-w-72'>
74
+ <SelectContent className="min-w-72">
104
75
  {models.map((m) => (
105
76
  <SelectItem key={m.value} value={m.value}>
106
77
  <SelectItemText>
107
- <span className='font-medium'>{m.displayName}</span>
78
+ <span className="font-medium">{m.displayName}</span>
108
79
  </SelectItemText>
109
- {m.description ? <span className='text-label text-fg-4'>{m.description}</span> : null}
80
+ {m.description ? <span className="text-label text-fg-4">{m.description}</span> : null}
110
81
  </SelectItem>
111
82
  ))}
112
83
  </SelectContent>
@@ -1,39 +1,18 @@
1
1
  import type { LucideIcon } from 'lucide-react'
2
2
  import type { PermissionMode } from '@workerdeck/protocol'
3
3
  import { ClipboardList, Code, Hand, ShieldCheck, TriangleAlert, Zap } from 'lucide-react'
4
- import {
5
- Select,
6
- SelectContent,
7
- SelectItem,
8
- SelectItemText,
9
- SelectTrigger,
10
- SelectValue,
11
- } from '../ui/Select.tsx'
4
+ import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
12
5
  import { cn } from '../../lib/utils.ts'
13
6
 
14
7
  export type PermissionModeMeta = {
15
8
  value: PermissionMode
16
- /** The name Claude Code itself uses. */
17
9
  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
10
  shortLabel: string
21
- /** What the mode actually does — the CLI's own one-liners. */
22
11
  description: string
23
12
  icon: LucideIcon
24
13
  dangerous?: boolean
25
14
  }
26
15
 
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
- */
37
16
  export const PERMISSION_MODES: PermissionModeMeta[] = [
38
17
  {
39
18
  value: 'default',
@@ -60,10 +39,6 @@ export const PERMISSION_MODES: PermissionModeMeta[] = [
60
39
  value: 'auto',
61
40
  label: 'Auto',
62
41
  shortLabel: 'Auto',
63
- // Engine-neutral on purpose: on claude this is the operator-configurable
64
- // auto-mode classifier, on codex it is `approvalsReviewer: 'auto_review'`,
65
- // a fixed OpenAI-prompted subagent. Both "something other than you decides";
66
- // naming Claude here reads as a bug on a codex session.
67
42
  description: 'The agent handles permission decisions',
68
43
  icon: Zap,
69
44
  },
@@ -71,8 +46,6 @@ export const PERMISSION_MODES: PermissionModeMeta[] = [
71
46
  value: 'dontAsk',
72
47
  label: "Don't ask",
73
48
  shortLabel: "Don't ask",
74
- // The CLI's own definition, and the opposite of bypass: it never prompts,
75
- // and anything not already permitted is denied rather than allowed.
76
49
  description: 'Never ask — deny anything not pre-approved',
77
50
  icon: ShieldCheck,
78
51
  },
@@ -86,67 +59,40 @@ export const PERMISSION_MODES: PermissionModeMeta[] = [
86
59
  },
87
60
  ]
88
61
 
89
- export const permissionModeMeta = (mode: PermissionMode): PermissionModeMeta | undefined =>
90
- PERMISSION_MODES.find((m) => m.value === mode)
62
+ export function permissionModeMeta(mode: PermissionMode): PermissionModeMeta | undefined {
63
+ return PERMISSION_MODES.find((m) => m.value === mode)
64
+ }
91
65
 
92
- /** One offered mode as plain data — no icon, no React. What a host chrome
93
- * outside the panel (a VS Code QuickPick) needs to draw the same list. */
94
66
  export type PermissionModeChoice = {
95
67
  value: PermissionMode
96
68
  label: string
97
69
  description: string
98
70
  dangerous?: boolean
99
- /** Offered but unreachable: a session not started for bypass can never gain
100
- * it, and saying so beats omitting the row. */
101
71
  disabled?: boolean
102
72
  }
103
73
 
104
- /**
105
- * The modes this session may actually be switched into, with the reasons baked
106
- * in — the same filtering {@link PermissionModeSelect} applies, so an embedder
107
- * rendering its own picker cannot drift from the panel's.
108
- */
109
- export function permissionModeChoices(
110
- modes?: readonly PermissionMode[],
111
- canBypass?: boolean,
112
- ): PermissionModeChoice[] {
74
+ export function permissionModeChoices(modes?: readonly PermissionMode[], canBypass?: boolean): PermissionModeChoice[] {
113
75
  const offered = modes ? PERMISSION_MODES.filter((m) => modes.includes(m.value)) : PERMISSION_MODES
114
76
  return offered.map((m) => ({
115
77
  value: m.value,
116
78
  label: m.label,
117
79
  description:
118
- m.value === 'bypassPermissions' && canBypass === false
119
- ? 'Only available to a session started in this mode'
120
- : m.description,
80
+ m.value === 'bypassPermissions' && canBypass === false ? 'Only available to a session started in this mode' : m.description,
121
81
  dangerous: m.dangerous,
122
82
  disabled: m.value === 'bypassPermissions' && canBypass === false,
123
83
  }))
124
84
  }
125
85
 
126
86
  export interface PermissionModeSelectProps {
127
- /** The session's current mode (TranscriptState.permissionMode). */
128
87
  mode?: PermissionMode
129
88
  onModeChange: (mode: PermissionMode) => void
130
- /** Restrict what is offered — most of {@link PERMISSION_MODES} is Claude Code
131
- * vocabulary the other engines have no meaning for. Defaults to all of them;
132
- * pass the session's `capabilities.permissionModes`. */
133
89
  modes?: readonly PermissionMode[]
134
- /**
135
- * Whether this session may be switched into `bypassPermissions` at all. The
136
- * CLI refuses unless the process was spawned for it, so a session that didn't
137
- * ask up front can never gain it. The row is shown disabled rather than hidden
138
- * — "you can't have this here" is a more useful answer than a row that
139
- * silently isn't there. `undefined` (an older server) offers it.
140
- */
141
90
  canBypass?: boolean
142
- /** 'toolbar' (default) is the composer's compact borderless trigger;
143
- * 'form' is a standard field-sized Select for create/settings forms. */
144
91
  variant?: 'toolbar' | 'form'
145
92
  disabled?: boolean
146
93
  className?: string
147
94
  }
148
95
 
149
- /** Permission-mode switcher: compact in the composer toolbar, field-sized in forms. */
150
96
  export function PermissionModeSelect({
151
97
  mode,
152
98
  onModeChange,
@@ -158,46 +104,42 @@ export function PermissionModeSelect({
158
104
  }: PermissionModeSelectProps) {
159
105
  const dangerous = mode === 'bypassPermissions'
160
106
  const offered = modes ? PERMISSION_MODES.filter((m) => modes.includes(m.value)) : PERMISSION_MODES
161
- const unavailable = (meta: PermissionModeMeta) =>
162
- meta.value === 'bypassPermissions' && canBypass === false
107
+ const unavailable = (meta: PermissionModeMeta) => meta.value === 'bypassPermissions' && canBypass === false
163
108
  return (
164
109
  <Select
165
110
  items={offered.map((m) => ({ value: m.value, label: m.label }))}
166
111
  value={mode ?? null}
167
112
  onValueChange={(value) => {
168
- if (typeof value === 'string' && value !== mode) onModeChange(value as PermissionMode)
113
+ if (typeof value === 'string' && value !== mode) {
114
+ onModeChange(value as PermissionMode)
115
+ }
169
116
  }}
170
- disabled={disabled}>
117
+ disabled={disabled}
118
+ >
171
119
  <SelectTrigger
172
- aria-label='Permission mode'
120
+ aria-label="Permission mode"
173
121
  className={cn(
174
- variant === 'toolbar' &&
175
- 'h-6 max-w-44 border-transparent bg-transparent hover:bg-surface-hover',
122
+ variant === 'toolbar' && 'h-6 max-w-44 border-transparent bg-transparent hover:bg-surface-hover',
176
123
  dangerous ? 'text-danger' : variant === 'toolbar' ? 'text-fg-3' : undefined,
177
124
  className,
178
- )}>
125
+ )}
126
+ >
179
127
  <span className={cn('truncate', variant === 'toolbar' && 'text-label')}>
180
- <SelectValue placeholder='permissions' />
128
+ <SelectValue placeholder="permissions" />
181
129
  </span>
182
130
  </SelectTrigger>
183
- <SelectContent className='min-w-64'>
131
+ <SelectContent className="min-w-64">
184
132
  {offered.map((m) => {
185
133
  const blocked = unavailable(m)
186
134
  return (
187
135
  <SelectItem key={m.value} value={m.value} disabled={blocked}>
188
136
  <SelectItemText>
189
- <span className='flex items-center gap-2'>
190
- <m.icon
191
- className={cn('size-3.5 shrink-0', m.dangerous ? 'text-danger' : 'text-fg-3')}
192
- />
137
+ <span className="flex items-center gap-2">
138
+ <m.icon className={cn('size-3.5 shrink-0', m.dangerous ? 'text-danger' : 'text-fg-3')} />
193
139
  <span className={cn('font-medium', m.dangerous && 'text-danger')}>{m.label}</span>
194
140
  </span>
195
141
  </SelectItemText>
196
- <span
197
- className={cn(
198
- 'pl-5.5 text-label',
199
- blocked ? 'text-fg-4' : m.dangerous ? 'text-danger/80' : 'text-fg-4',
200
- )}>
142
+ <span className={cn('pl-5.5 text-label', blocked ? 'text-fg-4' : m.dangerous ? 'text-danger/80' : 'text-fg-4')}>
201
143
  {blocked ? 'Only available to a session started in this mode' : m.description}
202
144
  </span>
203
145
  </SelectItem>
@@ -5,29 +5,17 @@ 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
9
  import { toolIcon } from '../../lib/tool-icon.ts'
10
+ import { Response } from './Response.tsx'
9
11
 
10
12
  export interface PermissionPromptProps {
11
13
  request: PermissionRequest
12
14
  onApprove: (requestId: string) => void
13
- /** `message` is fed back to the agent, which can then try something else;
14
- * `interrupt` also stops the turn. */
15
15
  onDeny: (requestId: string, message?: string, interrupt?: boolean) => void
16
16
  className?: string
17
17
  }
18
18
 
19
- /**
20
- * Generic allow/deny prompt for a pending permission request.
21
- *
22
- * Three outcomes, not two: denying usually means "not that, try something else",
23
- * so plain Deny lets the turn continue (with an optional reason the agent reads)
24
- * while "Deny & stop" also interrupts.
25
- *
26
- * The heading is whatever the engine authored — `title`, else `displayName`.
27
- * Composing "wants to use {tool}" instead would be wrong for codex, where an
28
- * approval is usually an *escalation after a sandbox refusal* and the runner has
29
- * already written the sentence that says so.
30
- */
31
19
  export function PermissionPrompt({ request, onApprove, onDeny, className }: PermissionPromptProps) {
32
20
  const [showInput, setShowInput] = useState(false)
33
21
  const [denying, setDenying] = useState(false)
@@ -40,76 +28,84 @@ export function PermissionPrompt({ request, onApprove, onDeny, className }: Perm
40
28
  setDenying(false)
41
29
  }
42
30
 
31
+ const plan = planFromRequest(request)
43
32
  const summary = toolInputPreview(request.input)
44
33
  const ToolIcon = toolIcon(request.toolName)
45
34
 
46
35
  return (
47
- <div
48
- data-slot='permission-prompt'
49
- className={cn('rounded-lg border border-warning/40 bg-warning-bg p-3', className)}>
50
- <div className='flex items-start gap-2.5'>
51
- <Hand className='mt-0.5 size-4 shrink-0 text-warning' />
52
- <div className='min-w-0 flex-1'>
53
- <div className='text-body-sm font-medium text-fg-1'>
54
- {request.title ?? request.displayName ?? 'Permission needed'}
36
+ <div data-slot="permission-prompt" className={cn('rounded-lg border border-warning/40 bg-warning-bg p-3', className)}>
37
+ <div className="flex items-start gap-2.5">
38
+ <Hand className="mt-0.5 size-4 shrink-0 text-warning" />
39
+ <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')}
55
42
  </div>
56
- {request.description ? (
57
- <div className='mt-0.5 text-label text-fg-3'>{request.description}</div>
58
- ) : null}
59
- {request.decisionReason ? (
60
- <div className='mt-0.5 text-label text-fg-4'>{request.decisionReason}</div>
61
- ) : null}
62
- <div className='mt-1.5 flex min-w-0 items-center gap-2'>
63
- <ToolIcon className='size-3 shrink-0 text-fg-3' />
64
- <span className='shrink-0 font-mono text-label font-medium text-fg-2'>
65
- {request.toolName}
66
- </span>
67
- {summary ? (
68
- <span className='min-w-0 truncate font-mono text-label text-fg-4'>{summary}</span>
69
- ) : null}
70
- </div>
71
- <button
72
- type='button'
73
- className='mt-1 font-mono text-label text-fg-3 underline-offset-2 hover:underline'
74
- onClick={() => setShowInput((v) => !v)}>
75
- {showInput ? 'Hide' : 'Show'} {request.toolName} input
76
- </button>
77
- {showInput ? (
78
- <pre className='mt-1.5 max-h-48 overflow-auto rounded-md bg-code-bg px-2.5 py-1.5 font-mono text-label whitespace-pre-wrap text-fg-2'>
79
- {JSON.stringify(request.input, null, 2)}
80
- </pre>
81
- ) : null}
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}
45
+ {plan ? (
46
+ <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
+ <Response>{plan}</Response>
48
+ </div>
49
+ ) : (
50
+ <>
51
+ <div className="mt-1.5 flex min-w-0 items-center gap-2">
52
+ <ToolIcon className="size-3 shrink-0 text-fg-3" />
53
+ <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}
55
+ </div>
56
+ <button
57
+ type="button"
58
+ className="mt-1 font-mono text-label text-fg-3 underline-offset-2 hover:underline"
59
+ onClick={() => setShowInput((v) => !v)}
60
+ >
61
+ {showInput ? 'Hide' : 'Show'} {request.toolName} input
62
+ </button>
63
+ {showInput ? (
64
+ <pre className="mt-1.5 max-h-48 overflow-auto rounded-md bg-code-bg px-2.5 py-1.5 font-mono text-label whitespace-pre-wrap text-fg-2">
65
+ {JSON.stringify(request.input, null, 2)}
66
+ </pre>
67
+ ) : null}
68
+ </>
69
+ )}
82
70
  </div>
83
- <div className='flex shrink-0 flex-wrap justify-end gap-1.5'>
84
- <Button size='sm' onClick={() => onApprove(request.id)}>
85
- Allow
71
+ <div className="flex shrink-0 flex-wrap justify-end gap-1.5">
72
+ <Button size="sm" onClick={() => onApprove(request.id)}>
73
+ {plan ? 'Approve plan' : 'Allow'}
86
74
  </Button>
87
- <Button size='sm' variant='outline' onClick={() => setDenying((v) => !v)}>
88
- Deny
75
+ <Button size="sm" variant="outline" onClick={() => setDenying((v) => !v)}>
76
+ {plan ? 'Keep planning' : 'Deny'}
89
77
  </Button>
90
- <Button size='sm' variant='outline' onClick={() => deny(true)}>
91
- Deny &amp; stop
78
+ <Button size="sm" variant="outline" onClick={() => deny(true)}>
79
+ {plan ? 'Stop the turn' : 'Deny & stop'}
92
80
  </Button>
93
81
  </div>
94
82
  </div>
95
83
  {denying ? (
96
- <div className='mt-2.5 flex items-center gap-2 border-t border-warning/30 pt-2.5'>
84
+ <div className="mt-2.5 flex items-center gap-2 border-t border-warning/30 pt-2.5">
97
85
  <Input
98
86
  autoFocus
99
87
  value={reason}
100
88
  onChange={(e) => setReason(e.target.value)}
101
89
  onKeyDown={(e) => {
102
- if (e.key === 'Enter') deny(false)
103
- if (e.key === 'Escape') setDenying(false)
90
+ if (e.key === 'Enter') {
91
+ deny(false)
92
+ }
93
+ if (e.key === 'Escape') {
94
+ setDenying(false)
95
+ }
104
96
  }}
105
- placeholder='Reason (optional) — the agent reads this and can try something else'
106
- className='h-7 flex-1'
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
+ }
102
+ className="h-7 flex-1"
107
103
  />
108
- <Button size='sm' variant='outline' onClick={() => setDenying(false)}>
104
+ <Button size="sm" variant="outline" onClick={() => setDenying(false)}>
109
105
  Cancel
110
106
  </Button>
111
- <Button size='sm' variant='destructive' onClick={() => deny(false)}>
112
- Deny
107
+ <Button size="sm" variant="destructive" onClick={() => deny(false)}>
108
+ {plan ? 'Keep planning' : 'Deny'}
113
109
  </Button>
114
110
  </div>
115
111
  ) : null}