@workerdeck/ui 0.7.0 → 0.10.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 (38) hide show
  1. package/README.md +44 -3
  2. package/build/index.d.mts +767 -21
  3. package/build/index.mjs +3268 -348
  4. package/build/index.mjs.map +1 -1
  5. package/package.json +5 -4
  6. package/src/components/agent/CodeEditor.tsx +300 -0
  7. package/src/components/agent/Composer.tsx +379 -56
  8. package/src/components/agent/ContextDialog.tsx +99 -0
  9. package/src/components/agent/EditorTabs.tsx +165 -0
  10. package/src/components/agent/FileTree.tsx +287 -0
  11. package/src/components/agent/FileViewer.tsx +148 -0
  12. package/src/components/agent/HostFilesDialog.tsx +218 -0
  13. package/src/components/agent/McpDialog.tsx +363 -0
  14. package/src/components/agent/ModelSelect.tsx +34 -6
  15. package/src/components/agent/PermissionModeSelect.tsx +99 -22
  16. package/src/components/agent/PermissionPrompt.tsx +72 -6
  17. package/src/components/agent/PromptTokenText.tsx +39 -0
  18. package/src/components/agent/SessionEmptyState.tsx +65 -0
  19. package/src/components/agent/SessionInfoDialog.tsx +163 -0
  20. package/src/components/agent/SessionPanel.tsx +380 -40
  21. package/src/components/agent/SessionWorkspace.tsx +282 -0
  22. package/src/components/agent/SkillsDialog.tsx +195 -0
  23. package/src/components/agent/StatusBar.tsx +85 -18
  24. package/src/components/agent/ToolCallCard.tsx +80 -4
  25. package/src/components/agent/Transcript.tsx +109 -12
  26. package/src/components/agent/UsageDialog.tsx +168 -0
  27. package/src/components/prompt-area/prompt-area-engine.ts +53 -0
  28. package/src/components/prompt-area/types.ts +15 -0
  29. package/src/components/prompt-area/use-prompt-area.ts +20 -0
  30. package/src/components/ui/CopyButton.tsx +8 -1
  31. package/src/components/ui/Dialog.tsx +92 -0
  32. package/src/components/ui/Menu.tsx +55 -0
  33. package/src/components/ui/Splitter.tsx +133 -0
  34. package/src/components/ui/Tooltip.tsx +22 -5
  35. package/src/index.ts +53 -1
  36. package/src/lib/clipboard.ts +56 -0
  37. package/src/lib/format.ts +48 -0
  38. package/src/lib/tool-icon.ts +74 -0
@@ -1,15 +1,40 @@
1
- import { useMemo, type ReactNode } from 'react'
2
- import type { SlashCommandInfo } from '@workerdeck/protocol'
3
- import { ArrowUp, Square } from 'lucide-react'
1
+ import { useImperativeHandle, useMemo, useRef, useState, type ReactNode, type Ref } from 'react'
2
+ import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
3
+ import type { StagedAttachment, UseAttachmentsResult } from '@workerdeck/react'
4
+ import {
5
+ ArrowUp,
6
+ FileText,
7
+ Paperclip,
8
+ RotateCw,
9
+ Sparkles,
10
+ Square,
11
+ TriangleAlert,
12
+ X,
13
+ } from 'lucide-react'
4
14
  import { Button } from '../ui/Button.tsx'
15
+ import { Spinner } from '../ui/Spinner.tsx'
5
16
  import { PromptArea } from '../prompt-area/prompt-area.tsx'
6
17
  import { usePromptAreaState } from '../prompt-area/use-prompt-area-state.ts'
7
- import { commandTrigger } from '../prompt-area/trigger-presets.ts'
18
+ import { commandTrigger, mentionTrigger } from '../prompt-area/trigger-presets.ts'
8
19
  import type { TriggerSuggestion } from '../prompt-area/types.ts'
9
20
  import { cn } from '../../lib/utils.ts'
21
+ import { formatBytes } from '../../lib/format.ts'
22
+
23
+ /** Files matching an `@` query, for the composer's file trigger. Structural so
24
+ * the ui package doesn't have to reach for the protocol's `HostFileMatch`. */
25
+ export type ComposerFileMatch = { path: string; relative: string }
26
+
27
+ /** Imperative surface for panels that draft a message the user then finishes —
28
+ * the skills dialog's "Use this skill". Nothing here sends. */
29
+ export type ComposerHandle = {
30
+ /** Append plain text at the caret's end and focus, separating it from
31
+ * whatever is already there. */
32
+ insertText: (text: string) => void
33
+ }
10
34
 
11
35
  export interface ComposerProps {
12
- onSend: (text: string) => void
36
+ /** `attachmentIds` are the staged uploads, in the order they were picked. */
37
+ onSend: (text: string, attachmentIds: string[]) => void
13
38
  onInterrupt: () => void
14
39
  busy: boolean
15
40
  /** Disable input entirely (session failed/closed). */
@@ -17,86 +42,263 @@ export interface ComposerProps {
17
42
  placeholder?: string
18
43
  /** Slash commands offered as autocomplete; picked ones render as chips. */
19
44
  commands?: SlashCommandInfo[]
20
- /** Left side of the toolbar row (mode selects, attachments, …). */
45
+ /**
46
+ * Skills offered under a **`$`** popover of their own — codex's sigil, kept
47
+ * separate from `/` because the two behave differently. A skill is a typing
48
+ * aid, not a command: picking one inserts editable text (the skill's own
49
+ * `defaultPrompt` where it has one, else `$name`) and nothing is sent. No
50
+ * engine parses `$skillname` as syntax, which is exactly why these can never
51
+ * resolve to a chip the way `commands` do.
52
+ */
53
+ skills?: SkillInfo[]
54
+ /** Host-file search behind the `@` trigger. Omit to leave `@` inert — a
55
+ * gateway without host files has nothing to complete. */
56
+ onSearchFiles?: (query: string, options: { signal: AbortSignal }) => Promise<ComposerFileMatch[]>
57
+ /** Attachment staging (see `useAttachments`). Omit for a text-only composer. */
58
+ attachments?: UseAttachmentsResult
59
+ /** Left side of the toolbar row (mode selects, …). */
21
60
  toolbar?: ReactNode
22
61
  className?: string
62
+ ref?: Ref<ComposerHandle>
23
63
  }
24
64
 
25
65
  /** CLI names may carry display annotations (e.g. "foo (MCP)") the parser rejects. */
26
66
  const cleanName = (name: string) => name.replace(/\s*\(MCP\)$/i, '')
27
67
 
28
- /** Framed prompt input built on prompt-area's contentEditable: typing "/" — at the
29
- * start or after whitespace — opens a suggestion dropdown fed by `commands`, and a
30
- * picked command becomes an inline chip. Submit button flips to stop while a turn
31
- * is running (messages still queue while busy). */
68
+ /**
69
+ * What a picked skill types into the composer.
70
+ *
71
+ * The engine's own `defaultPrompt` when it declared one — it knows what its
72
+ * skill wants to be asked — and otherwise `$name`, which is codex's native way
73
+ * of referring to a skill in prompt text: its `skill-creator` documents the form
74
+ * (`Use $skill-x at /path/to/skill-x to solve problem y`) and its own bundled
75
+ * prompts are written that way ("Use $pdf to …"). Spelling it the way the engine
76
+ * spells it beats paraphrasing into "Use the X skill to".
77
+ *
78
+ * Either way it ends in a space so the caret lands ready for the rest of the
79
+ * sentence, and either way it is ordinary text: nothing here is submitted, and
80
+ * nothing is parsed back out.
81
+ */
82
+ export function skillPrompt(skill: SkillInfo): string {
83
+ const base = skill.defaultPrompt?.trim() || `$${skill.name}`
84
+ return /\s$/.test(base) ? base : base + ' '
85
+ }
86
+
87
+ /** Ranks a haystack set against the typed query: 2 for a prefix hit, 1 for a
88
+ * substring, 0 for no match. Shared so commands and skills sort as one list
89
+ * rather than two concatenated ones. */
90
+ function matchScore(query: string, haystacks: string[]): number {
91
+ const needle = query.toLowerCase()
92
+ const lowered = haystacks.map((s) => s.toLowerCase())
93
+ if (lowered.some((h) => h.startsWith(needle))) return 2
94
+ return lowered.some((h) => h.includes(needle)) ? 1 : 0
95
+ }
96
+
97
+ /**
98
+ * Framed prompt input built on prompt-area's contentEditable.
99
+ *
100
+ * Three completions ride the same field and behave nothing alike. `/` is the
101
+ * CLI's command list and `$` is the engine's skill list — both local, so they
102
+ * filter completely and instantly; `@` is a search against the host filesystem,
103
+ * debounced and abortable so a fast typist makes one request rather than eight.
104
+ *
105
+ * `/` and `$` are separate keys rather than one merged menu, and that mirrors
106
+ * the engines themselves: codex completes skills on `$` and reserves `/` for
107
+ * commands. The behaviours differ too — a command resolves to a **chip**,
108
+ * because the CLI really does parse `/name` out of the message, while a skill
109
+ * resolves to plain editable **text**, because no engine parses `$name` as
110
+ * syntax; it is prose the model reads. Rendering them alike would promise
111
+ * something that does not happen.
112
+ *
113
+ * Files can arrive three ways — the paperclip, a drop, or a paste — because on a
114
+ * desktop all three are things people already do, and the upload starts the
115
+ * moment one lands rather than at send time.
116
+ */
32
117
  export function Composer({
33
118
  onSend,
34
119
  onInterrupt,
35
120
  busy,
36
121
  disabled,
37
- placeholder = 'Message Claude…',
122
+ placeholder = 'Message the agent…',
38
123
  commands,
124
+ skills,
125
+ onSearchFiles,
126
+ attachments,
39
127
  toolbar,
40
128
  className,
129
+ ref,
41
130
  }: ComposerProps) {
42
131
  const { bind, plainText, isEmpty, clear, focus } = usePromptAreaState()
132
+ const fileInput = useRef<HTMLInputElement>(null)
133
+ const [dragging, setDragging] = useState(false)
134
+
135
+ useImperativeHandle(
136
+ ref,
137
+ () => ({
138
+ insertText: (text: string) => {
139
+ // A space in front only when there is something to separate from, so a
140
+ // draft into an empty composer doesn't start with one.
141
+ const prefix = plainText.length > 0 && !/\s$/.test(plainText) ? ' ' : ''
142
+ bind.ref.current?.appendText(prefix + text)
143
+ focus()
144
+ },
145
+ }),
146
+ [bind.ref, plainText, focus],
147
+ )
43
148
 
44
149
  const triggers = useMemo(() => {
45
- if (!commands || commands.length === 0) return undefined
46
- // The CLI list can contain the same skill name from several sources — first wins.
47
- const seen = new Set<string>()
48
- const unique = commands.flatMap((c) => {
49
- const name = cleanName(c.name)
50
- if (seen.has(name)) return []
51
- seen.add(name)
52
- return [{ ...c, name }]
53
- })
54
- return [
55
- commandTrigger({
56
- onSearch: (query: string): TriggerSuggestion[] => {
57
- const needle = query.toLowerCase()
58
- const scored = unique.flatMap((c) => {
59
- const haystacks = [c.name, ...(c.aliases ?? [])].map((s) => s.toLowerCase())
60
- const score = haystacks.some((h) => h.startsWith(needle))
61
- ? 2
62
- : haystacks.some((h) => h.includes(needle))
63
- ? 1
64
- : 0
65
- return score === 0 ? [] : [{ c, score }]
66
- })
67
- scored.sort((a, b) => b.score - a.score)
68
- return scored.map(({ c }) => ({
69
- value: c.name,
70
- label: `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ''}`,
71
- description: c.description,
72
- }))
73
- },
74
- // Chip text renders as trigger + displayText — return the bare name so
75
- // the chip reads "/name" (label carries the argument hint for the menu).
76
- onSelect: (suggestion) => suggestion.value,
77
- chipClassName: 'font-mono',
78
- }),
79
- ]
80
- }, [commands])
150
+ const configured = []
151
+ const usableSkills = (skills ?? []).filter((s) => s.enabled)
152
+ if (commands && commands.length > 0) {
153
+ // The CLI list can contain the same skill name from several sources — first wins.
154
+ const seen = new Set<string>()
155
+ const unique = commands.flatMap((c) => {
156
+ const name = cleanName(c.name)
157
+ if (seen.has(name)) return []
158
+ seen.add(name)
159
+ return [{ ...c, name }]
160
+ })
161
+ configured.push(
162
+ commandTrigger({
163
+ onSearch: (query: string): TriggerSuggestion[] => {
164
+ const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
165
+ for (const c of unique) {
166
+ // "wrapup" should find "dev:wrapup" — the bare half of a
167
+ // namespaced name is what people type.
168
+ const score = matchScore(query, [c.name, ...(c.aliases ?? []), ...c.name.split(':')])
169
+ if (score === 0) continue
170
+ scored.push({
171
+ score,
172
+ suggestion: {
173
+ value: c.name,
174
+ label: `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ''}`,
175
+ description: c.description,
176
+ },
177
+ })
178
+ }
179
+ scored.sort((a, b) => b.score - a.score)
180
+ return scored.map(({ suggestion }) => suggestion)
181
+ },
182
+ // Chip text renders as trigger + displayText — return the bare name so
183
+ // the chip reads "/name" (label carries the argument hint for the menu).
184
+ onSelect: (suggestion) => suggestion.value,
185
+ chipClassName: 'font-mono',
186
+ }),
187
+ )
188
+ }
189
+ if (usableSkills.length > 0) {
190
+ // `$`, not `/`, because that is codex's own sigil — its TUI completes
191
+ // skills on `$` and reserves `/` for commands, and its bundled prompts
192
+ // refer to skills that way in prose ("Use $pdf to …"). Matching it means
193
+ // muscle memory transfers, and it keeps the two lists from being one
194
+ // ambiguous menu of things that behave differently.
195
+ configured.push(
196
+ commandTrigger({
197
+ char: '$',
198
+ accessibilityLabel: 'skill',
199
+ onSearch: (query: string): TriggerSuggestion[] => {
200
+ const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
201
+ for (const skill of usableSkills) {
202
+ const score = matchScore(query, [skill.name, ...skill.name.split(/[-:_]/)])
203
+ if (score === 0) continue
204
+ const summary = skill.shortDescription ?? skill.description
205
+ scored.push({
206
+ score,
207
+ suggestion: {
208
+ value: skill.name,
209
+ label: skill.displayName ?? skill.name,
210
+ description: summary
211
+ ? `Skill · ${summary}`
212
+ : 'Skill · inserts a message you can edit',
213
+ icon: <Sparkles className='size-3.5 text-fg-3' />,
214
+ },
215
+ })
216
+ }
217
+ scored.sort((a, b) => b.score - a.score)
218
+ return scored.map(({ suggestion }) => suggestion)
219
+ },
220
+ // Always text, never a chip: a skill is not wire syntax the engine
221
+ // parses back out, so what lands has to stay ordinary editable prose.
222
+ // Returning a string unconditionally is what makes this trigger's
223
+ // whole list behave that way.
224
+ insertAsText: (suggestion) => {
225
+ const skill = usableSkills.find((s) => s.name === suggestion.value)
226
+ return skill ? skillPrompt(skill) : `$${suggestion.value} `
227
+ },
228
+ }),
229
+ )
230
+ }
231
+ if (onSearchFiles) {
232
+ configured.push(
233
+ mentionTrigger({
234
+ // A round trip per keystroke would be eight requests for one word; the
235
+ // route is cheap but not free.
236
+ searchDebounceMs: 150,
237
+ onSearch: async (query, options) => {
238
+ const matches = await onSearchFiles(query, options)
239
+ return matches.map((match) => ({
240
+ value: match.relative,
241
+ label: match.relative,
242
+ description: match.path,
243
+ }))
244
+ },
245
+ onSelect: (suggestion) => suggestion.value,
246
+ chipStyle: 'inline',
247
+ chipClassName: 'font-mono',
248
+ emptyMessage: 'No matching files',
249
+ }),
250
+ )
251
+ }
252
+ return configured.length > 0 ? configured : undefined
253
+ }, [commands, skills, onSearchFiles])
254
+
255
+ const staged = attachments?.items ?? []
256
+ // A photo on its own is a message — send doesn't wait for text. It does wait
257
+ // for the upload, since an id that hasn't landed can't be named.
258
+ const canSend =
259
+ !disabled &&
260
+ (!isEmpty || staged.length > 0) &&
261
+ !attachments?.uploading &&
262
+ !attachments?.hasFailure
81
263
 
82
264
  const submit = () => {
83
- const trimmed = plainText.trim()
84
- if (!trimmed || disabled) return
85
- onSend(trimmed)
265
+ if (!canSend) return
266
+ onSend(plainText.trim(), attachments?.readyIds ?? [])
267
+ attachments?.clear()
86
268
  clear()
87
269
  focus()
88
270
  }
89
271
 
90
- const canSend = !disabled && !isEmpty
272
+ const pick = (files: FileList | null) => {
273
+ if (files && files.length > 0) attachments?.add(files)
274
+ }
91
275
 
92
276
  return (
93
277
  <div data-slot='composer' className={cn('px-3 pb-3', className)}>
94
278
  <div
279
+ onDragOver={(e) => {
280
+ if (attachments && !attachments.disabled) {
281
+ e.preventDefault()
282
+ setDragging(true)
283
+ }
284
+ }}
285
+ onDragLeave={() => setDragging(false)}
286
+ onDrop={(e) => {
287
+ if (!attachments || attachments.disabled) return
288
+ e.preventDefault()
289
+ setDragging(false)
290
+ pick(e.dataTransfer.files)
291
+ }}
95
292
  className={cn(
96
293
  'mx-auto w-full max-w-3xl overflow-hidden rounded-lg border border-border bg-bg shadow-(--shadow-xs)',
97
294
  'transition-colors focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30',
295
+ dragging && 'border-ring ring-2 ring-ring/30',
98
296
  disabled && 'opacity-60',
99
297
  )}>
298
+ {/* Above the field, like the picture you are talking about should be. */}
299
+ {staged.length > 0 && attachments ? (
300
+ <AttachmentStrip attachments={attachments} />
301
+ ) : null}
100
302
  <PromptArea
101
303
  {...bind}
102
304
  triggers={triggers}
@@ -105,12 +307,41 @@ export function Composer({
105
307
  placeholder={disabled ? 'Session ended' : placeholder}
106
308
  minHeight={28}
107
309
  maxHeight={192}
108
- aria-label='Message Claude'
310
+ aria-label='Message the agent'
109
311
  className='px-3 pt-2.5 pb-1 text-body-sm text-text'
312
+ onImagePaste={(file) => attachments?.add([file])}
110
313
  />
111
314
  <div className='flex items-center justify-between gap-2 px-2 pb-2'>
112
- <div className='flex min-w-0 items-center gap-1'>{toolbar}</div>
113
- {busy ? (
315
+ <div className='flex min-w-0 items-center gap-1'>
316
+ {/* An attach affordance the engine has no meaning for is not a
317
+ choice — the capability record decides whether it exists. */}
318
+ {attachments && !attachments.disabled ? (
319
+ <>
320
+ <input
321
+ ref={fileInput}
322
+ type='file'
323
+ multiple
324
+ accept={attachments.accept || undefined}
325
+ className='hidden'
326
+ onChange={(e) => {
327
+ pick(e.target.files)
328
+ // Re-picking the same file must fire `change` again.
329
+ e.target.value = ''
330
+ }}
331
+ />
332
+ <Button
333
+ variant='ghost'
334
+ size='icon-sm'
335
+ aria-label='Attach files'
336
+ disabled={disabled}
337
+ onClick={() => fileInput.current?.click()}>
338
+ <Paperclip className='size-4' />
339
+ </Button>
340
+ </>
341
+ ) : null}
342
+ {toolbar}
343
+ </div>
344
+ {busy && !canSend ? (
114
345
  <Button
115
346
  variant='outline'
116
347
  size='icon-sm'
@@ -131,9 +362,101 @@ export function Composer({
131
362
  )}
132
363
  </div>
133
364
  </div>
134
- <div className='mx-auto mt-1 w-full max-w-3xl text-center text-label text-fg-4'>
135
- Enter to send · Shift+Enter for a new line
365
+ {attachments?.error ? (
366
+ <div className='mx-auto mt-1 flex w-full max-w-3xl items-center gap-2 text-label text-danger'>
367
+ <TriangleAlert className='size-3 shrink-0' />
368
+ <span className='min-w-0 flex-1'>{attachments.error}</span>
369
+ <button
370
+ type='button'
371
+ onClick={attachments.dismissError}
372
+ aria-label='Dismiss'
373
+ className='shrink-0 opacity-70 hover:opacity-100'>
374
+ <X className='size-3' />
375
+ </button>
376
+ </div>
377
+ ) : (
378
+ <div className='mx-auto mt-1 w-full max-w-3xl text-center text-label text-fg-4'>
379
+ Enter to send · Shift+Enter for a new line
380
+ </div>
381
+ )}
382
+ </div>
383
+ )
384
+ }
385
+
386
+ /** Staged files as a scrolling row of chips above the field. The thumbnail is
387
+ * the local blob, so nothing here waits on the network; the upload's state rides
388
+ * on top of it and the ✕ takes it back off. */
389
+ function AttachmentStrip({ attachments }: { attachments: UseAttachmentsResult }) {
390
+ return (
391
+ <div className='flex gap-2 overflow-x-auto border-b border-border px-2 py-2'>
392
+ {attachments.items.map((item) => (
393
+ <AttachmentChip
394
+ key={item.key}
395
+ item={item}
396
+ onRetry={() => attachments.retry(item.key)}
397
+ onRemove={() => attachments.remove(item.key)}
398
+ />
399
+ ))}
400
+ </div>
401
+ )
402
+ }
403
+
404
+ function AttachmentChip({
405
+ item,
406
+ onRetry,
407
+ onRemove,
408
+ }: {
409
+ item: StagedAttachment
410
+ onRetry: () => void
411
+ onRemove: () => void
412
+ }) {
413
+ const failed = item.status === 'failed'
414
+ return (
415
+ <div
416
+ className='group relative shrink-0'
417
+ title={failed ? `${item.name} — ${item.error}` : `${item.name} · ${formatBytes(item.bytes)}`}>
418
+ <div
419
+ className={cn(
420
+ 'flex size-14 items-center justify-center overflow-hidden rounded-md border border-border bg-surface',
421
+ failed && 'border-danger/50',
422
+ )}>
423
+ {item.previewUrl ? (
424
+ <img src={item.previewUrl} alt={item.name} className='size-full object-cover' />
425
+ ) : (
426
+ <div className='flex flex-col items-center gap-0.5 text-fg-3'>
427
+ <FileText className='size-4' />
428
+ <span className='max-w-12 truncate text-[9px] font-semibold uppercase'>
429
+ {extensionOf(item.name)}
430
+ </span>
431
+ </div>
432
+ )}
136
433
  </div>
434
+ {item.status === 'uploading' ? (
435
+ <div className='absolute inset-0 flex items-center justify-center rounded-md bg-black/35'>
436
+ <Spinner className='size-4 text-white' />
437
+ </div>
438
+ ) : null}
439
+ {failed ? (
440
+ <button
441
+ type='button'
442
+ onClick={onRetry}
443
+ aria-label={`Retry ${item.name}`}
444
+ className='absolute inset-0 flex items-center justify-center rounded-md bg-black/45 text-warning'>
445
+ <RotateCw className='size-4' />
446
+ </button>
447
+ ) : null}
448
+ <button
449
+ type='button'
450
+ onClick={onRemove}
451
+ aria-label={`Remove ${item.name}`}
452
+ className='absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full border border-border bg-surface text-fg-3 shadow-(--shadow-xs) hover:text-fg-1'>
453
+ <X className='size-2.5' />
454
+ </button>
137
455
  </div>
138
456
  )
139
457
  }
458
+
459
+ const extensionOf = (name: string) => {
460
+ const dot = name.lastIndexOf('.')
461
+ return dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE'
462
+ }
@@ -0,0 +1,99 @@
1
+ import type { ContextUsage } from '@workerdeck/protocol'
2
+ import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { formatTokens } from '../../lib/format.ts'
5
+
6
+ export interface ContextDialogProps {
7
+ usage?: ContextUsage
8
+ open: boolean
9
+ onOpenChange: (open: boolean) => void
10
+ }
11
+
12
+ /** The CLI reports category colors as its own theme token names ('inactive',
13
+ * 'promptBorder', ...), not CSS colors — only pass through what CSS can render. */
14
+ const cssColor = (color: string): string | undefined =>
15
+ typeof CSS !== 'undefined' && CSS.supports('color', color) ? color : undefined
16
+
17
+ const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
18
+
19
+ /**
20
+ * What is in the model's context window right now, category by category.
21
+ *
22
+ * One of the three panels the status bar opens. Context, usage and session info
23
+ * are different questions asked at different moments, so they are different
24
+ * screens rather than one "details" list you scroll past two answers to reach.
25
+ */
26
+ export function ContextDialog({ usage, open, onOpenChange }: ContextDialogProps) {
27
+ return (
28
+ <Dialog open={open} onOpenChange={onOpenChange}>
29
+ <DialogContent>
30
+ <DialogHeader title='Context' description={usage?.model} />
31
+ <DialogBody>
32
+ {!usage ? (
33
+ <p className='py-6 text-center text-body-sm text-fg-4'>
34
+ No reading yet — the context window is measured after a turn completes.
35
+ </p>
36
+ ) : (
37
+ <>
38
+ <div className='flex items-baseline justify-between gap-4'>
39
+ <span className='text-label text-fg-3'>Used</span>
40
+ <span className='font-mono text-body-sm text-fg-1'>
41
+ {formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} ·{' '}
42
+ {usage.percentage.toFixed(0)}%
43
+ </span>
44
+ </div>
45
+ <div className='mt-2 h-2 overflow-hidden rounded-full bg-border'>
46
+ <div
47
+ className={cn('h-full rounded-full', usageTint(usage.percentage))}
48
+ style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
49
+ />
50
+ </div>
51
+ {/* Codex reports occupancy with no breakdown, so an engine can send
52
+ a real reading and an empty `categories`. A "Breakdown" heading
53
+ over nothing reads as a failed load; the row above already says
54
+ everything that is known. */}
55
+ {usage.categories.length > 0 ? (
56
+ <div className='mt-5'>
57
+ <h3 className='text-label font-medium text-fg-3'>Breakdown</h3>
58
+ <div className='mt-2 flex flex-col gap-3'>
59
+ {usage.categories.map((category) => {
60
+ const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
61
+ const color = cssColor(category.color)
62
+ return (
63
+ <div key={category.name}>
64
+ <div className='flex items-baseline justify-between gap-3'>
65
+ <span className='flex min-w-0 items-center gap-2'>
66
+ <span
67
+ className='size-2 shrink-0 rounded-full bg-fg-4'
68
+ style={color ? { backgroundColor: color } : undefined}
69
+ />
70
+ <span className='truncate text-body-sm text-fg-1'>
71
+ {category.name}
72
+ </span>
73
+ </span>
74
+ <span className='shrink-0 font-mono text-label text-fg-3'>
75
+ {formatTokens(category.tokens)}
76
+ </span>
77
+ </div>
78
+ <div className='mt-1 h-1 overflow-hidden rounded-full bg-border'>
79
+ <div
80
+ className='h-full rounded-full bg-fg-4'
81
+ style={{
82
+ width: `${Math.min(100, share)}%`,
83
+ backgroundColor: color,
84
+ }}
85
+ />
86
+ </div>
87
+ </div>
88
+ )
89
+ })}
90
+ </div>
91
+ </div>
92
+ ) : null}
93
+ </>
94
+ )}
95
+ </DialogBody>
96
+ </DialogContent>
97
+ </Dialog>
98
+ )
99
+ }