Change Claude Code's effort level per agent: Ctrl+↑ and Ctrl+↓ step the main thread or the subagent in view, shown as a colored meter in the footer (‹ › to…

Per-agent effort levels for Claude Code: step them from the keyboard, see them at a glance.

Opus 5.5 ▰▰▰▱▱ high./config toggles choose which levels the keys step through./plugin marketplace add Anerco/plugins
/plugin install effort-cycle@anerco
There is nothing to bind: the keys work right away. The tasks list's rows need python3 on your PATH (on macOS, from xcode-select --install or Homebrew).
macOS: Ctrl+↑ and Ctrl+↓ are Mission Control's shortcuts by default. Use Option+↑ and Option+↓ instead, with your terminal set to send Option as Meta (Terminal.app: Use Option as Meta key; iTerm2: Left Option key set to Esc+; Ghostty: macos-option-as-alt = true).
Updates: in /plugin, choose Marketplaces → anerco → Enable auto-update. Or run claude plugin update effort-cycle@anerco and restart Claude Code.
Built and tested against Claude Code 2.1.291. effort-cycle is a mod (a plugin of function hooks), an early-access API that changes between releases.
| Control | What it does |
|---|---|
| Ctrl+↑ | One level up for the agent in view, stopping at max |
| Ctrl+↓ | One level down, stopping at low |
| ‹ and › in the footer | Point at the footer's label, then click ‹ to step down or › to step up |
/effort, Alt+P | Claude Code's own controls still work, and take over from the plugin's pick |
The agent in view is the main thread, or the subagent whose transcript you opened from the tasks list. A pick lasts for the session. The footer shows the agent's model and level, led by its type in a subagent's transcript:
Opus 5.5 ▰▰▰▱▱ high main thread
Opus 5.5 ‹ ▰▰▰▱▱ high › pointer on the label
Explore · Opus 5.5 ▰▰▱▱▱ medium a subagent's transcript
The colors follow your theme, from gray at low to red at max. At max the model's name turns red too, and a light sweeps the bar while Claude works. Each Agent call in the transcript gets a line with its agent's level.
Each subagent's row in the tasks list under the prompt shows its model and level, in the footer's colors:
◯ Fix the parser · Opus 5.5 ▰▰▰▱▱ high · Reading the failing test · 53m 11s · ↓ 499.8k tokens
◯ Find the config · Sonnet 5.5 ▰▰▱▱▱ medium · Searching settings · 2m 4s · ↓ 31.2k tokens
The rows take no clicks; open a subagent's transcript to step it. Claude Code redraws them every five seconds, or in about 0.4 s with Tasks list rows: update at once on (Settings).
The main thread starts at the level Claude Code gives its model: CLAUDE_CODE_EFFORT_LEVEL if it is set, else a level your settings save for the model (as /effort and Alt+P save one), else the model's default: medium on Opus 5.5 and Sonnet 5.5, xhigh on Opus 4.7, high on the rest.
A subagent starts at its definition's effort, else the main thread's level. Its meter is empty (—) until its first model request says which, a moment after it starts; a press before then shows wait.
The plugin adds these toggles to /config:
| Toggle | Default | What it does |
|---|---|---|
| Effort keys: include low | on | The keys and ‹ › step through low |
| Effort keys: include medium | on | … through medium |
| Effort keys: include high | on | … through high |
| Effort keys: include xhigh | on | … through xhigh |
| Effort keys: include max | on | … through max |
| Tasks list rows: update at once | off | Rows follow a change in about 0.4 s, not up to 5 s |
A level that is off is skipped for every model. Update at once narrows the terminal by one column and back on each change, so the right edge flickers briefly; it needs python3 (details). The values are saved in ~/.claude/settings.json under pluginConfigs.
/effort and Alt+P do.— until its first model request. One started before the plugin loaded shows no model either, until its next request.set -g mouse on. Without them, use the keys./diff lists more than eight files they scroll it instead; close it to step again. Rebinding app:diffFileListUp and app:diffFileListDown moves them too.python3. A subagentStatusLine in your own settings replaces the plugin's. On Windows they show only where Claude Code runs commands through Git Bash.See docs/how-it-works.md for what the plugin hooks, the tasks list's row files, Remote Control and development.
No telemetry, no network requests. effort-cycle reads your settings, CLAUDE_CODE_EFFORT_LEVEL and the session's model and agents, and keeps levels in the session. For the tasks list it writes subagent levels under ~/.claude/subagent-rows/sessions/ (deleted after a week) and sets one variable, EFFORT_CYCLE_ROWS.
hooks/register.tsx 758 lines1import { update } from 'claude-code'
2import type { AgentInfo, EngineInterface as Engine, PluginOptions, Register, RenderElement, Timer } from 'claude-code'
3
4import type { AgentEffort } from '../types'
5
6// Ctrl+↑ (or Alt+↑) steps the effort level up (low → medium → high → xhigh → max)
7// and Ctrl+↓ (or Alt+↓) down, as do Alt+E and Alt+Shift+E where a person bound
8// them (the README no longer offers that binding; one made before keeps working),
9// each stopping at the end, for the agent in view: the main
10// thread, or the subagent whose transcript the person opened from the tasks
11// list. Before the main thread's first request on a model, its level is the one
12// Claude Code resolves for that model (engineLevel), as Claude Code's own
13// `◐ medium · /effort` shows it. The footer shows that agent's model and level
14// (`Opus 5.5 ▰▰▰▱▱ high`, a subagent's led by its type, the meter and word colored cool to hot, the
15// blocks a press filled or emptied lit for a moment) the moment either changes,
16// and a line under each Agent call shows the level of the agent it started.
17// Steps run one at a time. A press past either end lights the word instead. At
18// max the model name turns red too, and while Claude works a light sweeps across
19// the bar. The keys step only through the levels /config's five include toggles
20// allow, for every model.
21//
22// The footer's label is a stepper too: while the pointer is over it a `‹` shows
23// before the meter and a `›` after the word (`Opus 5.5 ‹ ▰▰▰▱▱ high ›`), and a
24// click on either steps down or up exactly as the keys do, for the agent the
25// footer shows. Each caret takes three cells, kept blank while it is hidden, and
26// the word's slot is as wide as the widest level, so neither the label nor the
27// carets move as they show or as the level changes, and quick clicks land on the
28// same caret. A caret is a Client (./caret.tsx), drawn hidden in a Box placed
29// over its cells that the label's hover reveals: the engine neither counts a
30// Client's quick clicks as a double click nor selects anything under them, and
31// each post carries the caret's running count of presses, so a post a frame
32// replaced still steps. Nothing about a caret ever inverts.
33//
34// Each agent's level is its own. A subagent's starts as the engine resolved it
35// (its definition's effort, else the parent's), read off its first model
36// request, since neither `$.agent.list()` nor `agent.spawn` carries an effort;
37// until then its meter is empty, and a press leaves it and says `wait`. Its
38// model is known sooner, from the spawn, and shows beside the empty meter. A press
39// while its transcript is in view sets the level its next requests go out with
40// and leaves the main thread and every other agent as they were. Only the band
41// above the prompt is told which transcript is in view (`view.agentId`), so the
42// band records it for the footer to draw, and a press steps the agent recorded
43// for the surface it came from: what that surface's footer shows. Each surface
44// keeps its own: a session attached over Remote Control draws the band and the
45// footer on the other surface too, showing its own transcript.
46//
47// A key reaches a mod without a prompt only through a Button naming an engine
48// keybinding action, and a plugin brings no keybindings, so hidden Buttons above
49// the prompt, beside whatever other plugins draw there, borrow actions. Two take
50// the diff panel's file-list actions, app:diffFileListUp and app:diffFileListDown,
51// whose default keys, Ctrl+↑ and Alt+↑, Ctrl+↓ and Alt+↓, do nothing at the
52// prompt, so they step with no setup. The panel (/diff, fullscreen) mounts its
53// own handler for them only while its list holds more files than it shows
54// (eight) and scrolls: then they scroll it instead. A person who rebinds those
55// actions moves these keys with them. Two take strip:jump9 and strip:jump8
56// (idle at the prompt), which a person's keybindings.json may bind meta+e and
57// meta+shift+e to. No key with Shift as its only modifier can
58// (Shift+Tab, Shift+Up): the engine hands a Button only chords and Ctrl or Alt
59// keys, even with the mode switch unbound. A slash command leaves a transcript
60// row. Claude Code's /effort prints rows too, so the mod never runs it: it sends
61// each agent's level on that agent's model requests. The status line script reruns only
62// on the engine's own changes, so model and level are drawn here, as a footer
63// mode label, and not by ~/.claude/statusline.sh. Nothing tells a mod about an
64// alt+p model pick, but the band above the prompt draws again once the picker
65// closes, so the band notices the new model and records it, which redraws the
66// label. Changing effort the engine's way (/effort, alt+p) drops the main
67// thread's pick, and a subagent's when the engine's level for it changes too;
68// alt+p's effort is seen at the next request.
69//
70// The tasks list draws no part a mod can, but its rows take a `subagentStatusLine`
71// command's text, and a plugin may ship that setting: the plugin's settings.json names
72// `[ -z "$EFFORT_CYCLE_ROWS" ] || exec python3 -I -S "$EFFORT_CYCLE_ROWS"`, which Claude
73// Code applies beneath every settings file while the plugin is enabled. It substitutes no
74// ${CLAUDE_PLUGIN_ROOT} there and runs the command in the session's folder with this
75// process's environment, so the mod sets EFFORT_CYCLE_ROWS to its own
76// subagent-rows/rows.py as the session starts (see pointRows). A subagentStatusLine in
77// the person's own settings wins over the plugin's. While settings name one, either way,
78// the mod leaves each subagent's model and
79// level as the footer writes them, `Opus 5.5 ▰▰▰▱▱ high` in the footer's colors (no
80// carets: nothing there takes a click), in
81// ~/.claude/subagent-rows/sessions/<session id>/effort-cycle.json as
82// `{ "order": 10, "agents": { "<agent id>": "<text>" } }`, for a command that joins
83// every plugin's part of a row (subagent-rows/rows.py is one). It is written when an
84// agent spawns, with its model and the footer's empty meter, `Opus 5.5 ▱▱▱▱▱ —`, and
85// whenever a level changes. Claude Code runs the command every five seconds, and 300
86// ms after its count of agents changes (so a spawn's row has its model from its first
87// draw), so a row follows a press within five seconds. With /config's "Tasks list
88// rows: update at once" on, each write of a changed level also nudges the terminal one
89// column narrower and back 5 ms later (see nudgeRows): Claude Code reruns the command 300
90// ms after the terminal's width changes too, so the row follows in about 0.4 s.
91const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
92
93const override = { plugin: 'effort-cycle', key: 'override' } as const
94const base = { plugin: 'effort-cycle', key: 'base' } as const
95const agents = { plugin: 'effort-cycle', key: 'agents' } as const
96const spawns = { plugin: 'effort-cycle', key: 'spawns' } as const
97const viewed = { plugin: 'effort-cycle', key: 'viewedOn' } as const
98const drawnModel = { plugin: 'effort-cycle', key: 'drawnModel' } as const
99const flash = { plugin: 'effort-cycle', key: 'flash' } as const
100const swept = { plugin: 'effort-cycle', key: 'swept' } as const
101const listed = { plugin: 'effort-cycle', key: 'listed' } as const
102const pressesSeen = { plugin: 'effort-cycle', key: 'pressesSeen' } as const
103
104// The label's color per level, by theme key, so it follows the person's theme: cool to hot.
105const HEAT: Record<string, string> = { low: 'inactive', medium: 'success', high: 'warning', xhigh: 'claude', max: 'error' }
106const FLASH_MS = 1000
107// The word's slot is as wide as the longest level, so the model and meter stay put as the level changes.
108const WIDEST = Math.max(...LEVELS.map(l => l.length))
109// The word while no request of a subagent's has said its level, and after a press on it.
110const UNSEEN = '—'
111const WAIT = 'wait'
112// The sweep moves one block per step, as a terminal draws it, then rests: 5 steps of 85ms in each 1530ms.
113const SWEEP_STEP_MS = 85
114const SWEEP_STEPS = 18
115// This plugin's place among the parts of a tasks list row. A row takes ANSI codes and not theme keys, so each theme key
116// the label's colors use is written as the terminal's own color for it: gray, green, yellow, orange, red.
117const ROW_ORDER = 10
118const ANSI: Record<string, string> = { inactive: '90', success: '32', warning: '33', claude: '38;5;208', error: '31' }
119// The cells each caret takes, all of which take its click: a blank, the caret, a blank.
120const CARET_CELLS = 3
121// A change written within this long of the last nudge rides on it: Claude Code reruns the command 300 ms after the
122// width changed, which the nudge's child does some 10-20 ms after it starts, so the rerun reads the file well after
123// such a write. A burst of presses makes one nudge (two repaints), not one each.
124const NUDGE_RIDE_MS = 250
125// The nudge's child, run by python3 isolated (-I: nothing from the environment, the user's site or the working
126// directory) and without site (-S), started in a few milliseconds. On its controlling terminal, Claude Code's, it
127// narrows the width by one column and restores it 5 ms later, the restore only while the width is still the one it
128// set, so a resize the person made in between stands. The pause is for Claude Code to read the narrowed width: with
129// none it missed it 3 times in 8, with 1 ms now and then while busy, with 5 ms not once in 35 (in tmux, fullscreen).
130// Claude Code may paint a frame at the narrower width whatever the pause; it did most times in that test. The two
131// steps run in a process of its own that nothing stopping the child reaches (a reload, an interrupt, a timeout):
132// forked, in a process group of its own, its output closed, ignoring the signals a terminal sends; once it has
133// narrowed, it restores. Without a terminal (a desktop or remote host, a headless run) it does nothing.
134const NUDGE_SCRIPT = `
135import fcntl, os, signal, struct, termios, time
136for s in (signal.SIGHUP, signal.SIGINT, signal.SIGQUIT, signal.SIGTERM):
137 signal.signal(s, signal.SIG_IGN)
138try:
139 fd = os.open('/dev/tty', os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
140 def size():
141 return struct.unpack('HHHH', fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8)))
142 def resize(rows, cols, x, y):
143 fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, x, y))
144 rows, cols, x, y = size()
145except OSError:
146 raise SystemExit(0)
147if cols > 1 and os.fork() == 0:
148 os.setpgid(0, 0)
149 null = os.open(os.devnull, os.O_RDWR)
150 for n in (0, 1, 2):
151 os.dup2(null, n)
152 narrowed = False
153 try:
154 resize(rows, cols - 1, x, y)
155 narrowed = True
156 time.sleep(0.005)
157 narrowed = size()[:2] == (rows, cols - 1)
158 except OSError:
159 pass
160 finally:
161 if narrowed:
162 resize(rows, cols, x, y)
163 os._exit(0)
164`
165
166// Runs while Claude works; the band starts and stops it.
167let sweeper: Timer | undefined
168// When the last nudge started, by the session's clock; and whether one could not start (no python3), after which
169// none is tried again until the plugin loads again. A reload starts both over.
170let lastNudgeAt: number | undefined
171let isNudgeBroken = false
172// Whether EFFORT_CYCLE_ROWS names this load's script (see pointRows).
173let isRowsPointed = false
174// The surfaces where a caret's module failed: their footer draws no carets until the plugin loads again.
175const caretsFailed = new Set<string>()
176
177// What the footer draws for the agent in view: its model and level (null while unknown), a subagent's label.
178type Shown = { model?: string; level: string | null; label?: string }
179// The step a press made, as the flash state holds it.
180type Flash = { id: number; from: number; to: number; agentId: string | null }
181// A run of the label's text and its look, which the footer draws as a Text and a tasks list row writes in ANSI.
182type Style = { color?: string; dimColor?: true; bold?: true }
183type Run = { text: string; style: Style }
184
185export const register: Register = (on, options) => {
186 lastNudgeAt = undefined
187 isNudgeBroken = false
188 isRowsPointed = false
189
190 // The plugin's own subagentStatusLine command runs the script EFFORT_CYCLE_ROWS names: this installed version's.
191 on('session.start', async ($, e, next) => {
192 await pointRows($)
193 return next(e)
194 })
195
196 on('turn.step', async function* ($, e, next) {
197 if (e.agentId !== undefined) {
198 const chosen = await agentStep($, options, e.agentId, e.model, e.effort)
199 return yield* next(chosen === null ? e : { ...e, effort: chosen as typeof e.effort })
200 }
201 if (typeof e.effort !== 'string') return yield* next(e)
202 const held = (await $.state.get(base)).value ?? null
203 if (held?.model !== e.model || held.level !== e.effort) {
204 // First request on this model, or the level changed the engine's way: follow the engine.
205 if (held?.model === e.model) await $.state.set(override, null)
206 await $.state.set(base, { model: e.model, level: e.effort })
207 }
208 const chosen = (await $.state.get(override)).value ?? null
209 return yield* next(chosen?.model === e.model ? { ...e, effort: chosen.level as typeof e.effort } : e)
210 })
211
212 // Ties each Agent call to the agent it started, for the line under the call, and notes the model it runs on.
213 on('agent.spawn', async ($, e, next) => {
214 const started = await next(e)
215 if (started.agentId !== undefined) {
216 await $.state.set({ ...spawns, id: e.tool_use_id }, started.agentId)
217 await agentSpawned($, started.agentId, started.model)
218 }
219 return started
220 }).catch(($, e, next) => next(e))
221
222 on('command.run', { command: 'effort' }, async ($, e, next) => {
223 const ran = await next(e)
224 await $.state.set(override, null)
225 const level = e.args.trim()
226 if (LEVELS.includes(level)) await $.state.set(base, { model: await $.session.model(), level })
227 return ran
228 })
229
230 on('command.run', { command: 'model' }, async ($, e, next) => {
231 const ran = await next(e)
232 await $.state.set(drawnModel, await $.session.model())
233 return ran
234 })
235
236 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
237 await $.state.get(drawnModel)
238 const agentId = (await $.state.get({ ...viewed, id: e.surface })).value ?? null
239 const pressed = (await $.state.get(flash)).value ?? null
240 const head = (await $.state.get(swept)).value ?? null
241 const { model, level, label } = await shown($, agentId)
242 const lit = litFor(pressed, agentId)
243 const ui = $.ui.resolve(e)
244 const { Box, Text } = ui
245 // The carets are Clients, which the terminal and the desktop draw (the two that draw this footer); a surface without
246 // them, or where their module failed, gets the label alone.
247 const Client = 'Client' in ui && !caretsFailed.has(e.surface) ? ui.Client : undefined
248 const named: RenderElement[] = [
249 ...(label === undefined ? [] : [<Text dimColor>{label} ·</Text>]),
250 ...(model === undefined ? [] : [textOf(modelName(model, level))]),
251 ]
252 // A caret's cells, blank, and over them, hidden, the caret, which the label's hover reveals: the keyed Box below is
253 // the hover's scope, and a Box placed `absolute` shows without moving anything. No Box between the two has a key,
254 // which would make the caret's own cells its scope.
255 const caret = (key: string, glyph: string, by: 1 | -1) =>
256 Client === undefined ? undefined : (
257 <Box flexShrink={0} width={CARET_CELLS} height={1}>
258 <Text>{' '.repeat(CARET_CELLS)}</Text>
259 <Box position="absolute" top={0} left={0} display="none" hover={{ display: 'flex' }}>
260 <Client key={key} module="./caret.tsx" props={{ glyph, by }} width={CARET_CELLS} height={1} />
261 </Box>
262 </Box>
263 )
264 // The engine's own modes stay as it draws them, dim and joined by ` & `; the label follows: who, the model, the
265 // down caret, the meter, the word in a slot as wide as the widest, the up caret.
266 return (
267 <Box>
268 {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')} & </Text>}
269 <Box key="effort">
270 {named.flatMap((part, i) => (i === 0 ? [part] : [' ', part]))}
271 {caret('effort-down', '‹', -1) ?? (named.length > 0 && ' ')}
272 {meter(level, lit, head).map(textOf)}
273 {' '}
274 {textOf(word(level, lit, WIDEST))}
275 {caret('effort-up', '›', 1)}
276 </Box>
277 </Box>
278 )
279 })
280
281 // A footer caret was clicked: as many steps of the agent the surface's footer shows as the caret's count of presses
282 // moved since the last post heard from it, so a press whose post the frame replaced still steps. The keys' steps and
283 // these run one at a time.
284 on('ui.message', async ($, e, next) => {
285 const pressed = caretPress(e.module, e.data)
286 if (pressed !== undefined)
287 await serially(async () => {
288 const seen = { ...pressesSeen, id: pressed.caret }
289 const before = (await $.state.get(seen)).value
290 // A count below the last one heard is a new count (the caret's module loaded again): all of it is new.
291 const steps = before === undefined || pressed.presses < before ? pressed.presses : pressed.presses - before
292 await $.state.set(seen, pressed.presses)
293 const agentId = (await $.state.get({ ...viewed, id: e.surface })).value ?? null
294 // Past the end every press only lights the word again, so a few more than the levels show it as well as all.
295 for (let i = 0; i < Math.min(steps, LEVELS.length); i++) await stepAgent($, options, pressed.by, agentId)
296 })
297 return next(e)
298 })
299
300 // A caret's module failed on a surface: once the engine draws the footer there again, it draws it without carets.
301 on('ui.fault', async ($, e, next) => {
302 if (e.component === 'SessionMode' && e.module.endsWith('caret.tsx')) caretsFailed.add(e.surface)
303 return next(e)
304 })
305
306 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
307 const model = await $.session.model()
308 const agentId = e.props.view?.agentId ?? null
309 const here = { ...viewed, id: e.surface }
310 // A render may not write state, so the new model and the transcript in view are recorded just after.
311 if (model !== (await $.state.get(drawnModel)).value) recordSoon($, () => $.state.set(drawnModel, model))
312 if (agentId !== ((await $.state.get(here)).value ?? null)) recordSoon($, () => $.state.set(here, agentId))
313 if (e.props.isWorking !== (sweeper !== undefined)) $.clock.after(0, () => sweep($, e.props.isWorking, e.surface))
314 // The band is shared: what the plugins beneath and Claude Code's surveys draw there stays, the buttons hidden beside it.
315 const below = await next(e)
316 const { Box, Button } = $.ui.resolve(e)
317 return (
318 <Box flexDirection="column">
319 {below}
320 <Box display="none">
321 <Button key="effort-up-arrow" label="effort up" action="app:diffFileListUp" onPress={press => step($, options, 1, press.surface)} />
322 <Button key="effort-down-arrow" label="effort down" action="app:diffFileListDown" onPress={press => step($, options, -1, press.surface)} />
323 <Button key="effort-up" label="effort up" action="strip:jump9" onPress={press => step($, options, 1, press.surface)} />
324 <Button key="effort-down" label="effort down" action="strip:jump8" onPress={press => step($, options, -1, press.surface)} />
325 </Box>
326 </Box>
327 )
328 })
329
330 // Under an Agent call, the level of the agent it started, once a request of its has said it.
331 on('ui.render', { component: 'ToolUse', props: { tool: 'Agent' } }, async ($, e, next) => {
332 const drawn = await next(e)
333 const agentId = (await $.state.get({ ...spawns, id: e.props.tool_use_id })).value
334 const held = agentId === undefined ? undefined : (await $.state.get({ ...agents, id: agentId })).value
335 const level = held === undefined ? null : agentLevel(held)
336 if (held === undefined || level === null) return drawn
337 const { Box, Text } = $.ui.resolve(e)
338 return (
339 <Box flexDirection="column">
340 {drawn}
341 <Box key="effort" paddingLeft={2}>
342 <Text dimColor>{displayName(held.model)} </Text>
343 {meter(level).map(textOf)}
344 <Text {...heat(level)}> {level}</Text>
345 </Box>
346 </Box>
347 )
348 })
349}
350
351// Records a subagent's request and answers the level the keys picked for it, null to send the engine's.
352// Its first request seeds its level; a later one whose level the engine changed on the same model drops
353// the pick, as on the main thread.
354async function agentStep($: Engine, options: PluginOptions, agentId: string, model: string, effort: string | number | undefined): Promise<string | null> {
355 const ref = { ...agents, id: agentId }
356 const level = typeof effort === 'string' ? effort : null
357 const held = (await $.state.get(ref)).value
358 if (held !== undefined && held.model === model && held.base === level) return level === null ? null : held.override
359 const label = held === undefined ? await labelFor($, agentId) : held.label
360 const now = await update($, ref, current => {
361 if (current === undefined) return { model, base: level, override: null, ...(label === undefined ? {} : { label }) }
362 const isEngineChange = current.model === model && current.base !== null && current.base !== level
363 return { ...current, model, base: level, override: isEngineChange ? null : current.override }
364 })
365 // The engine's own forks are no agent of the list, and have no row.
366 if (now.label !== undefined) await listLevel($, options, agentId, agentLevel(now))
367 return level === null ? null : now.override
368}
369
370// A subagent started, on the model its spawn resolved: the footer and its tasks list row show that model beside the
371// empty meter until a request of its says its level. A request that came first has said both already, and the engine's
372// own forks, no agent of the list, are left to their requests.
373async function agentSpawned($: Engine, agentId: string, model: string) {
374 const ref = { ...agents, id: agentId }
375 if ((await $.state.get(ref)).value !== undefined) return
376 const label = await labelFor($, agentId)
377 if (label === undefined) return
378 await update($, ref, current => current ?? { model, base: null, override: null, label })
379 // Its row's part goes out at once, so the rerun Claude Code makes 300 ms after its count of agents changed draws the
380 // model; that rerun comes anyway, so the spawn's write nudges nothing.
381 const levels = await update($, listed, all => (all !== undefined && agentId in all ? all : { ...all, [agentId]: null }))
382 await writeRows($, levels)
383}
384
385// Ctrl+↑ and Ctrl+↓ (Alt+↑ and Alt+↓; Alt+E and Alt+Shift+E where bound): a step of the agent the surface views.
386async function step($: Engine, options: PluginOptions, by: 1 | -1, surface: string) {
387 await serially(async () => stepAgent($, options, by, (await $.state.get({ ...viewed, id: surface })).value ?? null))
388}
389
390// Makes a write a render could not, once the render is over. A timer can fire while another site draws, which refuses
391// the write too, so a refused one is tried again a little later, a few times.
392const RECORD_TRIES = 10
393const RECORD_RETRY_MS = 30
394function recordSoon($: Engine, write: () => Promise<unknown>, tries = RECORD_TRIES) {
395 $.clock.after(tries === RECORD_TRIES ? 0 : RECORD_RETRY_MS, async () => {
396 try {
397 await write()
398 } catch {
399 if (tries > 1) recordSoon($, write, tries - 1)
400 }
401 })
402}
403
404// Runs steps one after another: each reads the level the last one wrote, so two presses close together make two.
405let queue: Promise<unknown> = Promise.resolve()
406function serially<T>(work: () => Promise<T>): Promise<T> {
407 const run = queue.then(work, work)
408 queue = run.catch(() => undefined)
409 return run
410}
411
412// The next allowed level above (or below) the agent's current one (null: the main thread); past the end the
413// level stays and the word lights, and while a subagent's level is unknown the word says `wait`.
414async function stepAgent($: Engine, options: PluginOptions, by: 1 | -1, agentId: string | null) {
415 const allowed = LEVELS.filter(level => options[`include${level.charAt(0).toUpperCase()}${level.slice(1)}`] !== false)
416 const above = (current: number) =>
417 by > 0 ? allowed.find(l => LEVELS.indexOf(l) > current) : [...allowed].reverse().find(l => LEVELS.indexOf(l) < current)
418 let current = -1
419 let level: string | undefined
420 if (agentId === null) {
421 const model = await $.session.model()
422 current = LEVELS.indexOf(await effortFor($, model))
423 level = above(current)
424 if (level) await $.state.set(override, { model, level })
425 } else {
426 const ref = { ...agents, id: agentId }
427 const held = (await $.state.get(ref)).value
428 const known = held === undefined ? null : agentLevel(held)
429 if (held !== undefined && known !== null) {
430 current = LEVELS.indexOf(known)
431 const chosen = above(current)
432 if (chosen) {
433 await update($, ref, now => ({ ...(now ?? held), override: chosen }))
434 await listLevel($, options, agentId, chosen)
435 }
436 level = chosen
437 }
438 }
439 // The blocks the step crossed light for a moment; only the latest press's timer clears them.
440 const id = ((await $.state.get(flash)).value?.id ?? 0) + 1
441 await $.state.set(flash, { id, from: current, to: level ? LEVELS.indexOf(level) : current, agentId })
442 $.clock.after(FLASH_MS, async () => {
443 if ((await $.state.get(flash)).value?.id === id) await $.state.set(flash, null)
444 })
445}
446
447// Leaves a subagent's level for its row in the tasks list (see the header), with the model it runs on. Every level is
448// kept, so the first write once settings name a subagentStatusLine command has them all; a level that did not change
449// writes nothing. With the nudge on, a write also nudges the terminal so the row follows at once, unawaited, so a step
450// never waits on it.
451async function listLevel($: Engine, options: PluginOptions, agentId: string, level: string | null) {
452 if (level === null) return
453 const before = (await $.state.get(listed)).value?.[agentId]
454 if (before === level) return
455 const levels = await update($, listed, all => ({ ...all, [agentId]: level }))
456 if ((await writeRows($, levels)) && options.nudgeRows === true) void nudgeRows($)
457}
458
459// Writes every subagent's part of its row, while settings name a subagentStatusLine command (the plugin's own, from its
460// settings.json, or the person's); true once written.
461async function writeRows($: Engine, levels: Record<string, string | null>): Promise<boolean> {
462 if ((await $.settings.read()).subagentStatusLine === undefined) return false
463 // A plugin enabled after the session started may not have seen it start.
464 if (!isRowsPointed) await pointRows($)
465 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
466 if (home === undefined) return false
467 const parts: Record<string, string> = {}
468 for (const [id, held] of Object.entries(levels)) parts[id] = rowPart((await $.state.get({ ...agents, id })).value?.model, held)
469 const file = `${home}/.claude/subagent-rows/sessions/${await $.session.id()}/effort-cycle.json`
470 await $.fs.write(file, JSON.stringify({ order: ROW_ORDER, agents: parts }))
471 return true
472}
473
474// Points the plugin's subagentStatusLine command at this installed version's rows.py. The plugin's settings.json cannot
475// name the script's path itself: Claude Code substitutes no ${CLAUDE_PLUGIN_ROOT} in a setting, runs the command in the
476// session's folder, and gives it no CLAUDE_PLUGIN_ROOT, but it does give it this process's environment. The install's
477// folder changes with each version, so the path is set on every load. Never rejects: unset, the command prints nothing
478// and the rows stay Claude Code's own.
479async function pointRows($: Engine): Promise<void> {
480 const script = `${$.plugin.root}/subagent-rows/rows.py`
481 try {
482 if ((await $.env.get('EFFORT_CYCLE_ROWS')) !== script) await $.env.set('EFFORT_CYCLE_ROWS', script)
483 isRowsPointed = true
484 } catch {
485 // Refused: tried again before the next write of the rows.
486 }
487}
488
489// The width nudge, /config's "Tasks list rows: update at once": Claude Code reruns the subagentStatusLine command every
490// five seconds, and 300 ms after the terminal's width changes, so a child on its terminal narrows it one column and
491// restores it 5 ms later (NUDGE_SCRIPT), and the rows follow a press in about 0.4 s. It costs two repaints, and usually a
492// frame drawn one column narrower shows for a moment. A change within NUDGE_RIDE_MS of the last nudge rides on it. Only
493// while a terminal draws the session; never rejects, whatever the host has or lacks.
494async function nudgeRows($: Engine): Promise<void> {
495 if (isNudgeBroken) return
496 try {
497 const now = await $.clock.now()
498 if (lastNudgeAt !== undefined && now - lastNudgeAt < NUDGE_RIDE_MS) return
499 lastNudgeAt = now
500 if (!(await $.session.surfaces()).includes('terminal')) return
501 await $.process.run(['python3', '-I', '-S', '-c', NUDGE_SCRIPT], { cwd: $.plugin.root, timeoutMs: 5000 })
502 } catch {
503 // No python3, or no way to start it: the rows follow at Claude Code's own pace from now on.
504 isNudgeBroken = true
505 }
506}
507
508// A subagent's part of its tasks list row: its model, meter and level as the footer writes them, in the footer's colors
509// as ANSI, `Opus 5.5 ▰▰▰▱▱ high`, with no carets, padding or flash; the footer's empty meter, `▱▱▱▱▱ —`, while no
510// request of its has said its level.
511function rowPart(model: string | undefined, level: string | null): string {
512 const named = model === undefined ? [] : [modelName(model, level), plain(' ')]
513 return ansi([...named, ...meter(level), plain(' '), word(level)])
514}
515
516// Runs as ANSI text: each run's color and weight, a run alike to the one before joined to it.
517function ansi(runs: Run[]): string {
518 const joined: { text: string; codes: [string, string] }[] = []
519 for (const run of runs) {
520 const codes = ansiCodes(run.style)
521 const last = joined.at(-1)
522 if (last !== undefined && last.codes[0] === codes[0]) last.text += run.text
523 else joined.push({ text: run.text, codes })
524 }
525 return joined.map(({ text, codes: [on, off] }) => (on === '' ? text : `\x1b[${on}m${text}\x1b[${off}m`)).join('')
526}
527
528// A look's ANSI codes: those that turn it on, and those that turn just that off again, so nothing around it changes.
529function ansiCodes(style: Style): [string, string] {
530 const on: string[] = []
531 const off: string[] = []
532 if (style.bold) on.push('1')
533 if (style.dimColor) on.push('2')
534 if (style.bold || style.dimColor) off.push('22')
535 if (style.color !== undefined) {
536 on.push(ANSI[style.color] ?? '39')
537 off.push('39')
538 }
539 return [on.join(';'), off.join(';')]
540}
541
542// What a footer caret posted, read back: which caret, its running count of presses and which way; undefined for
543// anything else.
544function caretPress(module: string, data: unknown): { caret: string; presses: number; by: 1 | -1 } | undefined {
545 if (!module.endsWith('caret.tsx') || typeof data !== 'object' || data === null) return undefined
546 const { caret, presses, by } = data as { caret?: unknown; presses?: unknown; by?: unknown }
547 if (typeof caret !== 'string' || typeof presses !== 'number' || !Number.isInteger(presses) || presses < 0) return undefined
548 if (by !== 1 && by !== -1) return undefined
549 return { caret, presses, by }
550}
551
552// While Claude works, steps the sweep's head along the bar at max; otherwise stops it and clears the head.
553function sweep($: Engine, working: boolean, surface: string) {
554 if (working === (sweeper !== undefined)) return
555 sweeper?.cancel()
556 sweeper = undefined
557 if (!working) return void $.state.set(swept, null)
558 let tick = 0
559 sweeper = $.clock.every(SWEEP_STEP_MS, async () => {
560 const at = tick++ % SWEEP_STEPS
561 // The head moves over the blocks and leaves; nothing changes while it rests.
562 if (at > LEVELS.length) return
563 const onMax = (await shown($, (await $.state.get({ ...viewed, id: surface })).value ?? null)).level === 'max'
564 const head = onMax && at < LEVELS.length ? at : null
565 if ((await $.state.get(swept)).value !== head) await $.state.set(swept, head)
566 })
567}
568
569// The agent in view's model and level: the main thread's, or a subagent's as its requests and the keys left it.
570async function shown($: Engine, agentId: string | null): Promise<Shown> {
571 if (agentId === null) {
572 const model = await $.session.model()
573 return { model, level: await effortFor($, model) }
574 }
575 const held = (await $.state.get({ ...agents, id: agentId })).value
576 const label = held === undefined ? await labelFor($, agentId) : held.label
577 return { ...(held === undefined ? {} : { model: held.model }), level: held === undefined ? null : agentLevel(held), ...(label === undefined ? {} : { label }) }
578}
579
580// A subagent's level: the keys' pick, else the engine's; null while no request of its has said it.
581function agentLevel(held: AgentEffort): string | null {
582 return held.override ?? held.base
583}
584
585// What the footer calls a subagent: its agent type, a teammate spawned as none by its name; undefined for a loop the session
586// lists no agent for (the engine's own forks).
587async function labelFor($: Engine, agentId: string): Promise<string | undefined> {
588 try {
589 const info = (await $.agent.list()).find(agent => agent.id === agentId)
590 return info === undefined ? undefined : labelOf(info)
591 } catch {
592 return undefined
593 }
594}
595
596function labelOf(info: AgentInfo): string {
597 return info.type === 'teammate' && info.name !== undefined ? info.name : info.type
598}
599
600// The latest press, when it stepped this agent (null: the main thread): what lights its meter.
601function litFor(pressed: Flash | null, agentId: string | null): Flash | null {
602 return pressed !== null && (pressed.agentId ?? null) === agentId ? pressed : null
603}
604
605// The model's name as the footer and a tasks list row write it: dim, and at max in max's color.
606function modelName(model: string, level: string | null): Run {
607 return { text: displayName(model), style: level === 'max' ? heat(level) : { dimColor: true } }
608}
609
610// The five blocks of a level's meter, filled up to the level in its color; the blocks a press just filled or
611// emptied bright, and at max the sweep's head.
612function meter(level: string | null, lit: Flash | null = null, head: number | null = null): Run[] {
613 const filled = level === null ? 0 : LEVELS.indexOf(level) + 1
614 return LEVELS.map((_, i): Run => {
615 const full = i < filled
616 const changed = lit !== null && i > Math.min(lit.from, lit.to) && i <= Math.max(lit.from, lit.to)
617 if (changed) return { text: full ? '▰' : '▱', style: { color: 'text', bold: true } }
618 if (i === head && level === 'max') return { text: '▰', style: { color: 'text' } }
619 return { text: full ? '▰' : '▱', style: full ? heat(level) : { dimColor: true } }
620 })
621}
622
623// The level's word after the meter, in its color and bold at max: lit by a press past either end, `wait` after a
624// press on a subagent whose level is unknown, `—` until then. The footer pads it as wide as the widest.
625function word(level: string | null, lit: Flash | null = null, width = 0): Run {
626 const atEnd = lit !== null && lit.from === lit.to
627 const style: Style = atEnd ? { color: 'text', bold: true } : level === 'max' ? { ...heat(level), bold: true } : heat(level)
628 return { text: (level ?? (atEnd ? WAIT : UNSEEN)).padEnd(width), style }
629}
630
631// A level's color, by theme key; dim while it is unknown.
632function heat(level: string | null): Style {
633 const color = level === null ? undefined : HEAT[level]
634 return color === undefined ? { dimColor: true } : { color }
635}
636
637// A run as the footer draws it: a Text every surface draws.
638function textOf(run: Run): RenderElement {
639 const props: Record<string, string | boolean> = {}
640 if (run.style.color !== undefined) props.color = run.style.color
641 if (run.style.dimColor) props.dimColor = true
642 if (run.style.bold) props.bold = true
643 return { type: 'Text', props, children: [run.text] }
644}
645
646function plain(text: string): Run {
647 return { text, style: {} }
648}
649
650// The level the main thread's next request goes out with: the keys' or the carets' pick, else the engine's
651// (its last request's, or before any the level Claude Code resolves for the model).
652async function effortFor($: Engine, model: string): Promise<string> {
653 const chosen = (await $.state.get(override)).value
654 if (chosen?.model === model) return chosen.level
655 const held = (await $.state.get(base)).value
656 if (held?.model === model) return held.level
657 return engineLevel($, model)
658}
659
660// The level Claude Code sends a model's requests at before any has said it, resolved as Claude Code 2.1.291 resolves
661// it (and shows it, `◐ medium · /effort`, as a session starts): CLAUDE_CODE_EFFORT_LEVEL; else the first settings
662// source, highest precedence first, that gives the model a level, in `modelSettings` under its name (or a dated, [1m],
663// Bedrock or Vertex spelling of it) or at its top level, the user's own top-level `effortLevel` only for the models
664// before Opus 5.5 and Sonnet 5.5; else the model's own default. A source Claude Code did not load (`--setting-sources`)
665// is passed over: its level is not the one the merged settings hold. A level Claude Code does not save (`max`, a typo)
666// gives the model's default. An organization's default, the model list Anthropic's API serves and Claude Code's
667// server-side flags can change a model's default with nothing a plugin can read; the first request says what it is,
668// and the footer follows that from then on.
669async function engineLevel($: Engine, model: string): Promise<string> {
670 const fromEnv = await envLevel($)
671 if (fromEnv !== undefined) return fromEnv
672 const id = canonical(model)
673 const merged = await $.settings.read()
674 const mergedModel = modelSettingsOf(merged)
675 let userLevel: unknown
676 for (const source of SOURCES) {
677 const settings = await $.settings.read({ source })
678 const named = modelEntry(settings, id)
679 if (named !== undefined && mergedModel[named.key]?.effortLevel === named.level) return savedLevel(named.level) ?? modelDefault(id)
680 if (settings.effortLevel === undefined || settings.effortLevel !== merged.effortLevel) continue
681 if (source !== 'user') return savedLevel(settings.effortLevel) ?? modelDefault(id)
682 userLevel = settings.effortLevel
683 }
684 const isLegacy = LEGACY_MODELS.has(id) || !/^claude-/.test(id)
685 return (isLegacy ? savedLevel(userLevel) : undefined) ?? modelDefault(id)
686}
687
688// CLAUDE_CODE_EFFORT_LEVEL, as Claude Code reads it: a level (`med` for medium) wins over every setting, a number and
689// `unset` or `auto` show as high, anything else is no level.
690async function envLevel($: Engine): Promise<string | undefined> {
691 let value: string | undefined
692 try {
693 value = (await $.env.get('CLAUDE_CODE_EFFORT_LEVEL'))?.trim().toLowerCase()
694 } catch {
695 return undefined
696 }
697 if (!value) return undefined
698 const level = value === 'med' ? 'medium' : value
699 if (LEVELS.includes(level)) return level
700 return value === 'unset' || value === 'auto' || Number.isInteger(parseInt(value, 10)) ? 'high' : undefined
701}
702
703// Settings sources, highest precedence first, as Claude Code walks them for a model's level.
704const SOURCES = ['policy', 'flag', 'local', 'project', 'user'] as const
705// The models a top-level `effortLevel` in ~/.claude/settings.json still applies to in Claude Code 2.1.291, and to a
706// name it cannot read as a Claude model; a later model takes only a level saved for it (`modelSettings`, as /effort
707// and Alt+P save one) or a project, local, `--settings` or managed top-level one.
708const LEGACY_MODELS = new Set([
709 'claude-3-5-haiku', 'claude-3-5-sonnet', 'claude-3-7-sonnet', 'claude-haiku-4-5', 'claude-sonnet-4-0', 'claude-sonnet-4-5',
710 'claude-sonnet-4-6', 'claude-sonnet-5', 'claude-opus-4-0', 'claude-opus-4-1', 'claude-opus-4-5', 'claude-opus-4-6',
711 'claude-opus-4-7', 'claude-opus-4-8', 'claude-opus-5', 'claude-fable-5', 'claude-fable-5-1', 'claude-mythos-5', 'claude-mythos-5-1',
712])
713// A model's default level where Claude Code 2.1.291's own model catalog gives one other than high.
714const MODEL_DEFAULTS: Record<string, string> = { 'claude-opus-5-5': 'medium', 'claude-sonnet-5-5': 'medium', 'claude-opus-4-7': 'xhigh' }
715
716function modelDefault(id: string): string {
717 return MODEL_DEFAULTS[id] ?? 'high'
718}
719
720// A level as settings save one: low to xhigh; anything else none.
721function savedLevel(value: unknown): string | undefined {
722 return typeof value === 'string' && LEVELS.includes(value) && value !== 'max' ? value : undefined
723}
724
725// A settings file's `modelSettings`: its own JSON, typed unknown, read as entries that may carry a level.
726function modelSettingsOf(settings: Readonly<Record<string, unknown>>): Readonly<Record<string, { readonly effortLevel?: unknown } | undefined>> {
727 const value = settings.modelSettings
728 return typeof value === 'object' && value !== null ? (value as Readonly<Record<string, { readonly effortLevel?: unknown } | undefined>>) : {}
729}
730
731// The `modelSettings` entry that gives a model a level in one settings file: the one under its canonical name, else
732// one under another spelling of it.
733function modelEntry(settings: Readonly<Record<string, unknown>>, id: string): { key: string; level: unknown } | undefined {
734 let found: { key: string; level: unknown } | undefined
735 for (const [key, entry] of Object.entries(modelSettingsOf(settings))) {
736 if (entry?.effortLevel === undefined || canonical(key) !== id) continue
737 if (key === id) return { key, level: entry.effortLevel }
738 found ??= { key, level: entry.effortLevel }
739 }
740 return found
741}
742
743// A model's canonical name, as settings key it: claude-haiku-4-5-20251001, claude-opus-5-5[1m],
744// us.anthropic.claude-opus-5-5-v1:0 and claude-opus-5-5@20260101 → claude-haiku-4-5, claude-opus-5-5.
745function canonical(model: string): string {
746 const name = model.trim().toLowerCase().replace(/\[[12]m\]$/, '')
747 const found = /claude-[a-z0-9-]*[a-z0-9]/.exec(name)?.[0]
748 return found === undefined ? name : found.replace(/-v\d+$/, '').replace(/-\d{8}$/, '')
749}
750
751// claude-opus-5-5 → Opus 5.5, claude-haiku-4-5-20251001 → Haiku 4.5, …[1m] → … 1M.
752function displayName(model: string): string {
753 const long = model.endsWith('[1m]')
754 const [family, ...version] = model.replace('[1m]', '').replace(/^claude-/, '').split('-').filter(p => !/^\d{8}$/.test(p))
755 if (!family) return model
756 return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version.join('.')}`.trim() + (long ? ' 1M' : '')
757}
758hooks/caret.tsx 46 lines1import type { ClientModule } from 'claude-code'
2
3// One caret of the footer's label, `‹` (down) or `›` (up), drawn by the surface itself with a blank cell either side
4// of it, so the whole three-cell region takes the press. The label's hover shows and hides it; its hooks module steps
5// the agent the footer shows by `by` for every left-button press.
6//
7// It is a Client and not a Button because a Button inverts under the pointer, and because the engine counts quick
8// clicks on one cell as a double or triple click and selects a word or a line in place of the second and third
9// (copy-on-select copies it); while a Client holds the pointer nothing is selected and every press reaches it. A post
10// is delivered at most once a frame, a later one replacing one not yet delivered, so each post carries the running
11// count of this instance's presses and the hooks module steps by how far it moved: a press whose post was replaced
12// still counts. Dim at rest, at full strength under the pointer, and never inverted, held or not.
13type Props = { glyph: string; by: number }
14type State = { id: string }
15type Pointer = { presses: number; isOver: boolean }
16
17// Each instance's presses and whether the pointer is on it, by the id its state holds; kept here rather than in
18// setState, so a press that comes before the instance has drawn again still counts.
19const pointers = new Map<string, Pointer>()
20
21const Caret: ClientModule<Props, State> = (props, surface) => {
22 const id = surface.state?.id ?? `${Date.now().toString(36)}.${Math.random().toString(36).slice(2)}`
23 if (surface.state === undefined) surface.setState({ id })
24 // Set on every call, so the listener is always this instance's current one, with its current props.
25 surface.onPointer(event => {
26 const now = pointers.get(id) ?? { presses: 0, isOver: false }
27 let next = now
28 if (event.type === 'down' && event.button === 'left') {
29 next = { presses: now.presses + 1, isOver: true }
30 surface.post({ caret: id, presses: next.presses, by: props.by })
31 } else if (event.type === 'enter') next = { ...now, isOver: true }
32 else if (event.type === 'leave') next = { ...now, isOver: false }
33 pointers.set(id, next)
34 if (next.isOver !== now.isOver) surface.setState({ id })
35 })
36 const { Text } = surface.elements
37 return (
38 <Text>
39 {' '}
40 <Text dimColor={pointers.get(id)?.isOver !== true}>{props.glyph}</Text>{' '}
41 </Text>
42 )
43}
44
45export default Caret
46types/index.d.ts 39 lines1/** A subagent's effort: what its own requests carried and what the keys picked for it. */
2export type AgentEffort = {
3 /** The model its last request named; before any, the one its spawn resolved. */
4 model: string
5 /** The engine's level on its last request, before the keys' pick: the one it was spawned with (its definition's effort, else the parent's) or the engine's since; null until a request of its carries a level. */
6 base: string | null
7 /** The level the keys or the footer's ‹ › picked while its transcript was in view, sent on its requests; null follows the engine's. */
8 override: string | null
9 /** What the footer calls it: its agent type, a teammate spawned as none by its name; absent when the session lists no such agent. */
10 label?: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'effort-cycle': {
16 /** The level the keys or the footer's ‹ › picked for a model, sent on its main-loop requests; null follows the engine's own. */
17 override: { model: string; level: string } | null
18 /** The engine's own level for a model, as its last main-loop request carried it before the override. */
19 base: { model: string; level: string } | null
20 /** Each subagent's effort, by its agent id (the `agentId` its `turn.step` carries). */
21 agents: StateFamily<AgentEffort>
22 /** The agent an Agent call started, by the call's tool_use_id, so the call's row can show its effort. */
23 spawns: StateFamily<string>
24 /** The agent whose transcript is in view, by surface (`terminal`, `desktop`), as that surface's band last saw it; null while the main conversation is. */
25 viewedOn: StateFamily<string | null>
26 /** The model the footer label last drew; a change (/model, alt+p) draws it again. */
27 drawnModel: string
28 /** The step a key or a caret just made, as level indexes, whose blocks the meter lights for a moment (`from` equal to `to` is a press past the end, which lights the word); `id` counts the presses so only the latest clears it; `agentId` is the agent it stepped, null for the main thread. */
29 flash: { id: number; from: number; to: number; agentId: string | null } | null
30 /** The block the sweep lights at max while Claude works, by index; null between sweeps and otherwise. */
31 swept: number | null
32 /** Each subagent's level for its row in the tasks list, by agent id, as the file last got it or gets it at its next write; null for one whose spawn said its model and no request yet its level. */
33 listed: Record<string, string | null>
34 /** The running count of presses each footer caret last posted, by the caret's id, so a later post steps by how far it moved. */
35 pressesSeen: StateFamily<number>
36 }
37 }
38}
39