SLOPSHOPPER

subagent-tuner

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.

newpanebandguardtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagent-tuner
› 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 Tuner [ *Manual ][ Auto ] · default [ rec. ]/[ rec. ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Tuner [ *Manual ][ Auto ] · default [ rec. ]/[ rec. ] ⟨Claude Code's own drawing⟩
README

subagent-tuner

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.

A pick pane in Manual, and Auto replacing the model the agent asked for

What it does

  • A band above the prompt: Tuner [Manual][Auto] · default [model]/[effort] [Reset]. Click to switch the mode or cycle the saved default.
  • Manual (the default) asks before a subagent starts: a pane beside the transcript on terminals 144 columns or wider, a short chain of dialogs on narrower ones. A dismissed question starts the subagent with what was shown.
  • Auto never asks. The saved default wins, even over a model or effort the agent set itself (for example a CLAUDE.md rule that makes it pass model: "opus"). A toast says what was applied.
  • Recommendation by task kind: review, audit or security → opus/high; implement, build or fix → sonnet/medium; explore, search or research → haiku/low. A long prompt gets one step more effort.

Where it stays out of the way

  • claude -p and SDK hosts: nothing is changed.
  • Forks: never touched.
  • VS Code extension: no band is possible there, so it never asks and behaves as Auto.
  • No network calls. It changes only the 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.

Install

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

Typechecking

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.

License

MIT, see LICENSE.

Source 8 files
hooks/register.tsx 722 lines
1// @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}
722
hooks/dialog.ts 134 lines
1// @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}
134
hooks/pane-view.tsx 106 lines
1// @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}
106
hooks/recommend.ts 110 lines
1// @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'}`
110
hooks/state.ts 65 lines
1// @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) })
65
hooks/stack.ts 62 lines
1// @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
62
hooks/view.tsx 123 lines
1// @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}
123
types/index.d.ts 45 lines
1// 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