@workerdeck/ui 0.7.0 → 0.11.0

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