@workerdeck/ui 0.15.0 → 0.17.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 (71) hide show
  1. package/README.md +60 -0
  2. package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
  3. package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
  4. package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
  5. package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
  6. package/build/format.d.mts +39 -4
  7. package/build/format.mjs +2 -118
  8. package/build/index.d.mts +493 -45
  9. package/build/index.mjs +343 -17
  10. package/build/index.mjs.map +1 -1
  11. package/build/status-Ydzi7n6j.mjs +143 -0
  12. package/build/status-Ydzi7n6j.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -1
  14. package/build/workspace.mjs +108 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +16 -7
  17. package/src/components/agent/Composer.tsx +189 -89
  18. package/src/components/agent/Conversation.tsx +12 -12
  19. package/src/components/agent/FileCard.tsx +0 -26
  20. package/src/components/agent/FileTree.tsx +9 -8
  21. package/src/components/agent/Loader.tsx +22 -72
  22. package/src/components/agent/Message.tsx +11 -46
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/ProjectIcon.tsx +119 -0
  25. package/src/components/agent/QuestionPrompt.tsx +0 -122
  26. package/src/components/agent/Reasoning.tsx +5 -19
  27. package/src/components/agent/Response.tsx +1 -132
  28. package/src/components/agent/SessionBrowser.tsx +84 -3
  29. package/src/components/agent/SessionPanel.tsx +249 -28
  30. package/src/components/agent/SessionWorkspace.tsx +29 -0
  31. package/src/components/agent/StatusBar.tsx +20 -4
  32. package/src/components/agent/ToolCallCard.tsx +85 -112
  33. package/src/components/agent/Transcript.tsx +780 -203
  34. package/src/components/agent/UsageDialog.tsx +20 -106
  35. package/src/components/agent/UsageMeters.tsx +133 -0
  36. package/src/components/agent/pulse.tsx +3 -2
  37. package/src/components/agent/tool-result-fetch.tsx +36 -0
  38. package/src/components/agent/tool-result-image.tsx +209 -0
  39. package/src/components/agent/transcript-rows.ts +173 -0
  40. package/src/components/agent/transcript-variant.tsx +29 -51
  41. package/src/components/agent/use-height-epoch.ts +60 -0
  42. package/src/components/agent/use-path-links.ts +147 -0
  43. package/src/components/agent/use-transcript-jumps.ts +190 -0
  44. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  45. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  46. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  47. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  48. package/src/components/terminal/StatusLine.tsx +159 -0
  49. package/src/components/terminal/TerminalTranscript.tsx +225 -0
  50. package/src/components/terminal/affordances.tsx +118 -0
  51. package/src/components/terminal/blocks.ts +232 -0
  52. package/src/components/terminal/diff.tsx +130 -0
  53. package/src/components/terminal/height.ts +770 -0
  54. package/src/components/terminal/image-box.ts +53 -0
  55. package/src/components/terminal/items.tsx +486 -0
  56. package/src/components/terminal/markdown.tsx +191 -0
  57. package/src/components/terminal/press.tsx +120 -0
  58. package/src/components/terminal/prompt.tsx +343 -0
  59. package/src/components/terminal/result-preview.ts +86 -0
  60. package/src/components/terminal/row.tsx +132 -0
  61. package/src/components/terminal/scrubber.tsx +784 -0
  62. package/src/components/terminal/surface.tsx +80 -0
  63. package/src/components/terminal/tool-run.ts +224 -0
  64. package/src/index.ts +36 -1
  65. package/src/lib/status.ts +59 -3
  66. package/src/lib/tool-icon.ts +14 -0
  67. package/src/styles/terminal.css +1081 -0
  68. package/src/styles/theme.css +41 -0
  69. package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
  70. package/build/format.mjs.map +0 -1
  71. package/src/components/agent/line-prompt.tsx +0 -249
@@ -0,0 +1,80 @@
1
+ import type { CSSProperties, HTMLAttributes } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+ import {
4
+ AffordanceProvider,
5
+ resolveAffordances,
6
+ type TerminalAffordances,
7
+ } from './affordances.tsx'
8
+
9
+ /**
10
+ * The root of the terminal theme: the element that establishes the character
11
+ * cell every row inside it lands on.
12
+ *
13
+ * It exists as a component rather than a class because the cell has to be *set*
14
+ * somewhere — the font, the size and the line height together are what make
15
+ * `1ch` mean one column and `--term-line` mean one row — and because that is the
16
+ * one place a host is allowed to change the metrics. Everything below this
17
+ * element measures itself in those two units and never in pixels.
18
+ *
19
+ * The geometry and the palette live in `styles/terminal.css`; this only names
20
+ * the element and hands down the two numbers.
21
+ */
22
+ export interface TerminalSurfaceProps extends HTMLAttributes<HTMLDivElement> {
23
+ /**
24
+ * Cell size in **whole pixels**. Fractional values put every other row on a
25
+ * half-pixel: text softens and the diff bands show a seam, which is exactly
26
+ * what a grid renderer must not do. Defaults (13/18) are the CLI's own.
27
+ */
28
+ fontSize?: number
29
+ lineHeight?: number
30
+ /**
31
+ * How far a full-bleed band reaches past the content edge — normally the
32
+ * scroller's own horizontal padding. A band cancels it with matched negative
33
+ * margin and padding, so a diff hunk's wash runs to the viewport edge the way
34
+ * a terminal's line does, instead of stopping at a gutter.
35
+ */
36
+ bleed?: string
37
+ /**
38
+ * The things a real terminal cannot do — the pointer hover fill, the
39
+ * hover-revealed copy actions. `false` turns them all off, an object picks.
40
+ * Default: all on. See {@link TerminalAffordances}; none of them costs layout,
41
+ * so switching them off changes no glyph's position.
42
+ */
43
+ affordances?: TerminalAffordances | boolean
44
+ }
45
+
46
+ export function TerminalSurface({
47
+ fontSize,
48
+ lineHeight,
49
+ bleed,
50
+ affordances,
51
+ className,
52
+ style,
53
+ children,
54
+ ...props
55
+ }: TerminalSurfaceProps) {
56
+ const resolved = resolveAffordances(affordances)
57
+ return (
58
+ <div
59
+ data-terminal=''
60
+ // A space-separated list so CSS can ask for one with `~=` — the styling
61
+ // half of the switch, where the JS half (whether a button exists at all)
62
+ // rides the context.
63
+ data-affordances={
64
+ [resolved.hover && 'hover', resolved.actions && 'actions'].filter(Boolean).join(' ') ||
65
+ undefined
66
+ }
67
+ className={cn('min-w-0', className)}
68
+ style={
69
+ {
70
+ ...(fontSize !== undefined && { '--term-font-size': `${Math.round(fontSize)}px` }),
71
+ ...(lineHeight !== undefined && { '--term-line': `${Math.round(lineHeight)}px` }),
72
+ ...(bleed !== undefined && { '--term-bleed': bleed }),
73
+ ...style,
74
+ } as CSSProperties
75
+ }
76
+ {...props}>
77
+ <AffordanceProvider value={resolved}>{children}</AffordanceProvider>
78
+ </div>
79
+ )
80
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * What a folded run of tool calls is, and the one line that stands for it.
3
+ *
4
+ * The fold was shell-only, and the screenshot that started this made the cost
5
+ * obvious: a run of six calls that happened to alternate `Bash` with an MCP tool
6
+ * folded into *four* rows reading "Ran 1 shell command", "Ran 2 shell commands",
7
+ * "Ran 1 shell command" — a count for every gap between the calls it could not
8
+ * group. The grouping rule was right and the *membership* rule was too narrow.
9
+ *
10
+ * So any consecutive tool calls fold. The CLI's own line is the target
11
+ * (`called roam-code, ran 1 shell command`), and the claim is unchanged from the
12
+ * shell version: a tool call is almost never what you came back to read, and six
13
+ * of them bury the sentence that is.
14
+ *
15
+ * Pure and separate because `items.tsx` draws this line and `height.ts` wraps it
16
+ * to predict the row's pixel height without a DOM — the same reason
17
+ * `result-preview.ts` exists. Two spellings would be two different heights.
18
+ */
19
+ import type { TranscriptItem } from '@workerdeck/react'
20
+ import { toolInputPreview } from '../../lib/format.ts'
21
+ import { isShellTool } from '../../lib/tool-icon.ts'
22
+
23
+ type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
24
+
25
+ /**
26
+ * What breaks a run.
27
+ *
28
+ * *Consecutive* is still the whole rule, and it is the reason the fold is honest:
29
+ * anything the model said between two calls breaks the run, because that
30
+ * sentence is the reason the second one happened and a count spanning it would
31
+ * claim the two were one act. The recap boundary breaks it too — the virtualized
32
+ * shell folds each side separately — so a count never spans "what you already
33
+ * read".
34
+ *
35
+ * `parentToolUseId` is the one addition the wider membership rule needs: a
36
+ * subagent's calls are drawn stepped in behind a rule, and folding one together
37
+ * with a top-level call would put rows from two different frames of reference
38
+ * under a single count.
39
+ */
40
+ export function foldsTogether(a: ToolCallItem, b: ToolCallItem): boolean {
41
+ return a.parentToolUseId === b.parentToolUseId
42
+ }
43
+
44
+ /**
45
+ * The family a tool counts as in a run's breakdown.
46
+ *
47
+ * An MCP tool is `mcp__<server>__<tool>`, and the *server* is the useful unit:
48
+ * "3 roam-code" is a thing that happened, where three separate tool names are a
49
+ * list to read. Shell tools from both engines collapse to "shell" for the same
50
+ * reason. Everything else is its own name, lowercased so the breakdown reads as
51
+ * prose rather than as identifiers.
52
+ */
53
+ export function toolFamily(name: string): string {
54
+ if (isShellTool(name)) return 'shell'
55
+ const mcp = /^mcp__([^_]+(?:_[^_]+)*?)__/.exec(name)
56
+ if (mcp?.[1]) return mcp[1].replace(/_/g, '-')
57
+ return name.toLowerCase()
58
+ }
59
+
60
+ /**
61
+ * The run's one line.
62
+ *
63
+ * A shell-only run keeps its old wording exactly — "Ran 3 shell commands" — both
64
+ * because it is the commonest run by far and because it is already the sentence
65
+ * people read here; widening the fold should not have re-worded the case that
66
+ * was working. Anything mixed gets the count plus a breakdown, loudest family
67
+ * first.
68
+ */
69
+ export function runSummary(items: readonly ToolCallItem[], busy: boolean): string {
70
+ const verb = busy ? 'Running ' : 'Ran '
71
+ const tail = busy ? '…' : ''
72
+ const n = items.length
73
+
74
+ const counts = new Map<string, number>()
75
+ for (const item of items) {
76
+ const family = toolFamily(item.name)
77
+ counts.set(family, (counts.get(family) ?? 0) + 1)
78
+ }
79
+ if (counts.size === 1 && counts.has('shell')) {
80
+ return `${verb}${n} shell command${n === 1 ? '' : 's'}${tail}`
81
+ }
82
+ // Descending by count, then alphabetical — a stable order matters more than it
83
+ // looks: this string is the row's measured height, so a run whose breakdown
84
+ // reordered between renders would remeasure for no reason.
85
+ const breakdown = [...counts.entries()]
86
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
87
+ .map(([family, count]) => `${count} ${family}`)
88
+ .join(', ')
89
+ // The ellipsis trails the whole line, not the count — "Running 2 tools… · 1
90
+ // read, 1 shell" reads as though the sentence ended and then carried on.
91
+ return `${verb}${n} tool${n === 1 ? '' : 's'} · ${breakdown}${tail}`
92
+ }
93
+
94
+ /* ── The task block's one line ─────────────────────────────────────────────
95
+ *
96
+ * A `Task` call and everything its subagent produced collapse to one row (see
97
+ * `blocks.ts`), and these are that row's words. Same contract as `runSummary`:
98
+ * `height.ts` wraps these exact strings to predict the row's pixel height with
99
+ * no DOM, so the component must render them verbatim — two spellings would be
100
+ * two different heights.
101
+ */
102
+
103
+ const clip = (text: string, max = 80): string =>
104
+ text.length > max ? text.slice(0, max - 1) + '…' : text
105
+
106
+ const trimmed = (value: unknown): string | undefined =>
107
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
108
+
109
+ /**
110
+ * The row's identity half: which task this is.
111
+ *
112
+ * The Claude SDK's `Task` input carries `subagent_type` (e.g. "Explore") and a
113
+ * 3–5 word `description`, and both are worth the line: parallel tasks are the
114
+ * whole reason the block exists, and two rows both reading `Task(…)` answer
115
+ * nothing. `Task(Explore · find the auth check)` — falling back to the
116
+ * ordinary input preview when an engine sends neither, so the header is never
117
+ * emptier than a plain tool row's.
118
+ */
119
+ export function taskLabel(task: ToolCallItem): string {
120
+ const input = task.input as { description?: unknown; subagent_type?: unknown } | null
121
+ const description = trimmed(input?.description)
122
+ const agent = trimmed(input?.subagent_type)
123
+ const inner =
124
+ agent && description
125
+ ? `${agent} · ${clip(description)}`
126
+ : (agent ?? (description ? clip(description) : toolInputPreview(task.input)))
127
+ return `${task.name}(${inner})`
128
+ }
129
+
130
+ const callBusy = (call: ToolCallItem): boolean =>
131
+ call.status === 'running' || call.status === 'pending'
132
+
133
+ /** Did this one call fail? Both spellings are needed: an out-of-loop execution
134
+ * failure sets `status` with no `is_error` block to read, and an engine can flag
135
+ * `is_error` on a call the reducer has not settled yet. */
136
+ export const callFailed = (call: ToolCallItem): boolean =>
137
+ call.status === 'failed' || call.result?.isError === true
138
+
139
+ /**
140
+ * Does a folded run colour red? **Only when its last call failed.**
141
+ *
142
+ * It used to be `some`, on the argument that a failure should colour the block
143
+ * rather than fragment it. The argument was right about not fragmenting and
144
+ * wrong about `some`: a run is a sequence the model worked through, and a
145
+ * failure it recovered from two calls later is how work goes — a grep that
146
+ * matched nothing, a build fixed on the second go. Reddening the whole run for
147
+ * it means a normal working session is painted red, which spends the colour
148
+ * that should have been left for the one thing still broken.
149
+ *
150
+ * The last call is the run's *outcome*, and an outcome is what a collapsed row
151
+ * can honestly claim. The failures inside it are not hidden — they are one
152
+ * press away, each red on its own row, and the recap counts every one. The
153
+ * **scrubber agrees with this rule** rather than overriding it: it marks a
154
+ * failed call only when the call is its row's outcome, which for a run is
155
+ * exactly this one. It used to mark every member on the argument that the
156
+ * rail asks a different question; against a real session that was nine alarms
157
+ * on the rail for a transcript reddening one row.
158
+ */
159
+ export function runFailed(items: readonly ToolCallItem[]): boolean {
160
+ const last = items[items.length - 1]
161
+ return last !== undefined && callFailed(last)
162
+ }
163
+
164
+ /** Is anything inside still going? The call itself, normally — the Task
165
+ * settles only when its subagent finishes — but a bridged or deferred child
166
+ * can outlive it, and a pulse that stopped while a child still worked would
167
+ * read as a hang. */
168
+ export function taskBusy(task: ToolCallItem, children: readonly TranscriptItem[]): boolean {
169
+ return callBusy(task) || children.some((child) => child.kind === 'tool_call' && callBusy(child))
170
+ }
171
+
172
+ /**
173
+ * Does the row colour red? **The task's own outcome, and nothing else.**
174
+ *
175
+ * It used to be "or any child call's", which does not survive contact with a
176
+ * real subagent: an agent that ran a hundred calls, one of them a grep that
177
+ * matched nothing, came back with a red line saying it had failed. It had not —
178
+ * it had done exactly what it was asked, and the transcript said otherwise in
179
+ * the one colour reserved for things that need a human.
180
+ *
181
+ * This is the call `SubagentInfo.status` already makes, and it made it for this
182
+ * reason (see `packages/protocol`): the sub-agent's **own** `tool_result`
183
+ * `is_error`, deliberately not `taskFailed`. The argument there was that a
184
+ * nothing-matched grep must not read as a failed run *beside a session name*;
185
+ * what a hundred-call agent shows is that it must not read that way beside the
186
+ * `Task` row either. Two surfaces, one rule, one spelling.
187
+ *
188
+ * Nothing is concealed by this. A failed child is red on its own row, one press
189
+ * away, and the recap counts it. The **scrubber follows this rule too** and no
190
+ * longer marks such a child: a red tick on the rail says precisely what this
191
+ * row is forbidden from saying. The sub-agent band still says an agent ran
192
+ * here, and the task's own red tick still says it came back broken — which is
193
+ * what the two channels are for.
194
+ */
195
+ export function taskFailed(task: ToolCallItem): boolean {
196
+ return callFailed(task)
197
+ }
198
+
199
+ /**
200
+ * The collapsed task row's one line: identity, then scale.
201
+ *
202
+ * `Task(Explore · find the auth check) · 7 tools…` while the subagent works —
203
+ * the count grows as it does, which is the row's progress reading, and the
204
+ * trailing ellipsis is the same in-flight signal `runSummary` uses (the pulse
205
+ * in the gutter carries the beat). Settled, the ellipsis drops:
206
+ * `… · 7 tools`. "Tools" and not "tool calls" because `runSummary` already
207
+ * chose that word for the same count one row over.
208
+ *
209
+ * The counts are counted from the absorbed children, never read from the
210
+ * engine's structured Task output — WorkerDeck does not plumb structured tool
211
+ * results to clients, so a transcript replayed tomorrow must spell the same
212
+ * line from the same items it holds today.
213
+ *
214
+ * With no tool calls yet — the subagent thinking, or only its brief arrived —
215
+ * the line says `working…`, because `0 tools…` reads as a stall; settled with
216
+ * none it says `done`.
217
+ */
218
+ export function taskSummary(task: ToolCallItem, children: readonly TranscriptItem[]): string {
219
+ const busy = taskBusy(task, children)
220
+ const calls = children.reduce((n, child) => n + (child.kind === 'tool_call' ? 1 : 0), 0)
221
+ const label = taskLabel(task)
222
+ if (calls === 0) return busy ? `${label} · working…` : `${label} · done`
223
+ return `${label} · ${calls} tool${calls === 1 ? '' : 's'}${busy ? '…' : ''}`
224
+ }
package/src/index.ts CHANGED
@@ -71,11 +71,44 @@ export {
71
71
  type SessionPanelProps,
72
72
  type SessionSurfacePanel,
73
73
  type SessionVitals,
74
+ type TerminalMetrics,
74
75
  } from './components/agent/SessionPanel.tsx'
75
76
  // The workspace layout and its Monaco editor live at `@workerdeck/ui/workspace`
76
77
  // — deliberately unreachable from here, so importing this entry never drags
77
78
  // Monaco into the bundle. See `src/workspace.ts`.
78
79
  export { Transcript, type TranscriptProps } from './components/agent/Transcript.tsx'
80
+ // The terminal theme. `SessionPanel`/`Transcript` reach it through
81
+ // `variant: 'terminal'` and most embedders need nothing else — these are for a
82
+ // host composing the surface by hand (a prompt outside the panel, a status line
83
+ // in its own chrome) and for the primitives, so a host's own row lands on the
84
+ // same character cell as ours.
85
+ export {
86
+ TerminalSurface,
87
+ type TerminalSurfaceProps,
88
+ } from './components/terminal/surface.tsx'
89
+ export {
90
+ TerminalTranscript,
91
+ TerminalItemView,
92
+ type TerminalTranscriptProps,
93
+ } from './components/terminal/TerminalTranscript.tsx'
94
+ export { TerminalStatusLine, type TerminalStatusLineProps } from './components/terminal/StatusLine.tsx'
95
+ export {
96
+ TerminalPermissionPrompt,
97
+ type TerminalPermissionPromptProps,
98
+ } from './components/terminal/PermissionPrompt.tsx'
99
+ export {
100
+ TerminalQuestionPrompt,
101
+ type TerminalQuestionPromptProps,
102
+ } from './components/terminal/QuestionPrompt.tsx'
103
+ export { TerminalDiff, previewPatch } from './components/terminal/diff.tsx'
104
+ export { TerminalMarkdown, type TerminalMarkdownProps } from './components/terminal/markdown.tsx'
105
+ export { Band, Blank, Ink, Row, type RowProps, type Tone } from './components/terminal/row.tsx'
106
+ export {
107
+ CopyAction,
108
+ WithActions,
109
+ useAffordances,
110
+ type TerminalAffordances,
111
+ } from './components/terminal/affordances.tsx'
79
112
  export {
80
113
  Conversation,
81
114
  ConversationContent,
@@ -125,6 +158,7 @@ export {
125
158
  export { StatusBar, type StatusBarProps } from './components/agent/StatusBar.tsx'
126
159
  export { ContextDialog, type ContextDialogProps } from './components/agent/ContextDialog.tsx'
127
160
  export { UsageDialog, type UsageDialogProps } from './components/agent/UsageDialog.tsx'
161
+ export { UsageMeters, useMinuteClock } from './components/agent/UsageMeters.tsx'
128
162
  export {
129
163
  SessionInfoDialog,
130
164
  type SessionInfoDialogProps,
@@ -146,7 +180,8 @@ export {
146
180
  export { SessionStatusIcon } from './components/agent/SessionBrowser.tsx'
147
181
  // Lifted out of the VS Code sidebar once the dashboard grew a collapsed rail
148
182
  // that needs the same glyph — two copies of a trademark set is one too many.
149
- export { EngineIcon } from './components/agent/EngineIcon.tsx'
183
+ export { EngineIcon, engineMark } from './components/agent/EngineIcon.tsx'
184
+ export { ProjectIcon } from './components/agent/ProjectIcon.tsx'
150
185
  export {
151
186
  SessionEmptyState,
152
187
  type SessionEmptyStateProps,
package/src/lib/status.ts CHANGED
@@ -63,8 +63,59 @@ export function meterSeverity(pct: number | undefined): StatusSeverity {
63
63
  return 'none'
64
64
  }
65
65
 
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
+ export type UsageLane = 'session' | 'weekly' | 'model'
91
+
92
+ /** The window a lane points at, or `undefined` when this account has none. */
93
+ export function usageWindow(
94
+ rateLimits: Record<string, RateLimitInfo> | undefined,
95
+ lane: UsageLane,
96
+ ): { key: string; info: RateLimitInfo } | undefined {
97
+ if (!rateLimits) return undefined
98
+ if (lane === 'session') {
99
+ const info = rateLimits.five_hour
100
+ return info ? { key: 'five_hour', info } : undefined
101
+ }
102
+ if (lane === 'weekly') {
103
+ const info = rateLimits.seven_day
104
+ return info ? { key: 'seven_day', info } : undefined
105
+ }
106
+ // Model-scoped: same "fullest wins" rule as the single slot, over the subset.
107
+ const scoped = Object.fromEntries(
108
+ Object.entries(rateLimits).filter(
109
+ ([key]) => key.startsWith('seven_day_') && key !== 'seven_day_oauth_apps',
110
+ ),
111
+ )
112
+ return tightestWindow(scoped)
113
+ }
114
+
66
115
  /** The rate-limit window that gets the one visible slot: whichever is fullest,
67
- * since the binding constraint is the one worth glancing at. */
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. */
68
119
  export function tightestWindow(
69
120
  rateLimits: Record<string, RateLimitInfo> | undefined,
70
121
  ): { key: string; info: RateLimitInfo } | undefined {
@@ -85,11 +136,16 @@ export function tightestWindow(
85
136
  return best
86
137
  }
87
138
 
88
- /** A rate-limit window's key, named for a human. */
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. */
89
143
  export function windowLabel(key: string): string {
90
144
  if (key === 'five_hour') return 'Session'
91
145
  if (key === 'seven_day') return 'Weekly'
92
- return key.replaceAll('_', ' ')
146
+ const scoped = key.startsWith('seven_day_') ? key.slice('seven_day_'.length) : key
147
+ const words = scoped.replaceAll('_', ' ')
148
+ return words.charAt(0).toUpperCase() + words.slice(1)
93
149
  }
94
150
 
95
151
  export type ModelReadings = { model?: string; models: readonly ModelOption[] }
@@ -73,6 +73,20 @@ export function toolIcon(toolName: string): LucideIcon {
73
73
  }
74
74
  }
75
75
 
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
+ */
86
+ export function isShellTool(toolName: string): boolean {
87
+ return toolName === 'Bash' || toolName === 'CodexCommand'
88
+ }
89
+
76
90
  /**
77
91
  * Does this tool *change* the workspace?
78
92
  *