@workerdeck/ui 0.9.0 → 0.12.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 (65) hide show
  1. package/README.md +81 -3
  2. package/build/SessionPanel-Dy9lQrOV.d.mts +319 -0
  3. package/build/SessionPanel-NQ8ksCfj.mjs +8474 -0
  4. package/build/SessionPanel-NQ8ksCfj.mjs.map +1 -0
  5. package/build/format-DqR56Y8l.mjs +162 -0
  6. package/build/format-DqR56Y8l.mjs.map +1 -0
  7. package/build/format-ljc3lKpA.d.mts +59 -0
  8. package/build/format.d.mts +66 -0
  9. package/build/format.mjs +119 -0
  10. package/build/format.mjs.map +1 -0
  11. package/build/index.d.mts +671 -88
  12. package/build/index.mjs +387 -5160
  13. package/build/index.mjs.map +1 -1
  14. package/build/workspace.d.mts +226 -0
  15. package/build/workspace.mjs +861 -0
  16. package/build/workspace.mjs.map +1 -0
  17. package/package.json +22 -4
  18. package/src/components/agent/CodeEditor.tsx +300 -0
  19. package/src/components/agent/Composer.tsx +522 -87
  20. package/src/components/agent/ContextDialog.tsx +99 -0
  21. package/src/components/agent/Conversation.tsx +11 -3
  22. package/src/components/agent/EditorTabs.tsx +165 -0
  23. package/src/components/agent/FileCard.tsx +26 -0
  24. package/src/components/agent/FileTree.tsx +287 -0
  25. package/src/components/agent/FileViewer.tsx +148 -0
  26. package/src/components/agent/HostFilesDialog.tsx +218 -0
  27. package/src/components/agent/Loader.tsx +82 -14
  28. package/src/components/agent/McpDialog.tsx +363 -0
  29. package/src/components/agent/Message.tsx +51 -17
  30. package/src/components/agent/ModelSelect.tsx +34 -6
  31. package/src/components/agent/PermissionModeSelect.tsx +133 -22
  32. package/src/components/agent/PermissionPrompt.tsx +164 -6
  33. package/src/components/agent/PromptTokenText.tsx +39 -0
  34. package/src/components/agent/QuestionPrompt.tsx +122 -0
  35. package/src/components/agent/Reasoning.tsx +20 -5
  36. package/src/components/agent/Response.tsx +128 -0
  37. package/src/components/agent/SessionBrowser.tsx +428 -0
  38. package/src/components/agent/SessionEmptyState.tsx +65 -0
  39. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  40. package/src/components/agent/SessionPanel.tsx +783 -91
  41. package/src/components/agent/SessionWorkspace.tsx +317 -0
  42. package/src/components/agent/SkillsDialog.tsx +195 -0
  43. package/src/components/agent/StatusBar.tsx +85 -18
  44. package/src/components/agent/ToolCallCard.tsx +252 -30
  45. package/src/components/agent/Transcript.tsx +513 -30
  46. package/src/components/agent/UsageDialog.tsx +168 -0
  47. package/src/components/agent/line-prompt.tsx +249 -0
  48. package/src/components/agent/pulse.tsx +60 -0
  49. package/src/components/agent/transcript-variant.tsx +123 -0
  50. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  51. package/src/components/prompt-area/types.ts +15 -0
  52. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  53. package/src/components/ui/CodeBlock.tsx +40 -2
  54. package/src/components/ui/CopyButton.tsx +28 -3
  55. package/src/components/ui/Dialog.tsx +92 -0
  56. package/src/components/ui/Menu.tsx +55 -0
  57. package/src/components/ui/Splitter.tsx +133 -0
  58. package/src/components/ui/Tooltip.tsx +22 -5
  59. package/src/format.ts +11 -0
  60. package/src/index.ts +67 -2
  61. package/src/lib/clipboard.ts +56 -0
  62. package/src/lib/format.ts +114 -0
  63. package/src/lib/status.ts +124 -0
  64. package/src/lib/tool-icon.ts +96 -0
  65. package/src/workspace.ts +28 -0
@@ -1,18 +1,42 @@
1
- import { useState } from 'react'
1
+ import { useEffect, useState } from 'react'
2
2
  import type { TranscriptItem } from '@workerdeck/react'
3
- import { ChevronDown, Clock, Wrench } from 'lucide-react'
3
+ import { ChevronDown, Clock } from 'lucide-react'
4
4
  import { Badge } from '../ui/Badge.tsx'
5
5
  import { CodeBlock } from '../ui/CodeBlock.tsx'
6
6
  import { Spinner } from '../ui/Spinner.tsx'
7
7
  import { cn } from '../../lib/utils.ts'
8
8
  import { toolInputPreview } from '../../lib/format.ts'
9
+ import { isMutatingTool, toolIcon } from '../../lib/tool-icon.ts'
10
+ import { LINE_INDENT, LinePayload } from './line-prompt.tsx'
11
+ import { usePulse } from './pulse.tsx'
12
+ import { LineGlyph, useLines } from './transcript-variant.tsx'
9
13
 
10
14
  export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
11
15
 
12
16
  const RESULT_PREVIEW_CHARS = 2000
13
17
 
18
+ /** Codex's built-in image tools name a host path rather than sending bytes —
19
+ * an event log carries references, never base64. Rendering the picture means
20
+ * reading that path back through the gateway's host-file route. */
21
+ const IMAGE_TOOLS = new Set(['CodexImageGeneration', 'CodexImageView'])
22
+
23
+ const imagePathOf = (item: ToolCallItem): string | undefined => {
24
+ if (!IMAGE_TOOLS.has(item.name)) return undefined
25
+ const input = item.input as { savedPath?: unknown; path?: unknown } | null
26
+ const path = input?.savedPath ?? input?.path
27
+ return typeof path === 'string' ? path : undefined
28
+ }
29
+
14
30
  export interface ToolCallCardProps {
15
31
  item: ToolCallItem
32
+ /**
33
+ * Reads a host file as a data URL, for tools whose output is a picture on the
34
+ * host. Resolves `undefined` when the gateway won't serve that path — a
35
+ * generated image saved outside the allowed roots (codex's default
36
+ * `$CODEX_HOME/generated_images/`) is one, and the card then names the path
37
+ * instead of showing it.
38
+ */
39
+ hostImage?: (path: string) => Promise<string | undefined>
16
40
  className?: string
17
41
  }
18
42
 
@@ -27,17 +51,161 @@ const STATE_BADGE = {
27
51
  failed: { label: 'Error', variant: 'danger', busy: false },
28
52
  } as const
29
53
 
30
- export function ToolCallCard({ item, className }: ToolCallCardProps) {
54
+ /** The gutter dot's colour in `lines`: the state, said without a badge. */
55
+ const STATE_GLYPH = {
56
+ running: 'text-info',
57
+ pending: 'text-info',
58
+ deferred: 'text-accent',
59
+ settled: 'text-fg-4',
60
+ failed: 'text-danger',
61
+ } as const
62
+
63
+ /**
64
+ * The language to highlight a payload as.
65
+ *
66
+ * Parameters are always JSON. A result is whatever file the call was about — so
67
+ * the extension in `file_path`/`path` is the best evidence there is, and a call
68
+ * that names no file gets no guess (plain text renders fine and a wrong grammar
69
+ * is worse than none).
70
+ */
71
+ const EXTENSION_LANGUAGE: Record<string, string> = {
72
+ ts: 'ts', tsx: 'tsx', js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js',
73
+ json: 'json', jsonc: 'json', md: 'md', mdx: 'md', css: 'css', scss: 'scss',
74
+ html: 'html', xml: 'xml', yml: 'yaml', yaml: 'yaml', toml: 'toml',
75
+ py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java', kt: 'kotlin',
76
+ swift: 'swift', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cs: 'csharp',
77
+ php: 'php', sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'fish', sql: 'sql',
78
+ graphql: 'graphql', dockerfile: 'dockerfile', diff: 'diff', patch: 'diff',
79
+ }
80
+
81
+ function resultLanguage(item: ToolCallItem): string | undefined {
82
+ if (item.name === 'Bash' || item.name === 'CodexCommand') return 'bash'
83
+ if (item.name === 'CodexFileChange') return 'diff'
84
+ const input = item.input as { file_path?: unknown; path?: unknown } | null
85
+ const path = input?.file_path ?? input?.path
86
+ if (typeof path !== 'string') return undefined
87
+ const extension = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
88
+ return EXTENSION_LANGUAGE[extension]
89
+ }
90
+
91
+ type Status = keyof typeof STATE_BADGE
92
+
93
+ export function ToolCallCard({ item, hostImage, className }: ToolCallCardProps) {
94
+ const lines = useLines()
31
95
  const [open, setOpen] = useState(false)
32
96
  const [fullResult, setFullResult] = useState(false)
33
- const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
97
+ const imagePath = imagePathOf(item)
98
+ const status: Status = item.status ?? (item.result === undefined ? 'running' : 'settled')
34
99
  const badge = STATE_BADGE[status]
35
100
  const isError = status === 'failed' || item.result?.isError === true
101
+ // Ticks only while this row is actually running, and only in the variant with a
102
+ // gutter to pulse in — an idle transcript of a hundred settled tools starts no
103
+ // timers at all.
104
+ const pulse = usePulse(lines && badge.busy)
105
+ const Icon = toolIcon(item.name)
36
106
 
37
107
  const resultText = item.result?.text ?? ''
38
108
  const truncated = !fullResult && resultText.length > RESULT_PREVIEW_CHARS
39
109
  const shownResult = truncated ? resultText.slice(0, RESULT_PREVIEW_CHARS) : resultText
40
110
 
111
+ // In `lines` the payloads are dim label + highlighted band, not framed cards:
112
+ // a tool call is already one row, and boxing what it expands into puts back
113
+ // the chrome the variant exists to remove.
114
+ const blockVariant = lines ? 'plain' : 'panel'
115
+ const Payload = lines ? HighlightedPayload : PlainPayload
116
+ const details = open ? (
117
+ <div className={cn('flex flex-col', lines ? 'gap-1 py-1' : 'gap-2 border-t border-border p-2.5')}>
118
+ <Payload
119
+ code={JSON.stringify(item.input, null, 2)}
120
+ language='json'
121
+ label='Parameters'
122
+ variant={blockVariant}
123
+ />
124
+ {item.logs?.length ? (
125
+ <Payload code={item.logs.join('\n')} label='Logs' variant={blockVariant} />
126
+ ) : null}
127
+ {item.result !== undefined ? (
128
+ <div>
129
+ <Payload
130
+ code={shownResult || '(empty result)'}
131
+ language={resultLanguage(item)}
132
+ label={isError ? 'Error' : 'Result'}
133
+ variant={blockVariant}
134
+ className={cn(isError && (lines ? '[&_pre]:text-danger' : 'border-danger/40 [&_pre]:text-danger'))}
135
+ />
136
+ {truncated ? (
137
+ <button
138
+ type='button'
139
+ className='mt-1 text-label text-fg-3 underline-offset-2 hover:underline'
140
+ onClick={() => setFullResult(true)}>
141
+ Show all {resultText.length.toLocaleString()} chars
142
+ </button>
143
+ ) : null}
144
+ </div>
145
+ ) : null}
146
+ </div>
147
+ ) : null
148
+
149
+ // The picture is the point of the call — shown without expanding, the way the
150
+ // tool's own output would be if the engine had sent bytes.
151
+ const image = imagePath && hostImage ? <HostImage path={imagePath} load={hostImage} lines={lines} /> : null
152
+
153
+ if (lines) {
154
+ return (
155
+ <div data-slot='tool-call' data-state={status} className={cn('w-full', className)}>
156
+ <button
157
+ type='button'
158
+ onClick={() => setOpen((v) => !v)}
159
+ className='flex w-full items-baseline gap-2 text-left outline-none'>
160
+ <LineGlyph
161
+ className={
162
+ // A settled write is green: skimming a run, "what did it change"
163
+ // is the question you come back to, and it is the one you might
164
+ // need to undo. Every other state keeps its own colour — a failed
165
+ // write is a failure first.
166
+ status === 'settled' && !isError && isMutatingTool(item.name)
167
+ ? 'text-success'
168
+ : STATE_GLYPH[status]
169
+ }>
170
+ {/* Running: the mark's own pulse, so a working tool row and the
171
+ transcript's working line beat together. Settled: a plain dot,
172
+ which reads as "done" precisely by not moving. */}
173
+ {badge.busy ? pulse : '●'}
174
+ </LineGlyph>
175
+ <span className='min-w-0 flex-1 truncate text-body-sm leading-5 text-fg-3'>
176
+ <span className='font-medium text-fg-1'>{item.name}</span>
177
+ <span className='text-fg-4'>({toolInputPreview(item.input)})</span>
178
+ </span>
179
+ {item.backend && item.backend !== 'server' ? (
180
+ <span className='shrink-0 text-label text-fg-4'>{item.backend}</span>
181
+ ) : null}
182
+ {/* No Spinner here: the gutter glyph animates now, and two spinners on
183
+ one row is one too many. `cards` keeps its own — it has no gutter. */}
184
+ {status === 'deferred' ? <Clock className='size-3 shrink-0 self-center text-fg-4' /> : null}
185
+ {isError && !badge.busy ? (
186
+ <span className='shrink-0 text-label text-danger'>error</span>
187
+ ) : null}
188
+ </button>
189
+ {/* Collapsed, the first line of the output is the whole story most of
190
+ the time — a summary line costs one row and saves an expand. */}
191
+ {!open && resultText ? (
192
+ <div className='flex items-baseline gap-2'>
193
+ <LineGlyph className='text-fg-4'>⎿</LineGlyph>
194
+ <span
195
+ className={cn(
196
+ 'min-w-0 flex-1 truncate text-label leading-5',
197
+ isError ? 'text-danger' : 'text-fg-4',
198
+ )}>
199
+ {resultSummary(resultText)}
200
+ </span>
201
+ </div>
202
+ ) : null}
203
+ {image}
204
+ {details ? <div className={LINE_INDENT}>{details}</div> : null}
205
+ </div>
206
+ )
207
+ }
208
+
41
209
  return (
42
210
  <div
43
211
  data-slot='tool-call'
@@ -50,8 +218,13 @@ export function ToolCallCard({ item, className }: ToolCallCardProps) {
50
218
  'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors outline-none',
51
219
  'hover:bg-surface-hover focus-visible:bg-surface-hover',
52
220
  )}>
53
- <Wrench className='size-3.5 shrink-0 text-fg-3' />
221
+ <Icon className='size-3.5 shrink-0 text-fg-3' />
54
222
  <span className='shrink-0 font-mono text-body-sm font-medium text-fg-1'>{item.name}</span>
223
+ {item.backend && item.backend !== 'server' ? (
224
+ <Badge variant='neutral' className='shrink-0'>
225
+ {item.backend}
226
+ </Badge>
227
+ ) : null}
55
228
  <span className='min-w-0 flex-1 truncate font-mono text-label text-fg-4'>
56
229
  {toolInputPreview(item.input)}
57
230
  </span>
@@ -64,31 +237,80 @@ export function ToolCallCard({ item, className }: ToolCallCardProps) {
64
237
  className={cn('size-3.5 shrink-0 text-fg-4 transition-transform', open && 'rotate-180')}
65
238
  />
66
239
  </button>
67
- {open ? (
68
- <div className='flex flex-col gap-2 border-t border-border p-2.5'>
69
- <CodeBlock code={JSON.stringify(item.input, null, 2)} label='Parameters' />
70
- {item.logs?.length ? (
71
- <CodeBlock code={item.logs.join('\n')} label='Logs' />
72
- ) : null}
73
- {item.result !== undefined ? (
74
- <div>
75
- <CodeBlock
76
- code={shownResult || '(empty result)'}
77
- label={isError ? 'Error' : 'Result'}
78
- className={cn(isError && 'border-danger/40 [&_pre]:text-danger')}
79
- />
80
- {truncated ? (
81
- <button
82
- type='button'
83
- className='mt-1 text-label text-fg-3 underline-offset-2 hover:underline'
84
- onClick={() => setFullResult(true)}>
85
- Show all {resultText.length.toLocaleString()} chars
86
- </button>
87
- ) : null}
88
- </div>
89
- ) : null}
90
- </div>
91
- ) : null}
240
+ {image}
241
+ {details}
242
+ </div>
243
+ )
244
+ }
245
+
246
+ /** The first line worth showing of a tool result, for the collapsed summary. */
247
+ function resultSummary(text: string): string {
248
+ const first = text.split('\n').find((line) => line.trim() !== '') ?? ''
249
+ const rest = text.trimEnd().split('\n').length - 1
250
+ const trimmed = first.trim()
251
+ return rest > 0 ? `${trimmed} (+${rest} lines)` : trimmed
252
+ }
253
+
254
+ type PayloadProps = {
255
+ code: string
256
+ label: string
257
+ language?: string
258
+ variant: 'panel' | 'plain'
259
+ className?: string
260
+ }
261
+
262
+ /** The framed card, for the `cards` transcript. Unhighlighted by design: it is
263
+ * structured data in a panel, not a file. */
264
+ function PlainPayload({ code, label, variant, className }: PayloadProps) {
265
+ return <CodeBlock code={code} label={label} variant={variant} className={className} />
266
+ }
267
+
268
+ /** The terminal payload — shared with the line-shaped prompts. */
269
+ function HighlightedPayload({ code, label, language, className }: PayloadProps) {
270
+ return <LinePayload code={code} label={label} language={language} className={className} />
271
+ }
272
+
273
+ /**
274
+ * A picture that lives on the host, fetched through the gateway's host-file
275
+ * route and shown inline.
276
+ *
277
+ * Silent on failure by design: a path outside the server's allowed roots is the
278
+ * *expected* case for codex's default save location, and the card's result text
279
+ * already names where the file went. An error banner over that would be noise
280
+ * about a thing the operator can fix in one line of config.
281
+ */
282
+ function HostImage({
283
+ path,
284
+ load,
285
+ lines,
286
+ }: {
287
+ path: string
288
+ load: (path: string) => Promise<string | undefined>
289
+ lines?: boolean
290
+ }) {
291
+ const [src, setSrc] = useState<string | undefined>()
292
+ useEffect(() => {
293
+ let cancelled = false
294
+ setSrc(undefined)
295
+ load(path)
296
+ .then((url) => {
297
+ if (!cancelled) setSrc(url)
298
+ })
299
+ .catch(() => {
300
+ // Not readable from here — the path in the result is the answer.
301
+ })
302
+ return () => {
303
+ cancelled = true
304
+ }
305
+ }, [path, load])
306
+ if (!src) return null
307
+ return (
308
+ <div className={cn(lines ? cn('py-1', LINE_INDENT) : 'border-t border-border p-2.5')}>
309
+ <img
310
+ src={src}
311
+ alt={path.split('/').pop() ?? 'Generated image'}
312
+ className='max-h-96 w-auto max-w-full rounded-md border border-border'
313
+ />
92
314
  </div>
93
315
  )
94
316
  }