@workerdeck/ui 0.22.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-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
  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 +80 -24
  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 +9 -124
  14. package/build/workspace.mjs +13 -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 +4 -6
  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 +345 -923
  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 +108 -215
  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 +136 -19
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-13l25ubU.d.mts +0 -609
  143. package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -1,31 +1,22 @@
1
1
  import { useCallback, useEffect, useState } from 'react'
2
2
  import type { WorkerDeckClient } from '@workerdeck/client'
3
3
  import type { HostDirEntry, HostFileMatch } from '@workerdeck/protocol'
4
- import { ChevronLeft, File, Folder, Link2, Search } from 'lucide-react'
4
+ import { ChevronLeft, File, Search } from 'lucide-react'
5
5
  import { Button } from '../ui/Button.tsx'
6
6
  import { CodeBlock } from '../ui/CodeBlock.tsx'
7
7
  import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
8
8
  import { Input } from '../ui/Input.tsx'
9
9
  import { Spinner } from '../ui/Spinner.tsx'
10
10
  import { formatBytes } from '../../lib/format.ts'
11
+ import { EntryIcon } from './EntryIcon.tsx'
11
12
 
12
13
  export interface HostFilesDialogProps {
13
14
  client: WorkerDeckClient
14
- /** The session's working directory — the browser is rooted here. */
15
15
  cwd: string | undefined
16
16
  open: boolean
17
17
  onOpenChange: (open: boolean) => void
18
18
  }
19
19
 
20
- /**
21
- * Browse the project the session is working in.
22
- *
23
- * Deliberately rooted at the session's cwd rather than at the server's
24
- * `hostFiles.roots`: the roots are the *security* boundary (the server enforces
25
- * them on every request), but what someone wants while watching an agent is this
26
- * project's tree. Read-only — writing is a separate server opt-in and not
27
- * something a session viewer should be doing behind the agent's back.
28
- */
29
20
  export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDialogProps) {
30
21
  const [path, setPath] = useState<string | undefined>(cwd)
31
22
  const [entries, setEntries] = useState<HostDirEntry[]>([])
@@ -54,22 +45,23 @@ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDi
54
45
  [client],
55
46
  )
56
47
 
57
- // Opening resets the browser to the session's directory. Navigation from here
58
- // is explicit (`list`) rather than an effect on `path`, so walking into a
59
- // folder is one request and not two.
60
48
  useEffect(() => {
61
- if (!open) return
49
+ if (!open) {
50
+ return
51
+ }
62
52
  setFile(undefined)
63
53
  setQuery('')
64
54
  setMatches(undefined)
65
55
  setError(undefined)
66
- if (cwd) void list(cwd)
56
+ if (cwd) {
57
+ void list(cwd)
58
+ }
67
59
  }, [open, cwd, list])
68
60
 
69
- // Debounced, and only while there is something to search for; an empty box is
70
- // "show me the directory again", not "search for everything".
71
61
  useEffect(() => {
72
- if (!open || !cwd) return
62
+ if (!open || !cwd) {
63
+ return
64
+ }
73
65
  const q = query.trim()
74
66
  if (!q) {
75
67
  setMatches(undefined)
@@ -92,10 +84,7 @@ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDi
92
84
  setFile({
93
85
  path: response.path,
94
86
  bytes: response.bytes,
95
- content:
96
- response.encoding === 'utf8'
97
- ? response.content
98
- : '(binary file — not shown)',
87
+ content: response.encoding === 'utf8' ? response.content : '(binary file — not shown)',
99
88
  })
100
89
  } catch (e) {
101
90
  setError(e instanceof Error ? e.message : 'Could not read that file')
@@ -109,87 +98,79 @@ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDi
109
98
 
110
99
  return (
111
100
  <Dialog open={open} onOpenChange={onOpenChange}>
112
- <DialogContent size='lg'>
101
+ <DialogContent size="lg">
113
102
  <DialogHeader
114
103
  title={file ? file.path.split('/').pop()! : 'Files'}
115
104
  description={file ? file.path : path}
116
105
  actions={
117
106
  file ? (
118
- <Button variant='ghost' size='xs' onClick={() => setFile(undefined)}>
119
- <ChevronLeft className='size-3.5' />
107
+ <Button variant="ghost" size="xs" onClick={() => setFile(undefined)}>
108
+ <ChevronLeft className="size-3.5" />
120
109
  Back
121
110
  </Button>
122
111
  ) : parent ? (
123
- <Button variant='ghost' size='xs' onClick={() => void list(parent)}>
124
- <ChevronLeft className='size-3.5' />
112
+ <Button variant="ghost" size="xs" onClick={() => void list(parent)}>
113
+ <ChevronLeft className="size-3.5" />
125
114
  Up
126
115
  </Button>
127
116
  ) : null
128
117
  }
129
118
  />
130
- <DialogBody className='flex flex-col gap-3'>
131
- {error ? (
132
- <div className='rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger'>{error}</div>
133
- ) : null}
119
+ <DialogBody className="flex flex-col gap-3">
120
+ {error ? <div className="rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger">{error}</div> : null}
134
121
 
135
122
  {file ? (
136
123
  <>
137
- <p className='text-label text-fg-4'>{formatBytes(file.bytes)}</p>
124
+ <p className="text-label text-fg-4">{formatBytes(file.bytes)}</p>
138
125
  <CodeBlock code={file.content} label={file.path.split('/').pop()} />
139
126
  </>
140
127
  ) : (
141
128
  <>
142
- <div className='relative'>
143
- <Search className='absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4' />
129
+ <div className="relative">
130
+ <Search className="absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-fg-4" />
144
131
  <Input
145
132
  value={query}
146
133
  onChange={(e) => setQuery(e.target.value)}
147
- placeholder='Search this project…'
148
- className='pl-7'
134
+ placeholder="Search this project…"
135
+ className="pl-7"
149
136
  spellCheck={false}
150
137
  />
151
138
  </div>
152
139
  {loading && shown.length === 0 ? (
153
- <div className='py-6 text-center'>
154
- <Spinner className='size-4 text-fg-4' />
140
+ <div className="py-6 text-center">
141
+ <Spinner className="size-4 text-fg-4" />
155
142
  </div>
156
143
  ) : shown.length === 0 ? (
157
- <p className='py-6 text-center text-body-sm text-fg-4'>
158
- {matches ? 'No matching files.' : 'This directory is empty.'}
159
- </p>
144
+ <p className="py-6 text-center text-body-sm text-fg-4">{matches ? 'No matching files.' : 'This directory is empty.'}</p>
160
145
  ) : (
161
- <ul className='flex flex-col'>
146
+ <ul className="flex flex-col">
162
147
  {matches
163
148
  ? matches.map((match) => (
164
149
  <li key={match.path}>
165
150
  <button
166
- type='button'
151
+ type="button"
167
152
  onClick={() => void openFile(match.path)}
168
- className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
169
- <File className='size-3.5 shrink-0 text-fg-4' />
170
- <span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
171
- {match.relative}
172
- </span>
153
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover"
154
+ >
155
+ <File className="size-3.5 shrink-0 text-fg-4" />
156
+ <span className="min-w-0 flex-1 truncate font-mono text-label text-fg-1">{match.relative}</span>
173
157
  </button>
174
158
  </li>
175
159
  ))
176
160
  : entries.map((entry) => (
177
161
  <li key={entry.path}>
178
162
  <button
179
- type='button'
180
- onClick={() =>
181
- entry.type === 'dir' ? void list(entry.path) : void openFile(entry.path)
182
- }
183
- className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
163
+ type="button"
164
+ onClick={() => (entry.type === 'dir' ? void list(entry.path) : void openFile(entry.path))}
165
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover"
166
+ >
184
167
  <EntryIcon type={entry.type} />
185
- <span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
168
+ <span className="min-w-0 flex-1 truncate font-mono text-label text-fg-1">
186
169
  {entry.name}
187
170
  {entry.type === 'dir' ? '/' : ''}
188
171
  </span>
189
172
  {entry.bytes !== undefined ? (
190
- <span className='shrink-0 text-label text-fg-4'>
191
- {formatBytes(entry.bytes)}
192
- </span>
173
+ <span className="shrink-0 text-label text-fg-4">{formatBytes(entry.bytes)}</span>
193
174
  ) : null}
194
175
  </button>
195
176
  </li>
@@ -197,9 +178,7 @@ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDi
197
178
  </ul>
198
179
  )}
199
180
  {truncated && !matches ? (
200
- <p className='text-label text-fg-4'>
201
- More entries than the server will return — use the search box.
202
- </p>
181
+ <p className="text-label text-fg-4">More entries than the server will return — use the search box.</p>
203
182
  ) : null}
204
183
  </>
205
184
  )}
@@ -208,11 +187,3 @@ export function HostFilesDialog({ client, cwd, open, onOpenChange }: HostFilesDi
208
187
  </Dialog>
209
188
  )
210
189
  }
211
-
212
- /** A symlink is reported as itself and never silently resolved — following it is
213
- * the next request's problem, and that request is refused if it escapes the roots. */
214
- function EntryIcon({ type }: { type: HostDirEntry['type'] }) {
215
- if (type === 'dir') return <Folder className='size-3.5 shrink-0 text-accent' />
216
- if (type === 'symlink') return <Link2 className='size-3.5 shrink-0 text-fg-4' />
217
- return <File className='size-3.5 shrink-0 text-fg-4' />
218
- }
@@ -1,39 +1,18 @@
1
1
  import { cn } from '../../lib/utils.ts'
2
2
 
3
3
  export interface LoaderProps {
4
- /** Overrides the cycling verb — for a state that has one true name
5
- * ("Starting session…"). */
6
4
  label?: string
7
- /** When the current run began, for the elapsed clock. Absent = no clock.
8
- * Unused by the card treatment; kept so the `cards` and `terminal` loaders
9
- * take the same call site in `Transcript`. */
10
5
  startedAt?: number
11
- /** Context tokens in play. Same story as `startedAt`. */
12
6
  tokens?: number
13
7
  className?: string
14
8
  }
15
9
 
16
- /**
17
- * "The agent is working and hasn't produced output yet."
18
- *
19
- * Three dots pulsing in CSS — the chat convention, for the `cards` variant. The
20
- * terminal theme has its own working line (`terminal/items.tsx`'s `WorkingRow`,
21
- * with the mark's pulse in the gutter and the run's readings beside the verb),
22
- * because there it is a *row of the transcript* rather than a spinner floating
23
- * over one.
24
- */
25
10
  export function Loader({ label, className }: LoaderProps) {
26
11
  return (
27
- <div
28
- data-slot='loader'
29
- className={cn('flex items-center gap-2 py-1 text-body-sm text-fg-4', className)}>
30
- <span className='flex items-center gap-1'>
12
+ <div data-slot="loader" className={cn('flex items-center gap-2 py-1 text-body-sm text-fg-4', className)}>
13
+ <span className="flex items-center gap-1">
31
14
  {[0, 1, 2].map((i) => (
32
- <span
33
- key={i}
34
- className='size-1.5 animate-pulse rounded-full bg-fg-4'
35
- style={{ animationDelay: `${i * 160}ms` }}
36
- />
15
+ <span key={i} className="size-1.5 animate-pulse rounded-full bg-fg-4" style={{ animationDelay: `${i * 160}ms` }} />
37
16
  ))}
38
17
  </span>
39
18
  {label ? <span>{label}</span> : null}
@@ -1,10 +1,6 @@
1
1
  import { useCallback, useEffect, useState } from 'react'
2
2
  import type { WorkerDeckClient } from '@workerdeck/client'
3
- import type {
4
- McpServerActionRequest,
5
- McpServerStatusInfo,
6
- McpServerToolInfo,
7
- } from '@workerdeck/protocol'
3
+ import type { McpServerActionRequest, McpServerStatusInfo, McpServerToolInfo } from '@workerdeck/protocol'
8
4
  import { ChevronLeft, ChevronRight, Power, PowerOff, RotateCw } from 'lucide-react'
9
5
  import { Badge, type BadgeProps } from '../ui/Badge.tsx'
10
6
  import { Button } from '../ui/Button.tsx'
@@ -17,18 +13,10 @@ export interface McpDialogProps {
17
13
  sessionId: string | undefined
18
14
  open: boolean
19
15
  onOpenChange: (open: boolean) => void
20
- /**
21
- * Whether this engine can reconnect/enable/disable a server
22
- * (`EngineCapabilities.mcpServerActions`). False renders the panel read-only:
23
- * codex reports rich status but exposes no per-server action, and buttons
24
- * that 501 are worse than buttons that aren't there.
25
- */
26
16
  canManageServers?: boolean
27
17
  className?: string
28
18
  }
29
19
 
30
- /** The engine's status vocabulary is open — anything unrecognised renders
31
- * neutrally rather than being forced into one of these. */
32
20
  const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
33
21
  connected: 'success',
34
22
  failed: 'danger',
@@ -37,37 +25,18 @@ const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
37
25
  disabled: 'neutral',
38
26
  }
39
27
 
40
- /**
41
- * The session's MCP servers, at the CLI's own `/mcp` depth: servers → one server
42
- * → its tools → one tool, with Reconnect / Enable / Disable where they apply.
43
- *
44
- * Two things vary by engine rather than being fixed here. **The actions** exist
45
- * only where the engine has them (`canManageServers`): codex reports rich status
46
- * but has no per-server reconnect or toggle, so its panel is read-only. And
47
- * **tool parameters** appear only where the engine reports a schema — codex
48
- * returns each tool's full JSON Schema, the Agent SDK returns none at all, so
49
- * the tool view either renders it or says why it can't, rather than leaving a
50
- * silent gap or claiming the absence is universal.
51
- */
52
- export function McpDialog({
53
- client,
54
- sessionId,
55
- open,
56
- onOpenChange,
57
- canManageServers = true,
58
- className,
59
- }: McpDialogProps) {
28
+ export function McpDialog({ client, sessionId, open, onOpenChange, canManageServers = true, className }: McpDialogProps) {
60
29
  const [servers, setServers] = useState<McpServerStatusInfo[] | undefined>()
61
30
  const [error, setError] = useState<string | undefined>()
62
31
  const [loading, setLoading] = useState(false)
63
32
  const [busyServer, setBusyServer] = useState<string | undefined>()
64
- // The drill-down, by name rather than by object — an action replaces the whole
65
- // list, and a held reference would go stale on the first Reconnect.
66
33
  const [selectedServer, setSelectedServer] = useState<string | undefined>()
67
34
  const [selectedTool, setSelectedTool] = useState<string | undefined>()
68
35
 
69
36
  const load = useCallback(async () => {
70
- if (!sessionId) return
37
+ if (!sessionId) {
38
+ return
39
+ }
71
40
  setLoading(true)
72
41
  setError(undefined)
73
42
  try {
@@ -80,14 +49,18 @@ export function McpDialog({
80
49
  }, [client, sessionId])
81
50
 
82
51
  useEffect(() => {
83
- if (!open) return
52
+ if (!open) {
53
+ return
54
+ }
84
55
  setSelectedServer(undefined)
85
56
  setSelectedTool(undefined)
86
57
  void load()
87
58
  }, [open, load])
88
59
 
89
60
  const act = async (name: string, action: McpServerActionRequest['action']) => {
90
- if (!sessionId) return
61
+ if (!sessionId) {
62
+ return
63
+ }
91
64
  setBusyServer(name)
92
65
  setError(undefined)
93
66
  try {
@@ -108,32 +81,23 @@ export function McpDialog({
108
81
  <DialogContent className={className}>
109
82
  <DialogHeader
110
83
  title={title}
111
- description={
112
- tool ? `${server?.name} tool` : server ? server.serverInfo?.name : undefined
113
- }
84
+ description={tool ? `${server?.name} tool` : server ? server.serverInfo?.name : undefined}
114
85
  actions={
115
86
  selectedServer ? (
116
- <Button
117
- variant='ghost'
118
- size='xs'
119
- onClick={() => (selectedTool ? setSelectedTool(undefined) : setSelectedServer(undefined))}>
120
- <ChevronLeft className='size-3.5' />
87
+ <Button variant="ghost" size="xs" onClick={() => (selectedTool ? setSelectedTool(undefined) : setSelectedServer(undefined))}>
88
+ <ChevronLeft className="size-3.5" />
121
89
  Back
122
90
  </Button>
123
91
  ) : (
124
- <Button variant='ghost' size='xs' onClick={() => void load()} disabled={loading}>
125
- {loading ? <Spinner className='size-3 text-current' /> : <RotateCw className='size-3' />}
92
+ <Button variant="ghost" size="xs" onClick={() => void load()} disabled={loading}>
93
+ {loading ? <Spinner className="size-3 text-current" /> : <RotateCw className="size-3" />}
126
94
  Refresh
127
95
  </Button>
128
96
  )
129
97
  }
130
98
  />
131
99
  <DialogBody>
132
- {error ? (
133
- <div className='mb-3 rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger'>
134
- {error}
135
- </div>
136
- ) : null}
100
+ {error ? <div className="mb-3 rounded-md bg-danger-bg px-3 py-2 text-body-sm text-danger">{error}</div> : null}
137
101
  {tool ? (
138
102
  <ToolView tool={tool} />
139
103
  ) : server ? (
@@ -144,18 +108,8 @@ export function McpDialog({
144
108
  onAct={(action) => void act(server.name, action)}
145
109
  onSelectTool={setSelectedTool}
146
110
  />
147
- ) : error && !servers ? (
148
- // The strip above already says what went wrong. Falling through to
149
- // the list here would print "No MCP servers configured" underneath
150
- // it — a claim about the operator's config that a failed request
151
- // gives us no standing to make.
152
- null
153
- ) : (
154
- <ServerList
155
- servers={servers}
156
- loading={loading}
157
- onSelect={setSelectedServer}
158
- />
111
+ ) : error && !servers ? null : (
112
+ <ServerList servers={servers} loading={loading} onSelect={setSelectedServer} />
159
113
  )}
160
114
  </DialogBody>
161
115
  </DialogContent>
@@ -174,42 +128,36 @@ function ServerList({
174
128
  }) {
175
129
  if (loading && !servers) {
176
130
  return (
177
- <div className='py-6 text-center'>
178
- <Spinner className='size-4 text-fg-4' />
131
+ <div className="py-6 text-center">
132
+ <Spinner className="size-4 text-fg-4" />
179
133
  </div>
180
134
  )
181
135
  }
182
136
  if (!servers?.length) {
183
- return (
184
- <p className='py-6 text-center text-body-sm text-fg-4'>
185
- No MCP servers configured for this session.
186
- </p>
187
- )
137
+ return <p className="py-6 text-center text-body-sm text-fg-4">No MCP servers configured for this session.</p>
188
138
  }
189
- // Grouped by where they were configured, like the CLI's own screen.
190
139
  const scopes = [...new Set(servers.map((s) => s.scope ?? 'other'))]
191
140
  return (
192
- <div className='flex flex-col gap-4'>
141
+ <div className="flex flex-col gap-4">
193
142
  {scopes.map((scope) => (
194
143
  <div key={scope}>
195
- <h3 className='text-label font-medium text-fg-3 capitalize'>{scope}</h3>
196
- <ul className='mt-1 flex flex-col'>
144
+ <h3 className="text-label font-medium text-fg-3 capitalize">{scope}</h3>
145
+ <ul className="mt-1 flex flex-col">
197
146
  {servers
198
147
  .filter((s) => (s.scope ?? 'other') === scope)
199
148
  .map((s) => (
200
149
  <li key={s.name}>
201
150
  <button
202
- type='button'
151
+ type="button"
203
152
  onClick={() => onSelect(s.name)}
204
- className='flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-surface-hover'>
205
- <span className='min-w-0 flex-1 truncate text-body-sm text-fg-1'>{s.name}</span>
206
- {s.tools?.length ? (
207
- <span className='shrink-0 text-label text-fg-4'>{s.tools.length} tools</span>
208
- ) : null}
209
- <Badge variant={STATUS_VARIANT[s.status] ?? 'neutral'} dot className='shrink-0'>
153
+ className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-surface-hover"
154
+ >
155
+ <span className="min-w-0 flex-1 truncate text-body-sm text-fg-1">{s.name}</span>
156
+ {s.tools?.length ? <span className="shrink-0 text-label text-fg-4">{s.tools.length} tools</span> : null}
157
+ <Badge variant={STATUS_VARIANT[s.status] ?? 'neutral'} dot className="shrink-0">
210
158
  {s.status}
211
159
  </Badge>
212
- <ChevronRight className='size-3.5 shrink-0 text-fg-4' />
160
+ <ChevronRight className="size-3.5 shrink-0 text-fg-4" />
213
161
  </button>
214
162
  </li>
215
163
  ))}
@@ -235,78 +183,64 @@ function ServerView({
235
183
  }) {
236
184
  const disabled = server.status === 'disabled'
237
185
  return (
238
- <div className='flex flex-col gap-4'>
186
+ <div className="flex flex-col gap-4">
239
187
  <div>
240
- <DialogRow label='Status'>
188
+ <DialogRow label="Status">
241
189
  <Badge variant={STATUS_VARIANT[server.status] ?? 'neutral'} dot>
242
190
  {server.status}
243
191
  </Badge>
244
192
  </DialogRow>
245
- {server.transport ? <DialogRow label='Transport'>{server.transport}</DialogRow> : null}
246
- {server.scope ? <DialogRow label='Scope'>{server.scope}</DialogRow> : null}
193
+ {server.transport ? <DialogRow label="Transport">{server.transport}</DialogRow> : null}
194
+ {server.scope ? <DialogRow label="Scope">{server.scope}</DialogRow> : null}
247
195
  {server.serverInfo ? (
248
- <DialogRow label='Server'>
196
+ <DialogRow label="Server">
249
197
  {server.serverInfo.name} {server.serverInfo.version}
250
198
  </DialogRow>
251
199
  ) : null}
252
200
  {server.command ? (
253
- <DialogRow label='Command' mono>
201
+ <DialogRow label="Command" mono>
254
202
  {[server.command, ...(server.args ?? [])].join(' ')}
255
203
  </DialogRow>
256
204
  ) : null}
257
205
  {server.url ? (
258
- <DialogRow label='URL' mono>
206
+ <DialogRow label="URL" mono>
259
207
  {server.url}
260
208
  </DialogRow>
261
209
  ) : null}
262
210
  </div>
263
211
 
264
- {server.error ? (
265
- <div className='rounded-md bg-danger-bg px-3 py-2 text-body-sm break-words text-danger'>
266
- {server.error}
267
- </div>
268
- ) : null}
212
+ {server.error ? <div className="rounded-md bg-danger-bg px-3 py-2 text-body-sm break-words text-danger">{server.error}</div> : null}
269
213
 
270
- {/* Absent, not disabled, when the engine has no per-server action: a
271
- greyed-out Reconnect invites the question "why can't I?" on every
272
- visit, where nothing at all reads as "this engine works differently". */}
273
214
  {canManage ? (
274
- <div className='flex gap-2'>
275
- <Button variant='outline' size='sm' disabled={busy} onClick={() => onAct('reconnect')}>
276
- {busy ? <Spinner className='size-3 text-current' /> : <RotateCw className='size-3' />}
215
+ <div className="flex gap-2">
216
+ <Button variant="outline" size="sm" disabled={busy} onClick={() => onAct('reconnect')}>
217
+ {busy ? <Spinner className="size-3 text-current" /> : <RotateCw className="size-3" />}
277
218
  Reconnect
278
219
  </Button>
279
- <Button
280
- variant='outline'
281
- size='sm'
282
- disabled={busy}
283
- onClick={() => onAct(disabled ? 'enable' : 'disable')}>
284
- {disabled ? <Power className='size-3' /> : <PowerOff className='size-3' />}
220
+ <Button variant="outline" size="sm" disabled={busy} onClick={() => onAct(disabled ? 'enable' : 'disable')}>
221
+ {disabled ? <Power className="size-3" /> : <PowerOff className="size-3" />}
285
222
  {disabled ? 'Enable' : 'Disable'}
286
223
  </Button>
287
224
  </div>
288
225
  ) : null}
289
226
 
290
227
  <div>
291
- <h3 className='text-label font-medium text-fg-3'>Tools</h3>
228
+ <h3 className="text-label font-medium text-fg-3">Tools</h3>
292
229
  {!server.tools?.length ? (
293
- <p className='py-2 text-body-sm text-fg-4'>
294
- {server.status === 'connected'
295
- ? 'This server exposes no tools.'
296
- : 'Tools are listed once the server connects.'}
230
+ <p className="py-2 text-body-sm text-fg-4">
231
+ {server.status === 'connected' ? 'This server exposes no tools.' : 'Tools are listed once the server connects.'}
297
232
  </p>
298
233
  ) : (
299
- <ul className='mt-1 flex flex-col'>
234
+ <ul className="mt-1 flex flex-col">
300
235
  {server.tools.map((t) => (
301
236
  <li key={t.name}>
302
237
  <button
303
- type='button'
238
+ type="button"
304
239
  onClick={() => onSelectTool(t.name)}
305
- className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover'>
306
- <span className='min-w-0 flex-1 truncate font-mono text-label text-fg-1'>
307
- {t.name}
308
- </span>
309
- <ChevronRight className='size-3.5 shrink-0 text-fg-4' />
240
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-surface-hover"
241
+ >
242
+ <span className="min-w-0 flex-1 truncate font-mono text-label text-fg-1">{t.name}</span>
243
+ <ChevronRight className="size-3.5 shrink-0 text-fg-4" />
310
244
  </button>
311
245
  </li>
312
246
  ))}
@@ -320,42 +254,35 @@ function ServerView({
320
254
  function ToolView({ tool }: { tool: McpServerToolInfo }) {
321
255
  const annotations = tool.annotations
322
256
  return (
323
- <div className='flex flex-col gap-3'>
257
+ <div className="flex flex-col gap-3">
324
258
  {tool.description ? (
325
- <p className='text-body-sm whitespace-pre-wrap text-fg-2'>{tool.description}</p>
259
+ <p className="text-body-sm whitespace-pre-wrap text-fg-2">{tool.description}</p>
326
260
  ) : (
327
- <p className='text-body-sm text-fg-4'>This tool carries no description.</p>
261
+ <p className="text-body-sm text-fg-4">This tool carries no description.</p>
328
262
  )}
329
263
  {annotations ? (
330
- <div className='flex flex-wrap gap-1.5'>
331
- {annotations.readOnly ? <Badge variant='success'>read-only</Badge> : null}
332
- {annotations.destructive ? <Badge variant='danger'>destructive</Badge> : null}
333
- {annotations.openWorld ? <Badge variant='warning'>open world</Badge> : null}
264
+ <div className="flex flex-wrap gap-1.5">
265
+ {annotations.readOnly ? <Badge variant="success">read-only</Badge> : null}
266
+ {annotations.destructive ? <Badge variant="danger">destructive</Badge> : null}
267
+ {annotations.openWorld ? <Badge variant="warning">open world</Badge> : null}
334
268
  </div>
335
269
  ) : null}
336
- {/* Engine-dependent, and said as such: codex returns each tool's full
337
- JSON Schema, the Agent SDK returns none at all. So this is a real
338
- section where one exists and an explanation where it doesn't — never a
339
- silent gap, and never a claim that no engine has them. */}
340
270
  {tool.inputSchema !== undefined ? (
341
271
  <div>
342
- <h3 className='text-label font-medium text-fg-3'>Parameters</h3>
343
- <pre className='mt-1 max-h-64 overflow-auto rounded-md bg-surface px-3 py-2 text-label text-fg-2'>
272
+ <h3 className="text-label font-medium text-fg-3">Parameters</h3>
273
+ <pre className="mt-1 max-h-64 overflow-auto rounded-md bg-surface px-3 py-2 text-label text-fg-2">
344
274
  {safeSchema(tool.inputSchema)}
345
275
  </pre>
346
276
  </div>
347
277
  ) : (
348
278
  <p className={cn('text-label text-fg-4')}>
349
- Parameters aren’t available: this engine names and describes each tool but reports no
350
- input schema.
279
+ Parameters aren’t available: this engine names and describes each tool but reports no input schema.
351
280
  </p>
352
281
  )}
353
282
  </div>
354
283
  )
355
284
  }
356
285
 
357
- /** The schema is an opaque JSON document from another process — pretty-print it,
358
- * and never let an unserializable value take the dialog down with it. */
359
286
  function safeSchema(schema: unknown): string {
360
287
  try {
361
288
  return JSON.stringify(schema, null, 2)