SLOPSHOPPER

model-hint

Suggests haiku, sonnet or opus for the task you are typing, live, in a band above the prompt. Instant word rules, then a small model reads the draft when you…

newbandcommandtoaststatusprompt
v0.5.0MITupdated 2026-10-15hashswingingslasher/model-hint
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-hint
› 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 › /model-hint ⎿ model-hint: Model hint off for this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

model-hint

A Claude Code mod that reads the prompt as you type and suggests the model tier the task wants, in a band above the prompt. Two passes: word rules answer on every keystroke for free, then, once you pause for 300 ms, a small model (haiku by default) reads the draft and its verdict replaces the rules'. One short completion per pause, nothing while you are typing.

opus  open-ended, multi-system game architecture   [ Switch to opus ]  [ Hide ]
┌──────────────────────────────────────────────────────────────────────────────┐
│ > i need to build a AAA video game similar to GTA 5 in volume and playability │
└──────────────────────────────────────────────────────────────────────────────┘

While you type it says reading the draft...; the verdict lands 1.2 to 1.9 s after you stop typing (a 300 ms pause, then a haiku call that measured 0.9 to 1.6 s). A draft judged once in the session answers instantly from a cache. The Switch button appears only when the session's model does not cover the verdict; a covered one reads opus hard concurrency bug · covered by fable. The band clears when you submit.

Where it runs

| Where | Works | Notes | | :- | :- | :- | | Claude Code in a terminal | Yes | Seen working on 2.1.296 and 2.1.299 | | VS Code, terminal mode | Yes | Same engine as above; setup below | | VS Code, graphical chat panel | No | The panel sends no keystroke events to mods and has no band to draw into. Tracked upstream in #99045 and #99401 | | JetBrains terminal, Claude Code Desktop | Expected | Not yet seen working | | Windows | Untested | |

VS Code setup. The extension's terminal mode runs whatever claude is first on your PATH, and the extension does not install one.

  1. Install or upgrade the standalone CLI to 2.1.296 or later (npm i -g @anthropic-ai/claude-code), and check claude --version in a new terminal.
  2. In VS Code settings, turn on claudeCode.useTerminal.
  3. Install the plugin at user scope (below).
  4. Open Claude Code. It now opens in a terminal tab, with the band above the prompt.

Terminal mode keeps inline diffs but gives up the panel's plan review, @-mention chips and click-to-rewind.

Why not just route automatically

Some tools pick a model for you without saying so. model-hint does the opposite: it tells you which tier it thinks the task needs and why, before you send, and leaves the choice with you. Nothing changes unless you press Switch. When it is wrong, you can see its reason and ignore it.

Install

/plugin install model-hint --marketplace hashswingingslasher/model-hint

Answer y to add the marketplace, then pick the user scope so it loads in every session. Needs a Claude Code build with function hooks (mods), 2.1.296 or later.

To run it from a checkout instead:

claude --plugin-dir /path/to/model-hint

What it does

| Draft | Hint | Why | | :- | :- | :- | | fix the typo in the README | haiku | mechanical edit, short ask | | add a --verbose flag to the CLI and log the resolved config | sonnet | ordinary coding task | | there is a race condition in the job queue, find the root cause | opus | hard bug class | | i need to build a AAA video game similar to GTA 5 | opus | open-ended, huge scope (from the model) | | /model opus, !git status, an empty box | nothing | the model never sees it |

Rules pass. Each signal in hooks/classify.ts has a weight. The weights sum to a score; below one threshold the hint is haiku, above the other it is opus, between them sonnet. With the model pass on, the rules' guess is never shown as a tier; it stands in only when the model pass fails, labelled (judge API error, rules' guess). With engine set to rules it is the verdict, with the two strongest signals as the reason.

Model pass. After a pause, the draft goes to the judge model with a fixed system prompt (hooks/judge.ts) that describes the three tiers and asks for {"tier","reason"} in at most sixty output tokens at low effort. The verdict lands only if you have not typed since, and it stays on screen while you keep typing, until the next pause re-judges. Drafts under 15 characters, commands and shell escapes are never sent. A failed or malformed reply leaves the rules' hint in place.

A session on Fable counts as covering an opus hint, so the band does not ask you to step down.

Switch to <tier> changes the session's model through the /config Model row. Where that row is not available or is locked by policy, the button shows a toast with the /model command to type instead. Hide closes the band until the hint changes or you submit. s and h press them while the band has focus (ctrl+x tab).

/model-hint turns the band off and on for the session.

Options

Set in /config under the plugin's rows, or at install.

| Option | Values | Default | Effect | | :- | :- | :- | :- | | engine | model, rules | model | rules turns the model pass off: words only, no completions | | judge | a model alias or id | haiku | Which model reads the draft | | show | always, when-different | always | when-different hides the band entirely when the session's model covers the verdict | | bias | balanced, cost, quality | balanced | Moves the two thresholds. cost needs more evidence before suggesting opus; quality needs less |

How it is built

Three hooks and one render site, all in hooks/register.tsx:

  • prompt.edit fires on each edit the composer makes. The hook lets the edit through, runs the rules on the draft that results, writes the hint to $.state only when it changed, and arms a $.clock.after timer for the model pass. Each edit cancels the previous timer and aborts any completion in flight, so at most one model call is ever pending.
  • prompt.fill covers a draft another plugin wrote into the box, which raises no edit.
  • prompt.submit clears the hint.
  • ui.render on AbovePrompt reads the hint, the session's model and the Hide state and draws the band, or yields to the engine. A failed model pass is logged to the debug log (claude --debug) and named in the band.
  • $.ui.status mirrors the hint under the prompt until a band has drawn once. It is kept for surfaces not yet checked, such as mobile; it does not help in the VS Code panel, where none of the hooks above fire.

hooks/classify.ts and hooks/judge.ts are pure and have no engine dependency; hooks/model.ts maps a model id to a tier. The $.state contract is in types/index.d.ts.

Development

claude plugin validate .   # manifest, hooks module, state contract
claude plugin test .       # tests/*.test.ts against the engine's own runtime
npm install && npm run typecheck

tsc needs the API declarations the engine lays in .claude-plugin/types/ the first time it loads the mod from this folder (claude --plugin-dir .). They are gitignored.

Live check (haiku judge, 10 prompts labelled before the run: 3 haiku, 4 sonnet, 3 opus). 10 of 10 matched (14 Oct 2026). In live terminal sessions on 15 Oct the verdict landed 1.2 to 1.9 s after the last keystroke, median about 1.5 s. Run with claude --debug to see the same timing for your own prompts: each verdict logs its time after the last edit, split into the pause and the judge call.

Tests cover the rules on their own, the judge's prompt and reply parsing, the model-id mapping, and the band on both the terminal and desktop surfaces: it draws when the tier differs, stays quiet when covered, hides and comes back, clears on submit, takes the model's verdict after the pause, cancels the pass when typing resumes, and drops a verdict for a draft that moved on. The judge model and the clock are mocked.

Limits

  • The rules pass reads words, not intent, and is only there to answer instantly. The model pass is the real judgment, and it costs one small completion per pause; set engine to rules to avoid that.
  • It knows three tiers. Provider-specific ids are mapped by the substring haiku, sonnet, opus or fable; anything else is treated as unknown and the band always shows.
  • The switch button depends on the /config Model row, which policy can lock.
  • Mods are an early-access Claude Code API and can change between releases.

License

MIT

Source 5 files
hooks/register.tsx 307 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Suggestion } from '../types'
5import { classify, isSkippable, type Bias } from './classify'
6import * as judge from './judge'
7import { satisfies, tierOf } from './model'
8
9const COMMAND = 'model-hint'
10
11const suggestion = atom({ plugin: 'model-hint', key: 'suggestion' } as const, null)
12const isEnabled = atom({ plugin: 'model-hint', key: 'isEnabled' } as const, true)
13const isDismissed = atom({ plugin: 'model-hint', key: 'isDismissed' } as const, false)
14const judgeNote = atom({ plugin: 'model-hint', key: 'judgeNote' } as const, null)
15
16const BIASES: readonly Bias[] = ['balanced', 'cost', 'quality']
17const SHOWS = ['always', 'when-different'] as const
18const ENGINES = ['model', 'rules'] as const
19type Show = (typeof SHOWS)[number]
20type Engine = (typeof ENGINES)[number]
21
22/**
23 * How long the person must pause before the model reads the draft. A judge call
24 * measured 0.8 to 1.4 s (median about 0.95 s), so 300 ms keeps the verdict near
25 * 1.25 s after typing stops; a stray keystroke only aborts the call in flight.
26 */
27export const PAUSE_MS = 300
28
29/** Verdicts kept per session, so a draft judged once answers instantly. */
30const CACHE_SIZE = 64
31
32type Settings = { bias: Bias; show: Show; engine: Engine; judgeModel: string }
33
34function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
35  return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : fallback
36}
37
38function sameSuggestion(a: Suggestion | null, b: Suggestion | null): boolean {
39  if (a === null || b === null) return a === b
40  return a.tier === b.tier && a.reasons.join('|') === b.reasons.join('|')
41}
42
43/** Writes the hint only when it changed, so a stable classification never redraws; a change also clears a Hide. */
44async function publish($: EngineInterface, next: Suggestion | null, force: boolean): Promise<void> {
45  let changed = false
46  await update($, suggestion, prev => {
47    changed = !sameSuggestion(prev, next)
48    return changed ? next : prev
49  })
50  if (changed || force) await update($, isDismissed, () => false)
51}
52
53// One pending model pass at a time, for the draft as it stood when the person
54// paused. Module state: a hot reload drops it, which is the right outcome.
55let pending: Timer | undefined
56let inflight: AbortController | undefined
57let generation = 0
58
59// Verdicts by judge model and trimmed draft, oldest first. Backspacing to a
60// draft already judged, or pausing twice on the same text, costs no call.
61const verdicts = new Map<string, Suggestion>()
62
63// Wall-clock time of the last edit, for the debug log's latency line only.
64let editedAt = 0
65
66function cacheKey(settings: Settings, draft: string): string {
67  return `${settings.judgeModel}\u0000${draft.trim()}`
68}
69
70function remember(key: string, verdict: Suggestion): void {
71  verdicts.delete(key)
72  verdicts.set(key, verdict)
73  if (verdicts.size > CACHE_SIZE) {
74    const oldest = verdicts.keys().next().value
75    if (oldest !== undefined) verdicts.delete(oldest)
76  }
77}
78
79// Whether this session draws the band. Learned at the first AbovePrompt render,
80// not from the surface's name: until then the status line under the prompt
81// carries the hint. That covers surfaces with a status line but no band; it does
82// nothing in the VS Code panel, where prompt.edit never fires.
83let hasBand = false
84
85async function mirror($: EngineInterface, settings: Settings): Promise<void> {
86  if (hasBand) return
87  const [hint, enabled, note] = await Promise.all([read($, suggestion), read($, isEnabled), read($, judgeNote)])
88  if (hint === null || !enabled) {
89    $.ui.status(undefined)
90    return
91  }
92  if (hint.source === 'rules' && settings.engine === 'model' && note === null) {
93    $.ui.status('model-hint: reading the draft...')
94    return
95  }
96  const current = tierOf(await $.session.model())
97  const covered = satisfies(current, hint.tier)
98  if (covered && settings.show === 'when-different') {
99    $.ui.status(undefined)
100    return
101  }
102  const tail = covered && current ? `covered by ${current}` : `/model ${hint.tier} to switch`
103  $.ui.status(`model-hint: ${hint.tier} · ${hint.reasons.join(', ')}${note ? ` (${note}, rules' guess)` : ''} · ${tail}`)
104}
105
106function cancelPending(): number {
107  pending?.cancel()
108  inflight?.abort()
109  generation += 1
110  return generation
111}
112
113/**
114 * The instant pass. The rules answer at once; a model verdict already on
115 * screen is kept while the person keeps typing, so the band does not flip
116 * between the two sources on every keystroke. Then the model pass is armed.
117 */
118async function onDraft($: EngineInterface, draft: string, settings: Settings): Promise<void> {
119  const mine = cancelPending()
120  editedAt = Date.now()
121
122  const rules = classify(draft, settings.bias)
123  await update($, judgeNote, () => null)
124
125  const known = settings.engine === 'model' && rules !== null ? verdicts.get(cacheKey(settings, draft)) : undefined
126  if (known) {
127    await publish($, known, false)
128    await mirror($, settings)
129    return
130  }
131
132  const held = await read($, suggestion)
133  const keepModelVerdict = held?.source === 'model' && rules !== null
134  if (!keepModelVerdict) await publish($, rules, isSkippable(draft))
135  await mirror($, settings)
136
137  if (settings.engine !== 'model' || rules === null || draft.trim().length < judge.MIN_LENGTH) return
138  pending = $.clock.after(PAUSE_MS, () => {
139    void ask($, draft, mine, settings)
140  })
141}
142
143/** The model pass. A verdict lands only while it is still the latest and the draft has not moved on. */
144async function ask($: EngineInterface, draft: string, mine: number, settings: Settings): Promise<void> {
145  const stop = new AbortController()
146  inflight = stop
147  const firedAt = Date.now()
148  // A failure is shown in the band and logged; it never sits silently on "reading".
149  const fail = async (note: string, detail: string) => {
150    $.ui.log(`model-hint: ${note}: ${detail}`, { to: 'debug' })
151    if (mine !== generation) return
152    await update($, judgeNote, () => note)
153    await mirror($, settings)
154  }
155  let reply
156  try {
157    reply = await $.model.complete(judge.request(draft, settings.judgeModel), { signal: stop.signal })
158  } catch (err) {
159    await fail('judge call failed', String(err))
160    return
161  }
162  if (mine !== generation) return
163  if (!reply.isAnswered) {
164    if (reply.reason === 'aborted') return
165    const detail = reply.reason === 'api-error' ? `${reply.status ?? ''} ${reply.error}` : reply.reason
166    await fail(reply.reason === 'api-error' ? 'judge API error' : 'judge gave no answer', detail)
167    return
168  }
169
170  const verdict = judge.parse(reply.text)
171  if (verdict === null) {
172    await fail('judge reply unreadable', reply.text.slice(0, 200))
173    return
174  }
175  const box = await $.prompt.read()
176  if (box.text !== draft) {
177    $.ui.log(`model-hint: draft moved on before the verdict landed (${box.text.length} vs ${draft.length} chars)`, { to: 'debug' })
178    return
179  }
180  remember(cacheKey(settings, draft), verdict)
181  const now = Date.now()
182  $.ui.log(
183    `model-hint: verdict ${verdict.tier} (${verdict.reasons[0]}) ${now - editedAt} ms after the last edit ` +
184      `(timer ${firedAt - editedAt} ms, judge call ${now - firedAt} ms)`,
185    { to: 'debug' },
186  )
187  await publish($, verdict, false)
188  await mirror($, settings)
189}
190
191export const register: Register = (on, options) => {
192  const settings: Settings = {
193    bias: pick(options.bias, BIASES, 'balanced'),
194    show: pick<Show>(options.show, SHOWS, 'always'),
195    engine: pick<Engine>(options.engine, ENGINES, 'model'),
196    judgeModel: typeof options.judge === 'string' && options.judge.trim() ? options.judge.trim() : 'haiku',
197  }
198
199  on('session.start', async ($, e, next) => {
200    await $.command.register({
201      name: COMMAND,
202      description: 'Toggle the model hint band above the prompt',
203    })
204    return next(e)
205  })
206
207  on('command.run', { command: COMMAND }, async $ => {
208    const enabled = await update($, isEnabled, was => !was)
209    await mirror($, settings)
210    return { text: enabled ? 'Model hint on.' : 'Model hint off for this session.' }
211  }).catch(() => ({ text: 'Model hint: could not toggle, see the debug log.' }))
212
213  // Fires on each edit the composer makes; `next(e)` resolves the box after it.
214  on('prompt.edit', async ($, e, next) => {
215    const box = await next(e)
216    await onDraft($, box.text, settings)
217    return box
218  })
219
220  // A plugin's fill replaces the draft without a prompt.edit, so re-read it.
221  on('prompt.fill', async ($, e, next) => {
222    const filled = await next(e)
223    if (filled.isFilled) {
224      const box = await $.prompt.read()
225      await onDraft($, box.text, settings)
226    }
227    return filled
228  })
229
230  // A failure here must never hold a prompt back: let it through untouched.
231  on('prompt.submit', async ($, e, next) => {
232    cancelPending()
233    await update($, suggestion, () => null)
234    await update($, isDismissed, () => false)
235    await update($, judgeNote, () => null)
236    $.ui.status(undefined)
237    return next(e)
238  }).catch((_$, e, next) => next(e))
239
240  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
241    if (!hasBand) {
242      hasBand = true
243      $.ui.status(undefined)
244    }
245    if (e.props.hasSurvey) return next(e)
246
247    const [hint, enabled, dismissed, note] = await Promise.all([
248      read($, suggestion),
249      read($, isEnabled),
250      read($, isDismissed),
251      read($, judgeNote),
252    ])
253    if (hint === null || !enabled || dismissed) return next(e)
254
255    const current = tierOf(await $.session.model())
256    const covered = satisfies(current, hint.tier)
257    if (covered && settings.show === 'when-different') return next(e)
258
259    const { Box, Button, Text } = $.ui.resolve(e)
260    const tier = hint.tier
261    const hide = <Button key="hide" label="Hide" hotkey="h" onPress={() => update($, isDismissed, () => true)} />
262
263    // The rules' guess is never shown as a tier while the model is still reading.
264    if (hint.source === 'rules' && settings.engine === 'model' && note === null) {
265      return (
266        <Box gap={1}>
267          <Text dimColor>reading the draft...</Text>
268          {hide}
269        </Box>
270      )
271    }
272
273    const switchTo = async () => {
274      const rows = await $.config.list()
275      const row = rows.find(r => r.key === 'model')
276      if (!row || row.isLocked) {
277        $.ui.toast(`Type /model ${tier} to switch`)
278        return
279      }
280      const set = await $.config.set({ key: 'model', value: tier })
281      if (set.deny) $.ui.toast(`Could not switch: ${set.deny}. Type /model ${tier}`)
282      else await update($, isDismissed, () => true)
283    }
284
285    const detail = [
286      hint.reasons.join(', '),
287      note ? `(${note}, rules' guess)` : '',
288      covered && current ? `· covered by ${current}` : '',
289    ]
290      .filter(Boolean)
291      .join(' ')
292
293    return (
294      <Box gap={1}>
295        <Text color={covered ? 'success' : 'suggestion'} bold>
296          {tier}
297        </Text>
298        <Text dimColor wrap="truncate-end">
299          {detail}
300        </Text>
301        {!covered && <Button key="switch" label={`Switch to ${tier}`} hotkey="s" onPress={switchTo} />}
302        {hide}
303      </Box>
304    )
305  })
306}
307
hooks/classify.ts 113 lines
1import type { Suggestion, Tier } from '../types'
2
3/**
4 * Scores a draft prompt and picks a tier. Pure: no engine, no model call, so it
5 * runs on every keystroke without cost and is unit-tested in isolation.
6 *
7 * The score is a sum of signals. Negative leans haiku, positive leans opus,
8 * the middle is sonnet. `bias` shifts the two thresholds so a cost-conscious
9 * person needs more evidence before being pushed up a tier, and vice versa.
10 */
11
12export type Bias = 'balanced' | 'cost' | 'quality'
13
14type Signal = {
15  /** Shown to the person as the reason for the pick; keep it short. */
16  label: string
17  weight: number
18  test: (draft: string, lower: string) => boolean
19}
20
21const words = (list: string[]) => new RegExp(`\\b(?:${list.join('|')})\\b`, 'i')
22
23const HEAVY_VERBS = words([
24  'architect', 'design', 'redesign', 'rearchitect', 'restructure', 'migrate', 'migration',
25  'refactor', 'rewrite', 'port', 'overhaul', 'modernize', 'modernise', 'decompose',
26  'modulari[sz]e', 'made modular', 'make .{1,30} modular', 'decouple', 'untangle', 'separat(?:e|ed|ing)',
27  'split (?:up|out|into|apart)', 'break (?:up|apart|out)', 'extract (?:into|out|a|the)', 'consolidate',
28])
29const HARD_PROBLEMS = words([
30  'race condition', 'deadlock', 'memory leak', 'flaky', 'intermittent', 'heisenbug',
31  'root cause', 'regression', 'data loss', 'corrupt(?:ed|ion)?', 'nondeterministic',
32  'performance', 'latency', 'throughput', 'scal(?:e|ing|ability)', 'concurren(?:cy|t)',
33])
34const JUDGMENT = words([
35  'trade-?offs?', 'strategy', 'approach', 'options?', 'compare', 'evaluate', 'assess',
36  'recommend', 'should (?:we|i)', 'pros and cons', 'which is better', 'critique', 'review',
37  'audit', 'security', 'threat model', 'plan', 'roadmap', 'spec', 'rfc', 'proposal',
38])
39const WIDE_SCOPE = words([
40  'across', 'entire', 'whole', 'all (?:the )?(?:files|modules|packages|services|tests)',
41  'codebase', 'monorepo', 'end[- ]to[- ]end', 'every', 'throughout', 'system[- ]wide',
42  '(?:multiple|several|many) (?:features|modules|files|services|components|packages|places)',
43])
44const DEBUG = words(['debug(?:ging|ged)?', 'investigate', 'troubleshoot(?:ing)?', 'diagnose', 'figure out', 'why (?:does|is|do|did|are|was|were)', "doesn't work", 'not working', 'broken', 'crash(?:es|ing)?'])
45
46const TRIVIAL = words([
47  'typo', 'rename', 'spelling', 'capitali[sz]e', 'lowercase', 'uppercase', 'indent',
48  'reformat', 'prettier', 'lint', 'alphabeti[sz]e', 'reorder',
49  'add a comment', 'remove (?:the )?comment', 'bump (?:the )?version', 'changelog',
50  'git (?:status|log|diff|add|commit|push|pull|stash|branch)', 'commit this', 'commit message',
51  'run (?:the )?tests?', 'run (?:the )?build', 'run (?:the )?linter', 'npm (?:run|test|install)',
52  'what does .{1,40} (?:do|mean|return)', 'what is .{1,40}\\?', 'where is', 'where are', 'which file',
53  'list (?:the|all)', 'show me', 'how many',
54  'translate', 'convert .{1,30} to (?:json|yaml|csv|markdown|md)', 'one[- ]liner', 'quick question',
55])
56const SMALL_SCOPE = words(['this line', 'this function', 'this file', 'one line', 'single line', 'tiny', 'minor', 'quick'])
57
58/** Past this length a draft is a brief, not a mechanical edit, whatever words it uses. */
59const MECHANICAL_MAX = 140
60
61const SIGNALS: Signal[] = [
62  { label: 'architecture or large refactor', weight: 3, test: (_, l) => HEAVY_VERBS.test(l) },
63  { label: 'hard bug class', weight: 3, test: (_, l) => HARD_PROBLEMS.test(l) },
64  { label: 'needs judgment', weight: 3, test: (_, l) => JUDGMENT.test(l) },
65  { label: 'wide scope', weight: 2, test: (_, l) => WIDE_SCOPE.test(l) },
66  { label: 'debugging', weight: 2, test: (_, l) => DEBUG.test(l) },
67  { label: 'multi-step', weight: 3, test: d => countSteps(d) >= 3 },
68  { label: 'long brief', weight: 2, test: d => d.length >= 600 },
69  { label: 'detailed brief', weight: 1, test: d => d.length >= 250 && d.length < 600 },
70  { label: 'mechanical edit', weight: -3, test: (d, l) => d.length < MECHANICAL_MAX && TRIVIAL.test(l) },
71  { label: 'small scope', weight: -1, test: (_, l) => SMALL_SCOPE.test(l) },
72  { label: 'short ask', weight: -1, test: d => d.trim().length < 40 },
73]
74
75/** Numbered items, bullets, or "then"/"after that" chains count as steps. */
76export function countSteps(draft: string): number {
77  const listed = (draft.match(/(?:^|\n)\s*(?:\d+[.)]|[-*•])\s+\S/g) ?? []).length
78  const chained = (draft.match(/\b(?:then|after that|next|finally|afterwards|once that)\b/gi) ?? []).length
79  return Math.max(listed, chained + 1)
80}
81
82/** Drafts the model never sees: slash commands, shell escapes, nothing typed. */
83export function isSkippable(draft: string): boolean {
84  const t = draft.trimStart()
85  return t.length === 0 || t.startsWith('/') || t.startsWith('!')
86}
87
88const THRESHOLDS: Record<Bias, { up: number; down: number }> = {
89  balanced: { up: 3, down: -2 },
90  cost: { up: 5, down: -1 },
91  quality: { up: 2, down: -3 },
92}
93
94export function classify(draft: string, bias: Bias = 'balanced'): Suggestion | null {
95  if (isSkippable(draft)) return null
96
97  const lower = draft.toLowerCase()
98  const hits = SIGNALS.filter(s => s.test(draft, lower))
99  const score = hits.reduce((sum, s) => sum + s.weight, 0)
100  const { up, down } = THRESHOLDS[bias]
101
102  const tier: Tier = score >= up ? 'opus' : score <= down ? 'haiku' : 'sonnet'
103
104  // Explain the pick with the signals that pulled in its direction, strongest first.
105  const towards = hits
106    .filter(s => (tier === 'opus' ? s.weight > 0 : tier === 'haiku' ? s.weight < 0 : true))
107    .sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight))
108    .map(s => s.label)
109  const reasons = towards.length > 0 ? towards.slice(0, 2) : ['ordinary coding task']
110
111  return { tier, reasons, score, source: 'rules' }
112}
113
hooks/judge.ts 47 lines
1import type { ModelCompleteRequest } from 'claude-code'
2
3import type { Suggestion, Tier } from '../types'
4
5/**
6 * The model pass. The rules in classify.ts answer instantly from words; this
7 * reads the task. It runs once the person pauses, on a small model, with a
8 * fixed system prompt and the draft as the only user message.
9 */
10
11const TIERS: readonly Tier[] = ['haiku', 'sonnet', 'opus']
12
13export const SYSTEM = [
14  'You route a coding-assistant request to one of three model tiers.',
15  'Reply with JSON only, nothing else: {"tier":"haiku"|"sonnet"|"opus","reason":"<at most six words>"}',
16  '',
17  'haiku: mechanical or lookup work. One obvious change, a rename, a typo, running a command, a factual question about the code.',
18  'sonnet: a well-specified feature, fix or test in a known area. The default for ordinary coding.',
19  'opus: architecture or design, open-ended or ambiguous goals, large or multi-system scope, hard debugging, trade-off judgement, plans and reviews, anything a senior engineer would need days for.',
20  '',
21  'Judge the task itself, not the wording or length. The text is a draft the person is still typing; judge what they are asking for so far.',
22].join('\n')
23
24/** Drafts shorter than this are judged by the rules alone; the model would see too little. */
25export const MIN_LENGTH = 15
26
27export function request(draft: string, model: string): ModelCompleteRequest {
28  return { model, system: SYSTEM, prompt: draft, maxTokens: 60, effort: 'low', timeoutMs: 8000 }
29}
30
31/** Reads the verdict out of the reply; null when it names no tier. */
32export function parse(reply: string): Suggestion | null {
33  const json = reply.match(/\{[\s\S]*\}/)?.[0]
34  if (!json) return null
35  let parsed: unknown
36  try {
37    parsed = JSON.parse(json)
38  } catch {
39    return null
40  }
41  if (typeof parsed !== 'object' || parsed === null) return null
42  const { tier, reason } = parsed as { tier?: unknown; reason?: unknown }
43  if (typeof tier !== 'string' || !(TIERS as readonly string[]).includes(tier)) return null
44  const why = typeof reason === 'string' && reason.trim() ? reason.trim().slice(0, 60) : 'judged by the model'
45  return { tier: tier as Tier, reasons: [why], score: 0, source: 'model' }
46}
47
hooks/model.ts 25 lines
1import type { Tier } from '../types'
2
3/**
4 * Maps what `/model` shows (an alias, a full id, a Bedrock or Vertex id) to the
5 * tier the hint reasons about. Fable sits above opus and satisfies an opus
6 * hint; anything unrecognised is null and the hint always shows.
7 */
8export type Current = Tier | 'fable' | null
9
10export function tierOf(model: string): Current {
11  const m = model.toLowerCase()
12  if (m.includes('fable')) return 'fable'
13  if (m.includes('opus')) return 'opus'
14  if (m.includes('sonnet')) return 'sonnet'
15  if (m.includes('haiku')) return 'haiku'
16  return null
17}
18
19/** Whether the session's model already covers the suggested tier. */
20export function satisfies(current: Current, suggested: Tier): boolean {
21  if (current === null) return false
22  if (current === suggested) return true
23  return current === 'fable' && suggested === 'opus'
24}
25
types/index.d.ts 31 lines
1/** The three tiers the hint picks between. */
2export type Tier = 'haiku' | 'sonnet' | 'opus'
3
4/** What produced a suggestion: the instant word rules, or the small model that reads the draft after a pause. */
5export type Source = 'rules' | 'model'
6
7/** What the classifier concluded about the current draft. */
8export type Suggestion = {
9  tier: Tier
10  /** Short, human-readable grounds for the pick, e.g. "typo fix, one line". */
11  reasons: string[]
12  /** The rules' net score: negative leans haiku, positive leans opus. 0 for a model verdict. */
13  score: number
14  source: Source
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'model-hint': {
20      /** The hint for the draft as it stands; null while the box is empty or the draft is a command. */
21      suggestion: Suggestion | null
22      /** Session toggle, flipped by /model-hint. */
23      isEnabled: boolean
24      /** True once the person hides the band for the current draft; reset when the draft is submitted or emptied. */
25      isDismissed: boolean
26      /** Why the last model pass gave no verdict, shown in the band in place of "(reading...)"; null while none failed. */
27      judgeNote: string | null
28    }
29  }
30}
31