@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
package/src/lib/format.ts CHANGED
@@ -51,6 +51,54 @@ export function formatRelativeTime(epochMs: number | undefined, now = Date.now()
51
51
  return `${d}d ago`
52
52
  }
53
53
 
54
+ /**
55
+ * Human label for a rate-limit window key, compact: 'five_hour' → "5h",
56
+ * 'seven_day_opus' → "7d opus". The per-model suffix is an open set — the CLI
57
+ * adds buckets as plans gain them — so it is rewritten rather than enumerated.
58
+ */
59
+ export function formatRateLimitWindow(key: string): string {
60
+ if (key === 'five_hour') return '5h'
61
+ if (key === 'seven_day') return '7d'
62
+ const spaced = key.replaceAll('_', ' ')
63
+ return key.startsWith('seven_day_') ? `7d ${spaced.slice('seven day '.length)}` : spaced
64
+ }
65
+
66
+ /** The same key spelled out, where there is room: 'five_hour' → "5-hour
67
+ * session", 'seven_day_fable' → "Weekly · Fable". */
68
+ export function formatRateLimitWindowLong(key: string): string {
69
+ if (key === 'five_hour') return '5-hour session'
70
+ if (key === 'seven_day') return 'Weekly'
71
+ if (key === 'seven_day_oauth_apps') return 'Weekly · apps'
72
+ const capitalize = (s: string) => s.replace(/\b\w/g, (c) => c.toUpperCase())
73
+ if (!key.startsWith('seven_day_')) return capitalize(key.replaceAll('_', ' '))
74
+ return `Weekly · ${capitalize(key.slice('seven_day_'.length).replaceAll('_', ' '))}`
75
+ }
76
+
77
+ /**
78
+ * How long a rate-limit window is, in seconds — the denominator behind the pace
79
+ * marker. Derived from the key rather than reported: the CLI sends a reset time
80
+ * and a percentage, never a duration. `undefined` for a window whose key doesn't
81
+ * say, and the marker is then simply not drawn rather than guessed.
82
+ */
83
+ export function rateLimitWindowSeconds(key: string): number | undefined {
84
+ if (key === 'five_hour') return 5 * 3600
85
+ if (key.startsWith('seven_day')) return 7 * 86_400
86
+ return undefined
87
+ }
88
+
89
+ /** "8 secs ago" / "3 mins ago" — a freshness line finer-grained than
90
+ * {@link formatRelativeTime}, because a poll that just landed should say so. */
91
+ export function formatAgoPrecise(epochMs: number, now = Date.now()): string {
92
+ const seconds = Math.max(0, Math.floor((now - epochMs) / 1000))
93
+ if (seconds < 60) return `${seconds} sec${seconds === 1 ? '' : 's'} ago`
94
+ if (seconds < 3600) {
95
+ const minutes = Math.floor(seconds / 60)
96
+ return `${minutes} min${minutes === 1 ? '' : 's'} ago`
97
+ }
98
+ const hours = Math.floor(seconds / 3600)
99
+ return `${hours} hour${hours === 1 ? '' : 's'} ago`
100
+ }
101
+
54
102
  /** Compact one-line preview of a tool input for card headers. */
55
103
  export function toolInputPreview(input: unknown, max = 80): string {
56
104
  if (input === null || input === undefined) return ''
@@ -65,3 +113,69 @@ export function toolInputPreview(input: unknown, max = 80): string {
65
113
  const text = JSON.stringify(input) ?? ''
66
114
  return text.length > max ? text.slice(0, max - 1) + '…' : text
67
115
  }
116
+
117
+ /** Families whose name isn't just a capitalised first letter, and how the
118
+ * vendor writes the version after it. GPT is `GPT-5.6`; everyone else spaces
119
+ * it. Anything unlisted is title-cased and spaced. */
120
+ const MODEL_FAMILIES: Record<string, { name: string; joiner?: string }> = {
121
+ gpt: { name: 'GPT', joiner: '-' },
122
+ deepseek: { name: 'DeepSeek' },
123
+ glm: { name: 'GLM' },
124
+ qwen: { name: 'Qwen' },
125
+ kimi: { name: 'Kimi' },
126
+ llama: { name: 'Llama' },
127
+ mistral: { name: 'Mistral' },
128
+ grok: { name: 'Grok' },
129
+ }
130
+
131
+ /**
132
+ * The name a person says, from a wire model id:
133
+ *
134
+ * - `claude-opus-5[1m]` → "Opus 5"
135
+ * - `claude-haiku-4-5-20251001` → "Haiku 4.5"
136
+ * - `gpt-5.6-luna` → "GPT-5.6 Luna"
137
+ * - `gemini-2.5-pro` → "Gemini 2.5 Pro"
138
+ * - `o3-mini` → "o3 Mini"
139
+ *
140
+ * Three kinds of token after the family, because vendors mix them freely: a
141
+ * **version** (`5`, `4-5`, `5.6` — joined with dots, since Anthropic splits what
142
+ * OpenAI writes as one token), a **code name or tier** (`luna`, `codex`, `pro`,
143
+ * `mini` — kept and capitalised, since it is often the only thing telling two
144
+ * models apart), and a **snapshot date** (`20251001` — dropped; it is a build,
145
+ * not a version).
146
+ *
147
+ * Anything genuinely unreadable falls back to the id: a wrong name is worse than
148
+ * a raw one, which is at least true.
149
+ *
150
+ * The server has a narrower version of this (`friendlyModelName` in core's
151
+ * `normalize.ts`) that derives Claude catalog names at authoring time. This one
152
+ * is the *render-time* fallback for an id with no catalog row behind it — the
153
+ * sidebar has only `SessionInfo.model` — so it has to cope with every vendor the
154
+ * provider engine can reach, not just the CLI's own.
155
+ */
156
+ export function friendlyModel(id: string | undefined): string | undefined {
157
+ if (!id) return undefined
158
+ const withoutVariant = id.split('[')[0] ?? id
159
+ const parts = withoutVariant.toLowerCase().split('-').filter(Boolean)
160
+ if (parts[0] === 'claude') parts.shift()
161
+ const familyToken = parts.shift()
162
+ if (!familyToken) return id
163
+ const family = MODEL_FAMILIES[familyToken]
164
+ const name =
165
+ family?.name ??
166
+ // OpenAI's reasoning series is lower-case by its own convention ('o3-mini'),
167
+ // and "O3" reads as a different product.
168
+ (/^o\d+$/.test(familyToken)
169
+ ? familyToken
170
+ : `${familyToken.charAt(0).toUpperCase()}${familyToken.slice(1)}`)
171
+
172
+ const version: string[] = []
173
+ const words: string[] = []
174
+ for (const part of parts) {
175
+ if (/^\d{8}$/.test(part)) continue
176
+ if (/^\d+(\.\d+)?$/.test(part)) version.push(part)
177
+ else words.push(`${part.charAt(0).toUpperCase()}${part.slice(1)}`)
178
+ }
179
+ const versioned = version.length > 0 ? `${name}${family?.joiner ?? ' '}${version.join('.')}` : name
180
+ return [versioned, ...words].join(' ')
181
+ }
@@ -0,0 +1,124 @@
1
+ import type { ContextUsage, ModelOption, RateLimitInfo, SessionStatus } from '@workerdeck/protocol'
2
+
3
+ /**
4
+ * How a session's live readings become a status line — the pure half, so every
5
+ * host spells "Needs approval", "80% is a warning" and "which window is the
6
+ * binding one" the same way.
7
+ *
8
+ * Structurally typed against `SessionVitals` rather than importing it: this file
9
+ * ships from the React-free `@workerdeck/ui/format` entry, and a host drawing
10
+ * the readings outside React (the VS Code extension host in the window status
11
+ * bar) must not pull a component graph in to do it. A real `SessionVitals`
12
+ * satisfies every shape here.
13
+ */
14
+ export type StatusSeverity = 'none' | 'warning' | 'error'
15
+
16
+ /** What a status slot shows, before any host's icon vocabulary gets involved.
17
+ * `icon` is a VS Code codicon name — the one host-shaped thing left, because the
18
+ * alternative is a second mapping table in the only consumer. */
19
+ export type StatusPresentation = {
20
+ icon: string
21
+ label: string
22
+ severity: StatusSeverity
23
+ }
24
+
25
+ export type StatusReadings = {
26
+ status: SessionStatus
27
+ /** `@workerdeck/react`'s `ConnectionState`, structurally — the link state wins
28
+ * the slot, so it has to be part of the reading. */
29
+ connection?: 'live' | 'reconnecting' | 'offline'
30
+ }
31
+
32
+ const STATUS_META: Record<SessionStatus, StatusPresentation> = {
33
+ starting: { icon: 'loading~spin', label: 'Starting', severity: 'none' },
34
+ running: { icon: 'loading~spin', label: 'Running', severity: 'none' },
35
+ awaiting_approval: { icon: 'warning', label: 'Needs approval', severity: 'warning' },
36
+ idle: { icon: 'check', label: 'Idle', severity: 'none' },
37
+ parked: { icon: 'debug-pause', label: 'Parked', severity: 'none' },
38
+ failed: { icon: 'error', label: 'Failed', severity: 'error' },
39
+ closed: { icon: 'circle-slash', label: 'Closed', severity: 'none' },
40
+ }
41
+
42
+ /**
43
+ * The status slot, connection first. A session status held over a dead socket is
44
+ * the last thing we heard, not the current state — so a lost link takes the slot
45
+ * rather than letting "Running" imply a turn is still streaming.
46
+ */
47
+ export function statusPresentation(vitals: StatusReadings | undefined): StatusPresentation {
48
+ if (!vitals) return { icon: 'hubot', label: 'Connecting…', severity: 'none' }
49
+ if (vitals.connection === 'offline') {
50
+ return { icon: 'debug-disconnect', label: 'Offline', severity: 'error' }
51
+ }
52
+ if (vitals.connection === 'reconnecting') {
53
+ return { icon: 'sync~spin', label: 'Reconnecting…', severity: 'warning' }
54
+ }
55
+ return STATUS_META[vitals.status] ?? { icon: 'hubot', label: vitals.status, severity: 'none' }
56
+ }
57
+
58
+ /** 0–100 → the colour a meter wears. One pair of thresholds for every surface. */
59
+ export function meterSeverity(pct: number | undefined): StatusSeverity {
60
+ if (pct === undefined) return 'none'
61
+ if (pct >= 95) return 'error'
62
+ if (pct >= 80) return 'warning'
63
+ return 'none'
64
+ }
65
+
66
+ /** The rate-limit window that gets the one visible slot: whichever is fullest,
67
+ * since the binding constraint is the one worth glancing at. */
68
+ export function tightestWindow(
69
+ rateLimits: Record<string, RateLimitInfo> | undefined,
70
+ ): { key: string; info: RateLimitInfo } | undefined {
71
+ const entries = Object.entries(rateLimits ?? {})
72
+ if (entries.length === 0) return undefined
73
+ let best: { key: string; info: RateLimitInfo } | undefined
74
+ for (const [key, info] of entries) {
75
+ // A rejected window outranks any utilization: it is the one actually blocking.
76
+ const rank = info.status === 'rejected' ? Number.POSITIVE_INFINITY : (info.utilization ?? -1)
77
+ const bestRank =
78
+ best === undefined
79
+ ? Number.NEGATIVE_INFINITY
80
+ : best.info.status === 'rejected'
81
+ ? Number.POSITIVE_INFINITY
82
+ : (best.info.utilization ?? -1)
83
+ if (rank > bestRank) best = { key, info }
84
+ }
85
+ return best
86
+ }
87
+
88
+ /** A rate-limit window's key, named for a human. */
89
+ export function windowLabel(key: string): string {
90
+ if (key === 'five_hour') return 'Session'
91
+ if (key === 'seven_day') return 'Weekly'
92
+ return key.replaceAll('_', ' ')
93
+ }
94
+
95
+ export type ModelReadings = { model?: string; models: readonly ModelOption[] }
96
+
97
+ /**
98
+ * The catalog row a session is actually running, or `undefined` for a model the
99
+ * list doesn't name. Matched leniently: a session reports the *resolved* id
100
+ * (`claude-sonnet-5`) where the row may be keyed on the alias (`sonnet`), and
101
+ * either can carry a `[1m]` context-window suffix.
102
+ */
103
+ export function currentModel(vitals: ModelReadings | undefined): ModelOption | undefined {
104
+ const id = vitals?.model
105
+ if (!id) return undefined
106
+ const bare = (value: string) => value.replace(/\[.*\]$/, '')
107
+ const wanted = bare(id)
108
+ return vitals.models.find(
109
+ (m) => bare(m.value) === wanted || (m.resolvedModel && bare(m.resolvedModel) === wanted),
110
+ )
111
+ }
112
+
113
+ /** A session's model, named the way the picker names it. Falls back to the raw
114
+ * id, and to "Default" while the session is on the CLI's own pick. */
115
+ export function modelLabel(vitals: ModelReadings | undefined): string {
116
+ if (!vitals?.model) return 'Default'
117
+ return currentModel(vitals)?.displayName ?? vitals.model
118
+ }
119
+
120
+ /** Context percentage as its meter severity — the reading and the colour come
121
+ * from one place so a panel and a status bar never disagree. */
122
+ export function contextSeverity(usage: ContextUsage | undefined): StatusSeverity {
123
+ return meterSeverity(usage?.percentage)
124
+ }
@@ -0,0 +1,96 @@
1
+ import {
2
+ ArrowDownCircle,
3
+ CheckSquare,
4
+ FileDiff,
5
+ FileText,
6
+ FolderSearch,
7
+ Globe,
8
+ Image,
9
+ type LucideIcon,
10
+ MessageCircleQuestion,
11
+ PencilLine,
12
+ Puzzle,
13
+ Search,
14
+ Sparkles,
15
+ SquarePen,
16
+ Terminal,
17
+ UsersRound,
18
+ Wrench,
19
+ } from 'lucide-react'
20
+
21
+ /**
22
+ * An icon per tool, so a transcript can be skimmed by shape rather than read.
23
+ *
24
+ * The same mapping the iOS app makes, in lucide's vocabulary rather than SF
25
+ * Symbols — the two clients should be recognisably showing the same thing. An
26
+ * unknown tool falls back to a wrench, and an MCP tool (`mcp__server__name`) to
27
+ * the puzzle piece the MCP screens use, because "which server is this from" is
28
+ * the useful thing to see at a glance.
29
+ */
30
+ export function toolIcon(toolName: string): LucideIcon {
31
+ switch (toolName) {
32
+ case 'Bash':
33
+ case 'BashOutput':
34
+ case 'KillShell':
35
+ return Terminal
36
+ case 'Read':
37
+ return FileText
38
+ case 'Write':
39
+ return SquarePen
40
+ case 'Edit':
41
+ case 'MultiEdit':
42
+ case 'NotebookEdit':
43
+ return PencilLine
44
+ case 'Glob':
45
+ return FolderSearch
46
+ case 'Grep':
47
+ return Search
48
+ case 'WebFetch':
49
+ return ArrowDownCircle
50
+ case 'WebSearch':
51
+ return Globe
52
+ case 'Task':
53
+ case 'Agent':
54
+ return UsersRound
55
+ case 'TodoWrite':
56
+ return CheckSquare
57
+ case 'Skill':
58
+ return Sparkles
59
+ case 'AskUserQuestion':
60
+ return MessageCircleQuestion
61
+ // The codex engine's own tool names (see its runner's item mapping).
62
+ case 'CodexCommand':
63
+ return Terminal
64
+ case 'CodexFileChange':
65
+ return FileDiff
66
+ case 'CodexWebSearch':
67
+ return Globe
68
+ case 'CodexImageGeneration':
69
+ case 'CodexImageView':
70
+ return Image
71
+ default:
72
+ return toolName.startsWith('mcp__') ? Puzzle : Wrench
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Does this tool *change* the workspace?
78
+ *
79
+ * Worth its own colour in a transcript: skimming a run, "what did it edit" is a
80
+ * different question from "what did it look at", and a write is the one you
81
+ * might need to undo. Names from both first-party engines; an MCP tool is
82
+ * unknowable from its name, so it reads as neutral rather than guessed.
83
+ */
84
+ export function isMutatingTool(toolName: string): boolean {
85
+ switch (toolName) {
86
+ case 'Write':
87
+ case 'Edit':
88
+ case 'MultiEdit':
89
+ case 'NotebookEdit':
90
+ case 'Update':
91
+ case 'CodexFileChange':
92
+ return true
93
+ default:
94
+ return false
95
+ }
96
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The workspace layer — the VS Code-shaped layout around `SessionPanel`, and
3
+ * the Monaco editor at the middle of it.
4
+ *
5
+ * This is a **separate entry point** (`@workerdeck/ui/workspace`) for one
6
+ * reason: Monaco. It is only reachable from here, so an embedder who imports
7
+ * the root entry never pulls it into their module graph.
8
+ *
9
+ * That matters more than tree-shaking alone would suggest. Rollup does drop
10
+ * `CodeEditor` from a `SessionPanel`-only bundle — but Vite resolves Monaco's
11
+ * `new Worker(new URL(…, import.meta.url))` calls during *transform*, before
12
+ * tree-shaking runs, and emits ~9MB of language-service workers as assets that
13
+ * are never retracted. Keeping the import unreachable from the root entry is
14
+ * what actually prevents that; `sideEffects: false` does not.
15
+ *
16
+ * Consequently `monaco-editor` is an **optional peer dependency**: importing
17
+ * this entry means installing it, and importing only the root entry means not
18
+ * having to. See the README for the Vite configuration Monaco needs.
19
+ */
20
+
21
+ export {
22
+ SessionWorkspace,
23
+ type SessionWorkspaceProps,
24
+ } from './components/agent/SessionWorkspace.tsx'
25
+ export { FileTree, type FileTreeProps } from './components/agent/FileTree.tsx'
26
+ export { EditorTabs, type EditorTabsProps } from './components/agent/EditorTabs.tsx'
27
+ export { FileViewer, type FileViewerProps } from './components/agent/FileViewer.tsx'
28
+ export { CodeEditor, type CodeEditorProps } from './components/agent/CodeEditor.tsx'