SLOPSHOPPER

thinkfirst-ambition

A band above the prompt: Fable thinks (Reverb, Council, Summit, Deepen), Opus and Sonnet act, and a token meter keeps Fable's share honest.

bandcommandtoaststatuspromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · thinkfirst-ambition
› 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 › /ambition ⎿ thinkfirst-ambition: Ambition band shown. Keys 1–4: Reverb, Council, Summit, Deepen. [ Reverb ] [ Council ] [ Summit ] [ Deepen ] │ ○ Fable ○ Opus/Sonnet h: hide Fable 0% ▯▯▯▯▯▯▯▯▯▯ fable 0 · opus 7.9k · sonnet 0 · total 7.9k · spawns o0/s0 t: more ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ Reverb ] [ Council ] [ Summit ] [ Deepen ] │ ○ Fable ○ Opus/Sonnet h: hide Fable 0% ▯▯▯▯▯▯▯▯▯▯ fable 0 · opus 7.9k · sonnet 0 · total 7.9k · spawns o0/s0 t: more ⟨Claude Code's own drawing⟩
README

thinkfirst-ambition

A band above the prompt. Fable thinks, Opus and Sonnet act, and a token meter keeps Fable's share honest.

Install

At the prompt of a Claude Code terminal session:

/plugin install thinkfirst-ambition --marketplace DCXTribe/claude-code-think-first

From a shell:

claude plugin install thinkfirst-ambition --marketplace DCXTribe/claude-code-think-first

In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with DCXTribe/claude-code-think-first, then Add plugin.

What it does

Type your task in the prompt box, then press one key (the band must have focus: click it or ctrl+x tab). The first press opens a panel that explains the function: what it does, what you get back, when it helps and what Use puts in your prompt. Nothing is sent yet. Press the same key again (or the panel's run button) to run it. Another number switches the panel to that function, and c closes it.

KeyFunctionOne Fable call that returns…
1Reverbthe task's key noun rewritten as the real verb/outcome, one line
2Council3–5 lenses, one sentence each, Red Team always last
3Summitgoal beyond the goal · hard target (a number) · outside judge
4Deepenthe 4-things answers, then ## Rewritten task

The reply lands in a card above the prompt. u replaces your draft with it (Deepen uses only the rewritten task), a appends it to your draft as it is now, x closes it. Then press Enter as usual: the main model and its subagents do the work.

h hides the band, t switches the meter between summary and raw counts. /ambition shows it again; /ambition reset clears the meter; /ambition deepen <task> (or reverb, council, summit) runs a function on the task you give it, without focusing the band. Only one Fable call runs at a time.

Routing

  • Every band function runs on fable (Reverb/Council/Summit at high effort, Deepen at xhigh), with no history: it reads only what is in the prompt box.
  • A subagent that would run on Fable is rerouted to Opus, with a toast: one that names Fable, and one that names no model while the main session runs on Fable (it would inherit Fable). That second case also moves an agent type whose own definition names a model, since the hook cannot see it. Forks, workflow agents and resumed runs keep their model; the meter counts them under fable if they run there. A system-prompt section tells the main model to delegate hard pieces to opus and routine ones to sonnet, and never to Fable.
  • The meter counts billable tokens per lane (Fable / Opus / Sonnet / other) from every completed turn, main thread and subagents alike, plus the band's own Fable calls. Cache reads are left out of the share (every executor turn rereads the transcript from cache, cheaply) and shown in the raw row. Spawns are counted by the model they actually run on. Above 20% Fable share it turns amber.

What it runs and sends

  • Model calls. Each band function (Reverb, Council, Summit, Deepen) makes one call to Fable through your own Claude Code session, sending only the text in your prompt box (or the task given to /ambition <fn> <task>) and that function's fixed instructions. No conversation history goes with it. Nothing runs until you press a key or run the command.
  • System prompt. It adds one short section to Claude's system prompt: the routing rule (delegate to Opus or Sonnet, not Fable) and a note that a [Reverb], [Council] or [Summit] block in your prompt came from Fable.
  • Subagents. A subagent that would run on Fable is started on Opus instead, with a toast saying so.
  • Local state only. The token meter reads the usage Claude Code reports at the end of each turn and keeps it in this session's plugin state. It is not saved across sessions.
  • Nothing else. It reads no files, runs no processes, and contacts no service other than your Claude Code session's own model API.

Files

.claude-plugin/plugin.json   manifest
hooks/hooks.json             → register.tsx
hooks/register.tsx           the band, the four functions, the meter, the routing hooks
types/index.d.ts             state contract (card, busy, spend, isHidden, showRaw, armed)
tests/band.test.tsx          the band's drawing
tests/fixes.test.tsx         routing, /ambition <fn> <task>, append, the busy guard, the meter
tests/preview.test.tsx       first press explains, second press runs, switch and cancel

Validate with claude plugin validate ., type-check with tsc -p . once the engine has laid .claude-plugin/types/, test with claude plugin test ..

Tuning

Edit the top of hooks/register.tsx: THINKER (the model the band calls), FABLE_WARN_SHARE (the amber threshold), and the FN table (system prompts, effort, token caps and the explanation panel's text per function).

Source 2 files
hooks/register.tsx 501 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, ModelUsage, EngineInterface } from 'claude-code'
3
4import type { Card, Fn, Lane, Spend } from '../types'
5
6// ---------------------------------------------------------------------------
7// State
8// ---------------------------------------------------------------------------
9
10const PLUGIN = 'thinkfirst-ambition' as const
11const emptyLane = (): Lane => ({ in: 0, out: 0, cached: 0, calls: 0 })
12const emptySpend = (): Spend => ({
13  fable: emptyLane(),
14  opus: emptyLane(),
15  sonnet: emptyLane(),
16  other: emptyLane(),
17  spawns: { fable: 0, opus: 0, sonnet: 0, other: 0 },
18})
19
20// $.state outlives a reload, so a meter written by an older version may lack
21// fields this one counts: fill them with zeros rather than draw NaN.
22const asLane = (l: Partial<Lane> | undefined): Lane => ({ ...emptyLane(), ...l })
23const asSpend = (s: Partial<Spend> | undefined): Spend => ({
24  fable: asLane(s?.fable),
25  opus: asLane(s?.opus),
26  sonnet: asLane(s?.sonnet),
27  other: asLane(s?.other),
28  spawns: { ...emptySpend().spawns, ...s?.spawns },
29})
30
31const card = atom({ plugin: 'thinkfirst-ambition', key: 'card' } as const, null)
32const busy = atom({ plugin: 'thinkfirst-ambition', key: 'busy' } as const, null)
33const spend = atom({ plugin: 'thinkfirst-ambition', key: 'spend' } as const, emptySpend())
34const isHidden = atom({ plugin: 'thinkfirst-ambition', key: 'isHidden' } as const, false)
35const showRaw = atom({ plugin: 'thinkfirst-ambition', key: 'showRaw' } as const, false)
36const armed = atom({ plugin: 'thinkfirst-ambition', key: 'armed' } as const, null)
37
38// ---------------------------------------------------------------------------
39// Models and routing
40// ---------------------------------------------------------------------------
41
42/** The thinker. Every band function is one call to this model. */
43const THINKER = 'fable'
44/** Fable's share of all tokens above which the meter turns amber. */
45const FABLE_WARN_SHARE = 0.2
46
47type LaneName = 'fable' | 'opus' | 'sonnet' | 'other'
48
49export const laneOf = (model: string | undefined): LaneName => {
50  const m = (model ?? '').toLowerCase()
51  if (/fable|mythos/.test(m)) return 'fable'
52  if (/opus/.test(m)) return 'opus'
53  if (/sonnet/.test(m)) return 'sonnet'
54  return 'other'
55}
56
57/** The executor a subagent asked to run on Fable is moved to. */
58const FABLE_REROUTE = 'opus'
59
60/**
61 * The model a spawn should run on instead, or undefined to leave it.
62 *
63 * A spawn that names no model inherits its parent's (unless its agent type
64 * sets one, which this hook cannot see), so a Fable parent means a Fable
65 * child. A fork, a workflow agent and a resumed run keep their model: the
66 * engine refuses to change it.
67 */
68export const reroute = (e: {
69  model?: string
70  parentModel: string
71  fork: boolean
72  workflow?: unknown
73  resume?: unknown
74}): string | undefined => {
75  if (e.fork || e.workflow !== undefined || e.resume !== undefined) return undefined
76  return laneOf(e.model ?? e.parentModel) === 'fable' ? FABLE_REROUTE : undefined
77}
78
79const addUsage = (s0: Spend, lane: LaneName, u: ModelUsage): Spend => {
80  const s = asSpend(s0)
81  return {
82    ...s,
83    [lane]: {
84      in: s[lane].in + u.input_tokens + u.cache_creation_input_tokens,
85      out: s[lane].out + u.output_tokens,
86      cached: s[lane].cached + u.cache_read_input_tokens,
87      calls: s[lane].calls + 1,
88    },
89  }
90}
91
92/**
93 * Billable tokens: cache reads are left out, since every executor turn
94 * rereads the whole transcript at a tenth of the price, and counting them
95 * made Fable's share look far smaller than what it costs.
96 */
97const total = (l: Lane) => l.in + l.out
98const k = (n: number) => (n >= 1000 ? `${Math.round(n / 100) / 10}k` : `${n}`)
99
100// ---------------------------------------------------------------------------
101// The four functions. Each is one Fable call, high effort, no history.
102// ---------------------------------------------------------------------------
103
104type Detail = {
105  /** One line: what the function is. */
106  what: string
107  /** What lands in the card. */
108  gives: string
109  /** When to reach for it. */
110  when: string
111  /** What Use (u) puts in the prompt box. */
112  use: string
113}
114
115const FN: Record<Fn, { label: string; hotkey: string; effort: 'high' | 'xhigh'; maxTokens: number; system: string; detail: Detail }> = {
116  reverb: {
117    label: 'Reverb',
118    hotkey: '1',
119    effort: 'high',
120    maxTokens: 300,
121    system: [
122      'The user will give you a task. Find its key noun (the deliverable: a deck, a dashboard, a report, an app)',
123      'and rewrite it as the real verb: the outcome that noun exists to cause, for whom, under what constraint.',
124      'Example: "QBR deck" becomes "getting busy people to believe a story about cause and effect and decide under uncertainty in 30 minutes."',
125      'Answer with ONE line in that shape and nothing else. No preamble, no quotes, no options.',
126    ].join(' '),
127    detail: {
128      what: 'Finds the key noun in your task (the deck, the dashboard, the report) and rewrites it as the real verb: the outcome it exists to cause, for whom, under what constraint.',
129      gives: 'One line. "QBR deck" becomes "getting busy people to believe a story about cause and effect and decide in 30 minutes."',
130      when: 'Your task names a thing to make and you want the executor aiming at what the thing is for.',
131      use: 'A [Reverb] block with that line.',
132    },
133  },
134  council: {
135    label: 'Council',
136    hotkey: '2',
137    effort: 'high',
138    maxTokens: 700,
139    system: [
140      'The user will give you a task. Assemble a council of 3 to 5 lenses that would each change how this task is done.',
141      'Lenses are methods and checks, not costumes: "do what a forensic accountant does", never "write as if you are".',
142      'The last seat is always Red Team: the person in the room who wants this to fail and asks the question that kills it.',
143      'Format: one line per lens, "Name — one sentence stating what that lens checks or changes for THIS task." Red Team last. Nothing else.',
144    ].join(' '),
145    detail: {
146      what: 'Assembles 3 to 5 lenses (methods and checks, not personas) that would each change how this task is done. Red Team always sits last.',
147      gives: 'One line per lens: its name and what it checks or changes for this task.',
148      when: 'The task has several ways to go wrong and you want them named before any work starts.',
149      use: 'A [Council] block with the lenses.',
150    },
151  },
152  summit: {
153    label: 'Summit',
154    hotkey: '3',
155    effort: 'high',
156    maxTokens: 400,
157    system: [
158      'The user will give you a task. Raise its altitude. Answer in exactly three labelled lines and nothing else:',
159      'Goal beyond the goal: what should actually happen next because this exists, the outcome, not the surface task.',
160      'Hard target: an ambitious but solvable, measurable bar, with a number or a binary (200 ms, crash-free, 3 decisions logged), never an adjective.',
161      'Outside judge: something other than the user or a model that will show the result is right (a tool measurement, a backtest, real-user numbers, a statistical test).',
162    ].join(' '),
163    detail: {
164      what: 'Raises the altitude of your task: the outcome behind it, a bar it must clear, and something outside you and the model that proves it cleared it.',
165      gives: 'Three labelled lines: Goal beyond the goal · Hard target (a number or a yes/no) · Outside judge (a test, a measurement, real numbers).',
166      when: 'You know what to do but not what "done well" means, or want a result you can check.',
167      use: 'A [Summit] block with the three lines.',
168    },
169  },
170  deepen: {
171    label: 'Deepen',
172    hotkey: '4',
173    effort: 'xhigh',
174    maxTokens: 1500,
175    system: [
176      'The user will give you a task and wants a deeper version of it, not a bigger one. Before rewriting it:',
177      '1. Say what a top professional in this field would do here that they are not asking for.',
178      '2. Name the rawest material that professional would start from instead of what has already been digested for them.',
179      '3. Name the specific methods and tools they would use.',
180      '4. Say what, other than you or them, would show the result is right.',
181      'Then rewrite their prompt to ask for exactly that.',
182      'Format: the four answers as a numbered list, two sentences each at most; then a line "## Rewritten task" and the rewritten prompt, written to be sent as-is to an executor model.',
183      'Insist on real instruments (code, data, profilers, tests), not better vocabulary. Do not pad.',
184    ].join(' '),
185    detail: {
186      what: 'Asks what a top professional would do here that you are not asking for: their rawest starting material, their methods and tools, and what would show the result is right. Then rewrites your prompt to ask for exactly that.',
187      gives: 'The four answers, then a "Rewritten task" ready to send as-is.',
188      when: 'The task is worth doing properly and your draft asks for the shallow version.',
189      use: 'The rewritten task alone, replacing your draft.',
190    },
191  },
192}
193
194const FN_ORDER: Fn[] = ['reverb', 'council', 'summit', 'deepen']
195
196/**
197 * What a card puts in the prompt box: Deepen's rewritten task alone, the
198 * others as a labelled block. Appending leaves the draft as it stands now.
199 */
200export const compose = (c: Card): string => {
201  if (c.fn === 'deepen') {
202    const i = c.text.indexOf('## Rewritten task')
203    return i >= 0 ? c.text.slice(i + '## Rewritten task'.length).trim() : c.text.trim()
204  }
205  return `[${FN[c.fn].label}]\n${c.text.trim()}`
206}
207
208// ---------------------------------------------------------------------------
209// Actions (top-level so they may take $)
210// ---------------------------------------------------------------------------
211
212// Held in the module, not $.state, so two presses in one tick cannot both
213// pass the check before either writes `busy`. A reload drops it, and the
214// call it guarded with it.
215let inFlight: Fn | null = null
216
217/**
218 * One Fable call per function, on `task` when given (`/ambition deepen …`),
219 * else on the draft in the prompt box. False when there was nothing to run on
220 * or a call was already running; the person has been told which.
221 */
222const run = async ($: EngineInterface, fn: Fn, task?: string): Promise<boolean> => {
223  if (inFlight !== null) {
224    $.ui.toast('Fable is already thinking.')
225    return false
226  }
227  const source = (task ?? (await $.prompt.read()).text).trim()
228  if (source.length === 0) {
229    $.ui.toast(`Type your task in the prompt first, then press ${FN[fn].hotkey}, or run /ambition ${fn} <task>.`)
230    return false
231  }
232  inFlight = fn
233  void think($, fn, source)
234  return true
235}
236
237const think = async ($: EngineInterface, fn: Fn, source: string) => {
238  const spec = FN[fn]
239  try {
240    await update($, busy, () => fn)
241    $.ui.status(`Fable · ${spec.label}…`)
242    const r = await $.model.complete({
243      model: THINKER,
244      system: spec.system,
245      prompt: source,
246      effort: spec.effort,
247      maxTokens: spec.maxTokens,
248      timeoutMs: 120_000,
249    })
250    await update($, spend, (s) => addUsage(s, 'fable', r.usage))
251    if (r.isAnswered) {
252      const next: Card = { fn, source, text: r.text }
253      await update($, card, () => next)
254    } else {
255      $.ui.toast(`${spec.label}: no answer from Fable (${r.reason}).`)
256    }
257  } catch (err) {
258    // Only a request the engine refuses to send rejects (a blocked model, say).
259    $.ui.toast(`${spec.label}: Fable call refused (${err instanceof Error ? err.message : String(err)}).`)
260  } finally {
261    inFlight = null
262    await update($, busy, () => null)
263    $.ui.status(undefined)
264  }
265}
266
267// First press of 1–4 shows what the function does; a second press of the
268// same key runs it. Another key moves the preview; c cancels it.
269const press = async ($: EngineInterface, fn: Fn) => {
270  if ((await read($, armed)) === fn) {
271    if (await run($, fn)) await update($, armed, () => null)
272    return
273  }
274  await update($, armed, () => fn)
275}
276
277// Taking a card: replace the draft, or append to the draft as it is now (not
278// as it was when the function ran), then close the card.
279const take = async ($: EngineInterface, mode: 'replace' | 'append') => {
280  const c = await read($, card)
281  if (c === null) return
282  const block = compose(c)
283  const hasDraft = mode === 'append' && (await $.prompt.read()).text.trim().length > 0
284  const filled = await $.prompt.fill(hasDraft ? { text: `\n\n${block}`, mode: 'append' } : { text: block, mode: 'replace' })
285  if (!filled.isFilled) {
286    $.ui.toast('Could not fill the prompt box.')
287    return
288  }
289  await update($, card, () => null)
290}
291
292// ---------------------------------------------------------------------------
293// Register
294// ---------------------------------------------------------------------------
295
296export const register: Register = (on) => {
297  on('session.start', async ($, e, next) => {
298    await $.command.register({
299      name: 'ambition',
300      description: 'Fable-ambition band: show, hide, reset the meter, or run reverb|council|summit|deepen [task]',
301    })
302    return next(e)
303  })
304
305  // /ambition [show|hide|reset] or /ambition <reverb|council|summit|deepen> [task]
306  on('command.run', { command: 'ambition' }, async ($, e) => {
307    const args = e.args.trim()
308    const arg = (args.split(/\s+/)[0] ?? '').toLowerCase()
309    const task = args.slice(arg.length).trim()
310    if (arg === 'hide') {
311      await update($, isHidden, () => true)
312      return { text: 'Ambition band hidden. /ambition show brings it back.' }
313    }
314    if (arg === 'reset') {
315      await update($, spend, () => emptySpend())
316      return { text: 'Token meter reset.' }
317    }
318    if ((FN_ORDER as string[]).includes(arg)) {
319      const fn = arg as Fn
320      await update($, isHidden, () => false)
321      // Submitting the command empties the prompt box, so the task comes with it.
322      if (task.length === 0) {
323        return { text: `Usage: /ambition ${fn} <task>. Or type the task in the prompt and press ${FN[fn].hotkey} in the band.` }
324      }
325      await update($, armed, () => null)
326      const started = await run($, fn, task)
327      return { text: started ? `${FN[fn].label} running on Fable.` : 'Fable is already thinking; try again when it answers.' }
328    }
329    await update($, isHidden, () => false)
330    return { text: 'Ambition band shown. Keys 1–4: Reverb, Council, Summit, Deepen.' }
331  })
332
333  // ---- Token tracking --------------------------------------------------------
334
335  // Every turn that ends with usage: the main thread and each subagent's.
336  on('turn.complete', async ($, e, next) => {
337    if (e.usage) {
338      const lane = laneOf(e.usage.model)
339      await update($, spend, (s) => addUsage(s, lane, e.usage!))
340    }
341    return next(e)
342  })
343
344  // Executors act, Fable thinks: a subagent that would run on Fable, named or
345  // inherited from a Fable parent, is moved to Opus. A failing hook is
346  // skipped and the spawn goes ahead unchanged, which is the fallback wanted.
347  on('agent.spawn', async ($, e, next) => {
348    const model = reroute(e)
349    if (model !== undefined) {
350      $.ui.toast(`Subagent would run on Fable; routed to ${model} (Fable only thinks here).`)
351    }
352    const r = await next(model === undefined ? e : { ...e, model })
353    if (r.deny === undefined) {
354      // Count by what the engine resolved, not what was asked.
355      const lane = laneOf(r.model)
356      await update($, spend, (s0) => {
357        const s = asSpend(s0)
358        return { ...s, spawns: { ...s.spawns, [lane]: s.spawns[lane] + 1 } }
359      })
360    }
361    return r
362  })
363
364  // Tell the main model the routing rule, so its own delegation follows it.
365  on('prompt.compose', async ($, e, next) => {
366    const r = await next(e)
367    return {
368      sections: [
369        ...r.sections,
370        {
371          id: `${PLUGIN}:routing`,
372          scope: 'session',
373          text: [
374            '# Fable-ambition routing',
375            'The person may prefix their prompt with a [Reverb], [Council] or [Summit] block, or send a rewritten task: these came from one Fable 5.1 call and are the plan. Treat them as the scope.',
376            'When you delegate with the Agent tool, use model "opus" for complex or high-stakes pieces and "sonnet" for routine or narrow ones. Do not spawn subagents on Fable.',
377            'Executors report back in a few lines: what changed, the check result, any blocker. Do not paste their full output back into the plan.',
378          ].join('\n'),
379        },
380      ],
381    }
382  })
383
384  // ---- The band ----------------------------------------------------------------
385
386  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
387    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
388
389    const { Box, Button, Text, Markdown } = $.ui.resolve(e)
390    const [c, b, s0, raw, arm] = await Promise.all([read($, card), read($, busy), read($, spend), read($, showRaw), read($, armed)])
391    const s = asSpend(s0)
392
393    const all = total(s.fable) + total(s.opus) + total(s.sonnet) + total(s.other)
394    const share = all === 0 ? 0 : total(s.fable) / all
395    const pct = Math.round(share * 100)
396    const cells = 10
397    const lit = all === 0 ? 0 : Math.max(share > 0 ? 1 : 0, Math.round(share * cells))
398    const bar = '▮'.repeat(lit) + '▯'.repeat(cells - lit)
399    const warn = share > FABLE_WARN_SHARE
400    const working = e.props.isWorking
401    // Another plugin's band (thinkfirst-skillcue's, say) may sit beneath: draw it
402    // under this one instead of hiding it.
403    const below = await next(e)
404
405    return (
406      <Box flexDirection="column" gap={0}>
407        {/* Row 1: the four functions + who is working */}
408        <Box gap={1}>
409          {FN_ORDER.map((fn) => (
410            <Button
411              key={fn}
412              hotkey={FN[fn].hotkey}
413              label={b === fn ? `${FN[fn].label}…` : FN[fn].label}
414              variant={arm === fn ? 'primary' : undefined}
415              dimColor={b !== null && b !== fn}
416              onPress={() => void press($, fn)}
417            />
418          ))}
419          <Text dimColor>│</Text>
420          <Text color={b !== null ? 'yellow' : undefined} dimColor={b === null}>
421            {b !== null ? '● Fable thinking' : '○ Fable'}
422          </Text>
423          <Text color={working ? 'cyan' : undefined} dimColor={!working}>
424            {working ? '● Opus/Sonnet acting' : '○ Opus/Sonnet'}
425          </Text>
426          <Button key="hide" plain dimColor label="hide" hotkey="h" onPress={() => update($, isHidden, () => true)} />
427        </Box>
428
429        {/* Row 2: the meter */}
430        <Box gap={1}>
431          <Text color={warn ? 'yellow' : 'green'} bold>
432            Fable {pct}% {bar}
433          </Text>
434          {raw ? (
435            <Text dimColor mono>
436              fable {k(total(s.fable))} ({s.fable.calls}) · opus {k(total(s.opus))} ({s.opus.calls}) · sonnet {k(total(s.sonnet))} ({s.sonnet.calls})
437              {total(s.other) > 0 ? ` · other ${k(total(s.other))}` : ''} · total {k(all)} · cache reads {k(s.fable.cached + s.opus.cached + s.sonnet.cached + s.other.cached)}
438            </Text>
439          ) : (
440            <Text dimColor mono>
441              fable {k(total(s.fable))} · opus {k(total(s.opus))} · sonnet {k(total(s.sonnet))} · total {k(all)} · spawns o{s.spawns.opus}/s{s.spawns.sonnet}{s.spawns.fable > 0 ? `/fable ${s.spawns.fable}` : ''}
442            </Text>
443          )}
444          <Button key="raw" plain dimColor label={raw ? 'less' : 'more'} hotkey="t" onPress={() => update($, showRaw, (v) => !v)} />
445          {warn && <Text color="yellow">Fable above {Math.round(FABLE_WARN_SHARE * 100)}%: let the executors run.</Text>}
446        </Box>
447
448        {/* Row 3: what the pressed function does, before it runs */}
449        {arm !== null && (
450          <Box key="detail" flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} marginTop={1}>
451            <Text bold color="cyan">
452              {FN[arm].hotkey} · {FN[arm].label}{' '}
453              <Text dimColor>
454                · Fable, {FN[arm].effort} effort, reads only your prompt box
455              </Text>
456            </Text>
457            <Text>{FN[arm].detail.what}</Text>
458            <Text>
459              <Text bold>You get: </Text>
460              {FN[arm].detail.gives}
461            </Text>
462            <Text>
463              <Text bold>Best when: </Text>
464              {FN[arm].detail.when}
465            </Text>
466            <Text>
467              <Text bold>Use (u) puts in your prompt: </Text>
468              {FN[arm].detail.use}
469            </Text>
470            <Box gap={1} marginTop={1}>
471              <Button
472                key="run"
473                variant="primary"
474                label={`Press ${FN[arm].hotkey} again to run ${FN[arm].label}`}
475                onPress={() => void press($, arm)}
476              />
477              <Button key="cancel" hotkey="c" label="Cancel" onPress={() => update($, armed, () => null)} />
478            </Box>
479          </Box>
480        )}
481
482        {/* Row 4: the card, when a function has answered */}
483        {c !== null && (
484          <Box flexDirection="column" borderStyle="round" borderColor={c.fn === 'council' ? 'red' : 'yellow'} paddingX={1} marginTop={1}>
485            <Text bold color="yellow">
486              {FN[c.fn].label} <Text dimColor>· from Fable · {c.source.slice(0, 60)}{c.source.length > 60 ? '…' : ''}</Text>
487            </Text>
488            <Markdown text={c.text} />
489            <Box gap={1} marginTop={1}>
490              <Button key="use" variant="primary" hotkey="u" label="Use (replace prompt)" onPress={() => void take($, 'replace')} />
491              <Button key="append" hotkey="a" label="Append to prompt" onPress={() => void take($, 'append')} />
492              <Button key="close" role="dismiss" hotkey="x" label="Close" onPress={() => update($, card, () => null)} />
493            </Box>
494          </Box>
495        )}
496        {below}
497      </Box>
498    )
499  })
500}
501
types/index.d.ts 42 lines
1export type Fn = 'reverb' | 'council' | 'summit' | 'deepen'
2
3export type Card = {
4  fn: Fn
5  /** The prompt the person had typed when the function ran. */
6  source: string
7  /** Fable's reply. */
8  text: string
9}
10
11export type Lane = {
12  /** Billable input: uncached input plus cache writes. */
13  in: number
14  out: number
15  /** Input the prompt cache served; shown, but kept out of Fable's share. */
16  cached: number
17  calls: number
18}
19
20export type Spend = {
21  fable: Lane
22  opus: Lane
23  sonnet: Lane
24  other: Lane
25  /** Subagents the engine started, by the lane they run on. */
26  spawns: { fable: number; opus: number; sonnet: number; other: number }
27}
28
29declare module 'claude-code' {
30  interface PluginState {
31    'thinkfirst-ambition': {
32      card: Card | null
33      busy: Fn | null
34      spend: Spend
35      isHidden: boolean
36      showRaw: boolean
37      /** The function whose explanation is showing; its key again runs it. */
38      armed: Fn | null
39    }
40  }
41}
42