@workerdeck/ui 0.6.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/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/build/index.d.mts +927 -0
  4. package/build/index.mjs +5236 -0
  5. package/build/index.mjs.map +1 -0
  6. package/package.json +73 -0
  7. package/src/components/agent/Composer.tsx +139 -0
  8. package/src/components/agent/Conversation.tsx +59 -0
  9. package/src/components/agent/FileCard.tsx +50 -0
  10. package/src/components/agent/Loader.tsx +21 -0
  11. package/src/components/agent/Message.tsx +44 -0
  12. package/src/components/agent/ModelSelect.tsx +87 -0
  13. package/src/components/agent/PermissionModeSelect.tsx +94 -0
  14. package/src/components/agent/PermissionPrompt.tsx +52 -0
  15. package/src/components/agent/QuestionPrompt.tsx +193 -0
  16. package/src/components/agent/Reasoning.tsx +58 -0
  17. package/src/components/agent/Response.tsx +31 -0
  18. package/src/components/agent/SessionList.tsx +93 -0
  19. package/src/components/agent/SessionPanel.tsx +149 -0
  20. package/src/components/agent/StatusBar.tsx +140 -0
  21. package/src/components/agent/ToolCallCard.tsx +94 -0
  22. package/src/components/agent/Transcript.tsx +114 -0
  23. package/src/components/agent/status.ts +16 -0
  24. package/src/components/prompt-area/animated-placeholder.tsx +42 -0
  25. package/src/components/prompt-area/clipboard-helpers.ts +206 -0
  26. package/src/components/prompt-area/cursor-helpers.ts +244 -0
  27. package/src/components/prompt-area/dom-helpers.ts +721 -0
  28. package/src/components/prompt-area/file-strip.tsx +250 -0
  29. package/src/components/prompt-area/html-to-markdown.ts +278 -0
  30. package/src/components/prompt-area/image-strip.tsx +49 -0
  31. package/src/components/prompt-area/index.ts +23 -0
  32. package/src/components/prompt-area/prompt-area-engine.ts +705 -0
  33. package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
  34. package/src/components/prompt-area/prompt-area.tsx +375 -0
  35. package/src/components/prompt-area/remove-button.tsx +37 -0
  36. package/src/components/prompt-area/segment-helpers.ts +62 -0
  37. package/src/components/prompt-area/trigger-popover.tsx +139 -0
  38. package/src/components/prompt-area/trigger-presets.ts +143 -0
  39. package/src/components/prompt-area/types.ts +360 -0
  40. package/src/components/prompt-area/use-markdown-mode.ts +113 -0
  41. package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
  42. package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
  43. package/src/components/prompt-area/use-prompt-area.ts +1507 -0
  44. package/src/components/prompt-area/use-trigger-search.ts +115 -0
  45. package/src/components/ui/AlertDialog.tsx +56 -0
  46. package/src/components/ui/Badge.tsx +42 -0
  47. package/src/components/ui/Button.tsx +47 -0
  48. package/src/components/ui/Card.tsx +29 -0
  49. package/src/components/ui/CodeBlock.tsx +31 -0
  50. package/src/components/ui/CopyButton.tsx +28 -0
  51. package/src/components/ui/Input.tsx +20 -0
  52. package/src/components/ui/ProgressRing.tsx +49 -0
  53. package/src/components/ui/Select.tsx +80 -0
  54. package/src/components/ui/Sonner.tsx +22 -0
  55. package/src/components/ui/Spinner.tsx +6 -0
  56. package/src/components/ui/Textarea.tsx +21 -0
  57. package/src/components/ui/Tooltip.tsx +34 -0
  58. package/src/index.ts +99 -0
  59. package/src/lib/format.ts +67 -0
  60. package/src/lib/utils.ts +33 -0
  61. package/src/styles/theme.css +413 -0
@@ -0,0 +1,193 @@
1
+ import { useState } from 'react'
2
+ import type {
3
+ PermissionRequest,
4
+ QuestionBehavior,
5
+ UserQuestion,
6
+ UserQuestionOption,
7
+ } from '@workerdeck/protocol'
8
+ import { MessageCircleQuestion, X } from 'lucide-react'
9
+ import { Badge } from '../ui/Badge.tsx'
10
+ import { Button } from '../ui/Button.tsx'
11
+ import { Input } from '../ui/Input.tsx'
12
+ import { cn } from '../../lib/utils.ts'
13
+
14
+ export type QuestionBehaviorMeta = {
15
+ value: QuestionBehavior
16
+ label: string
17
+ description: string
18
+ }
19
+
20
+ /** The AskUserQuestion policies surfaced on job/session creation forms. */
21
+ export const QUESTION_BEHAVIORS: QuestionBehaviorMeta[] = [
22
+ { value: 'auto', label: 'Auto-answer', description: 'pick each question’s recommended option' },
23
+ { value: 'ask', label: 'Ask', description: 'wait for a watcher or webhook controller to answer' },
24
+ { value: 'deny', label: 'Disabled', description: 'the agent is told to decide on its own' },
25
+ ]
26
+
27
+ /** Extract well-formed questions from an AskUserQuestion permission request's input. */
28
+ export function parseUserQuestions(input: Record<string, unknown>): UserQuestion[] {
29
+ const raw = Array.isArray(input.questions) ? input.questions : []
30
+ return raw.flatMap((entry): UserQuestion[] => {
31
+ const q = entry as Partial<UserQuestion>
32
+ if (typeof q.question !== 'string' || !Array.isArray(q.options)) return []
33
+ const options = q.options.filter(
34
+ (o): o is UserQuestionOption => typeof (o as UserQuestionOption | undefined)?.label === 'string',
35
+ )
36
+ if (options.length === 0) return []
37
+ return [
38
+ {
39
+ question: q.question,
40
+ header: typeof q.header === 'string' ? q.header : '',
41
+ options,
42
+ multiSelect: q.multiSelect === true,
43
+ },
44
+ ]
45
+ })
46
+ }
47
+
48
+ type Selection = { labels: string[]; other: string; otherActive: boolean }
49
+
50
+ const EMPTY_SELECTION: Selection = { labels: [], other: '', otherActive: false }
51
+
52
+ /** A question's answer string: chosen label(s) (multi-select comma-joined), with any
53
+ * free-text "Other" appended — the value the CLI expects in `updatedInput.answers`. */
54
+ function answerFor(selection: Selection): string {
55
+ const parts = [...selection.labels]
56
+ if (selection.otherActive && selection.other.trim()) parts.push(selection.other.trim())
57
+ return parts.join(', ')
58
+ }
59
+
60
+ export interface QuestionPromptProps {
61
+ /** A pending permission whose toolName is 'AskUserQuestion'. */
62
+ request: PermissionRequest
63
+ /** Allow the tool with `updatedInput` (the original input plus `answers`). */
64
+ onAnswer: (requestId: string, updatedInput: Record<string, unknown>) => void
65
+ /** Deny the tool — the model proceeds without an answer. */
66
+ onDismiss: (requestId: string, message?: string) => void
67
+ className?: string
68
+ }
69
+
70
+ /** Interactive form for the AskUserQuestion tool: option buttons per question
71
+ * (multi-select where the question allows it), a free-text "Other" escape hatch,
72
+ * and the focused option's preview. Falls back to nothing renderable → the caller
73
+ * should show a generic PermissionPrompt if `parseUserQuestions` finds no questions. */
74
+ export function QuestionPrompt({ request, onAnswer, onDismiss, className }: QuestionPromptProps) {
75
+ const questions = parseUserQuestions(request.input)
76
+ const [selections, setSelections] = useState<Selection[]>(() =>
77
+ questions.map(() => EMPTY_SELECTION),
78
+ )
79
+
80
+ const update = (index: number, patch: Partial<Selection>) => {
81
+ setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
82
+ }
83
+
84
+ const toggle = (index: number, label: string, multiSelect: boolean) => {
85
+ const current = selections[index] ?? EMPTY_SELECTION
86
+ if (multiSelect) {
87
+ update(index, {
88
+ labels: current.labels.includes(label)
89
+ ? current.labels.filter((l) => l !== label)
90
+ : [...current.labels, label],
91
+ })
92
+ } else {
93
+ update(index, { labels: current.labels[0] === label ? [] : [label], otherActive: false })
94
+ }
95
+ }
96
+
97
+ const complete = questions.every((_, i) => answerFor(selections[i] ?? EMPTY_SELECTION) !== '')
98
+
99
+ const submit = () => {
100
+ const answers: Record<string, string> = {}
101
+ questions.forEach((q, i) => {
102
+ answers[q.question] = answerFor(selections[i] ?? EMPTY_SELECTION)
103
+ })
104
+ onAnswer(request.id, { ...request.input, answers })
105
+ }
106
+
107
+ return (
108
+ <div
109
+ data-slot='question-prompt'
110
+ className={cn('rounded-lg border border-info/40 bg-info-bg p-3', className)}>
111
+ <div className='flex items-start gap-2.5'>
112
+ <MessageCircleQuestion className='mt-0.5 size-4 shrink-0 text-info' />
113
+ <div className='flex min-w-0 flex-1 flex-col gap-3'>
114
+ {questions.map((q, index) => {
115
+ const selection = selections[index] ?? EMPTY_SELECTION
116
+ return (
117
+ <div key={index} className='flex flex-col gap-1.5'>
118
+ <div className='flex items-center gap-2'>
119
+ {q.header ? <Badge variant='info'>{q.header}</Badge> : null}
120
+ <span className='text-body-sm font-medium text-fg-1'>{q.question}</span>
121
+ </div>
122
+ <div className='flex flex-col gap-1'>
123
+ {q.options.map((option) => {
124
+ const selected = selection.labels.includes(option.label)
125
+ return (
126
+ <button
127
+ key={option.label}
128
+ type='button'
129
+ onClick={() => toggle(index, option.label, q.multiSelect === true)}
130
+ className={cn(
131
+ 'rounded-md border px-2.5 py-1.5 text-left transition-colors',
132
+ selected
133
+ ? 'border-info bg-bg'
134
+ : 'border-border bg-bg/50 hover:border-border-strong hover:bg-bg',
135
+ )}>
136
+ <span className='block text-body-sm font-medium text-fg-1'>
137
+ {option.label}
138
+ </span>
139
+ {option.description ? (
140
+ <span className='block text-label text-fg-4'>{option.description}</span>
141
+ ) : null}
142
+ {selected && option.preview ? (
143
+ <pre className='mt-1.5 max-h-40 overflow-auto rounded-md bg-code-bg px-2.5 py-1.5 font-mono text-label whitespace-pre-wrap text-fg-2'>
144
+ {option.preview}
145
+ </pre>
146
+ ) : null}
147
+ </button>
148
+ )
149
+ })}
150
+ <div className='flex items-center gap-2'>
151
+ <button
152
+ type='button'
153
+ onClick={() => update(index, { otherActive: !selection.otherActive })}
154
+ className={cn(
155
+ 'shrink-0 rounded-md border px-2.5 py-1.5 text-body-sm font-medium transition-colors',
156
+ selection.otherActive
157
+ ? 'border-info bg-bg text-fg-1'
158
+ : 'border-border bg-bg/50 text-fg-3 hover:border-border-strong hover:bg-bg',
159
+ )}>
160
+ Other…
161
+ </button>
162
+ {selection.otherActive ? (
163
+ <Input
164
+ autoFocus
165
+ value={selection.other}
166
+ onChange={(e) => update(index, { other: e.target.value })}
167
+ placeholder='Type your own answer'
168
+ className='flex-1'
169
+ />
170
+ ) : null}
171
+ </div>
172
+ </div>
173
+ </div>
174
+ )
175
+ })}
176
+ <div>
177
+ <Button size='sm' onClick={submit} disabled={!complete}>
178
+ Answer
179
+ </Button>
180
+ </div>
181
+ </div>
182
+ <Button
183
+ variant='ghost'
184
+ size='icon-sm'
185
+ aria-label='Dismiss question'
186
+ className='shrink-0'
187
+ onClick={() => onDismiss(request.id, 'Question dismissed by user')}>
188
+ <X className='size-3.5' />
189
+ </Button>
190
+ </div>
191
+ </div>
192
+ )
193
+ }
@@ -0,0 +1,58 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { Brain, ChevronDown } from 'lucide-react'
3
+ import { cn } from '../../lib/utils.ts'
4
+ import { Response } from './Response.tsx'
5
+
6
+ export interface ReasoningProps {
7
+ children: string
8
+ /** Auto-opens while true, auto-closes shortly after it flips false. */
9
+ isStreaming?: boolean
10
+ defaultOpen?: boolean
11
+ className?: string
12
+ }
13
+
14
+ /** Collapsible extended-thinking block: open while the model is thinking, tucks itself
15
+ * away once the thought is finished (unless the user toggled it manually). */
16
+ export function Reasoning({ children, isStreaming = false, defaultOpen, className }: ReasoningProps) {
17
+ const [open, setOpen] = useState(defaultOpen ?? isStreaming)
18
+ const userToggled = useRef(false)
19
+
20
+ useEffect(() => {
21
+ if (userToggled.current) return
22
+ if (isStreaming) {
23
+ setOpen(true)
24
+ } else {
25
+ const timer = setTimeout(() => setOpen(false), 600)
26
+ return () => clearTimeout(timer)
27
+ }
28
+ }, [isStreaming])
29
+
30
+ // Models with encrypted thinking emit the blocks but never the summary text. Then there is
31
+ // nothing to expand: show a bare "Thinking…" marker live, and nothing at all once it's done.
32
+ const hasText = children.trim() !== ''
33
+ if (!hasText && !isStreaming) return null
34
+
35
+ return (
36
+ <div data-slot='reasoning' data-streaming={isStreaming || undefined} className={cn('w-full', className)}>
37
+ <button
38
+ type='button'
39
+ disabled={!hasText}
40
+ onClick={() => {
41
+ userToggled.current = true
42
+ setOpen((v) => !v)
43
+ }}
44
+ className='flex items-center gap-1.5 text-label text-fg-3 transition-colors outline-none disabled:cursor-default hover:text-fg-1 disabled:hover:text-fg-3'>
45
+ <Brain className='size-3.5' />
46
+ <span>{isStreaming ? 'Thinking…' : 'Thought process'}</span>
47
+ {hasText ? (
48
+ <ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
49
+ ) : null}
50
+ </button>
51
+ {open && hasText ? (
52
+ <div className='mt-2 border-l-2 border-border pl-3 text-body-sm text-fg-3 [&_*]:text-fg-3'>
53
+ <Response streaming={isStreaming}>{children}</Response>
54
+ </div>
55
+ ) : null}
56
+ </div>
57
+ )
58
+ }
@@ -0,0 +1,31 @@
1
+ import { memo } from 'react'
2
+ import { Streamdown } from 'streamdown'
3
+ import { cn } from '../../lib/utils.ts'
4
+
5
+ export interface ResponseProps {
6
+ children: string
7
+ /** Streaming text: tolerate incomplete markdown (unclosed fences, half links). */
8
+ streaming?: boolean
9
+ className?: string
10
+ }
11
+
12
+ /** Markdown renderer for assistant output — streaming-safe via streamdown, code
13
+ * highlighted with shiki (dual theme follows [data-theme] through the dark: variant). */
14
+ export const Response = memo(
15
+ function Response({ children, streaming, className }: ResponseProps) {
16
+ return (
17
+ <Streamdown
18
+ mode={streaming ? 'streaming' : 'static'}
19
+ parseIncompleteMarkdown={streaming}
20
+ shikiTheme={['github-light', 'github-dark']}
21
+ className={cn(
22
+ 'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
23
+ className,
24
+ )}>
25
+ {children}
26
+ </Streamdown>
27
+ )
28
+ },
29
+ (prev, next) =>
30
+ prev.children === next.children && prev.streaming === next.streaming && prev.className === next.className,
31
+ )
@@ -0,0 +1,93 @@
1
+ import type { SessionInfo } from '@workerdeck/protocol'
2
+ import { Trash2 } from 'lucide-react'
3
+ import { Badge } from '../ui/Badge.tsx'
4
+ import { Button } from '../ui/Button.tsx'
5
+ import { cn } from '../../lib/utils.ts'
6
+ import { formatCost, formatRelativeTime } from '../../lib/format.ts'
7
+ import { STATUS_META } from './status.ts'
8
+
9
+ export interface SessionListItemProps {
10
+ session: SessionInfo
11
+ active?: boolean
12
+ onSelect?: (id: string) => void
13
+ onDelete?: (id: string) => void
14
+ }
15
+
16
+ export function SessionListItem({ session, active, onSelect, onDelete }: SessionListItemProps) {
17
+ const meta = STATUS_META[session.status]
18
+ return (
19
+ <div
20
+ data-slot='session-list-item'
21
+ data-active={active || undefined}
22
+ className={cn(
23
+ 'group flex w-full items-center gap-2 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors',
24
+ active ? 'border-border bg-surface' : 'hover:bg-surface-hover',
25
+ )}>
26
+ <button
27
+ type='button'
28
+ onClick={() => onSelect?.(session.id)}
29
+ className='min-w-0 flex-1 text-left outline-none'>
30
+ <div className='flex items-center gap-2'>
31
+ <span className='truncate text-body-sm font-medium text-fg-1'>
32
+ {session.title ?? session.id.slice(0, 8)}
33
+ </span>
34
+ <Badge variant={meta.variant} dot className='shrink-0'>
35
+ {meta.label}
36
+ </Badge>
37
+ </div>
38
+ <div className='mt-0.5 flex items-center gap-2 font-mono text-label text-fg-4'>
39
+ <span className='truncate'>{session.cwd}</span>
40
+ {session.profile ? <span className='shrink-0'>@{session.profile}</span> : null}
41
+ <span className='shrink-0'>{formatCost(session.totalCostUsd)}</span>
42
+ <span className='shrink-0'>{formatRelativeTime(session.lastActivityAt ?? session.createdAt)}</span>
43
+ </div>
44
+ </button>
45
+ {onDelete ? (
46
+ <Button
47
+ variant='ghost'
48
+ size='icon-sm'
49
+ aria-label='Close session'
50
+ className='opacity-0 transition-opacity group-hover:opacity-100'
51
+ onClick={() => onDelete(session.id)}>
52
+ <Trash2 className='size-3.5 text-fg-3' />
53
+ </Button>
54
+ ) : null}
55
+ </div>
56
+ )
57
+ }
58
+
59
+ export interface SessionListProps {
60
+ sessions: SessionInfo[]
61
+ activeId?: string
62
+ onSelect?: (id: string) => void
63
+ onDelete?: (id: string) => void
64
+ emptyText?: string
65
+ className?: string
66
+ }
67
+
68
+ export function SessionList({
69
+ sessions,
70
+ activeId,
71
+ onSelect,
72
+ onDelete,
73
+ emptyText = 'No sessions yet.',
74
+ className,
75
+ }: SessionListProps) {
76
+ return (
77
+ <div data-slot='session-list' className={cn('flex flex-col gap-1', className)}>
78
+ {sessions.length === 0 ? (
79
+ <div className='px-2.5 py-6 text-center text-body-sm text-fg-4'>{emptyText}</div>
80
+ ) : (
81
+ sessions.map((session) => (
82
+ <SessionListItem
83
+ key={session.id}
84
+ session={session}
85
+ active={session.id === activeId}
86
+ onSelect={onSelect}
87
+ onDelete={onDelete}
88
+ />
89
+ ))
90
+ )}
91
+ </div>
92
+ )
93
+ }
@@ -0,0 +1,149 @@
1
+ import { useEffect, useMemo, useState, type ReactNode } from 'react'
2
+ import type { WorkerDeckClient } from '@workerdeck/client'
3
+ import { PROVIDER_PERMISSION_MODES } from '@workerdeck/protocol'
4
+ import { useClaudeSession, useToolCallHost } from '@workerdeck/react'
5
+ import { cn } from '../../lib/utils.ts'
6
+ import { Composer } from './Composer.tsx'
7
+ import { ModelSelect } from './ModelSelect.tsx'
8
+ import { PermissionModeSelect } from './PermissionModeSelect.tsx'
9
+ import { PermissionPrompt } from './PermissionPrompt.tsx'
10
+ import { QuestionPrompt, parseUserQuestions } from './QuestionPrompt.tsx'
11
+ import { StatusBar } from './StatusBar.tsx'
12
+ import { Transcript } from './Transcript.tsx'
13
+
14
+ export interface SessionPanelProps {
15
+ client: WorkerDeckClient
16
+ sessionId: string | undefined
17
+ /** Optional slot rendered at the top, above the status bar. */
18
+ header?: ReactNode
19
+ className?: string
20
+ }
21
+
22
+ /**
23
+ * The all-in-one embeddable session surface: status bar, streaming transcript,
24
+ * permission prompts, composer. Attaches via useClaudeSession; remount (key) to switch
25
+ * sessions.
26
+ */
27
+ export function SessionPanel({ client, sessionId, header, className }: SessionPanelProps) {
28
+ // Rejected commands (the CLI refusing a permission-mode switch, say) render INSIDE
29
+ // the panel rather than through `toast`. The panel does not mount a `Toaster`, and
30
+ // an embedder that doesn't either would drop the only signal that a command failed
31
+ // — the select would just "not stick". An error channel a host can lose by omission
32
+ // is not an error channel.
33
+ const [protocolError, setProtocolError] = useState<string | undefined>(undefined)
34
+ const { state, connected, handle, send, approve, deny, interrupt, setModel, setPermissionMode } =
35
+ useClaudeSession(client, sessionId, { onProtocolError: setProtocolError })
36
+ // Callers are told to remount on a session switch, but a changed prop must not leave
37
+ // the previous session's failure on screen.
38
+ useEffect(() => setProtocolError(undefined), [sessionId])
39
+ // Host server-bridged tool calls (provider-engine sessions) in this tab, on the
40
+ // SAME handle the panel attached with — the bridge asks the first attached
41
+ // client. Free for Claude sessions: the guest loads lazily on the first call,
42
+ // which for them never comes.
43
+ useToolCallHost(handle)
44
+ const busy = state.status === 'running' || state.status === 'awaiting_approval'
45
+ const ended = state.status === 'failed' || state.status === 'closed'
46
+
47
+ // "/model" is handled panel-side (see handleSend) — surface it in the autocomplete
48
+ // even though the CLI's command list doesn't include it.
49
+ const commands = useMemo(() => {
50
+ if (!state.commands) return undefined
51
+ if (state.commands.some((c) => c.name === 'model')) return state.commands
52
+ return [
53
+ { name: 'model', description: 'Switch the model for this session', argumentHint: '<model>' },
54
+ ...state.commands,
55
+ ]
56
+ }, [state.commands])
57
+
58
+ // "/model <id>" switches the model directly instead of going to the CLI.
59
+ const handleSend = (text: string) => {
60
+ const modelCommand = /^\/model\s+(\S+)$/.exec(text)
61
+ if (modelCommand) {
62
+ setModel(modelCommand[1])
63
+ return
64
+ }
65
+ send(text)
66
+ }
67
+
68
+ return (
69
+ <div
70
+ data-slot='session-panel'
71
+ className={cn('flex h-full min-h-0 flex-col overflow-hidden bg-bg', className)}>
72
+ {header}
73
+ <StatusBar state={state} connected={connected} />
74
+ {protocolError ? (
75
+ <div className='px-3 pt-2'>
76
+ <div
77
+ role='alert'
78
+ className='mx-auto flex w-full max-w-3xl items-start gap-2 rounded-md border border-danger/40 bg-danger-bg px-3 py-2 text-body-sm text-danger'>
79
+ <span className='min-w-0 flex-1 break-words'>{protocolError}</span>
80
+ <button
81
+ type='button'
82
+ onClick={() => setProtocolError(undefined)}
83
+ aria-label='Dismiss error'
84
+ className='shrink-0 opacity-70 transition-opacity hover:opacity-100'>
85
+
86
+ </button>
87
+ </div>
88
+ </div>
89
+ ) : null}
90
+ <Transcript
91
+ state={state}
92
+ fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
93
+ />
94
+ {state.pendingApprovals.length > 0 ? (
95
+ <div className='px-3 pb-2'>
96
+ <div className='mx-auto flex w-full max-w-3xl flex-col gap-2'>
97
+ {state.pendingApprovals.map((request) =>
98
+ request.toolName === 'AskUserQuestion' &&
99
+ parseUserQuestions(request.input).length > 0 ? (
100
+ <QuestionPrompt
101
+ key={request.id}
102
+ request={request}
103
+ onAnswer={approve}
104
+ onDismiss={deny}
105
+ />
106
+ ) : (
107
+ <PermissionPrompt
108
+ key={request.id}
109
+ request={request}
110
+ onApprove={approve}
111
+ onDeny={deny}
112
+ />
113
+ ),
114
+ )}
115
+ </div>
116
+ </div>
117
+ ) : null}
118
+ <Composer
119
+ onSend={handleSend}
120
+ onInterrupt={interrupt}
121
+ busy={busy}
122
+ disabled={ended || !sessionId}
123
+ commands={commands}
124
+ toolbar={
125
+ <>
126
+ {state.models?.length ? (
127
+ <ModelSelect
128
+ models={state.models}
129
+ model={state.model}
130
+ onModelChange={setModel}
131
+ disabled={ended}
132
+ />
133
+ ) : null}
134
+ {state.permissionMode ? (
135
+ <PermissionModeSelect
136
+ mode={state.permissionMode}
137
+ onModeChange={setPermissionMode}
138
+ // A provider session rejects the CLI-only modes with a
139
+ // protocol_error — don't offer what can only fail.
140
+ modes={state.engine === 'provider' ? PROVIDER_PERMISSION_MODES : undefined}
141
+ disabled={ended}
142
+ />
143
+ ) : null}
144
+ </>
145
+ }
146
+ />
147
+ </div>
148
+ )
149
+ }
@@ -0,0 +1,140 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { TranscriptState } from '@workerdeck/react'
3
+ import type { ContextUsage, RateLimitInfo } from '@workerdeck/protocol'
4
+ import { WifiOff } from 'lucide-react'
5
+ import { Badge } from '../ui/Badge.tsx'
6
+ import { ProgressRing } from '../ui/ProgressRing.tsx'
7
+ import { Spinner } from '../ui/Spinner.tsx'
8
+ import { Tip } from '../ui/Tooltip.tsx'
9
+ import { cn } from '../../lib/utils.ts'
10
+ import { formatCost, formatCountdown, formatTokens } from '../../lib/format.ts'
11
+ import { STATUS_META } from './status.ts'
12
+
13
+ export interface StatusBarProps {
14
+ state: TranscriptState
15
+ connected: boolean
16
+ className?: string
17
+ }
18
+
19
+ /** Ticking clock for reset countdowns — rate_limit events are sparse, so tick locally. */
20
+ function useNow(intervalMs = 30_000): number {
21
+ const [now, setNow] = useState(() => Date.now())
22
+ useEffect(() => {
23
+ const timer = setInterval(() => setNow(Date.now()), intervalMs)
24
+ return () => clearInterval(timer)
25
+ }, [intervalMs])
26
+ return now
27
+ }
28
+
29
+ const utilizationColor = (pct: number) =>
30
+ pct >= 95 ? 'text-danger' : pct >= 80 ? 'text-warning' : 'text-fg-3'
31
+
32
+ /** The CLI reports category colors as its own theme token names ('inactive',
33
+ * 'promptBorder', ...), not CSS colors — only pass through what CSS can render. */
34
+ const cssColor = (color: string): string | undefined =>
35
+ typeof CSS !== 'undefined' && CSS.supports('color', color) ? color : undefined
36
+
37
+ function ContextMeter({ usage }: { usage: ContextUsage }) {
38
+ return (
39
+ <Tip
40
+ content={
41
+ <div className='flex min-w-44 flex-col gap-1 py-0.5'>
42
+ {usage.categories.map((c) => (
43
+ <div key={c.name} className='flex items-center gap-2'>
44
+ <span
45
+ className='size-2 shrink-0 rounded-full bg-fg-4'
46
+ style={cssColor(c.color) ? { backgroundColor: c.color } : undefined}
47
+ />
48
+ <span className='flex-1'>{c.name}</span>
49
+ <span className='font-mono text-fg-3'>{formatTokens(c.tokens)}</span>
50
+ </div>
51
+ ))}
52
+ <div className='mt-0.5 flex items-center justify-between gap-2 border-t border-border pt-1'>
53
+ <span>Total</span>
54
+ <span className='font-mono text-fg-3'>
55
+ {formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} (
56
+ {usage.percentage.toFixed(0)}%)
57
+ </span>
58
+ </div>
59
+ </div>
60
+ }>
61
+ <span
62
+ className={cn(
63
+ 'inline-flex cursor-default items-center gap-1 font-mono text-label',
64
+ utilizationColor(usage.percentage),
65
+ )}>
66
+ Ctx {formatTokens(usage.totalTokens)}
67
+ </span>
68
+ </Tip>
69
+ )
70
+ }
71
+
72
+ function RateLimitMeter({ label, info, now }: { label: string; info: RateLimitInfo; now: number }) {
73
+ // The CLI omits utilization on some updates — show the window without a made-up 0%.
74
+ const pct = info.utilization
75
+ const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
76
+ return (
77
+ <Tip
78
+ content={
79
+ <div className='flex min-w-36 flex-col gap-1 py-0.5'>
80
+ <div className='flex items-center justify-between gap-2'>
81
+ <span>{label} usage</span>
82
+ <span className='font-mono text-fg-3'>
83
+ {pct !== undefined ? `${pct.toFixed(1)}%` : '—'}
84
+ </span>
85
+ </div>
86
+ {resetsAtMs !== undefined ? (
87
+ <div className='flex items-center justify-between gap-2'>
88
+ <span>Resets in</span>
89
+ <span className='font-mono text-fg-3'>{formatCountdown(resetsAtMs, now)}</span>
90
+ </div>
91
+ ) : null}
92
+ {info.isUsingOverage ? <div className='text-warning'>Using overage</div> : null}
93
+ {info.status === 'rejected' ? <div className='text-danger'>Limit reached</div> : null}
94
+ </div>
95
+ }>
96
+ <span
97
+ className={cn(
98
+ 'inline-flex cursor-default items-center gap-1 font-mono text-label',
99
+ info.status === 'rejected' ? 'text-danger' : utilizationColor(pct ?? 0),
100
+ )}>
101
+ <ProgressRing value={pct ?? 0} />
102
+ {label}
103
+ {pct !== undefined ? ` ${pct.toFixed(0)}%` : ''}
104
+ {resetsAtMs !== undefined ? (
105
+ <span className='text-fg-4'>· {formatCountdown(resetsAtMs, now)}</span>
106
+ ) : null}
107
+ </span>
108
+ </Tip>
109
+ )
110
+ }
111
+
112
+ export function StatusBar({ state, connected, className }: StatusBarProps) {
113
+ const meta = STATUS_META[state.status]
114
+ const now = useNow()
115
+ const session = state.rateLimits?.five_hour
116
+ const weekly = state.rateLimits?.seven_day
117
+ return (
118
+ <div
119
+ data-slot='status-bar'
120
+ className={cn(
121
+ 'flex items-center gap-3 border-b border-border bg-surface px-3 py-2',
122
+ className,
123
+ )}>
124
+ <Badge variant={meta.variant} dot={!meta.busy}>
125
+ {meta.busy ? <Spinner className='size-3 text-current' /> : null}
126
+ {meta.label}
127
+ </Badge>
128
+ {state.contextUsage ? <ContextMeter usage={state.contextUsage} /> : null}
129
+ {session ? <RateLimitMeter label='Session' info={session} now={now} /> : null}
130
+ {weekly ? <RateLimitMeter label='Weekly' info={weekly} now={now} /> : null}
131
+ <span className='flex-1' />
132
+ {!connected ? (
133
+ <span className='inline-flex items-center gap-1 text-label text-warning'>
134
+ <WifiOff className='size-3' /> reconnecting…
135
+ </span>
136
+ ) : null}
137
+ <span className='font-mono text-label text-fg-3'>{formatCost(state.totalCostUsd)}</span>
138
+ </div>
139
+ )
140
+ }