@workerdeck/ui 0.23.0 → 1.0.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 (145) hide show
  1. package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +15 -23
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +5 -126
  14. package/build/workspace.mjs +11 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +3 -9
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +315 -996
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +104 -219
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +96 -32
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-BobynUo4.d.mts +0 -669
  143. package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
package/src/lib/format.ts CHANGED
@@ -1,96 +1,131 @@
1
1
  export function formatCost(usd: number | undefined): string {
2
- if (usd === undefined || Number.isNaN(usd)) return '—'
3
- if (usd === 0) return '$0.00'
4
- if (usd < 0.01) return '<$0.01'
2
+ if (usd === undefined || Number.isNaN(usd)) {
3
+ return ''
4
+ }
5
+ if (usd === 0) {
6
+ return '$0.00'
7
+ }
8
+ if (usd < 0.01) {
9
+ return '<$0.01'
10
+ }
5
11
  return `$${usd.toFixed(2)}`
6
12
  }
7
13
 
8
14
  export function formatDuration(ms: number): string {
9
- if (ms < 1000) return `${Math.round(ms)}ms`
15
+ if (ms < 1000) {
16
+ return `${Math.round(ms)}ms`
17
+ }
10
18
  const s = ms / 1000
11
- if (s < 60) return `${s.toFixed(1)}s`
19
+ if (s < 60) {
20
+ return `${s.toFixed(1)}s`
21
+ }
12
22
  const m = Math.floor(s / 60)
13
23
  return `${m}m ${Math.round(s % 60)}s`
14
24
  }
15
25
 
16
- /** Compact token count, Claude Code-style: 850 → "850", 359_000 → "359.0k", 1_200_000 → "1.2M". */
17
26
  export function formatTokens(tokens: number): string {
18
- if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
19
- if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`
27
+ if (tokens >= 1_000_000) {
28
+ return `${(tokens / 1_000_000).toFixed(1)}M`
29
+ }
30
+ if (tokens >= 1000) {
31
+ return `${(tokens / 1000).toFixed(1)}k`
32
+ }
20
33
  return String(Math.round(tokens))
21
34
  }
22
35
 
23
36
  export function formatBytes(bytes: number): string {
24
- if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
25
- if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`
37
+ if (bytes >= 1024 * 1024) {
38
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
39
+ }
40
+ if (bytes >= 1024) {
41
+ return `${(bytes / 1024).toFixed(1)} KB`
42
+ }
26
43
  return `${bytes} B`
27
44
  }
28
45
 
29
- /** Countdown to an epoch-ms deadline: "2h 18m", "12m", "<1m"; "now" once passed. */
30
46
  export function formatCountdown(untilEpochMs: number, now = Date.now()): string {
31
47
  const remaining = untilEpochMs - now
32
- if (remaining <= 0) return 'now'
48
+ if (remaining <= 0) {
49
+ return 'now'
50
+ }
33
51
  const minutes = Math.ceil(remaining / 60_000)
34
- if (minutes < 1) return '<1m'
35
- if (minutes < 60) return `${minutes}m`
52
+ if (minutes < 1) {
53
+ return '<1m'
54
+ }
55
+ if (minutes < 60) {
56
+ return `${minutes}m`
57
+ }
36
58
  const days = Math.floor(minutes / (60 * 24))
37
- if (days >= 1) return `${days}d ${Math.floor((minutes % (60 * 24)) / 60)}h`
59
+ if (days >= 1) {
60
+ return `${days}d ${Math.floor((minutes % (60 * 24)) / 60)}h`
61
+ }
38
62
  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
39
63
  }
40
64
 
41
65
  export function formatRelativeTime(epochMs: number | undefined, now = Date.now()): string {
42
- if (!epochMs) return '—'
66
+ if (!epochMs) {
67
+ return '—'
68
+ }
43
69
  const diff = Math.max(0, now - epochMs)
44
70
  const s = Math.floor(diff / 1000)
45
- if (s < 60) return 'just now'
71
+ if (s < 60) {
72
+ return 'just now'
73
+ }
46
74
  const m = Math.floor(s / 60)
47
- if (m < 60) return `${m}m ago`
75
+ if (m < 60) {
76
+ return `${m}m ago`
77
+ }
48
78
  const h = Math.floor(m / 60)
49
- if (h < 24) return `${h}h ago`
79
+ if (h < 24) {
80
+ return `${h}h ago`
81
+ }
50
82
  const d = Math.floor(h / 24)
51
83
  return `${d}d ago`
52
84
  }
53
85
 
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
86
  export function formatRateLimitWindow(key: string): string {
60
- if (key === 'five_hour') return '5h'
61
- if (key === 'seven_day') return '7d'
87
+ if (key === 'five_hour') {
88
+ return '5h'
89
+ }
90
+ if (key === 'seven_day') {
91
+ return '7d'
92
+ }
62
93
  const spaced = key.replaceAll('_', ' ')
63
94
  return key.startsWith('seven_day_') ? `7d ${spaced.slice('seven day '.length)}` : spaced
64
95
  }
65
96
 
66
- /** The same key spelled out, where there is room: 'five_hour' → "5-hour
67
- * session", 'seven_day_fable' → "Weekly · Fable". */
68
97
  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'
98
+ if (key === 'five_hour') {
99
+ return '5-hour session'
100
+ }
101
+ if (key === 'seven_day') {
102
+ return 'Weekly'
103
+ }
104
+ if (key === 'seven_day_oauth_apps') {
105
+ return 'Weekly · apps'
106
+ }
72
107
  const capitalize = (s: string) => s.replace(/\b\w/g, (c) => c.toUpperCase())
73
- if (!key.startsWith('seven_day_')) return capitalize(key.replaceAll('_', ' '))
108
+ if (!key.startsWith('seven_day_')) {
109
+ return capitalize(key.replaceAll('_', ' '))
110
+ }
74
111
  return `Weekly · ${capitalize(key.slice('seven_day_'.length).replaceAll('_', ' '))}`
75
112
  }
76
113
 
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
114
  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
115
+ if (key === 'five_hour') {
116
+ return 5 * 3600
117
+ }
118
+ if (key.startsWith('seven_day')) {
119
+ return 7 * 86_400
120
+ }
86
121
  return undefined
87
122
  }
88
123
 
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
124
  export function formatAgoPrecise(epochMs: number, now = Date.now()): string {
92
125
  const seconds = Math.max(0, Math.floor((now - epochMs) / 1000))
93
- if (seconds < 60) return `${seconds} sec${seconds === 1 ? '' : 's'} ago`
126
+ if (seconds < 60) {
127
+ return `${seconds} sec${seconds === 1 ? '' : 's'} ago`
128
+ }
94
129
  if (seconds < 3600) {
95
130
  const minutes = Math.floor(seconds / 60)
96
131
  return `${minutes} min${minutes === 1 ? '' : 's'} ago`
@@ -99,13 +134,13 @@ export function formatAgoPrecise(epochMs: number, now = Date.now()): string {
99
134
  return `${hours} hour${hours === 1 ? '' : 's'} ago`
100
135
  }
101
136
 
102
- /** Compact one-line preview of a tool input for card headers. */
103
137
  export function toolInputPreview(input: unknown, max = 80): string {
104
- if (input === null || input === undefined) return ''
138
+ if (input === null || input === undefined) {
139
+ return ''
140
+ }
105
141
  if (typeof input === 'object') {
106
142
  const o = input as Record<string, unknown>
107
- const primary =
108
- o.command ?? o.file_path ?? o.path ?? o.url ?? o.pattern ?? o.query ?? o.description
143
+ const primary = o.command ?? o.file_path ?? o.path ?? o.url ?? o.pattern ?? o.query ?? o.description
109
144
  if (typeof primary === 'string') {
110
145
  return primary.length > max ? primary.slice(0, max - 1) + '…' : primary
111
146
  }
@@ -114,9 +149,6 @@ export function toolInputPreview(input: unknown, max = 80): string {
114
149
  return text.length > max ? text.slice(0, max - 1) + '…' : text
115
150
  }
116
151
 
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
152
  const MODEL_FAMILIES: Record<string, { name: string; joiner?: string }> = {
121
153
  gpt: { name: 'GPT', joiner: '-' },
122
154
  deepseek: { name: 'DeepSeek' },
@@ -128,53 +160,36 @@ const MODEL_FAMILIES: Record<string, { name: string; joiner?: string }> = {
128
160
  grok: { name: 'Grok' },
129
161
  }
130
162
 
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
163
  export function friendlyModel(id: string | undefined): string | undefined {
157
- if (!id) return undefined
164
+ if (!id) {
165
+ return undefined
166
+ }
158
167
  const withoutVariant = id.split('[')[0] ?? id
159
168
  const parts = withoutVariant.toLowerCase().split('-').filter(Boolean)
160
- if (parts[0] === 'claude') parts.shift()
169
+ if (parts[0] === 'claude') {
170
+ parts.shift()
171
+ }
161
172
  const familyToken = parts.shift()
162
- if (!familyToken) return id
173
+ if (!familyToken) {
174
+ return id
175
+ }
163
176
  const family = MODEL_FAMILIES[familyToken]
164
177
  const name =
165
178
  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)}`)
179
+ // OpenAI's reasoning series is lower-case by its own convention ('o3-mini'); "O3" reads as a different product.
180
+ (/^o\d+$/.test(familyToken) ? familyToken : `${familyToken.charAt(0).toUpperCase()}${familyToken.slice(1)}`)
171
181
 
172
182
  const version: string[] = []
173
183
  const words: string[] = []
174
184
  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)}`)
185
+ if (/^\d{8}$/.test(part)) {
186
+ continue
187
+ }
188
+ if (/^\d+(\.\d+)?$/.test(part)) {
189
+ version.push(part)
190
+ } else {
191
+ words.push(`${part.charAt(0).toUpperCase()}${part.slice(1)}`)
192
+ }
178
193
  }
179
194
  const versioned = version.length > 0 ? `${name}${family?.joiner ?? ' '}${version.join('.')}` : name
180
195
  return [versioned, ...words].join(' ')
@@ -0,0 +1,16 @@
1
+ type PlanRequestLike = { toolName?: unknown; input?: unknown }
2
+
3
+ export function planFromRequest(request: PlanRequestLike | null | undefined): string | undefined {
4
+ if (request?.toolName !== 'ExitPlanMode') {
5
+ return undefined
6
+ }
7
+ const input = request.input
8
+ if (input === null || typeof input !== 'object') {
9
+ return undefined
10
+ }
11
+ const plan = (input as { plan?: unknown }).plan
12
+ if (typeof plan !== 'string' || plan.trim().length === 0) {
13
+ return undefined
14
+ }
15
+ return plan
16
+ }
package/src/lib/status.ts CHANGED
@@ -1,21 +1,7 @@
1
1
  import type { ModelOption, RateLimitInfo, SessionStatus } from '@workerdeck/protocol'
2
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
3
  export type StatusSeverity = 'none' | 'warning' | 'error'
15
4
 
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
5
  export type StatusPresentation = {
20
6
  icon: string
21
7
  label: string
@@ -24,8 +10,6 @@ export type StatusPresentation = {
24
10
 
25
11
  export type StatusReadings = {
26
12
  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
13
  connection?: 'live' | 'reconnecting' | 'offline'
30
14
  }
31
15
 
@@ -39,13 +23,10 @@ const STATUS_META: Record<SessionStatus, StatusPresentation> = {
39
23
  closed: { icon: 'circle-slash', label: 'Closed', severity: 'none' },
40
24
  }
41
25
 
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
26
  export function statusPresentation(vitals: StatusReadings | undefined): StatusPresentation {
48
- if (!vitals) return { icon: 'hubot', label: 'Connecting…', severity: 'none' }
27
+ if (!vitals) {
28
+ return { icon: 'hubot', label: 'Connecting…', severity: 'none' }
29
+ }
49
30
  if (vitals.connection === 'offline') {
50
31
  return { icon: 'debug-disconnect', label: 'Offline', severity: 'error' }
51
32
  }
@@ -55,46 +36,28 @@ export function statusPresentation(vitals: StatusReadings | undefined): StatusPr
55
36
  return STATUS_META[vitals.status] ?? { icon: 'hubot', label: vitals.status, severity: 'none' }
56
37
  }
57
38
 
58
- /** 0–100 → the colour a meter wears. One pair of thresholds for every surface. */
59
39
  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'
40
+ if (pct === undefined) {
41
+ return 'none'
42
+ }
43
+ if (pct >= 95) {
44
+ return 'error'
45
+ }
46
+ if (pct >= 80) {
47
+ return 'warning'
48
+ }
63
49
  return 'none'
64
50
  }
65
51
 
66
- /**
67
- * The three *lanes* a plan-usage reading can occupy, and the whole reason this
68
- * is a rule rather than a per-client `if`.
69
- *
70
- * The CLI reports one window per limit (`five_hour`, `seven_day`, and a
71
- * `seven_day_<model>` bucket per model-scoped limit — `seven_day_opus`,
72
- * `seven_day_sonnet`, and whatever `model_scoped` names next). A surface with
73
- * one slot shows the fullest of them, which is why `tightestWindow` exists —
74
- * but that answers "what is closest to blocking me", not "how much of *this
75
- * session's* budget have I spent", and those are different questions a reader
76
- * asks at different times. A weekly window at 71% will win the single slot over
77
- * a five-hour window at 60% every time, so the reading you actually watch while
78
- * working is the one you can never see.
79
- *
80
- * Hence three lanes, each independently showable:
81
- *
82
- * - `'session'` — the five-hour window. The one that resets while you work.
83
- * - `'weekly'` — the plain seven-day window, the account-wide ceiling.
84
- * - `'model'` — the fullest of the *model-scoped* weekly buckets. Deliberately
85
- * not a named model: which models have their own bucket is the plan's
86
- * business and changes without notice, so a client that hardcoded
87
- * `seven_day_opus` would show nothing the month it becomes something else.
88
- * The label comes from the key, so this lane names whatever it found.
89
- */
90
52
  export type UsageLane = 'session' | 'weekly' | 'model'
91
53
 
92
- /** The window a lane points at, or `undefined` when this account has none. */
93
54
  export function usageWindow(
94
55
  rateLimits: Record<string, RateLimitInfo> | undefined,
95
56
  lane: UsageLane,
96
57
  ): { key: string; info: RateLimitInfo } | undefined {
97
- if (!rateLimits) return undefined
58
+ if (!rateLimits) {
59
+ return undefined
60
+ }
98
61
  if (lane === 'session') {
99
62
  const info = rateLimits.five_hour
100
63
  return info ? { key: 'five_hour', info } : undefined
@@ -103,27 +66,19 @@ export function usageWindow(
103
66
  const info = rateLimits.seven_day
104
67
  return info ? { key: 'seven_day', info } : undefined
105
68
  }
106
- // Model-scoped: same "fullest wins" rule as the single slot, over the subset.
107
69
  const scoped = Object.fromEntries(
108
- Object.entries(rateLimits).filter(
109
- ([key]) => key.startsWith('seven_day_') && key !== 'seven_day_oauth_apps',
110
- ),
70
+ Object.entries(rateLimits).filter(([key]) => key.startsWith('seven_day_') && key !== 'seven_day_oauth_apps'),
111
71
  )
112
72
  return tightestWindow(scoped)
113
73
  }
114
74
 
115
- /** The rate-limit window that gets the one visible slot: whichever is fullest,
116
- * since the binding constraint is the one worth glancing at. Still the right
117
- * rule for a surface with exactly one slot; {@link usageWindow} is for one with
118
- * three. */
119
- export function tightestWindow(
120
- rateLimits: Record<string, RateLimitInfo> | undefined,
121
- ): { key: string; info: RateLimitInfo } | undefined {
75
+ export function tightestWindow(rateLimits: Record<string, RateLimitInfo> | undefined): { key: string; info: RateLimitInfo } | undefined {
122
76
  const entries = Object.entries(rateLimits ?? {})
123
- if (entries.length === 0) return undefined
77
+ if (entries.length === 0) {
78
+ return undefined
79
+ }
124
80
  let best: { key: string; info: RateLimitInfo } | undefined
125
81
  for (const [key, info] of entries) {
126
- // A rejected window outranks any utilization: it is the one actually blocking.
127
82
  const rank = info.status === 'rejected' ? Number.POSITIVE_INFINITY : (info.utilization ?? -1)
128
83
  const bestRank =
129
84
  best === undefined
@@ -131,18 +86,20 @@ export function tightestWindow(
131
86
  : best.info.status === 'rejected'
132
87
  ? Number.POSITIVE_INFINITY
133
88
  : (best.info.utilization ?? -1)
134
- if (rank > bestRank) best = { key, info }
89
+ if (rank > bestRank) {
90
+ best = { key, info }
91
+ }
135
92
  }
136
93
  return best
137
94
  }
138
95
 
139
- /** A rate-limit window's key, named for a human. A model-scoped bucket is named
140
- * for its model alone (`seven_day_fable` → "Fable"): the lane it sits in
141
- * already says weekly, and "Seven day fable" in a status bar is three words to
142
- * say one. */
143
96
  export function windowLabel(key: string): string {
144
- if (key === 'five_hour') return 'Session'
145
- if (key === 'seven_day') return 'Weekly'
97
+ if (key === 'five_hour') {
98
+ return 'Session'
99
+ }
100
+ if (key === 'seven_day') {
101
+ return 'Weekly'
102
+ }
146
103
  const scoped = key.startsWith('seven_day_') ? key.slice('seven_day_'.length) : key
147
104
  const words = scoped.replaceAll('_', ' ')
148
105
  return words.charAt(0).toUpperCase() + words.slice(1)
@@ -150,44 +107,37 @@ export function windowLabel(key: string): string {
150
107
 
151
108
  export type ModelReadings = { model?: string; models: readonly ModelOption[] }
152
109
 
153
- /**
154
- * The catalog row a session is actually running, or `undefined` for a model the
155
- * list doesn't name. Matched leniently: a session reports the *resolved* id
156
- * (`claude-sonnet-5`) where the row may be keyed on the alias (`sonnet`), and
157
- * either can carry a `[1m]` context-window suffix.
158
- */
159
110
  export function currentModel(vitals: ModelReadings | undefined): ModelOption | undefined {
160
111
  const id = vitals?.model
161
- if (!id) return undefined
112
+ if (!id) {
113
+ return undefined
114
+ }
162
115
  const bare = (value: string) => value.replace(/\[.*\]$/, '')
163
116
  const wanted = bare(id)
164
- return vitals.models.find(
165
- (m) => bare(m.value) === wanted || (m.resolvedModel && bare(m.resolvedModel) === wanted),
166
- )
117
+ return vitals.models.find((m) => bare(m.value) === wanted || (m.resolvedModel && bare(m.resolvedModel) === wanted))
167
118
  }
168
119
 
169
- /** A session's model, named the way the picker names it. Falls back to the raw
170
- * id, and to "Default" while the session is on the CLI's own pick. */
171
120
  export function modelLabel(vitals: ModelReadings | undefined): string {
172
- if (!vitals?.model) return 'Default'
121
+ if (!vitals?.model) {
122
+ return 'Default'
123
+ }
173
124
  return currentModel(vitals)?.displayName ?? vitals.model
174
125
  }
175
126
 
176
- /** Context percentage as its meter severity — the reading and the colour come
177
- * from one place so a panel and a status bar never disagree.
178
- *
179
- * Takes only the number it reads, so the compact `ContextReading` that rides
180
- * the sessions list and the full `ContextUsage` on the event stream are coloured
181
- * by one rule rather than two. */
182
127
  export function contextSeverity(usage: { percentage: number } | undefined): StatusSeverity {
183
128
  return meterSeverity(usage?.percentage)
184
129
  }
185
130
 
186
- /** {@link meterSeverity} as a text colour class. Here rather than beside each
187
- * meter because the status bar, the context dialog and a sessions-list row all
188
- * paint the same reading, and a fourth copy of the thresholds is a fourth
189
- * chance for one surface to call 81% orange and another call it grey. */
190
131
  export function meterColorClass(pct: number | undefined): string {
191
132
  const severity = meterSeverity(pct)
192
133
  return severity === 'error' ? 'text-danger' : severity === 'warning' ? 'text-warning' : 'text-fg-3'
193
134
  }
135
+
136
+ /**
137
+ * The fill colour for a meter bar. Deliberately a different ramp from
138
+ * {@link meterColorClass}: a bar reads as alarming later than a number does, so the tint
139
+ * turns at 70/90 where the text severity turns at 80/95.
140
+ */
141
+ export function meterTintClass(pct: number): string {
142
+ return pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent'
143
+ }
@@ -18,83 +18,73 @@ import {
18
18
  Wrench,
19
19
  } from 'lucide-react'
20
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
21
  export function toolIcon(toolName: string): LucideIcon {
31
22
  switch (toolName) {
32
23
  case 'Bash':
33
24
  case 'BashOutput':
34
- case 'KillShell':
25
+ case 'KillShell': {
35
26
  return Terminal
36
- case 'Read':
27
+ }
28
+ case 'Read': {
37
29
  return FileText
38
- case 'Write':
30
+ }
31
+ case 'Write': {
39
32
  return SquarePen
33
+ }
40
34
  case 'Edit':
41
35
  case 'MultiEdit':
42
- case 'NotebookEdit':
36
+ case 'NotebookEdit': {
43
37
  return PencilLine
44
- case 'Glob':
38
+ }
39
+ case 'Glob': {
45
40
  return FolderSearch
46
- case 'Grep':
41
+ }
42
+ case 'Grep': {
47
43
  return Search
48
- case 'WebFetch':
44
+ }
45
+ case 'WebFetch': {
49
46
  return ArrowDownCircle
50
- case 'WebSearch':
47
+ }
48
+ case 'WebSearch': {
51
49
  return Globe
50
+ }
52
51
  case 'Task':
53
- case 'Agent':
52
+ case 'Agent': {
54
53
  return UsersRound
55
- case 'TodoWrite':
54
+ }
55
+ case 'TodoWrite': {
56
56
  return CheckSquare
57
- case 'Skill':
57
+ }
58
+ case 'Skill': {
58
59
  return Sparkles
59
- case 'AskUserQuestion':
60
+ }
61
+ case 'AskUserQuestion': {
60
62
  return MessageCircleQuestion
61
- // The codex engine's own tool names (see its runner's item mapping).
62
- case 'CodexCommand':
63
+ }
64
+ case 'CodexCommand': {
63
65
  return Terminal
64
- case 'CodexFileChange':
66
+ }
67
+ case 'CodexFileChange': {
65
68
  return FileDiff
66
- case 'CodexWebSearch':
69
+ }
70
+ case 'CodexWebSearch': {
67
71
  return Globe
72
+ }
68
73
  case 'CodexImageGeneration':
69
- case 'CodexImageView':
74
+ case 'CodexImageView': {
70
75
  return Image
71
- default:
76
+ }
77
+ default: {
72
78
  return toolName.startsWith('mcp__') ? Puzzle : Wrench
79
+ }
73
80
  }
74
81
  }
75
82
 
76
- /**
77
- * Is this tool a shell command?
78
- *
79
- * Its own question because a run of them is *one* thing the reader skims past:
80
- * the terminal theme collapses consecutive shell calls into a single "Ran N
81
- * shell commands" line, the way the CLI does. Names from both first-party
82
- * engines. `BashOutput`/`KillShell` are excluded on purpose — they manage a
83
- * background shell rather than run something, and folding them into the count
84
- * would inflate it.
85
- */
83
+ // `BashOutput`/`KillShell` are excluded on purpose: they manage a background shell rather than run one.
86
84
  export function isShellTool(toolName: string): boolean {
87
85
  return toolName === 'Bash' || toolName === 'CodexCommand'
88
86
  }
89
87
 
90
- /**
91
- * Does this tool *change* the workspace?
92
- *
93
- * Worth its own colour in a transcript: skimming a run, "what did it edit" is a
94
- * different question from "what did it look at", and a write is the one you
95
- * might need to undo. Names from both first-party engines; an MCP tool is
96
- * unknowable from its name, so it reads as neutral rather than guessed.
97
- */
98
88
  export function isMutatingTool(toolName: string): boolean {
99
89
  switch (toolName) {
100
90
  case 'Write':
@@ -102,9 +92,11 @@ export function isMutatingTool(toolName: string): boolean {
102
92
  case 'MultiEdit':
103
93
  case 'NotebookEdit':
104
94
  case 'Update':
105
- case 'CodexFileChange':
95
+ case 'CodexFileChange': {
106
96
  return true
107
- default:
97
+ }
98
+ default: {
108
99
  return false
100
+ }
109
101
  }
110
102
  }
package/src/lib/utils.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  import { clsx, type ClassValue } from 'clsx'
2
2
  import { extendTailwindMerge } from 'tailwind-merge'
3
3
 
4
- // Register the custom text-* font-size utilities as a font-size class group, or
5
- // tailwind-merge collapses a size class and a color class (e.g. `text-label
6
- // text-muted-foreground`) into one bucket and silently drops the size.
4
+ // Register the custom text-* utilities as a font-size group, or tailwind-merge buckets a size class with a colour class and drops the size.
7
5
  const twMerge = extendTailwindMerge({
8
6
  extend: {
9
7
  classGroups: {