Picks the model and reasoning effort of every subagent: a recommendation by task kind, a band above the prompt, and a saved default that wins.

Vendored from https://github.com/Kruhlikau/subagent-tuner at tag v0.1.4 (MIT). I'm the author.
A mod that settles the model and reasoning effort of every subagent Claude Code starts, so a review can run on Opus and a file search on Haiku.

Tuner [Manual][Auto] · default [model]/[effort] [Reset]. Click to switch the mode or cycle the saved default.CLAUDE.md rule that makes it pass model: "opus"). A toast says what was applied.opus/high; implement, build or fix → sonnet/medium; explore, search or research → haiku/low. A long prompt gets one step more effort.claude -p and SDK hosts: nothing is changed.model and effort fields of Agent calls, keeps the mode and default in Claude Code's plugin store, and runs sh (macOS, Linux) or powershell.exe (Windows) only to wait for an answer in the pane.npx claude-code-templates --mod productivity/subagent-tuner
Needs Claude Code 2.1.287 or later (it sets effort on the Agent call, so the version must apply a per-call effort; tested on 2.1.295 and 2.1.296 on macOS and Windows 11). Or install straight from the upstream marketplace:
/plugin install subagent-tuner --marketplace Kruhlikau/subagent-tuner
types/claude-code.d.ts in this folder is the 2.1.283 declaration file, which has no effort on the Agent input and no AskUserQuestion input type. Each module therefore starts with // @ts-nocheck. Upstream typechecks cleanly against the 2.1.296 declarations; regenerate the shared file and the pragmas can go.
Tests: claude plugin test . runs the engine test; the unit tests under hooks/*.test.ts run against a fake $ in hooks/harness.ts.
MIT, see LICENSE.
hooks/register.tsx 722 lines1// @ts-nocheck: written for the Claude Code 2.1.296 types; this folder has the 2.1.283 declarations.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, MatchedHook, Register } from 'claude-code'
4
5import type {
6 SubagentTunerChoice as Choice,
7 SubagentTunerMode as Mode,
8 SubagentTunerPending as Pending,
9 SubagentTunerQueue,
10 SubagentTunerSettings,
11} from '../types'
12import {
13 EFFORT_OPTIONS,
14 MODEL_OPTIONS,
15 MORE_EFFORT_OPTIONS,
16 MORE_MODEL_OPTIONS,
17 fieldQuestion,
18 headerFor,
19 mainDialog,
20 readField,
21 readMain,
22} from './dialog'
23import { INHERIT_VALUE, paneView } from './pane-view'
24import {
25 FORK_TYPE,
26 describeChoice,
27 autoPatch,
28 hasOpenField,
29 presetFor,
30 recommend,
31 resolveDefault,
32} from './recommend'
33import {
34 DEFAULT_MODE,
35 STORE_MODE,
36 STORE_SAVED,
37 asDefault,
38 cycleField,
39 parseChoice,
40 parseMode,
41 patched,
42 withPending,
43 without,
44} from './state'
45import type { Field } from './state'
46import { rowsLeft, stacked } from './stack'
47import { MIN_COLUMNS, bandView } from './view'
48import type { Actions } from './view'
49
50/** How long after a launch a stalled save of the default is waited for. */
51export const SAVE_WAIT_MS = 2_000
52
53/** The longest a pick pane is waited for: `$.process.run` takes ten minutes at most (d.ts). */
54export const PANE_WAIT_MS = 600_000
55
56/** Every pane of the tuner starts with this, so a reload can find the stale ones. */
57export const PANE_PREFIX = 'subagent-tuner-'
58
59const PANE_ROWS = 9
60
61const settings = atom(
62 { plugin: 'subagent-tuner', key: 'settings' } as const,
63 { mode: DEFAULT_MODE, saved: null, hydrated: false } satisfies SubagentTunerSettings,
64)
65
66const queue = atom({ plugin: 'subagent-tuner', key: 'queue' } as const, {
67 items: [],
68} satisfies SubagentTunerQueue)
69
70type Host = EngineInterface
71type AgentHook = MatchedHook<'tool.call', { readonly tool: 'Agent' }>
72type AgentCall = Parameters<AgentHook>[1]
73type AgentNext = Parameters<AgentHook>[2]
74type Patch = Partial<Pick<AgentCall, 'model' | 'effort'>>
75
76// This module holds every call on `$`: the loader follows `$` only within
77// one file, so the siblings are pure (logic, the dialog's words, the view).
78
79/** What the store holds; a bad stored value counts as unset. */
80async function stored($: Host) {
81 const [mode, saved] = await Promise.all([$.store.get(STORE_MODE), $.store.get(STORE_SAVED)])
82
83 // An empty default (both fields inherited) is no default.
84 return { mode: parseMode(mode, DEFAULT_MODE), saved: asDefault(parseChoice(saved)) }
85}
86
87// Loaded once: the reducer keeps what is already there, so a load that lost a
88// race to a write (or to another load) changes nothing.
89async function ensureHydrated($: Host): Promise<void> {
90 if ((await read($, settings)).hydrated) {
91 return
92 }
93
94 const loaded = await stored($)
95
96 await update($, settings, current => (current.hydrated ? current : { ...loaded, hydrated: true }))
97}
98
99// Store writes are versioned per key, never queued behind one another. Each
100// click bumps the version and writes the state as it stands when the write
101// runs; a write that lands after a newer click writes again, so an earlier
102// $.store.set that finishes late cannot leave the store disagreeing with the
103// band, and one that never finishes holds nothing back. The click waits for its
104// write only for SAVE_WAIT_MS and says when a save is stuck. A store that
105// refuses a write is said, since the setting would otherwise vanish quietly; a
106// state that cannot be read rejects the click that asked. (The versions are a
107// module variable: a hot reload resets them, which only drops the repair of a
108// write already in flight.)
109const versions = new Map<string, number>()
110const WHAT: Record<string, string> = { [STORE_MODE]: 'the mode', [STORE_SAVED]: 'the default' }
111
112async function persistLatest($: Host, key: string, current: () => Promise<unknown>): Promise<void> {
113 versions.set(key, (versions.get(key) ?? 0) + 1)
114
115 const write = (async (): Promise<void> => {
116 let written = -1
117
118 while (written !== versions.get(key)) {
119 written = versions.get(key) ?? 0
120
121 const value = await current()
122
123 try {
124 await $.store.set(key, value)
125 } catch (error) {
126 $.ui.toast(`Subagent tuner: could not save (${error instanceof Error ? error.message : 'store error'})`)
127
128 return
129 }
130 }
131 })()
132 const stop = new AbortController()
133 const isStuck = await Promise.race([
134 write.then(() => false),
135 $.clock.sleep(SAVE_WAIT_MS, { signal: stop.signal }).then(
136 () => true,
137 () => false,
138 ),
139 ])
140
141 stop.abort()
142
143 if (isStuck) {
144 // Whatever the write does later, the click has been answered.
145 write.catch(() => undefined)
146 $.ui.toast(`Subagent tuner: saving ${WHAT[key] ?? 'a setting'} is taking too long`)
147 }
148}
149
150async function setMode($: Host, mode: Mode): Promise<void> {
151 await ensureHydrated($)
152 await update($, settings, current => ({ ...current, mode }))
153 await persistLatest($, STORE_MODE, async () => (await read($, settings)).mode)
154}
155
156async function changeSaved($: Host, change: (saved: Choice | null) => Choice | null): Promise<void> {
157 await ensureHydrated($)
158 await update($, settings, current => ({ ...current, saved: asDefault(change(current.saved)) }))
159 await persistLatest($, STORE_SAVED, async () => (await read($, settings)).saved)
160}
161
162const actionsFor = ($: Host): Actions => ({
163 setMode: mode => setMode($, mode),
164 cycle: (field: Field) => changeSaved($, saved => cycleField(saved, field)),
165 reset: () => changeSaved($, () => null),
166})
167
168// The call's model and effort are the tool's own literal unions; the tuner
169// holds them as plain strings it checked against the same names.
170const asPatch = (patch: { model?: string; effort?: string }): Patch => patch as Patch
171const asCall = (call: object): AgentCall => call as AgentCall
172
173/** The call with exactly the selection's model and effort. */
174function selected(e: AgentCall, choice: Choice): object {
175 const { model: _model, effort: _effort, ...rest } = e
176
177 return {
178 ...rest,
179 ...(choice.model === null ? {} : { model: choice.model }),
180 ...(choice.effort === null ? {} : { effort: choice.effort }),
181 }
182}
183
184// Manual: the selection is the whole answer, so the call's own value is replaced
185// by it, and inherit removes the field from the call.
186function applySelection($: Host, e: AgentCall, next: AgentNext, choice: Choice, label: string) {
187 if (choice.model !== (e.model ?? null) || choice.effort !== (e.effort ?? null)) {
188 $.ui.toast(`${label} -> ${describeChoice(choice)}`)
189 }
190
191 return next(asCall(selected(e, choice)))
192}
193
194// Auto: the saved default outvotes the call (a rule that makes the agent pass
195// `model: "opus"` included), so the toast says where the call's own choice was
196// overridden.
197function applyAuto($: Host, e: AgentCall, next: AgentNext, saved: Choice | null, rec: Choice) {
198 const patch = autoPatch(e, saved, rec)
199 const result = { model: patch.model ?? e.model ?? null, effort: patch.effort ?? e.effort ?? null }
200 const isOverride =
201 (patch.model !== undefined && e.model !== undefined && patch.model !== e.model) ||
202 (patch.effort !== undefined && e.effort !== undefined && patch.effort !== e.effort)
203
204 if (result.model !== (e.model ?? null) || result.effort !== (e.effort ?? null)) {
205 $.ui.toast(`${isOverride ? 'Subagent (overridden)' : 'Subagent'} -> ${describeChoice(result)}`)
206 }
207
208 return next({ ...e, ...asPatch(patch) })
209}
210
211// A person is asked only where the band can be drawn too (the terminal and the
212// desktop): on another surface (vscode, mobile) they could not change the mode or
213// the default and would be asked on every call.
214async function canAsk($: Host): Promise<boolean> {
215 return (await $.session.surfaces()).some(surface => surface === 'terminal' || surface === 'desktop')
216}
217
218// A -p run or an SDK host draws nothing: nobody there chose a mode or a default,
219// and a model changed behind its back would skew what the run measures (evals).
220async function isHeadless($: Host): Promise<boolean> {
221 return (await $.session.surfaces()).length === 0
222}
223
224const tuneAgent: AgentHook = async ($, e, next) => {
225 // A fork ignores model and effort.
226 if (e.subagent_type === FORK_TYPE || (await isHeadless($))) {
227 return next(e)
228 }
229
230 await ensureHydrated($)
231
232 const { mode, saved } = await read($, settings)
233 const rec = recommend(e)
234
235 if (mode === 'auto' || !(await canAsk($))) {
236 return applyAuto($, e, next, saved, rec)
237 }
238
239 // Manual asks only about what the call left open.
240 if (!hasOpenField(e)) {
241 return next(e)
242 }
243
244 const fallback = resolveDefault(saved, rec)
245
246 // The pane where it can be seated unasked (wide terminals); the engine's own
247 // chain of the engine's own dialogs, where it cannot.
248 const preset = presetFor(e, fallback)
249 const viaPane = await askInPane($, e, next.signal, preset)
250 let reading: Outcome
251
252 if (viaPane.kind === 'unavailable') {
253 // A pane that is not placed (a narrow terminal) is the normal case: dialogs, unannounced.
254 if (viaPane.reason !== NOT_PLACED) {
255 $.ui.toast(`Subagent tuner: pane unavailable: ${viaPane.reason}`)
256 }
257
258 reading = await askChain($, e, next.signal, preset)
259 } else {
260 reading = viaPane
261 }
262
263 // An interrupted dispatch has no one left to tell, and nothing to start.
264 if (next.signal.aborted) {
265 return { deny: 'interrupted' }
266 }
267
268 // A dismissed or failed question must not hold the subagent back.
269 if (reading.kind === 'unanswered') {
270 return applySelection($, e, next, reading.choice ?? preset, 'Subagent (no answer)')
271 }
272
273 if (reading.kind === 'unreadable') {
274 $.ui.toast(reading.problem)
275
276 return { deny: 'could not read the answer; nothing was started' }
277 }
278
279 if (reading.kind === 'cancel') {
280 return { deny: 'cancelled by user' }
281 }
282
283 return reading.kind === 'save'
284 ? launchAndSave($, next, asCall(selected(e, reading.choice)), reading.choice)
285 : next(asCall(selected(e, reading.choice)))
286}
287
288type Outcome =
289 | { kind: 'run' | 'save'; choice: Choice }
290 | { kind: 'cancel' }
291 /** Nothing usable came back: the launch goes on, with the selection if there was one. */
292 | { kind: 'unanswered'; choice?: Choice }
293 | { kind: 'unreadable'; problem: string }
294
295/** The pane route could not be used: why, in a few words. */
296type Unavailable = { kind: 'unavailable'; reason: string }
297
298const NOT_PLACED = 'not placed'
299
300// Where the pane cannot be seated, a chain of the engine's own dialogs (`$.ui.ask`:
301// a `$` call, so the hook's time budget does not count the waits). The first
302// carries the selection in its labels and what to do; Change walks the field
303// dialogs and comes back to it with the new values, until Run, Run and save, or
304// Cancel. A dismissed or refused dialog fails open to the selection as it stands.
305async function ask1($: Host, question: string, options: readonly string[], header: string): Promise<string | undefined> {
306 try {
307 return await $.ui.ask(question, { options, header })
308 } catch {
309 return undefined
310 }
311}
312
313// One field's dialog; text typed under Other that names no value is asked once
314// more, then the previous value stays. `undefined`: the dialog was dismissed.
315async function askField($: Host, call: AgentCall, field: Field, previous: string | null): Promise<string | null | undefined> {
316 const header = field === 'model' ? 'Model' : 'Effort'
317 const options = field === 'model' ? MODEL_OPTIONS : EFFORT_OPTIONS
318
319 for (let attempt = 0; attempt < 2; attempt += 1) {
320 const answer = await ask1($, fieldQuestion(call, field), options, header)
321
322 if (answer === undefined) {
323 return undefined
324 }
325
326 const value = readField(field, answer)
327
328 if (value === 'more') {
329 const rest = await ask1($, fieldQuestion(call, field), field === 'model' ? MORE_MODEL_OPTIONS : MORE_EFFORT_OPTIONS, header)
330
331 if (rest === undefined) {
332 return undefined
333 }
334
335 const picked = readField(field, rest)
336
337 if (picked !== undefined && picked !== 'more') {
338 return picked
339 }
340 } else if (value !== undefined) {
341 return value
342 }
343 }
344
345 return previous
346}
347
348async function askChain($: Host, call: AgentCall, signal: AbortSignal, preset: Choice): Promise<Outcome> {
349 let current: Choice = { ...preset }
350
351 chain: for (;;) {
352 // An interrupted dispatch asks nothing more.
353 if (signal.aborted) {
354 return { kind: 'unanswered', choice: current }
355 }
356
357 const dialog = mainDialog(call, current)
358 const answer = await ask1($, dialog.question, dialog.options, 'Subagent')
359
360 if (answer === undefined) {
361 return { kind: 'unanswered', choice: current }
362 }
363
364 const said = readMain(dialog, answer)
365
366 if (said.kind === 'run' || said.kind === 'save') {
367 return { kind: said.kind, choice: current }
368 }
369
370 if (said.kind === 'typed') {
371 return { kind: 'run', choice: said.choice }
372 }
373
374 if (said.kind === 'cancel') {
375 return { kind: 'cancel' }
376 }
377
378 if (said.kind === 'unreadable') {
379 return said
380 }
381
382 // A field dialog dismissed discards the whole change: back to the first
383 // dialog with the selection as it was when Change was chosen.
384 let changed = current
385
386 for (const field of ['model', 'effort'] as const) {
387 if (signal.aborted) {
388 return { kind: 'unanswered', choice: current }
389 }
390
391 const value = await askField($, call, field, changed[field])
392
393 if (value === undefined) {
394 continue chain
395 }
396
397 changed = { ...changed, [field]: value }
398 }
399
400 current = changed
401 }
402}
403
404// One pane for each launch, so each launch is bound to its pane by the pane's
405// id and several at once are tabs the person moves between (the engine shows
406// one). What the pane's selectors hold is kept here too, beside the state they
407// draw from: every read of one dispatch sees one moment, the one before the pane
408// opened, so the hook cannot read the selection back from the state. A reload
409// drops this, and the hook it served with it.
410type Session = {
411 title: string
412 choice: Choice
413 /** What the person (or the dispatch) decided; absent while it waits. */
414 decision?: 'run' | 'save' | 'cancel' | 'abort'
415 sentinel: string
416}
417
418const sessions = new Map<string, Session>()
419
420const paneIdFor = (toolUseId: string): string => `${PANE_PREFIX}${toolUseId.replace(/[^A-Za-z0-9_-]/g, '_')}`.slice(0, 64)
421
422const pendingFor = (call: AgentCall, id: string, preset: Choice): Pending => ({
423 id,
424 subagentType: call.subagent_type ?? 'general-purpose',
425 description: call.description,
426 model: preset.model,
427 effort: preset.effort,
428 isCallSet: call.model !== undefined || call.effort !== undefined,
429})
430
431const removePending = ($: Host, id: string): Promise<unknown> => update($, queue, q => without(q, id))
432
433async function pickValue($: Host, id: string, field: Field, value: string): Promise<void> {
434 const session = sessions.get(id)
435 const after = await update($, queue, q => patched(q, id, { [field]: value === INHERIT_VALUE ? null : value }))
436 const entry = after.items.find(one => one.id === id)
437
438 if (session !== undefined && entry !== undefined) {
439 session.choice = { model: entry.model, effort: entry.effort }
440 }
441}
442
443const shortReason = (error: unknown): string => (error instanceof Error ? error.message : 'error').slice(0, 60)
444
445type Shell = 'sh' | 'powershell.exe'
446
447// The wait runs in whichever of these starts first: sh on macOS and Linux,
448// powershell.exe on Windows, where Git puts no sh, mktemp or touch on the PATH.
449const SHELLS: readonly Shell[] = ['sh', 'powershell.exe']
450
451const POWERSHELL = ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command'] as const
452
453const probeArgv = (shell: Shell): string[] => (shell === 'sh' ? ['sh', '-c', 'exit 0'] : [...POWERSHELL, 'exit 0'])
454
455// The waiting child polls for the sentinel and removes it once seen, so a
456// finished wait leaves nothing behind.
457const waitArgv = (shell: Shell, sentinel: string): string[] =>
458 shell === 'sh'
459 ? ['sh', '-c', 'while [ ! -e "$1" ]; do sleep 0.2; done; rm -f -- "$1"', 'sh', sentinel]
460 : [
461 ...POWERSHELL,
462 `$p='${sentinel.replace(/'/g, "''")}'; while (-not (Test-Path -LiteralPath $p)) { Start-Sleep -Milliseconds 200 }; Remove-Item -LiteralPath $p -Force`,
463 ]
464
465// Removes the probe file the waits folder was tried with.
466const removeArgv = (shell: Shell, path: string): string[] =>
467 shell === 'sh'
468 ? ['sh', '-c', 'rm -f -- "$1"', 'sh', path]
469 : [...POWERSHELL, `Remove-Item -LiteralPath '${path.replace(/'/g, "''")}' -Force -ErrorAction SilentlyContinue`]
470
471// Found once per load (register resets it): a reload may find another host.
472let waitShell: Promise<Shell | undefined> | undefined
473
474function shellToWaitIn($: Host): Promise<Shell | undefined> {
475 waitShell ??= (async () => {
476 for (const shell of SHELLS) {
477 try {
478 await $.process.run(probeArgv(shell))
479
480 return shell
481 } catch {
482 // Not on this host: try the next.
483 }
484 }
485
486 return undefined
487 })()
488
489 return waitShell
490}
491
492// Ends the wait: records the decision once, then makes the sentinel appear.
493async function finish($: Host, id: string, decision: 'run' | 'save' | 'cancel' | 'abort'): Promise<void> {
494 const session = sessions.get(id)
495
496 if (session === undefined || session.decision !== undefined) {
497 return
498 }
499
500 session.decision = decision
501
502 try {
503 await $.fs.write(session.sentinel, '')
504 } catch {
505 $.ui.toast('Subagent tuner: could not release the pane; your choice applies when its wait ends')
506 }
507}
508
509// The hook's own time budget stops while a `$` call is in flight but not for an
510// await on a promise, so the wait is a child process that ends when the
511// sentinel appears. The sentinel lives in the plugin's own folder, named for the
512// call and the moment, so no two waits share one. A wait that ends without a
513// decision (the ten minutes ran out) is no answer; one that ends with a decision
514// recorded (the sentinel could not be written) honours it.
515async function askInPane($: Host, call: AgentCall, signal: AbortSignal, preset: Choice): Promise<Outcome | Unavailable> {
516 if (signal.aborted) {
517 return { kind: 'unanswered' }
518 }
519
520 const shell = await shellToWaitIn($)
521
522 if (shell === undefined) {
523 return { kind: 'unavailable', reason: 'no shell to wait in' }
524 }
525
526 // Aborted while the shell was found: nothing was made, nothing to undo.
527 if (signal.aborted) {
528 return { kind: 'unanswered' }
529 }
530
531 const id = paneIdFor(call.tool_use_id)
532 const entry = pendingFor(call, id, preset)
533 const sentinel = `${$.plugin.root}/.waits/${id}-${await $.clock.now()}`
534
535 // A folder that takes no write (read-only install, full disk) could never end
536 // the wait: the dialogs answer at once instead of a pane that holds the launch
537 // for ten minutes. The probe is a sibling the wait does not look for.
538 try {
539 await $.fs.write(`${sentinel}.probe`, '')
540 } catch (error) {
541 return { kind: 'unavailable', reason: `waits folder not writable: ${shortReason(error)}` }
542 }
543
544 await $.process.run(removeArgv(shell, `${sentinel}.probe`)).catch(() => undefined)
545
546 // Aborted while the probe ran: nothing is open yet.
547 if (signal.aborted) {
548 return { kind: 'unanswered' }
549 }
550
551 const session: Session = {
552 choice: { model: entry.model, effort: entry.effort },
553 sentinel,
554 title: headerFor(call).slice(0, 40),
555 }
556 const onAbort = (): void => {
557 finish($, id, 'abort').catch(() => undefined)
558 }
559
560 sessions.set(id, session)
561 signal.addEventListener('abort', onAbort)
562
563 try {
564 await update($, queue, q => withPending(q, entry))
565
566 const opened = await $.ui.open({
567 id,
568 title: session.title,
569 focus: true,
570 closeOnEscape: true,
571 holdToasts: true,
572 rows: PANE_ROWS,
573 })
574 const pane = (await $.ui.panes()).find(one => one.id === id)
575
576 // A pane that waits undrawn (a narrow terminal) cannot be answered.
577 if (!opened.isPlaced || pane === undefined || !pane.isPlaced) {
578 return { kind: 'unavailable', reason: NOT_PLACED }
579 }
580
581 if (!pane.isFocused) {
582 $.ui.toast('Subagent tuner: press ctrl+x then Tab to reach the pane')
583 }
584
585 try {
586 await $.process.run(waitArgv(shell, session.sentinel), { timeoutMs: PANE_WAIT_MS })
587 } catch {
588 // The wait ran out: the person's live selection stands, not the preset. A
589 // decision recorded all the same (the sentinel could not be written) stands too.
590 if (session.decision === undefined || session.decision === 'abort') {
591 return { kind: 'unanswered', choice: session.choice }
592 }
593 }
594
595 return session.decision === 'run' || session.decision === 'save'
596 ? { kind: session.decision, choice: session.choice }
597 : session.decision === 'cancel'
598 ? { kind: 'cancel' }
599 : { kind: 'unanswered' }
600 } catch (error) {
601 return { kind: 'unavailable', reason: shortReason(error) }
602 } finally {
603 signal.removeEventListener('abort', onAbort)
604 sessions.delete(id)
605 // Nothing is left behind on any path: the entry, the pane (the wait removed
606 // the sentinel). (A hot reload while the pane is open skips this and leaves
607 // the queue item behind; that is live-only, not testable here, and UNVERIFIED.)
608 await removePending($, id).catch(() => undefined)
609 await $.ui.close({ id }).catch(() => undefined)
610 }
611}
612
613// The person's choice starts the subagent whatever becomes of the save: the
614// save runs beside the launch (and says when it is stuck), and a failure of it
615// is said rather than lost.
616async function launchAndSave($: Host, next: AgentNext, chosen: Parameters<AgentNext>[0], choice: Choice) {
617 const saving = changeSaved($, () => choice).catch((error: unknown) => {
618 $.ui.toast(`Subagent tuner: could not save the default (${error instanceof Error ? error.message : 'error'})`)
619 })
620 const ran = await next(chosen)
621
622 await saving
623
624 return ran
625}
626
627export const register: Register = on => {
628 waitShell = undefined
629
630 on('session.start', async ($, e, next) => {
631 await ensureHydrated($)
632
633 // A pane a previous copy of this module left open has no one waiting on it.
634 try {
635 for (const pane of await $.ui.panes()) {
636 if (pane.id.startsWith(PANE_PREFIX)) {
637 await $.ui.close({ id: pane.id })
638 }
639 }
640 } catch {
641 // No panes to clear.
642 }
643
644 return next(e)
645 })
646
647 // A broken tuner must never keep a subagent from starting: whatever fails,
648 // the call goes on exactly as the model made it.
649 on('tool.call', { tool: 'Agent' }, tuneAgent).catch(($, e, next) => next(e))
650
651 // The person closing a pane (Escape, the close mark) cancels its launch.
652 on('ui.close', async ($, e, next) => {
653 const session = sessions.get(e.id)
654
655 if (e.origin.kind === 'person' && session !== undefined) {
656 await finish($, e.id, 'cancel')
657 }
658
659 return next(e)
660 })
661
662 // The pane's own body: the selectors and the buttons for the launch it holds.
663 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
664 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
665 return next(e)
666 }
667
668 const entry = (await read($, queue)).items.find(one => one.id === e.requestId)
669
670 if (entry === undefined) {
671 return next(e)
672 }
673
674 return paneView({
675 ui: $.ui.resolve(e),
676 columns: e.props.bodyColumns,
677 entry,
678 actions: {
679 pick: (id, field, value) => pickValue($, id, field, value),
680 run: id => finish($, id, 'run'),
681 save: id => finish($, id, 'save'),
682 cancel: id => finish($, id, 'cancel'),
683 },
684 })
685 })
686
687 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
688 // The band is raised on the terminal and the desktop only. Narrower than the
689 // smallest layout nothing fits, so nothing is drawn.
690 if (
691 e.props.hasSurvey ||
692 e.props.bodyColumns < MIN_COLUMNS ||
693 (e.surface !== 'terminal' && e.surface !== 'desktop')
694 ) {
695 return next(e)
696 }
697
698 const current = await read($, settings)
699 // Before the first load the state holds only the initial values: show
700 // what the store holds rather than a default that is not the person's.
701 const { mode, saved } = current.hydrated ? current : await stored($)
702
703 // The site draws one tree: ours goes above whatever the plugins beneath drew, and they size
704 // themselves to the rows the band leaves. A plugin beneath that fails never costs the band.
705 let below: unknown
706
707 try {
708 below = await next({ ...e, props: { ...e.props, maxRows: rowsLeft(e.props.maxRows) } })
709 } catch {
710 below = undefined
711 }
712
713 const ui = $.ui.resolve(e)
714
715 return stacked(
716 ui,
717 bandView({ ui, columns: e.props.bodyColumns, mode, saved, actions: actionsFor($) }),
718 below,
719 )
720 })
721}
722hooks/dialog.ts 134 lines1// @ts-nocheck: written for the Claude Code 2.1.296 types; this folder has the 2.1.283 declarations.
2import type { SubagentTunerChoice as Choice } from '../types'
3import type { Field } from './state'
4import { EFFORTS, MODELS, describeChoice } from './recommend'
5import type { AgentRequest } from './recommend'
6
7// The native dialogs, asked in a chain where the pick pane cannot be seated. The
8// engine's dialog takes at most four options and compares labels exactly.
9
10export const CHANGE_LABEL = 'Change model or effort'
11export const CANCEL_LABEL = 'Cancel'
12export const MORE_MODEL_LABEL = 'fable or inherit…'
13export const MORE_EFFORT_LABEL = 'xhigh, max or inherit…'
14const INHERIT_NAME = 'inherit'
15
16/** What Run says where the selection inherits both fields although the call set one. */
17export const INHERITED_TEXT = 'with inherited model and effort'
18
19// Each field dialog holds at most four options (the engine's limit): the fourth
20// opens the rest, so that every value, inherit included, can be clicked.
21export const MODEL_OPTIONS: readonly string[] = ['haiku', 'sonnet', 'opus', MORE_MODEL_LABEL]
22export const MORE_MODEL_OPTIONS: readonly string[] = ['fable', INHERIT_NAME]
23export const EFFORT_OPTIONS: readonly string[] = ['low', 'medium', 'high', MORE_EFFORT_LABEL]
24export const MORE_EFFORT_OPTIONS: readonly string[] = ['xhigh', 'max', INHERIT_NAME]
25
26const DESCRIPTION_CHARS = 60
27const TYPE_CHARS = 30
28
29
30const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max - 1)}…`)
31
32const agentOf = (call: AgentRequest): string => clip(call.subagent_type ?? 'general-purpose', TYPE_CHARS)
33
34export const headerFor = (call: AgentRequest): string => `${agentOf(call)} · ${clip(call.description ?? '', DESCRIPTION_CHARS)}`
35
36// The selection is the whole answer in Manual: a value the call set is only its
37// preset. An empty selection leaves the call as made only where it set nothing.
38export const shows = (call: AgentRequest, choice: Choice): string => {
39 if (choice.model !== null || choice.effort !== null) {
40 return describeChoice(choice)
41 }
42
43 return call.model === undefined && call.effort === undefined ? 'as the model asked' : INHERITED_TEXT
44}
45
46/** The first dialog: run with what is selected, save it, change it, or cancel. */
47export type MainDialog = {
48 question: string
49 run: string
50 /** Absent only where nothing is selected: there is no default to keep. */
51 save: string | null
52 options: readonly string[]
53}
54
55export function mainDialog(call: AgentRequest, current: Choice): MainDialog {
56 const run = `Run ${shows(call, current)}`
57 const save = current.model !== null || current.effort !== null ? `${run} + save default` : null
58
59 return {
60 question: `Run ${agentOf(call)}: ${clip(call.description ?? '', DESCRIPTION_CHARS)}?`,
61 run,
62 save,
63 options: [run, ...(save === null ? [] : [save]), CHANGE_LABEL, CANCEL_LABEL],
64 }
65}
66
67export const fieldQuestion = (call: AgentRequest, field: Field): string =>
68 `${field === 'model' ? 'Model' : 'Effort'} for ${agentOf(call)}?`
69
70/** `model/effort` or `model effort` typed under Other, each checked against the known names. */
71export function parseChoiceText(text: string): Choice | null {
72 const match = /^\s*([a-z]+)\s*[/\s]\s*([a-z]+)\s*$/i.exec(text)
73 const model = match?.[1]?.toLowerCase()
74 const effort = match?.[2]?.toLowerCase()
75
76 if (model === undefined || effort === undefined) {
77 return null
78 }
79
80 const isKnown = (MODELS as readonly string[]).includes(model) && (EFFORTS as readonly string[]).includes(effort)
81
82 return isKnown ? { model, effort } : null
83}
84
85export type MainReading =
86 | { kind: 'run' | 'save' | 'change' | 'cancel' }
87 | { kind: 'typed'; choice: Choice }
88 /** Neither an option nor a model/effort: nothing is started. */
89 | { kind: 'unreadable'; problem: string }
90
91/** Labels are compared exactly; free text is the dialog's Other. */
92export function readMain(dialog: MainDialog, text: string): MainReading {
93 if (text === dialog.run) {
94 return { kind: 'run' }
95 }
96
97 if (dialog.save !== null && text === dialog.save) {
98 return { kind: 'save' }
99 }
100
101 if (text === CHANGE_LABEL) {
102 return { kind: 'change' }
103 }
104
105 if (text === CANCEL_LABEL) {
106 return { kind: 'cancel' }
107 }
108
109 const typed = parseChoiceText(text)
110
111 return typed !== null
112 ? { kind: 'typed', choice: typed }
113 : { kind: 'unreadable', problem: `Subagent tuner: could not read "${clip(text, 30)}" as model/effort; nothing was started` }
114}
115
116/**
117 * A value of one field from a field dialog, or text typed under Other that names
118 * one (or inherit). `undefined` where it names none; `'more'` where the dialog's
119 * fourth option was chosen.
120 */
121export function readField(field: Field, text: string): string | null | 'more' | undefined {
122 if (text === (field === 'model' ? MORE_MODEL_LABEL : MORE_EFFORT_LABEL)) {
123 return 'more'
124 }
125
126 const name = text.trim().toLowerCase()
127
128 if (name === INHERIT_NAME) {
129 return null
130 }
131
132 return ((field === 'model' ? MODELS : EFFORTS) as readonly string[]).includes(name) ? name : undefined
133}
134hooks/pane-view.tsx 106 lines1// @ts-nocheck: written for the Claude Code 2.1.296 types; this folder has the 2.1.283 declarations.
2import type { Elements } from 'claude-code'
3
4import type { SubagentTunerPending as Pending } from '../types'
5import { EFFORTS, MODELS } from './recommend'
6import { INHERITED_TEXT } from './dialog'
7import type { Field } from './state'
8
9export const INHERIT_VALUE = 'inherit'
10
11/** From this width the Model and Effort selectors share a row. */
12export const SELECTORS_FROM = 52
13/** The three buttons share a row from this width, or from the width their labels need, if more. */
14export const BUTTONS_FROM = 50
15const BUTTONS_GAP = 2
16
17export type PaneActions = {
18 pick: (id: string, field: Field, value: string) => unknown
19 run: (id: string) => unknown
20 save: (id: string) => unknown
21 cancel: (id: string) => unknown
22}
23
24export type PaneData = {
25 ui: Elements['terminal'] | Elements['desktop']
26 columns: number
27 entry: Pending
28 actions: PaneActions
29}
30
31const clip = (text: string, max: number): string =>
32 max <= 0 ? '' : text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
33
34const LABEL: Record<Field, string> = { model: 'Model', effort: 'Effort' }
35
36/** `<agent type> · <description>`, clipped to the room. */
37export const headerLine = (entry: Pending, columns: number): string =>
38 clip(`${entry.subagentType} · ${entry.description}`, columns)
39
40function selector(data: PaneData, field: Field) {
41 const { Select } = data.ui
42 const current = data.entry[field] ?? INHERIT_VALUE
43
44 // The preset (the call's value, else the default or recommendation) is just
45 // the value the selector starts on: no mark, and every field is editable.
46 return (
47 <Select
48 key={field}
49 label={LABEL[field]}
50 options={[...(field === 'model' ? MODELS : EFFORTS), INHERIT_VALUE].map(value => ({ value }))}
51 value={current}
52 onSelect={value => data.actions.pick(data.entry.id, field, value)}
53 />
54 )
55}
56
57// Plain Buttons draw their hotkey (`r: Run`).
58// Inheriting both fields drops what the call set: Run says so.
59const runLabel = (entry: Pending): string =>
60 entry.model === null && entry.effort === null && entry.isCallSet ? `Run ${INHERITED_TEXT}` : 'Run'
61
62type ButtonKey = 'run' | 'save' | 'cancel'
63
64const buttonSpecs = (entry: Pending): { key: ButtonKey; hotkey: string; label: string }[] => [
65 { key: 'run', hotkey: 'r', label: runLabel(entry) },
66 { key: 'save', hotkey: 'd', label: 'Run and save as default' },
67 { key: 'cancel', hotkey: 'c', label: 'Cancel' },
68]
69
70// The width of the row: each plain Button draws `<hotkey>: <label>`, plus the gaps.
71const buttonsRowWidth = (entry: Pending): number => {
72 const specs = buttonSpecs(entry)
73
74 return specs.reduce((sum, one) => sum + one.hotkey.length + 2 + one.label.length, 0) + BUTTONS_GAP * (specs.length - 1)
75}
76
77function buttons(data: PaneData) {
78 const { Button } = data.ui
79 const { entry, actions } = data
80
81 return buttonSpecs(entry).map(({ key, hotkey, label }) => (
82 <Button key={key} plain hotkey={hotkey} label={label} autoFocus={key === 'run' ? true : undefined} onPress={() => actions[key](entry.id)} />
83 ))
84}
85
86export function paneView(data: PaneData) {
87 const { Box, Text } = data.ui
88 const selectors = [selector(data, 'model'), selector(data, 'effort')]
89
90 return (
91 <Box flexDirection="column">
92 <Text>{headerLine(data.entry, data.columns)}</Text>
93 {data.columns >= SELECTORS_FROM ? (
94 <Box gap={3}>{selectors}</Box>
95 ) : (
96 <Box flexDirection="column">{selectors}</Box>
97 )}
98 {data.columns >= Math.max(BUTTONS_FROM, buttonsRowWidth(data.entry)) ? (
99 <Box gap={BUTTONS_GAP}>{buttons(data)}</Box>
100 ) : (
101 <Box flexDirection="column">{buttons(data)}</Box>
102 )}
103 </Box>
104 )
105}
106hooks/recommend.ts 110 lines1// @ts-nocheck: written for the Claude Code 2.1.296 types; this folder has the 2.1.283 declarations.
2import type { SubagentTunerChoice as Choice } from '../types'
3
4export const MODELS = ['haiku', 'sonnet', 'opus', 'fable'] as const
5export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
6
7export type Model = (typeof MODELS)[number]
8export type Effort = (typeof EFFORTS)[number]
9
10/** A prompt longer than this asks for more thought than its task kind implies. */
11export const LONG_PROMPT_CHARS = 4000
12
13/** The agent type the engine runs as a fork: it ignores model and effort. */
14export const FORK_TYPE = 'fork'
15
16/** No recommendation: the subagent inherits the parent's model and effort. */
17export const INHERIT: Choice = { model: null, effort: null }
18
19type Rule = { kind: string; pattern: RegExp; model: Model; effort: Effort }
20
21// Ordered by what a miss costs: a review that ran on a cheap model is worse
22// than an implementation that ran on a dear one, so review wins a tie. Each
23// pattern opens on a word boundary, and `fix` closes on one so that "fixture"
24// is not a fix while fixed, hotfix and bugfix are.
25const RULES: readonly Rule[] = [
26 { kind: 'review', pattern: /\b(review|audit|verif|adversar|secur)/i, model: 'opus', effort: 'high' },
27 { kind: 'implement', pattern: /\b(implement|build|(hot|bug)?fix(es|ed|ing)?\b)/i, model: 'sonnet', effort: 'medium' },
28 { kind: 'explore', pattern: /\b(explor|search|research)/i, model: 'haiku', effort: 'low' },
29]
30
31export type AgentRequest = {
32 subagent_type?: string
33 description?: string
34 prompt?: string
35 model?: string
36 effort?: string
37}
38
39const firstMatch = (text: string): Rule | undefined => RULES.find(rule => rule.pattern.test(text))
40
41export function bumpEffort(effort: Effort): Effort {
42 const next = EFFORTS[EFFORTS.indexOf(effort) + 1]
43
44 return next ?? effort
45}
46
47/**
48 * The agent type names the job more reliably than the free-text description,
49 * so it is tried first.
50 */
51export function recommend(request: AgentRequest): Choice {
52 const rule = firstMatch(request.subagent_type ?? '') ?? firstMatch(request.description ?? '')
53
54 if (rule === undefined) {
55 return INHERIT
56 }
57
58 const isLong = (request.prompt ?? '').length > LONG_PROMPT_CHARS
59
60 return { model: rule.model, effort: isLong ? bumpEffort(rule.effort) : rule.effort }
61}
62
63/**
64 * What the tuner applies without asking: the saved default, each field of it
65 * falling back to the recommendation where the default leaves it inherited.
66 */
67export const resolveDefault = (saved: Choice | null, rec: Choice): Choice =>
68 saved === null
69 ? rec
70 : { model: saved.model ?? rec.model, effort: saved.effort ?? rec.effort }
71
72/** True while the call leaves the model or the effort for the tuner to settle. */
73export const hasOpenField = (request: AgentRequest): boolean =>
74 request.model === undefined || request.effort === undefined
75
76/**
77 * The fields the choice supplies, each only where the call left it unset: what
78 * the call set itself is never overridden.
79 */
80export function patchFor(request: AgentRequest, choice: Choice): { model?: string; effort?: string } {
81 return {
82 ...(request.model === undefined && choice.model !== null ? { model: choice.model } : {}),
83 ...(request.effort === undefined && choice.effort !== null ? { effort: choice.effort } : {}),
84 }
85}
86
87/**
88 * Auto: each field the saved default sets replaces what the call set; a field it
89 * leaves inherited keeps what the call set, else takes the recommendation.
90 */
91export function autoPatch(request: AgentRequest, saved: Choice | null, rec: Choice): { model?: string; effort?: string } {
92 return {
93 ...patchFor(request, resolveDefault(saved, rec)),
94 ...(saved?.model == null ? {} : { model: saved.model }),
95 ...(saved?.effort == null ? {} : { effort: saved.effort }),
96 }
97}
98
99/**
100 * Manual mode's starting selection, per field: what the call set, else the
101 * default or recommendation. The person may change every field of it.
102 */
103export const presetFor = (request: AgentRequest, fallback: Choice): Choice => ({
104 model: request.model ?? fallback.model,
105 effort: request.effort ?? fallback.effort,
106})
107
108export const describeChoice = (choice: Choice): string =>
109 `${choice.model ?? 'inherit'}/${choice.effort ?? 'inherit'}`
110hooks/state.ts 65 lines1// @ts-nocheck: written for the Claude Code 2.1.296 types; this folder has the 2.1.283 declarations.
2import type {
3 SubagentTunerChoice as Choice,
4 SubagentTunerMode as Mode,
5 SubagentTunerPending as Pending,
6 SubagentTunerQueue as Queue,
7} from '../types'
8import { EFFORTS, MODELS } from './recommend'
9
10// Pure parsers and reducers. The module that hooks events holds every call on
11// `$` and declares the atoms: the plugin loader follows `$`, and reads an
12// atom's reference, only within one file.
13
14export const DEFAULT_MODE: Mode = 'manual'
15export const STORE_MODE = 'mode'
16export const STORE_SAVED = 'saved'
17
18export type Field = 'model' | 'effort'
19
20export const parseMode = (value: unknown, fallback: Mode): Mode =>
21 value === 'manual' || value === 'auto' ? value : fallback
22
23const isOneOf = (list: readonly string[], value: unknown): boolean =>
24 value === null || (typeof value === 'string' && list.includes(value))
25
26/** A stored value that is not a choice over the known names counts as unset. */
27export function parseChoice(value: unknown): Choice | null {
28 if (typeof value !== 'object' || value === null) {
29 return null
30 }
31
32 const { model, effort } = value as Record<string, unknown>
33
34 if (!isOneOf(MODELS, model) || !isOneOf(EFFORTS, effort)) {
35 return null
36 }
37
38 return { model: model as string | null, effort: effort as string | null }
39}
40
41/** A choice with neither field set is no default at all. */
42export const asDefault = (choice: Choice | null): Choice | null =>
43 choice !== null && (choice.model !== null || choice.effort !== null) ? choice : null
44
45/** The next value of a field in its cycle: none, then each name in turn. */
46export function cycled(current: string | null, names: readonly string[]): string | null {
47 const at = current === null ? -1 : names.indexOf(current)
48
49 return names[at + 1] ?? null
50}
51
52export function cycleField(saved: Choice | null, field: Field): Choice | null {
53 const base = saved ?? { model: null, effort: null }
54
55 return asDefault({ ...base, [field]: cycled(base[field], field === 'model' ? MODELS : EFFORTS) })
56}
57
58export const withPending = (q: Queue, item: Pending): Queue => ({ items: [...q.items, item] })
59
60export const patched = (q: Queue, id: string, patch: Partial<Pending>): Queue => ({
61 items: q.items.map(one => (one.id === id ? { ...one, ...patch } : one)),
62})
63
64export const without = (q: Queue, id: string): Queue => ({ items: q.items.filter(one => one.id !== id) })
65hooks/stack.ts 62 lines1// @ts-nocheck: written for the Claude Code 2.1.296 types; this folder has the 2.1.283 declarations.
2// The tuner's band over the tree a plugin beneath drew for the same site. Pure: no `$`.
3//
4// The AbovePrompt site draws one tree, so a hook that never calls `next(e)` hides every plugin beneath it.
5// `next(e)` resolves to what those draw; the band is returned above it. (Adapted from session-healthbar's
6// hooks/pure/stack.ts: plugins cannot import each other, so the few lines needed are copied here.)
7import type { Elements, RenderElement } from 'claude-code'
8
9type Els = Pick<Elements['terminal'] | Elements['desktop'], 'Box'>
10
11/** The rows the band takes: it is one row (`height={1}` in view.tsx, held to it by a test). */
12export const BAND_ROWS = 1
13
14/** What `next(e)` gave, when it is a tree we can stack on: an object that names an element type. */
15export const isElement = (x: unknown): x is RenderElement =>
16 typeof x === 'object' && x !== null && typeof (x as { type?: unknown }).type === 'string'
17
18const childrenOf = (el: RenderElement): readonly unknown[] => {
19 const kids = (el as { children?: unknown }).children
20
21 return Array.isArray(kids) ? kids.flat(Infinity) : []
22}
23
24const lineCount = (s: string): number => s.split('\n').length
25
26/** The text a Text node holds: its strings, and the strings of the inline elements inside it. */
27const textOf = (n: unknown): string => {
28 if (typeof n === 'string') return n
29 if (typeof n === 'number') return String(n)
30
31 return isElement(n) ? childrenOf(n).map(textOf).join('') : ''
32}
33
34/**
35 * How many rows a tree takes, estimated from its shape alone: a Text is one row plus one per newline; a
36 * column Box the sum of its children, a row Box the tallest; any other element (a Button, an Input) one row.
37 * Borders, padding and wrapping are not counted, so this can undercount.
38 */
39export const rowsOf = (tree: unknown): number => {
40 if (typeof tree === 'string') return lineCount(tree)
41 if (typeof tree === 'number') return 1
42 if (!isElement(tree)) return 1
43 if (tree.type === 'Text') return lineCount(textOf(tree))
44 if (tree.type !== 'Box') return 1
45
46 const rows = childrenOf(tree).map(rowsOf)
47 const direction = String((tree.props as { flexDirection?: unknown } | undefined)?.flexDirection)
48 const isColumn = direction === 'column' || direction === 'column-reverse'
49
50 return isColumn ? rows.reduce((a, b) => a + b, 0) : Math.max(0, ...rows)
51}
52
53/** Whether `next(e)` gave a tree with something to draw: an empty Box is as good as nothing. */
54export const hasRows = (below: unknown): below is RenderElement => isElement(below) && rowsOf(below) > 0
55
56/** The rows the plugins beneath may take: what the site has, less the band's own; at least one. */
57export const rowsLeft = (maxRows: number): number => Math.max(1, maxRows - BAND_ROWS)
58
59/** The band over the tree beneath, one column; the band alone when nothing to draw came back. */
60export const stacked = ({ Box }: Els, band: RenderElement, below: unknown): RenderElement =>
61 hasRows(below) ? Box({ flexDirection: 'column', children: [band, below] }) : band
62hooks/view.tsx 123 lines1// @ts-nocheck: written for the Claude Code 2.1.296 types; this folder has the 2.1.283 declarations.
2import type { Elements } from 'claude-code'
3
4import type { SubagentTunerChoice as Choice, SubagentTunerMode as Mode } from '../types'
5import type { Field } from './state'
6
7export type Actions = {
8 setMode: (mode: Mode) => unknown
9 cycle: (field: Field) => unknown
10 reset: () => unknown
11}
12
13export type BandData = {
14 ui: Elements['terminal'] | Elements['desktop']
15 columns: number
16 mode: Mode
17 saved: Choice | null
18 actions: Actions
19}
20
21/** Marks the active mode in words, for where colour alone would not tell. */
22export const ACTIVE_MARK = '*'
23/** Trails a plain (unbracketed) cycler, which would otherwise look like text. */
24export const CYCLER_MARK = '>'
25
26type Layout = {
27 lead: string
28 manual: string
29 between: string
30 isBracketed: boolean
31 isShort: boolean
32 reset: string | null
33}
34
35/**
36 * Richest first, each giving up something other than the mode's words before the
37 * next: the word `default`, then brackets and the full effort name, then the
38 * lead and Reset. `Man` is the last resort.
39 */
40const LAYOUTS: readonly Layout[] = [
41 { lead: 'Tuner ', manual: 'Manual', between: ' · default ', isBracketed: true, isShort: false, reset: 'Reset' },
42 { lead: 'Tuner ', manual: 'Manual', between: ' · ', isBracketed: true, isShort: false, reset: '×' },
43 { lead: 'Tuner ', manual: 'Manual', between: ' ', isBracketed: false, isShort: true, reset: '×' },
44 { lead: '', manual: 'Manual', between: ' ', isBracketed: false, isShort: true, reset: null },
45 { lead: '', manual: 'Man', between: ' ', isBracketed: false, isShort: true, reset: null },
46]
47
48/** The width of the smallest layout in its worst case; narrower than this nothing is drawn. */
49export const MIN_COLUMNS = 31
50
51const NONE_LABEL = 'rec.'
52
53const nameOf = (value: string | null, isShort: boolean): string =>
54 value === null ? NONE_LABEL : isShort && value === 'medium' ? 'med' : value
55
56const cyclerLabel = (layout: Layout, value: string | null): string =>
57 `${nameOf(value, layout.isShort)}${layout.isBracketed ? '' : CYCLER_MARK}`
58
59// A bracketed Button is its label plus 4 cells, a plain one its label alone
60// (reference.md:127); the active mode carries one more cell for its mark.
61const buttonWidth = (label: string, isBracketed: boolean): number => label.length + (isBracketed ? 4 : 0)
62
63/** The cells the layout needs for what is to be drawn now: the labels it holds, not the worst they could be. */
64function neededWidth(layout: Layout, mode: Mode, saved: Choice | null): number {
65 const manual = `${mode === 'manual' ? ACTIVE_MARK : ''}${layout.manual}`
66 const auto = `${mode === 'auto' ? ACTIVE_MARK : ''}Auto`
67 const cyclers =
68 buttonWidth(cyclerLabel(layout, saved?.model ?? null), layout.isBracketed) +
69 1 +
70 buttonWidth(cyclerLabel(layout, saved?.effort ?? null), layout.isBracketed)
71 const reset = layout.reset === null || saved === null ? 0 : 1 + buttonWidth(layout.reset, layout.isBracketed)
72
73 return layout.lead.length + buttonWidth(manual, true) + buttonWidth(auto, true) + layout.between.length + cyclers + reset
74}
75
76/** The richest layout whose needed width fits; the smallest where none does. */
77const layoutFor = (columns: number, mode: Mode, saved: Choice | null): Layout =>
78 LAYOUTS.find(layout => neededWidth(layout, mode, saved) <= columns) ?? LAYOUTS[LAYOUTS.length - 1]!
79
80export function bandView({ ui, columns, mode, saved, actions }: BandData) {
81 const { Box, Button, Text } = ui
82 const layout = layoutFor(columns, mode, saved)
83 const plain = layout.isBracketed ? undefined : true
84 const field = (name: Field) => (
85 <Button
86 key={`default-${name}`}
87 label={cyclerLabel(layout, saved?.[name] ?? null)}
88 plain={plain}
89 onPress={() => actions.cycle(name)}
90 />
91 )
92
93 return (
94 <Box height={1} overflow="hidden">
95 {layout.lead === '' ? null : <Text dimColor>{layout.lead}</Text>}
96 <Box>
97 <Button
98 key="mode-manual"
99 label={mode === 'manual' ? `${ACTIVE_MARK}${layout.manual}` : layout.manual}
100 variant={mode === 'manual' ? 'primary' : undefined}
101 onPress={() => actions.setMode('manual')}
102 />
103 <Button
104 key="mode-auto"
105 label={mode === 'auto' ? `${ACTIVE_MARK}Auto` : 'Auto'}
106 variant={mode === 'auto' ? 'primary' : undefined}
107 onPress={() => actions.setMode('auto')}
108 />
109 </Box>
110 <Text dimColor>{layout.between}</Text>
111 {field('model')}
112 <Text dimColor>/</Text>
113 {field('effort')}
114 {layout.reset === null || saved === null ? null : (
115 <Box>
116 <Text> </Text>
117 <Button key="reset" label={layout.reset} plain={plain} onPress={() => actions.reset()} />
118 </Box>
119 )}
120 </Box>
121 )
122}
123types/index.d.ts 45 lines1// The contract of the subagent-tuner plugin: the state its band and its pick
2// pane draw from. A model or effort is null where the subagent inherits the
3// parent's.
4
5export type SubagentTunerMode = 'manual' | 'auto'
6
7export type SubagentTunerChoice = {
8 model: string | null
9 effort: string | null
10}
11
12export type SubagentTunerSettings = {
13 mode: SubagentTunerMode
14 /** The saved default; null while none is set. */
15 saved: SubagentTunerChoice | null
16 /** True once the persisted mode and default were loaded into this state. */
17 hydrated: boolean
18}
19
20/** A subagent launch waiting for the person in its pane. */
21export type SubagentTunerPending = {
22 /** The pane's id: one pane for each launch. */
23 id: string
24 subagentType: string
25 description: string
26 /** The current selection, which Run applies; it starts at the preset. */
27 model: string | null
28 effort: string | null
29 /** True where the call itself set a model or an effort (the preset may differ from it). */
30 isCallSet: boolean
31}
32
33export type SubagentTunerQueue = {
34 items: SubagentTunerPending[]
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'subagent-tuner': {
40 settings: SubagentTunerSettings
41 queue: SubagentTunerQueue
42 }
43 }
44}
45