SLOPSHOPPER

effort-cycle

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…

newbandspinnerrowsprocesstimer
★ 2v0.2.3MITupdated 2026-10-06Anerco/claude-code-effort-cycle
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · effort-cycle
› 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 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits & Opus 5.5 ▰▰▱▱▱ medium

Draws

Band
⟨Claude Code's own drawing⟩
README

effort-cycle

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

Ctrl+↑ steps the footer's effort meter up to max, a light sweeps the bar while Claude works and a subagent's row in the tasks list shows its own level, then Ctrl+↓ steps the meter back down

  • Ctrl+↑ and Ctrl+↓ step the level: low, medium, high, xhigh, max.
  • Per agent. The keys change the agent in view. Every other agent keeps its level.
  • A meter in the footer, colored cool to hot: Opus 5.5 ▰▰▰▱▱ high.
  • Clickable ‹ › appear around the meter when you point at it.
  • The tasks list shows each subagent's model and level in its row.
  • /config toggles choose which levels the keys step through.

Install

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

Usage

ControlWhat it does
Ctrl+↑One level up for the agent in view, stopping at max
Ctrl+↓One level down, stopping at low
‹ and › in the footerPoint at the footer's label, then click ‹ to step down or › to step up
/effort, Alt+PClaude 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.

Levels in the tasks list

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

Where a level starts

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.

Settings

The plugin adds these toggles to /config:

ToggleDefaultWhat it does
Effort keys: include lowonThe keys and ‹ › step through low
Effort keys: include mediumon… through medium
Effort keys: include highon… through high
Effort keys: include xhighon… through xhigh
Effort keys: include maxon… through max
Tasks list rows: update at onceoffRows 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.

Known issues

  • The spinner can show a different level from the footer (#1). The footer shows the level requests go out with.
  • A pick is lost on restart. A plugin cannot write settings, so it cannot save the level the way /effort and Alt+P do.
  • Before the first request, the footer works the level out. An organization's default, or one set on Anthropic's side, can differ. From the first request the footer follows Claude Code; a pick made before it stands.
  • A subagent shows — until its first model request. One started before the plugin loaded shows no model either, until its next request.
  • ‹ › need mouse events, as in Claude Code's fullscreen view. In tmux, add set -g mouse on. Without them, use the keys.
  • Ctrl+↑ and Ctrl+↓ are borrowed from the diff panel. While /diff lists more than eight files they scroll it instead; close it to step again. Rebinding app:diffFileListUp and app:diffFileListDown moves them too.
  • On macOS, Ctrl+↑ and Ctrl+↓ belong to Mission Control unless you turn them off in System Settings → Keyboard → Keyboard Shortcuts → Mission Control. Option+↑ and Option+↓ work too (Install).
  • The tasks list's levels need python3. A subagentStatusLine in your own settings replaces the plugin's. On Windows they show only where Claude Code runs commands through Git Bash.
  • Ultracode is not a step. It is a separate switch the plugin API cannot reach.

How it works

See docs/how-it-works.md for what the plugin hooks, the tasks list's row files, Remote Control and development.

Privacy

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.

License

MIT

Source 3 files
hooks/register.tsx 758 lines
1import { 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}
758
hooks/caret.tsx 46 lines
1import 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
46
types/index.d.ts 39 lines
1/** 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