SLOPSHOPPER

workbench

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…

newbandspinnerrowsguardprompt
v0.1.0no licenseupdated 2026-10-08Pigula1984/workbench
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workbench
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM Opus 5.5 · manual mode · /work/app · git:feat/auth-refresh · API $0.42 context 49% · 5h 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Opus 5.5 · manual mode · /work/app · git:feat/auth-refresh · API $0.42 context 49% · 5h 31%
README

workbench

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.

Mods

statusbar

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:

SegmentWhere it comes from
model$.session.model() at start, classic.PostModelSwitch on /model, the model of each turn.step
efforteffortLevel from settings (a per-model entry first), /effort, the effort of each turn.step
modepermission_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 ~
branchgit 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:

SegmentWhat it showsColors
contexthow full the context window is (context.percent)green < 50 %, amber 50–80 %, red > 80 %
5hthe five-hour rate-limit window used, and ↻ the time left until it resetssame
weekthe weekly rate-limit window used, and ↻ the local day and hour it resetssame
cachehow 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 readinggreen, 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.

workflow

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
StepColor
finishedgreen
current: agents still working, or the last step started while the script is between phasesblue, bold
not started yetgrey, no count
where a failed or stopped (failed, killed) run endedred, 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.

WhatWhere it comes from
the steps, in ordermeta.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 agentagent.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 finishedits turn.complete
how the run endedthe 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.

Layout

.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.

Run it

Install it for every session

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.

Try it for one session

claude --plugin-dir <path to the clone>

Work on the mods

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.

Check it

claude plugin validate .
claude plugin test .
Source 7 files
hooks/register.tsx 17 lines
1import 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}
17
mods/statusbar/statusbar.tsx 345 lines
1import { 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}
345
mods/workflow/workflow.tsx 219 lines
1import { 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}
219
mods/statusbar/format.ts 388 lines
1import 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}
388
mods/statusbar/state.ts 37 lines
1import 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}
37
mods/workflow/progress.ts 244 lines
1import 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  }))
244
types/index.d.ts 72 lines
1/**
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