A set of mods that put live session data around the prompt box: statusbar (model, effort, mode, directory, branch, usage) and workflow (a dynamic workflow's…

One Claude Code plugin, several small mods that put live session data around the prompt box. Each mod is a folder under mods/; hooks/register.tsx composes them.
A two-row band above the prompt (terminal and desktop surfaces), after one blank line:
Opus 5.5 · effort high · ⏵⏵ auto mode · ~/git/workbench · git:main · API $3.46
context 34% · 5h 13% ↻ 2h 14m · week 81% ↻ pon 14:00 · cache 47m
First row:
| Segment | Where it comes from |
|---|---|
| model | $.session.model() at start, classic.PostModelSwitch on /model, the model of each turn.step |
| effort | effortLevel from settings (a per-model entry first), /effort, the effort of each turn.step |
| mode | permission_mode_changed { to_mode }, which the CLI logs for an OpenTelemetry collector on every switch (read only, passed on unchanged); the permission mode on the classic events (UserPromptSubmit, Stop, PostToolUse, ...); the prompt footer's brief ... mode on flash after Shift+Tab |
| directory | $.session.cwd(), classic.CwdChanged; home shown as ~ |
| branch | git branch --show-current, refreshed after each turn, after Bash calls and every 5 s |
| API cost | $.session.usage().cost.usd: what the session would have cost at API prices, as /cost totals it, even on a subscription; dimmed |
Second row. Context, 5h and week come from $.session.usage(), refreshed after each response, at the end of a turn, after a compaction and every 5 s; cache is counted by the mod itself:
| Segment | What it shows | Colors |
|---|---|---|
| context | how full the context window is (context.percent) | green < 50 %, amber 50–80 %, red > 80 % |
| 5h | the five-hour rate-limit window used, and ↻ the time left until it resets | same |
| week | the weekly rate-limit window used, and ↻ the local day and hour it resets | same |
| cache | how long the prompt cache stays warm: a countdown of 1 h (the TTL the CLI uses for the main thread) from the last main-thread response; cleared on a model switch, since the new model has no cache yet. Plugins are not told the TTL, so this is the CLI's rule, not a reading | green, amber under 5 min, red cache cold |
The countdowns redraw every 30 s. A value the session has not reported yet (before the first response, or rate limits on an API key) is left out.
On a narrow terminal the directory shortens first, then first-row segments drop: API cost, effort, directory, branch, model, mode. The band gives way to a survey.
While a dynamic workflow runs, the band above the prompt (terminal and desktop surfaces) shows its steps under the statusbar's rows, one row per run:
Collect (3/3) -> Analyze (1/3) -> Summarize
| Step | Color |
|---|---|
| finished | green |
| current: agents still working, or the last step started while the script is between phases | blue, bold |
| not started yet | grey, no count |
where a failed or stopped (failed, killed) run ended | red, bold |
(finished/started) counts the step's agents whose turn has ended against those started so far. A run that completes shows every started step done. A run that ended stays on screen until the next prompt you send. The band gives way to a survey.
The two mods share the band: hooks/register.tsx registers the workflow mod first, so its hook is the outer one, takes the statusbar's tree from next(e) and adds its rows under it.
| What | Where it comes from |
|---|---|
| the steps, in order | meta.phases of the run's script, read from the scriptPath the Workflow tool answers; a phase no meta entry declares follows them, an agent under no phase counts under the workflow's name |
| each agent | agent.spawn with workflow.runId; its phase from agent-<id>.meta.json (workflowPhase) in the run's transcriptDir, read when it starts and when it ends |
| an agent finished | its turn.complete |
| how the run ended | the task's notification row (UserMessage, task.status) |
A resumed run keeps its finished agents; agents replayed from the run's journal or retried after a stall raise no event, so the counts show only live ones until the run completes. A run launched before the plugin loaded is not shown. The engine's own progress row for the run (the squares, 2/3 · 13s · tokens) stays: it is drawn outside every site a plugin can hook.
.claude-plugin/plugin.json manifest (name: workbench)
hooks/hooks.json names the one hooks module
hooks/register.tsx composes the mods
mods/statusbar/ the statusbar mod (statusbar.tsx has every `$` call)
mods/workflow/ the workflow mod (workflow.tsx has every `$` call)
types/index.d.ts the plugin's `$.state` contract
tests/ claude plugin test .
The engine follows $ only into functions declared in the same file, so each mod keeps its $ calls and its atoms in one file and its pure helpers (formatting, layout) in others, where the tests reach them directly.
A new mod: add mods/<name>/<name>.tsx exporting register<Name>(on), call it from hooks/register.tsx, and declare its state under workbench in types/index.d.ts.
Clone the repository, add the clone as a marketplace and install the plugin from it. The marketplace's entry is a relative path ("source": "./"), so Claude Code reads the plugin from the clone itself, not from a copy: after a git pull or an edit, /reload-plugins in a running session picks it up.
Windows (PowerShell):
git clone https://github.com/Pigula1984/workbench.git $HOME\git\workbench
claude plugin marketplace add $HOME\git\workbench
claude plugin install workbench@workbench
macOS and Linux:
git clone https://github.com/Pigula1984/workbench.git ~/git/workbench
claude plugin marketplace add ~/git/workbench
claude plugin install workbench@workbench
Any folder will do in place of git/workbench. /plugin turns it off and on again; to remove it:
claude plugin uninstall workbench@workbench
claude plugin marketplace remove workbench
Without a clone, claude plugin marketplace add Pigula1984/workbench adds the GitHub repository itself; the plugin then runs a copy made at install, which claude plugin update workbench@workbench refreshes.
claude --plugin-dir <path to the clone>
Name the clone in CLAUDE_CODE_PLUGIN_DIRS, in the env block of ~/.claude/settings.json (several folders are separated by ; on Windows, : on macOS and Linux). Every session loads it as a --plugin-dir, and an interactive one watches the folder: a saved file reloads the mods at once. Use this or the install above, not both, or the plugin loads twice.
Windows (%USERPROFILE%\.claude\settings.json; backslashes doubled in JSON):
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\<you>\\git\\workbench" } }
macOS:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/Users/<you>/git/workbench" } }
Linux:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/home/<you>/git/workbench" } }
~ works in place of the home folder.
claude plugin validate .
claude plugin test .hooks/register.tsx 17 lines1import type { Register } from 'claude-code'
2
3import { registerStatusbar } from '../mods/statusbar/statusbar'
4import { registerWorkflow } from '../mods/workflow/workflow'
5
6// One plugin, several mods: each mod is a folder under mods/ that exports a
7// register function, and this module composes them. A new mod is one import
8// and one call here.
9//
10// A plugin's hooks nest in the order they are registered, the first outermost.
11// The workflow mod goes first: its band hook wraps the statusbar's rows and
12// adds the workflow's steps under them.
13export const register: Register = on => {
14 registerWorkflow(on)
15 registerStatusbar(on)
16}
17mods/statusbar/statusbar.tsx 345 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { StatusbarState } from '../../types'
5import {
6 effortFromSettings,
7 isEffortLevel,
8 CACHE_TTL_MS,
9 layout,
10 modeFromHint,
11 modeFromSettings,
12 modeFromTelemetry,
13 SEPARATOR,
14 usageSegments,
15} from './format'
16import { applyChanges, changedKeys, safely } from './state'
17import type { Changes } from './state'
18
19// Everything the band shows. The hooks keep it fresh, the render hook reads it,
20// and a write redraws the band.
21const bar = atom({ plugin: 'workbench', key: 'statusbar' } as const, {
22 model: null,
23 effort: null,
24 mode: null,
25 cwd: null,
26 branch: null,
27 home: null,
28 contextPercent: null,
29 fiveHourPercent: null,
30 weekPercent: null,
31 fiveHourResetsAt: null,
32 weekResetsAt: null,
33 cacheWarmUntil: null,
34 costUsd: null,
35} satisfies StatusbarState as StatusbarState)
36
37const BRANCH_POLL_MS = 5000
38const COUNTDOWN_REDRAW_MS = 30_000
39const GIT_TIMEOUT_MS = 4000
40
41type ModeCarrier = { permission_mode?: string | undefined; agent_id?: string | undefined }
42
43// The `$` helpers live in this file: the engine follows `$` into functions
44// declared here, never across an import.
45
46async function patch($: EngineInterface, changes: Changes): Promise<void> {
47 const keys = changedKeys(await read($, bar), changes)
48
49 if (keys.length > 0) {
50 await update($, bar, state => applyChanges(state, changes, keys))
51 }
52}
53
54/**
55 * The branch of the session's directory: its name, a short commit id in
56 * parentheses when HEAD is detached, null outside a repository (or without git).
57 *
58 * `branch --show-current` rather than `rev-parse --abbrev-ref HEAD`: it also
59 * names the branch of a repository that has no commit yet.
60 */
61async function readBranch($: EngineInterface): Promise<string | null> {
62 try {
63 const named = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: GIT_TIMEOUT_MS })
64
65 if (named.exitCode !== 0) {
66 return null
67 }
68
69 const name = named.stdout.trim()
70
71 if (name !== '') {
72 return name
73 }
74
75 const commit = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { timeoutMs: GIT_TIMEOUT_MS })
76 const id = commit.stdout.trim()
77
78 return commit.exitCode === 0 && id !== '' ? `(${id})` : null
79 } catch {
80 return null
81 }
82}
83
84// The status line's own figures: free to ask for, so asked for often.
85async function refreshUsage($: EngineInterface): Promise<void> {
86 await safely(async () => {
87 const { context, rateLimits, cost } = await $.session.usage()
88 const fiveHour = rateLimits.find(limit => limit.kind === 'five_hour')
89 const week = rateLimits.find(limit => limit.kind === 'seven_day')
90
91 await patch($, {
92 contextPercent: context.percent ?? null,
93 fiveHourPercent: fiveHour?.percentUsed ?? null,
94 weekPercent: week?.percentUsed ?? null,
95 fiveHourResetsAt: fiveHour?.resetsAt ?? null,
96 weekResetsAt: week?.resetsAt ?? null,
97 costUsd: cost?.usd ?? null,
98 })
99 })
100}
101
102async function refreshBranch($: EngineInterface): Promise<void> {
103 await safely(async () => patch($, { branch: await readBranch($) }))
104}
105
106// A subagent's events carry its own mode, not the session's.
107async function noteMode($: EngineInterface, e: ModeCarrier, event: string): Promise<void> {
108
109 if (e.agent_id === undefined && e.permission_mode !== undefined) {
110 await safely(() => patch($, { mode: e.permission_mode }))
111 }
112}
113
114// Fills the bar once at start-up, then keeps the branch fresh on a timer.
115//
116// A hot reload runs this again over state the session has already built up, so the
117// mode and effort are taken from the settings only while nothing better is known:
118// the settings name where a session starts, not where it is by now.
119async function start($: EngineInterface): Promise<void> {
120 const known = await read($, bar)
121 const settings = await $.settings.read().catch(() => ({}))
122 const model = await $.session.model().catch(() => null)
123 const cwd = await $.session.cwd().catch(() => null)
124 const profile = await $.env.get('USERPROFILE').catch(() => undefined)
125 const home = profile ?? (await $.env.get('HOME').catch(() => undefined))
126
127 await patch($, {
128 model,
129 cwd,
130 home: home ?? null,
131 effort: known.effort ?? effortFromSettings(settings, model),
132 mode: known.mode ?? modeFromSettings(settings) ?? 'default',
133 })
134 await refreshBranch($)
135 await refreshUsage($)
136
137 // Checkouts made outside this session (another terminal, an editor), a compaction,
138 // a /clear, or a window that resets with the session idle show up within seconds.
139 $.clock.every(BRANCH_POLL_MS, () => {
140 void refreshUsage($)
141 void refreshBranch($)
142 })
143
144 // The countdown to the five-hour reset moves with the clock, not with the state.
145 $.clock.every(COUNTDOWN_REDRAW_MS, () => {
146 $.ui.invalidate('ui.render')
147 })
148}
149
150async function followModel($: EngineInterface, model: string): Promise<void> {
151 const settings = await $.settings.read()
152
153 await patch($, { model, effort: effortFromSettings(settings, model) })
154}
155
156// /effort changes it between requests: take the level typed, or re-read the settings.
157async function followEffortCommand($: EngineInterface, args: string): Promise<void> {
158 const typed = args.trim().toLowerCase()
159
160 if (isEffortLevel(typed)) {
161 await patch($, { effort: typed })
162
163 return
164 }
165
166 const settings = await $.settings.read()
167
168 await patch($, { effort: effortFromSettings(settings, (await read($, bar)).model) })
169}
170
171/**
172 * A band above the prompt: model, effort, permission mode, directory, branch.
173 *
174 * Everything the band shows lives in the `bar` atom. The hooks below only keep
175 * it fresh; the render hook reads it, so a write redraws the band.
176 */
177export const registerStatusbar = (on: On): void => {
178 let lastHint: string | undefined
179
180 on('session.start', ($, e, next) => {
181 void safely(() => start($))
182
183 return next(e)
184 })
185
186 // The model: /model, a picker, a fallback.
187 on('classic.PostModelSwitch', async ($, e, next) => {
188 await safely(() => followModel($, e.to_model))
189 // Another model has no cache of this conversation yet.
190 await safely(() => patch($, { cacheWarmUntil: null }))
191
192 return next(e)
193 })
194
195 // The effort each request really carries (the engine may lower it for a model); main loop only.
196 on('turn.step', async function* ($, e, next) {
197 if (e.agentId === undefined) {
198 await safely(() => patch($, { model: e.model, effort: e.effort === undefined ? null : String(e.effort) }))
199 }
200
201 const result = yield* next(e)
202
203 // Each response moves the context and the rate-limit windows, and rewrites the
204 // prompt cache: it stays warm a TTL from now.
205 if (e.agentId === undefined) {
206 await refreshUsage($)
207
208 if (result.usage !== null) {
209 await safely(() => patch($, { cacheWarmUntil: new Date(Date.now() + CACHE_TTL_MS).toISOString() }))
210 }
211 }
212
213 return result
214 })
215
216 on('command.run', { command: 'effort' }, async ($, e, next) => {
217 const ran = await next(e)
218
219 await safely(() => followEffortCommand($, e.args))
220
221 return ran
222 })
223
224 on('classic.CwdChanged', async ($, e, next) => {
225 await safely(() => patch($, { cwd: e.new_cwd }))
226 void refreshBranch($)
227
228 return next(e)
229 })
230
231 // The permission mode rides on the base fields of the classic events.
232 on('classic.SessionStart', async ($, e, next) => {
233 await noteMode($, e, 'SessionStart')
234
235 return next(e)
236 })
237
238 on('classic.UserPromptSubmit', async ($, e, next) => {
239 await noteMode($, e, 'UserPromptSubmit')
240
241 return next(e)
242 })
243
244 on('classic.PermissionRequest', async ($, e, next) => {
245 await noteMode($, e, 'PermissionRequest')
246
247 return next(e)
248 })
249
250 on('classic.PostToolUse', async ($, e, next) => {
251 await noteMode($, e, 'PostToolUse')
252
253 if (e.tool_name === 'Bash' && e.agent_id === undefined) {
254 void refreshBranch($)
255 }
256
257 return next(e)
258 })
259
260 on('classic.Stop', async ($, e, next) => {
261 await noteMode($, e, 'Stop')
262 void refreshBranch($)
263 void refreshUsage($)
264
265 return next(e)
266 })
267
268 // A compaction empties most of the context window.
269 on('classic.PostCompact', async ($, e, next) => {
270 void refreshUsage($)
271
272 return next(e)
273 })
274
275 // The surest word on a mode switch: every switch (Shift+Tab, a plan approved,
276 // /permissions) logs `permission_mode_changed` { from_mode, to_mode } for an
277 // OpenTelemetry collector. The engine raises it through the hooks whether or not
278 // a collector is configured; this hook only reads it and passes it on unchanged.
279 on('telemetry.log', { to: 'collector', event: 'permission_mode_changed' }, async ($, e, next) => {
280 const mode = modeFromTelemetry(e.attributes.to_mode)
281
282
283 if (mode !== undefined) {
284 await safely(() => patch($, { mode }))
285 }
286
287 return next(e)
288 })
289
290 // Shift+Tab changes the mode between events. The footer names the new mode for a
291 // moment, then draws without a label in every mode: take the flash, never the silence.
292 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
293 const { hint, isWorking } = e.props
294
295 if (hint !== lastHint) {
296 lastHint = hint
297
298 const mode = modeFromHint(hint)
299
300 if (mode !== undefined) {
301 // A render hook may not write state while it draws: write after it returns.
302 $.clock.after(0, () => {
303 void safely(() => patch($, { mode }))
304 })
305 }
306 }
307
308 return next(e)
309 })
310
311 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
312 if (e.props.hasSurvey) {
313 return next(e)
314 }
315
316 const state = await read($, bar)
317 const rows = [layout(state, e.props.bodyColumns), usageSegments(state, Date.now())].filter(row => row.length > 0)
318
319
320 if (rows.length === 0) {
321 return next(e)
322 }
323
324 const { Box, Text } = $.ui.resolve(e)
325
326 return (
327 <Box flexDirection="column" marginTop={1}>
328 {rows.map(row => (
329 <Box>
330 {row.map((segment, index) => (
331 <Box>
332 {index > 0 && <Text dimColor>{SEPARATOR}</Text>}
333 <Text color={segment.color} bold={segment.isBold} wrap="truncate">
334 {segment.text}
335 </Text>
336 {segment.detail !== undefined && <Text dimColor wrap="truncate">{` ${segment.detail}`}</Text>}
337 </Box>
338 ))}
339 </Box>
340 ))}
341 </Box>
342 )
343 })
344}
345mods/workflow/workflow.tsx 219 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { WorkflowState, WorkflowStatus } from '../../types'
5import { safely } from '../statusbar/state'
6import {
7 agentFinished,
8 agentPhased,
9 agentStarted,
10 ARROW,
11 chain,
12 cleared,
13 declaredPhases,
14 ended,
15 launched,
16 statusFromNotification,
17 unphased,
18} from './progress'
19
20// Every run the hint shows. The hooks keep it fresh, the render hook reads it,
21// and a write redraws the hint.
22const progress = atom({ plugin: 'workbench', key: 'workflow' } as const, { runs: [] } satisfies WorkflowState as WorkflowState)
23
24// When an agent's phase is looked for again after its start, if its meta file was not there yet.
25const PHASE_RETRY_MS = [1000, 3000, 10_000]
26
27// What the Workflow tool answers once its run is launched (its record's fields this mod reads).
28type Launched = {
29 status?: string
30 taskId?: string
31 runId?: string
32 workflowName?: string
33 transcriptDir?: string
34 scriptPath?: string
35}
36
37// The `$` helpers live in this file: the engine follows `$` into functions
38// declared here, never across an import.
39
40async function change($: EngineInterface, fn: (state: WorkflowState) => WorkflowState): Promise<void> {
41 await update($, progress, state => fn(state ?? { runs: [] }))
42}
43
44// The script is read from where the run keeps it, whichever way it was given
45// (inline, by name, by path, resumed): one source for the phase titles.
46async function noteLaunch($: EngineInterface, record: Launched): Promise<void> {
47 if (record.status !== 'async_launched' || record.runId === undefined) {
48 return
49 }
50
51 const script = record.scriptPath === undefined ? '' : await $.fs.read(record.scriptPath).catch(() => '')
52
53 await change($, state =>
54 launched(state, {
55 runId: record.runId as string,
56 taskId: record.taskId ?? null,
57 name: record.workflowName ?? null,
58 transcriptDir: record.transcriptDir ?? null,
59 phases: declaredPhases(script),
60 }),
61 )
62 await resolvePhases($)
63}
64
65// Each agent's phase is in the meta file the run writes beside its transcript
66// (`agent-<id>.meta.json`, `workflowPhase`); the spawn event does not carry it.
67// Looked for at a launch, at each agent's start (and shortly after), and at its end.
68async function resolvePhases($: EngineInterface): Promise<void> {
69 for (const { agentId, transcriptDir } of unphased(await read($, progress))) {
70 try {
71 const meta = JSON.parse(await $.fs.read(`${transcriptDir}/agent-${agentId}.meta.json`)) as {
72 workflowPhase?: unknown
73 }
74 const phase = typeof meta.workflowPhase === 'string' ? meta.workflowPhase : ''
75
76 await change($, state => agentPhased(state, agentId, phase))
77 } catch {
78 // Not written yet: the agent's end looks again.
79 }
80 }
81}
82
83async function noteEnd($: EngineInterface, taskId: string, status: WorkflowStatus): Promise<void> {
84 const { runs } = await read($, progress)
85
86 if (runs.some(run => run.taskId === taskId && run.status !== status)) {
87 await change($, state => ended(state, taskId, status))
88 }
89}
90
91/**
92 * The steps of each workflow run in the band above the prompt, under the
93 * statusbar: `Collect (3/3) -> Analyze (1/2) -> Summarize`, the
94 * finished steps green, the current one blue, the ones to come grey, and the
95 * step a failed or stopped run ended in red.
96 */
97export const registerWorkflow = (on: On): void => {
98 // A launch (or a resume) names the run, its task, its script and its folder.
99 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
100 const called = await next(e)
101
102 if (called.result !== undefined) {
103 await safely(() => noteLaunch($, called.result as Launched))
104 }
105
106 return called
107 }).catch(($, e, next) => next(e))
108
109 // Each agent a run's script starts; a retry of a stalled one is not raised again.
110 on('agent.spawn', async ($, e, next) => {
111 const spawned = await next(e)
112 const runId = e.workflow?.runId
113
114 if (runId !== undefined && runId !== '' && spawned.agentId !== undefined) {
115 const agentId = spawned.agentId
116
117 await safely(async () => {
118 await change($, state => agentStarted(state, runId, agentId))
119 await resolvePhases($)
120 })
121
122 // In case its meta file is written after the spawn resolves.
123 for (const delay of PHASE_RETRY_MS) {
124 $.clock.after(delay, () => {
125 void safely(() => resolvePhases($))
126 })
127 }
128 }
129
130 return spawned
131 }).catch(($, e, next) => next(e))
132
133 on('turn.complete', async ($, e, next) => {
134 const agentId = e.agentId
135
136 if (agentId !== undefined) {
137 await safely(async () => {
138 const { runs } = await read($, progress)
139
140 if (runs.some(run => run.agents.some(agent => agent.id === agentId && !agent.isFinished))) {
141 await change($, state => agentFinished(state, agentId))
142 await resolvePhases($)
143 }
144 })
145 }
146
147 return next(e)
148 })
149
150 // How the run ended reaches the session as its task's notification.
151 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, ($, e, next) => {
152 const { id, status } = e.props.task ?? {}
153
154 if (id !== undefined && status !== undefined) {
155 // A render hook may not write state while it draws: write after it returns.
156 $.clock.after(0, () => {
157 void safely(() => noteEnd($, id, statusFromNotification(status)))
158 })
159 }
160
161 return next(e)
162 })
163
164 // The person's next prompt clears the runs that ended; a running one stays.
165 on('prompt.submit', async ($, e, next) => {
166 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
167 await safely(async () => {
168 const { runs } = await read($, progress)
169
170 if (runs.some(run => run.status !== 'running')) {
171 await change($, cleared)
172 }
173 })
174 }
175
176 return next(e)
177 }).catch(($, e, next) => next(e))
178
179 // The band above the prompt: what the hooks beneath drew (the statusbar's
180 // rows, registered after this mod), then one row per run. Registered first,
181 // this hook is the outer one, so it sees their tree and adds to it.
182 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
183 const beneath = await next(e)
184
185 if (e.props.hasSurvey) {
186 return beneath
187 }
188
189 const rows = (await read($, progress)).runs.map(chain).filter(row => row.length > 0)
190
191 if (rows.length === 0) {
192 return beneath
193 }
194
195 const { Box, Text } = $.ui.resolve(e)
196 // A tree beneath is kept and the steps go under it; the engine's own band
197 // (nothing beneath had rows) is replaced by the steps alone.
198 const isTree = beneath.type === 'Box' || beneath.type === 'Text'
199
200 return (
201 <Box flexDirection="column" marginTop={isTree ? 0 : 1}>
202 {isTree && beneath}
203 {rows.map(row => (
204 <Box>
205 {row.map((segment, index) => (
206 <Box>
207 {index > 0 && <Text dimColor>{ARROW}</Text>}
208 <Text color={segment.color} bold={segment.isBold} wrap="truncate">
209 {segment.text}
210 </Text>
211 </Box>
212 ))}
213 </Box>
214 ))}
215 </Box>
216 )
217 })
218}
219mods/statusbar/format.ts 388 lines1import type { Color } from 'claude-code'
2
3import type { StatusbarState } from '../../types'
4
5export type SegmentId = 'model' | 'effort' | 'mode' | 'dir' | 'branch' | 'context' | 'fiveHour' | 'week' | 'cache' | 'cost'
6
7export type Segment = {
8 id: SegmentId
9 text: string
10 color: Color
11 isBold?: boolean
12 /** Drawn dim after the text (a reset time). */
13 detail?: string
14}
15
16export const SEPARATOR = ' · '
17
18const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
19
20export const isEffortLevel = (text: string): boolean =>
21 (EFFORT_LEVELS as readonly string[]).includes(text)
22
23const FAMILIES = 'opus|sonnet|haiku|fable'
24// claude-sonnet-5-5, claude-haiku-4-5-20251001, claude-sonnet-4-20250514[1m]
25const MODERN = new RegExp(
26 `^claude-(${FAMILIES})-(\\d+)(?:-(\\d{1,2}))?(?:-\\d{8})?(\\[[^\\]]*\\])?$`,
27 'i',
28)
29// claude-3-5-sonnet-20241022
30const LEGACY = new RegExp(
31 `^claude-(\\d+)(?:-(\\d{1,2}))?-(${FAMILIES})(?:-\\d{8})?(\\[[^\\]]*\\])?$`,
32 'i',
33)
34
35const titleCase = (word: string): string =>
36 word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
37
38const describeModel = (
39 family: string | undefined,
40 major: string | undefined,
41 minor: string | undefined,
42 context: string | undefined,
43): string => {
44 const version = minor === undefined ? major : `${major}.${minor}`
45 const window = context === undefined ? '' : ` (${context.slice(1, -1).toUpperCase()})`
46
47 return `${titleCase(family ?? '')} ${version}${window}`
48}
49
50/** `claude-sonnet-5-5` becomes `Sonnet 5.5`; an id it does not recognise stays as it is. */
51export const modelLabel = (id: string): string => {
52 if (/\s/.test(id)) {
53 return id
54 }
55
56 const modern = MODERN.exec(id)
57
58 if (modern) {
59 return describeModel(modern[1], modern[2], modern[3], modern[4])
60 }
61
62 const legacy = LEGACY.exec(id)
63
64 if (legacy) {
65 return describeModel(legacy[3], legacy[1], legacy[2], legacy[4])
66 }
67
68 return id.replace(/^claude-/, '')
69}
70
71/** A level stays as it is, a numeric thinking budget reads `8k`. */
72export const effortLabel = (effort: string | null): string | null => {
73 if (effort === null || effort === '') {
74 return null
75 }
76
77 if (/^\d+$/.test(effort)) {
78 const budget = Number(effort)
79
80 return budget >= 1000 ? `${Math.round(budget / 1000)}k` : effort
81 }
82
83 return effort
84}
85
86const effortColor = (effort: string): Color =>
87 effort === 'high' || effort === 'xhigh' || effort === 'max' ? 'suggestion' : 'inactive'
88
89// The words the prompt footer itself uses; the default mode is its "manual mode".
90const MODES = new Map<string, { text: string; color: Color }>([
91 ['default', { text: 'manual mode', color: 'inactive' }],
92 ['acceptEdits', { text: '⏵⏵ accept edits', color: 'autoAccept' }],
93 ['plan', { text: '⏸ plan mode', color: 'planMode' }],
94 ['auto', { text: '⏵⏵ auto mode', color: 'autoAccept' }],
95 ['dontAsk', { text: "⏵⏵ don't ask", color: 'warning' }],
96 ['bypassPermissions', { text: '⏵⏵ bypass', color: 'error' }],
97])
98
99export const modeLabel = (mode: string): { text: string; color: Color } =>
100 MODES.get(mode) ?? { text: mode, color: 'text' }
101
102/** Dollars with cents, a cent and below as `<$0.01`. */
103export const costLabel = (usd: number): string => (usd < 0.01 ? '<$0.01' : `$${usd.toFixed(2)}`)
104
105const clip = (text: string, max: number): string =>
106 text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`
107
108/**
109 * The directory with the home folder as `~`, forward slashes, and — when it is
110 * longer than `maxLength` — only its tail, led by `…/`.
111 */
112export const shortPath = (cwd: string, home: string | null, maxLength: number): string => {
113 let path = cwd.replace(/\\/g, '/')
114 const base = home?.replace(/\\/g, '/').replace(/\/+$/, '')
115
116 if (base && (path.toLowerCase() === base.toLowerCase() || path.toLowerCase().startsWith(`${base.toLowerCase()}/`))) {
117 path = `~${path.slice(base.length)}`
118 }
119
120 path = path.replace(/\/+$/, '') || '/'
121
122 if (path.length <= maxLength) {
123 return path
124 }
125
126 const parts = path.split('/')
127 let tail = ''
128
129 for (let i = parts.length - 1; i >= 0; i -= 1) {
130 const grown = tail === '' ? (parts[i] ?? '') : `${parts[i]}/${tail}`
131
132 if (grown.length + 2 > maxLength) {
133 break
134 }
135
136 tail = grown
137 }
138
139 if (tail === '') {
140 const last = parts[parts.length - 1] ?? path
141
142 return `…${last.slice(-(maxLength - 1))}`
143 }
144
145 return `…/${tail}`
146}
147
148export const buildSegments = (state: StatusbarState, dirMax: number): Segment[] => {
149 const segments: Segment[] = []
150
151 if (state.model) {
152 segments.push({ id: 'model', text: modelLabel(state.model), color: 'claude', isBold: true })
153 }
154
155 const effort = effortLabel(state.effort)
156
157 if (state.effort && effort) {
158 segments.push({
159 id: 'effort',
160 text: `effort ${effort}`,
161 color: effortColor(state.effort),
162 })
163 }
164
165 if (state.mode) {
166 const mode = modeLabel(state.mode)
167 segments.push({ id: 'mode', text: mode.text, color: mode.color, isBold: state.mode !== 'default' })
168 }
169
170 if (state.cwd) {
171 segments.push({ id: 'dir', text: shortPath(state.cwd, state.home, dirMax), color: 'suggestion' })
172 }
173
174 if (state.branch) {
175 segments.push({ id: 'branch', text: `git:${clip(state.branch, 28)}`, color: 'merged' })
176 }
177
178 if (state.costUsd !== null) {
179 segments.push({ id: 'cost', text: `API ${costLabel(state.costUsd)}`, color: 'inactive' })
180 }
181
182 return segments
183}
184
185const widthOf = (segments: readonly Segment[]): number =>
186 segments.reduce((sum, segment) => sum + segment.text.length, 0) +
187 SEPARATOR.length * Math.max(0, segments.length - 1)
188
189// What goes first when the row is too narrow: the least telling segment.
190const DROP_ORDER: readonly SegmentId[] = ['cost', 'effort', 'dir', 'branch', 'model', 'mode']
191
192/** The segments that fit in `columns` cells: the directory shortens first, then segments drop. */
193export const layout = (state: StatusbarState, columns: number): Segment[] => {
194 for (const dirMax of [32, 20, 12]) {
195 const segments = buildSegments(state, dirMax)
196
197 if (widthOf(segments) <= columns) {
198 return segments
199 }
200 }
201
202 let segments = buildSegments(state, 12)
203
204 for (const id of DROP_ORDER) {
205 if (widthOf(segments) <= columns) {
206 break
207 }
208
209 segments = segments.filter(segment => segment.id !== id)
210 }
211
212 return segments
213}
214
215// Green while there is room, amber past half, red past 80 %.
216const percentColor = (percent: number): Color =>
217 percent > 80 ? 'error' : percent >= 50 ? 'warning' : 'success'
218
219/** How long until `resetsAt`, rounded up to the minute: `37m`, `2h 14m`, `3d 4h`; null once it has passed. */
220export const timeUntil = (resetsAt: string, now: number): string | null => {
221 const ms = Date.parse(resetsAt) - now
222
223 if (!Number.isFinite(ms) || ms <= 0) {
224 return null
225 }
226
227 const minutes = Math.ceil(ms / 60_000)
228 const days = Math.floor(minutes / 1440)
229 const hours = Math.floor((minutes % 1440) / 60)
230 const rest = minutes % 60
231
232 if (days > 0) return `${days}d ${hours}h`
233 if (hours > 0) return `${hours}h ${rest}m`
234
235 return `${rest}m`
236}
237
238/** The day and local time of `resetsAt` in the host's locale (`pon 14:00`); null when it does not parse. */
239export const resetDay = (resetsAt: string, locale?: string): string | null => {
240 const date = new Date(resetsAt)
241
242 if (Number.isNaN(date.getTime())) {
243 return null
244 }
245
246 return new Intl.DateTimeFormat(locale, { weekday: 'short', hour: '2-digit', minute: '2-digit' })
247 .format(date)
248 .replace(/[.,]/g, '')
249 .replace(/\s+/g, ' ')
250 .trim()
251}
252
253/**
254 * The second row: context window, five-hour window and weekly window used, in
255 * per cent, the five-hour window with the time left until it resets and the
256 * weekly one with the day and hour it resets. A value the session has not
257 * reported (no response yet, or an API key with no rate-limit windows) is left
258 * out; with none, the row is empty.
259 */
260export const usageSegments = (state: StatusbarState, now: number, locale?: string): Segment[] => {
261 const percent = (value: number) => `${Math.round(value)}%`
262 const segments: Segment[] = []
263
264 if (state.contextPercent !== null) {
265 segments.push({
266 id: 'context',
267 text: `context ${percent(state.contextPercent)}`,
268 color: percentColor(state.contextPercent),
269 })
270 }
271
272 if (state.fiveHourPercent !== null) {
273 const left = state.fiveHourResetsAt === null ? null : timeUntil(state.fiveHourResetsAt, now)
274
275 segments.push({
276 id: 'fiveHour',
277 text: `5h ${percent(state.fiveHourPercent)}`,
278 color: percentColor(state.fiveHourPercent),
279 ...(left === null ? {} : { detail: `↻ ${left}` }),
280 })
281 }
282
283 if (state.weekPercent !== null) {
284 const day = state.weekResetsAt === null ? null : resetDay(state.weekResetsAt, locale)
285
286 segments.push({
287 id: 'week',
288 text: `week ${percent(state.weekPercent)}`,
289 color: percentColor(state.weekPercent),
290 ...(day === null ? {} : { detail: `↻ ${day}` }),
291 })
292 }
293
294 if (state.cacheWarmUntil !== null) {
295 const left = Date.parse(state.cacheWarmUntil) - now
296 const label = timeUntil(state.cacheWarmUntil, now)
297
298 segments.push(
299 label === null
300 ? { id: 'cache', text: 'cache cold', color: 'error' }
301 : { id: 'cache', text: `cache ${label}`, color: left <= CACHE_LOW_MS ? 'warning' : 'success' },
302 )
303 }
304
305 return segments
306}
307
308// Under five minutes left the next prompt is worth sending soon: amber.
309const CACHE_LOW_MS = 5 * 60_000
310
311/**
312 * How long the prompt cache keeps the main thread's prefix: the CLI writes the
313 * interactive main thread's cache with the one-hour TTL. (Plugins are not told
314 * the TTL; guessing five minutes from missing rate-limit windows showed a warm
315 * cache as cold.)
316 */
317export const CACHE_TTL_MS = 60 * 60_000
318
319const record = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
320 typeof value === 'object' && value !== null && !Array.isArray(value)
321 ? (value as Readonly<Record<string, unknown>>)
322 : undefined
323
324/** The effort the settings give: the model's own entry first, then the global one. */
325export const effortFromSettings = (
326 settings: Readonly<Record<string, unknown>>,
327 model: string | null,
328): string | null => {
329 const own = model === null ? undefined : record(record(settings.modelSettings)?.[model])?.effortLevel
330 const level = own ?? settings.effortLevel
331
332 return typeof level === 'string' || typeof level === 'number' ? String(level) : null
333}
334
335/** The permission mode a session starts in, per the settings. */
336export const modeFromSettings = (settings: Readonly<Record<string, unknown>>): string | null => {
337 const mode = record(settings.permissions)?.defaultMode
338
339 return typeof mode === 'string' ? mode : null
340}
341
342const MODE_TOKENS = new Map(
343 ['default', 'acceptEdits', 'plan', 'auto', 'dontAsk', 'bypassPermissions'].map(mode => [
344 mode.toLowerCase(),
345 mode,
346 ]),
347)
348
349/**
350 * A permission mode as the CLI's telemetry spells it: a plain string, or a
351 * first-party choice `{ value, of }` whose tokens are lowercase (`acceptedits`,
352 * maybe `accept_edits`). Undefined for anything that is not a known mode.
353 */
354export const modeFromTelemetry = (value: unknown): string | undefined => {
355 const raw =
356 typeof value === 'string'
357 ? value
358 : typeof value === 'object' && value !== null && typeof (value as { value?: unknown }).value === 'string'
359 ? (value as { value: string }).value
360 : undefined
361
362 return raw === undefined ? undefined : MODE_TOKENS.get(raw.toLowerCase().replace(/[_-]/g, ''))
363}
364
365/**
366 * The permission mode the prompt footer announces, read from the hint line.
367 *
368 * The footer names a mode only for a moment after a Shift+Tab ("plan mode on
369 * (shift+tab to cycle)", "auto mode on (...)", "accept edits on (...)", and
370 * "manual mode on" for the default mode), then redraws without a label in EVERY
371 * mode ("(shift+tab to cycle) · ← for agents"). So a label is news of a switch,
372 * and a line without one says nothing: it is no proof of the default mode.
373 *
374 * Undefined when the line names no mode.
375 */
376export const modeFromHint = (hint: string): string | undefined => {
377 const text = hint.toLowerCase()
378
379 if (/plan mode on/.test(text)) return 'plan'
380 if (/auto mode on/.test(text)) return 'auto'
381 if (/accept edits on/.test(text)) return 'acceptEdits'
382 if (/bypass permissions on/.test(text)) return 'bypassPermissions'
383 if (/don.?t ask on/.test(text)) return 'dontAsk'
384 if (/manual mode on/.test(text)) return 'default'
385
386 return undefined
387}
388mods/statusbar/state.ts 37 lines1import type { StatusbarState } from '../../types'
2
3// The pure half of the bar's state handling. The atom itself and every call that
4// takes `$` are in statusbar.tsx, the one file the engine follows `$` through.
5
6export type Changes = { [K in keyof StatusbarState]?: StatusbarState[K] | undefined }
7
8/** The keys of `changes` that would alter `current`; a key left out or `undefined` is kept. */
9export const changedKeys = (current: StatusbarState, changes: Changes): (keyof StatusbarState)[] =>
10 (Object.keys(changes) as (keyof StatusbarState)[]).filter(
11 key => changes[key] !== undefined && changes[key] !== current[key],
12 )
13
14/** `current` with `keys` taken from `changes`; `null` clears a value. */
15export const applyChanges = (
16 current: StatusbarState,
17 changes: Changes,
18 keys: readonly (keyof StatusbarState)[],
19): StatusbarState => {
20 const next = { ...current }
21
22 for (const key of keys) {
23 next[key] = changes[key] as never
24 }
25
26 return next
27}
28
29/** Runs `work` and swallows what it throws: an observer must never take the chain down. */
30export const safely = async (work: () => Promise<unknown>): Promise<void> => {
31 try {
32 await work()
33 } catch {
34 // The bar is decoration: a failed refresh leaves the last known value on screen.
35 }
36}
37mods/workflow/progress.ts 244 lines1import type { Color } from 'claude-code'
2
3import type { WorkflowRun, WorkflowState, WorkflowStatus } from '../../types'
4
5// The pure half of the workflow mod: reading a script's phases, the state's
6// transitions and the chain the prompt hint draws. The atom and every call that
7// takes `$` are in workflow.tsx.
8
9export const ARROW = ' -> '
10
11/** How one step of the chain is drawn: finished, running now, not started, or where the run stopped. */
12export type StepState = 'done' | 'active' | 'pending' | 'failed'
13
14export type Step = {
15 title: string
16 /** Agents of the phase whose turn ended. */
17 finished: number
18 /** Agents of the phase started so far. */
19 total: number
20 state: StepState
21}
22
23export type ChainSegment = { text: string; color: Color; isBold: boolean }
24
25const COLORS: Record<StepState, Color> = {
26 done: 'success',
27 active: 'suggestion',
28 pending: 'inactive',
29 failed: 'error',
30}
31
32// The index just past the bracket that closes the one opened before `from`;
33// string literals are skipped, so a `]` in a title does not close the list.
34const closingBracket = (source: string, from: number): number => {
35 let depth = 1
36 let quote: string | null = null
37
38 for (let index = from; index < source.length; index++) {
39 const char = source[index]
40
41 if (quote !== null) {
42 if (char === '\\') {
43 index++
44 } else if (char === quote) {
45 quote = null
46 }
47 } else if (char === "'" || char === '"' || char === '`') {
48 quote = char
49 } else if (char === '[') {
50 depth++
51 } else if (char === ']' && --depth === 0) {
52 return index
53 }
54 }
55
56 return source.length
57}
58
59/**
60 * The phase titles a workflow script's `meta.phases` declares, in order; none
61 * when it declares none. `meta` is a pure literal by the Workflow tool's rule,
62 * so the source names them as written.
63 */
64export const declaredPhases = (script: string): string[] => {
65 const meta = /\bmeta\s*=\s*\{/.exec(script)
66
67 if (meta === null) {
68 return []
69 }
70
71 const key = /\bphases\s*:\s*\[/g
72 key.lastIndex = meta.index
73 const list = key.exec(script)
74
75 if (list === null) {
76 return []
77 }
78
79 const start = list.index + list[0].length
80 const body = script.slice(start, closingBracket(script, start))
81
82 return [...body.matchAll(/\btitle\s*:\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1/g)].map(match =>
83 (match[2] ?? '').replace(/\\(.)/g, '$1'),
84 )
85}
86
87const emptyRun = (runId: string): WorkflowRun => ({
88 runId,
89 taskId: null,
90 name: null,
91 transcriptDir: null,
92 phases: [],
93 agents: [],
94 status: 'running',
95})
96
97const withRun = (state: WorkflowState, runId: string, change: (run: WorkflowRun) => WorkflowRun): WorkflowState => {
98 const known = state.runs.some(run => run.runId === runId)
99
100 return {
101 runs: known
102 ? state.runs.map(run => (run.runId === runId ? change(run) : run))
103 : [...state.runs, change(emptyRun(runId))],
104 }
105}
106
107export type Launch = Pick<WorkflowRun, 'runId' | 'taskId' | 'name' | 'transcriptDir' | 'phases'>
108
109/**
110 * A Workflow call started (or resumed) a run. A resume keeps the run's id: the
111 * agents that finished stay counted, the ones it cut short start over.
112 */
113export const launched = (state: WorkflowState, launch: Launch): WorkflowState =>
114 withRun(state, launch.runId, run => ({
115 ...run,
116 ...launch,
117 agents: run.status === 'running' ? run.agents : run.agents.filter(agent => agent.isFinished),
118 status: 'running',
119 }))
120
121/** One of the run's `agent()` calls started an agent. */
122export const agentStarted = (state: WorkflowState, runId: string, agentId: string): WorkflowState =>
123 withRun(state, runId, run =>
124 run.agents.some(agent => agent.id === agentId)
125 ? run
126 : { ...run, agents: [...run.agents, { id: agentId, phase: null, isFinished: false }] },
127 )
128
129const withAgent = (
130 state: WorkflowState,
131 agentId: string,
132 change: (agent: WorkflowRun['agents'][number]) => WorkflowRun['agents'][number],
133): WorkflowState => ({
134 runs: state.runs.map(run =>
135 run.agents.some(agent => agent.id === agentId)
136 ? { ...run, agents: run.agents.map(agent => (agent.id === agentId ? change(agent) : agent)) }
137 : run,
138 ),
139})
140
141/** The agent's meta file named its phase (`''` for none). */
142export const agentPhased = (state: WorkflowState, agentId: string, phase: string): WorkflowState =>
143 withAgent(state, agentId, agent => ({ ...agent, phase }))
144
145/** The agent's turn ended, answered or not. */
146export const agentFinished = (state: WorkflowState, agentId: string): WorkflowState =>
147 withAgent(state, agentId, agent => ({ ...agent, isFinished: true }))
148
149/** A task notification's word for how a task ended, as a run's status. */
150export const statusFromNotification = (status: string): WorkflowStatus =>
151 status === 'completed' ? 'completed' : status === 'killed' ? 'killed' : 'failed'
152
153/** The run's task ended; a task this mod does not track changes nothing. */
154export const ended = (state: WorkflowState, taskId: string, status: WorkflowStatus): WorkflowState => ({
155 runs: state.runs.map(run => (run.taskId === taskId ? { ...run, status } : run)),
156})
157
158/** The runs still running: the person's next prompt clears the ones that ended. */
159export const cleared = (state: WorkflowState): WorkflowState => ({
160 runs: state.runs.filter(run => run.status === 'running'),
161})
162
163/** The agents whose phase is not known yet, with the folder their meta file is in. */
164export const unphased = (state: WorkflowState): { agentId: string; transcriptDir: string }[] =>
165 state.runs.flatMap(run =>
166 run.transcriptDir === null
167 ? []
168 : run.agents
169 .filter(agent => agent.phase === null)
170 .map(agent => ({ agentId: agent.id, transcriptDir: run.transcriptDir as string })),
171 )
172
173/**
174 * The run's steps in order: the phases `meta` declares, then any other phase an
175 * agent was filed under. An agent filed under none counts toward a step named
176 * after the workflow; one whose phase is not read yet is not counted.
177 *
178 * While the run goes on, a step with agents still working is active, and so is
179 * a finished step no later step has started after (the script is between
180 * phases). A run that completed shows every started step done. One that failed
181 * or was stopped shows the steps before the one it stopped in done and that one
182 * red: the first with an agent unfinished, else the last that started.
183 */
184export const steps = (run: WorkflowRun): Step[] => {
185 const titles = [...run.phases]
186 const counts = new Map<string, { finished: number; total: number }>()
187
188 for (const agent of run.agents) {
189 if (agent.phase === null) {
190 continue
191 }
192
193 const title = agent.phase === '' ? (run.name ?? 'workflow') : agent.phase
194
195 if (!titles.includes(title)) {
196 titles.push(title)
197 }
198
199 const count = counts.get(title) ?? { finished: 0, total: 0 }
200
201 counts.set(title, {
202 finished: count.finished + (agent.isFinished ? 1 : 0),
203 total: count.total + 1,
204 })
205 }
206
207 const counted = titles.map(title => ({ title, ...(counts.get(title) ?? { finished: 0, total: 0 }) }))
208 const lastStarted = counted.reduce((last, step, index) => (step.total > 0 ? index : last), -1)
209
210 if (run.status === 'completed') {
211 return counted.map(step =>
212 step.total > 0 ? { ...step, finished: step.total, state: 'done' } : { ...step, state: 'pending' },
213 )
214 }
215
216 if (run.status !== 'running') {
217 const unfinished = counted.findIndex(step => step.finished < step.total)
218 const stop = unfinished >= 0 ? unfinished : Math.max(lastStarted, 0)
219
220 return counted.map((step, index) => ({
221 ...step,
222 state: index === stop ? 'failed' : index < stop && step.total > 0 ? 'done' : 'pending',
223 }))
224 }
225
226 return counted.map((step, index) => ({
227 ...step,
228 state:
229 step.total === 0
230 ? 'pending'
231 : step.finished < step.total || index >= lastStarted
232 ? 'active'
233 : 'done',
234 }))
235}
236
237/** The chain the prompt hint draws for one run: one segment per step, its count beside a started one. */
238export const chain = (run: WorkflowRun): ChainSegment[] =>
239 steps(run).map(step => ({
240 text: step.total > 0 ? `${step.title} (${step.finished}/${step.total})` : step.title,
241 color: COLORS[step.state],
242 isBold: step.state === 'active' || step.state === 'failed',
243 }))
244types/index.d.ts 72 lines1/**
2 * What the statusbar mod knows about the session. `null` is "not known yet"
3 * (or, for `branch`, "not in a git repository"): the bar leaves that segment out.
4 */
5export type StatusbarState = {
6 /** The main loop's model id as the engine reports it (`claude-sonnet-5-5`). */
7 model: string | null
8 /** A level (`xhigh`) or a numeric thinking budget, as text. */
9 effort: string | null
10 /** A permission mode: `default`, `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions`. */
11 mode: string | null
12 /** The session's directory, absolute. */
13 cwd: string | null
14 /** The checked-out branch; a short commit id in parentheses when detached. */
15 branch: string | null
16 /** The user's home directory, to shorten paths with `~`. */
17 home: string | null
18 /** How full the context window is, 0 to 100. */
19 contextPercent: number | null
20 /** How much of the five-hour rate-limit window is used, 0 to 100. */
21 fiveHourPercent: number | null
22 /** How much of the weekly rate-limit window is used, 0 to 100. */
23 weekPercent: number | null
24 /** When the five-hour window resets, ISO 8601. */
25 fiveHourResetsAt: string | null
26 /** When the weekly window resets, ISO 8601. */
27 weekResetsAt: string | null
28 /** When the main thread's prompt cache goes cold, ISO 8601: its last response plus the cache's lifetime. */
29 cacheWarmUntil: string | null
30 /** What the session would have cost at API prices, in US dollars, as /cost totals it. */
31 costUsd: number | null
32}
33
34/** Where a workflow run stands: running, or how it ended as its task notification says. */
35export type WorkflowStatus = 'running' | 'completed' | 'failed' | 'killed'
36
37/** One agent a workflow script's `agent()` started. */
38export type WorkflowAgent = {
39 /** The id `agent.spawn` answered, which the agent's `turn.complete` carries. */
40 id: string
41 /** The phase the run filed it under; `''` for none, `null` until its meta file is read. */
42 phase: string | null
43 /** True once its turn ended, answered or not. */
44 isFinished: boolean
45}
46
47/** One run of the Workflow tool, keyed by its `runId` (a resume keeps it). */
48export type WorkflowRun = {
49 runId: string
50 /** The background task's id, which its notification names; null until the launch is seen. */
51 taskId: string | null
52 /** `meta.name` of the script. */
53 name: string | null
54 /** Where the run keeps its agents' transcripts and meta files. */
55 transcriptDir: string | null
56 /** The phase titles the script's `meta.phases` declares, in order. */
57 phases: string[]
58 agents: WorkflowAgent[]
59 status: WorkflowStatus
60}
61
62/** What the workflow mod knows: the runs of this session it still shows. */
63export type WorkflowState = {
64 runs: WorkflowRun[]
65}
66
67declare module 'claude-code' {
68 interface PluginState {
69 workbench: { statusbar: StatusbarState; workflow: WorkflowState }
70 }
71}
72