SLOPSHOPPER

coach

A quiet AI-fluency thinking partner: shows where you are in the loop with Claude (plan, brief, review, own) and helps with the next step.

newpanebandrowsguardcommand
v0.2.0no licenseupdated 2026-10-09sebastiannicolajsen/ai-coach/coach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · coach
│ ┃ Coach ✕ │ ┃ ━ ── ── ─ Plan Decide what Claude should d ─ ━━ ── ─ Brief · push paused ─────────────────────────────────── │ ┃ ─ ━━ ── ─ Brief Say what you need ⟨Claude Code's own drawing⟩ │ ┃ ─ ── ━━ ─ Review Check what came back │ ┃ ─ ── ── ━ Own Stand behind what ships ● coach: Own · push paused │ ┃ ⏺ Read(src/auth.ts) │ ┃ THIS CONVERSATION ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Nothing to go on yet. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ STUCK? ⎿ Denied by coach: Push paused at the Own check. Wait for t │ ┃ │ ┃ [ My result isn't what I wanted ] [ How do I ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Ask about this work ⏎ Ask ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ SETTINGS › /coach │ ┃ ⎿ coach: Coach pane opened. │ ┃ Feedback model Haiku 5.5 ▾ │ ┃ │ ┃ Recommend a model On ▾ │ ┃ │ ┃ Before a prompt runs Prompt me ▾ │ ┃ │ ┃ Coach checks Every turn ▾ │ ┃ The coach follows your loop with Claude. Press ⋯ to focus on a step. ─ ── ━━ ─ Review Stepped out · push paused Opus 5.5 Prompt me ⋯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
The coach follows your loop with Claude. Press ⋯ to focus on a step. ─ ── ━━ ─ Review Stepped out · push paused Opus 5.5 Prompt me ⋯
Pane · Coach
━ ── ── ─ Plan Decide what Claude should do ─ ━━ ── ─ Brief Say what you need ─ ── ━━ ─ Review Check what came back ─ ── ── ━ Own Stand behind what ships THIS CONVERSATION Nothing to go on yet. STUCK? [ My result isn't what I wanted ] [ How do I check this? ] [ Ask about this work ⏎ Ask SETTINGS Feedback model Haiku 5.5 ▾ Recommend a model On ▾ Before a prompt runs Prompt me ▾ Coach checks Every turn ▾ Pause pushes for a check On ▾ Labels in the chat On ▾ Start quietly On ▾ Coach <1% of this session · Haiku 5.5 Based on the AI Fluency Framework by Dakan, Feller and Anthropic, CC BY-NC-SA 4.0.
Your message
─ ━━ ── ─ Brief · push paused ──────────────────────────────────────────────────────────────────── ⟨Claude Code's own drawing⟩
README

Coach

<a href="../dist/coach-0.2.0.zip"><img src="https://img.shields.io/badge/Download-Coach%200.2.0-2A9D8F?style=for-the-badge" alt="Download Coach 0.2.0"></a>

A quiet thinking partner for working with Claude Code. It shows which step of the working loop you are in and helps you take the next one well, without explaining a framework.

Install

From the zip (dist/coach-0.2.0.zip): unzip it into a folder, then start Claude Code with that folder as a plugin:

claude --plugin-dir /path/to/unzipped/coach

From this repository: add the repository as a local marketplace once, then install.

claude plugin marketplace add /path/to/ai-coach
claude plugin install coach@ai-coach

Start a new chat afterwards and run /coach help to see every command.

The loop

Four steps. Plan: decide what Claude should do. Brief: your next prompt. Review: check what Claude gave you. Own: stand behind what you ship. Brief and Review are the everyday loop; Plan and Own are the steps outside it.

The coach decides the step from what your message does:

Your messageStep
"Help me with a new pitch", "I need help with…", "Jeg har brug for hjælp til…"Plan · starting new work
"Make a plan…", "How would you approach…", "Should we…"Plan · asked for a plan
A new or next instructionBrief · a new instruction
"Why did you…", "Are you sure…", "That's wrong", "…instead"Review · checking Claude's result
"Looks good", "Perfect", "Final version", "Ready to send", "Det ser godt ud"Own · taking it as final
Push, publish, deploy, "send it to…"Own · about to ship

After every reply from Claude the step line says Review: it is your turn to check.

What you see

  • Chapter line above each of your messages: the step in its colour, what the message does in a few specific words (for example asks for three angles for the Nordlys pitch) and a thin line. When you leave Review for a new instruction it starts with moved on from Review. While the step is decided, the line shows Claude's working mark and Reading the step….
  • Feedback under your message: ✓ for a move worth repeating (for example Audience is named, Questioned the result), ◐ Could say who it is for and what it should achieve when a short request leaves out what its kind of request needs (an analysis: the question and which data; a piece of writing: who it is for and what it should achieve). A gap already named earlier in the chat is not named again. While the feedback is written, the row shows Claude's working mark and Reading your prompt….
  • Template in the empty prompt box after the reply, with named slots such as [audience] and [deadline]. Tab takes it.
  • Suggestions above the prompt: the coach's clay ✦, then a row of quiet buttons that put a question or prompt into the box. They are written for this conversation: the coach reads the last answer in full (its start and its end) and the exchanges before it, and each button names something concrete from them, such as an option Claude offered, a number to check or the next part to do. While they are written the row shows Writing suggestions…. They stay until your next prompt.
  • Model controls beside ⋯, in the style of the model and effort at the foot of the prompt box. The model slot shows the model in use and its effort as a small vertical bar that fills from low to max; a press opens Run Claude on (Haiku 5.5, Sonnet 5.5, Opus 5.5, Fable 5.1) and Effort (low to max), the recommended ones marked ✦. When the coach recommends another model or effort, the slot shows it instead, for example Opus 5.5 · planning with a clay bar, and a press switches to it. While the coach is still judging, the slot shows Claude's working mark. Then the mode, Prompt me (default), Auto-select or Only show, which a press cycles. See Model recommendations.
  • Own checks when you take a result as final or push: Tested myself, No client data and a third drawn from the conversation, plus Continue anyway. With Pause pushes on, a push waits until the checks are done.
  • Step line under the suggestions: the four bars, the current step and, after a reply, what to do now in a few specific words (for example check the 12% Nordlys figure), and ⋯. ⋯ opens one row: Focus on Plan, Brief, Review or Own, the coach's cost, Settings (the pane) and Turn off.
  • Pane (/coach): the four steps, what the conversation is about, a Stuck? chat, settings and attribution.

Model recommendations

The coach recommends the model a prompt suits, and how hard it should think (effort: low, medium, high, xhigh or max):

ModelSuits
Haiku 5.5Quick, small or mechanical work: a lookup, a rename, a short reply
Sonnet 5.5Everyday work with a clear instruction: a draft, routine code, carrying out an agreed plan
Opus 5.5Work that needs judgment: planning, open questions, reviewing important work, tricky debugging
Fable 5.1The hardest work: long multi-step tasks, large changes, strategy with many moving parts
  • After each reply the analysis names the model the likely next prompt suits, and it shows beside ⋯, marked ✦.
  • When you send a prompt, Haiku 5.5 judges the prompt itself (about a second), and the mode decides what happens when another model suits it:
  • Prompt me (default): the prompt is held before anything runs, and Claude's own dialog asks: This looks like planning. Send it with Opus 5.5 at high effort instead of Sonnet 5.5? with Send with Opus 5.5 · high (Recommended) first, then Keep Sonnet 5.5, Always switch for me (turns on Auto-select) and Stop asking (turns on Only show). After Keep, the same suggestion is not asked about again for three prompts.
  • Auto-select: the prompt goes to the suggested model, for that prompt only.
  • Only show: nothing is held or switched; the recommendation stays beside ⋯.
  • While you type (terminal only): when you pause, the draft is judged too. The desktop app does not share the draft with mods.
  • Picking a model in the slot, the picker or the dialog changes the session's own model and effort, as /config would, so the model picker at the foot of the prompt box shows the switch and people learn where it is done. Where the session offers no such setting, the switch applies to each request instead, until you pick another. /coach why says which way it went.

The coach prefers the current model when it fits nearly as well, because a switch loses the cached conversation. A switch applies to the main conversation only, never to subagents. Each judgement costs a fraction of a cent and counts toward the coach's cost.

Commands

CommandDoes
/coach helpList every command
/coachOpen the pane
/coach on · /coach offTurn the coach on or off, for every chat
/coach preview on · offShow every finding from the first session (for testing and demos); plain /coach preview toggles
/coach model haiku · haiku-5.5 · sonnet · opusPick the model that writes the feedback
/coach modelShow which model runs now
/coach recommend on · offShow the model suggestion beside Coach (on by default)
/coach switch ask · auto · offWhen a prompt suits another model: prompt me (default), auto-select, or only show
/coach whyThe last analysis: which calls ran, what came back and why anything was dropped
/coach settingsOpen the pane at the settings
/coach askOpen the Stuck? chat
/coach aboutWhat the coach reads and stores
/coach galleryA drawing test for the surface you are on

Feedback model and cost

The feedback runs on Haiku 5.5 by default. Switch with /coach model haiku, sonnet or opus, or in the pane under Feedback model. Haiku 5.5 is the fastest and cheapest; larger models are slower and cost more:

ModelInputOutputCache writeCache read
Haiku 4.5$1$5$1.25$0.10
Haiku 5.5$0.10$0.50$0.125$0.01
Sonnet 5.5$2$10$2.50$0.10
Opus 5.5$4$20$5$0.20

Prices per million tokens (Haiku 5.5 for prompts up to 100,000 tokens, which covers every coach call), from the Anthropic pricing page on 2026-10-08; they live in hooks/config.ts.

The Coach menu shows the coach's own cost as its share of the session, for example Coach 2% of this session; the pane adds the model, Coach 2% of this session · Haiku 5.5.

Settings

In the pane (/coach):

  • Feedback model: Haiku 5.5 (default), Haiku 4.5, Sonnet 5.5 or Opus 5.5.
  • Recommend a model: on by default.
  • Before a prompt runs: Prompt me (default), Auto-select or Only show.
  • Coach checks: every turn (default), every 3rd turn, or on focus only.
  • Pause pushes for a check: off by default; asked once on the first push.
  • Start quietly: on by default. The first session then shows only the chapter lines and the step line; sessions two and three allow one set of suggestions per five turns; from session four, behaviours you already show stop being coached. Off is the same as /coach preview on.
  • Labels in the chat: on or off.

Settings are shared by every chat and read again with each message.

Privacy

  • Reads your prompts, Claude's answers and tool names in the session, only to follow the loop.
  • Sends short excerpts to the chosen feedback model, and your prompt (in the terminal also the draft) to Haiku 5.5 for a model recommendation, through the session's own Claude connection, and to nothing else.
  • Keeps in memory a small context card for the session only.
  • Stores on disk counters and settings only, never message text.

Mods are not sandboxed and see every prompt. Distribute through your organisation's managed plugin marketplace after a security review; client and public-sector work needs sign-off from your data protection officer.

For developers

hooks/register.tsx wires every hook. Decisions are pure and unit-tested: moves.ts (which step a message is), gaps.ts (what a short request leaves out), state.ts, card.ts, validate.ts, fade.ts, cost.ts. haiku.ts makes the model calls and never throws. route.ts recommends a model for the draft and picks the model of each turn, which the turn.step hook applies. UI lives in hooks/ui/.

claude plugin validate .
claude plugin test .

After a change, raise the version in .claude-plugin/plugin.json, run claude plugin update coach@ai-coach, and rebuild the zip from the coach/ folder:

git ls-files -co --exclude-standard | zip -q ../dist/coach-<version>.zip -@

What the desktop app draws and refuses, and how the engine behaves, is in NOTES.md.

Attribution

Based on the AI Fluency Framework by Dakan, Feller and Anthropic, CC BY-NC-SA 4.0.

Source 30 files
hooks/register.tsx 369 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import {
4  addTrace,
5  dispatch,
6  loadPrefs,
7  openPane,
8  refreshUsage,
9  resetSession,
10  savePrefs,
11  saveSettings,
12  setEnabled,
13} from './actions'
14import { INITIAL } from './initial'
15import type { Ctx } from './ctx'
16import { DEFAULT_SETTINGS, EDIT_TOOLS, PLAN_FILE_RE, COACH_MODELS, MODEL_SWITCH_LABEL, ROUTER, isCoachModel } from './config'
17import type { CoachModelSwitch, CoachState } from '../types'
18import { formatTrace } from './trace'
19import { classifyCommand, onPrompt, onPush, onTurnComplete } from './flow'
20import { openAsk } from './meta'
21import { renderAbovePrompt } from './ui/band'
22import { withSurface } from './ui/glyph'
23import { assistantLabel, userLabel } from './ui/labels'
24import { renderGallery } from './ui/gallery'
25import { renderCommandOutput } from './ui/output'
26import { renderPane } from './ui/pane'
27import { chooseForTurn, isSameDraft, recommend, refreshSessionModel, stepEffort, stepModel } from './route'
28import { effortOf } from './parse-route'
29
30// What /coach help prints, one command per line.
31export const HELP = [
32  'Commands',
33  '/coach  open the pane: the steps, a Stuck? chat, settings',
34  '/coach on · /coach off  turn the coach on or off (for every chat)',
35  '/coach preview on · off  show every finding from the first session',
36  '/coach model haiku · haiku-5.5 · sonnet · opus  pick the model that writes the feedback (Haiku 5.5 is the default)',
37  '/coach model  show which model runs now',
38  '/coach recommend on · off  recommend a model for the next prompt, beside Coach (on by default)',
39  '/coach switch ask · auto · off  before a prompt runs on a model it does not suit: ask (default), switch by itself, or never',
40  '/coach why  the last analysis: what ran, what came back, what was dropped',
41  '/coach settings  open the pane at the settings',
42  '/coach ask  open the Stuck? chat',
43  '/coach about  what the coach reads and stores',
44  '/coach gallery  drawing test for the surface you are on',
45].join('\n')
46
47export const ABOUT = [
48  "Reads: your prompts, Claude's answers and tool names in this session, only to follow the loop.",
49  "Sends: short excerpts to the feedback model, and your draft to Haiku 5.5 for a model recommendation, through this session's own connection, and nothing to any other service.",
50  'Keeps in memory: a small context card for this session only, cleared when it ends.',
51  'Stores on disk: counters and settings only (on or off, sessions, dismissals), never message text.',
52].join('\n')
53
54const short = (e: Record<string, unknown>): string =>
55  String(e.file_path ?? e.command ?? e.pattern ?? e.path ?? '').slice(0, 60)
56
57const state = atom({ plugin: 'coach', key: 'state' } as const, INITIAL)
58
59// The desktop Code tab is an SDK host, so the person's prompts do not always arrive as 'composer'.
60// Skip only what other parties or the engine send.
61const NOT_THE_PERSON = [
62  'task-notification',
63  'scheduled-trigger',
64  'peer',
65  'peer-send-message',
66  'projects-relay',
67  'channel',
68  'coordinator',
69  'observer',
70  'observer-activity',
71  'auto-continuation',
72  'plugin',
73]
74export const isPersonPrompt = (kind: string): boolean => !NOT_THE_PERSON.includes(kind)
75
76let isCommandRegistered = false
77// Each edit takes a number; only the last one before a pause asks for a recommendation.
78let draftSeq = 0
79let isInitScheduled = false
80
81// State kept from before a reload can miss settings added since; every read fills them in from the defaults.
82export function withDefaults(st: CoachState): CoachState {
83  const s = { ...INITIAL, ...st }
84  return { ...s, prefs: { ...INITIAL.prefs, ...s.prefs, settings: { ...DEFAULT_SETTINGS, ...s.prefs?.settings } } }
85}
86
87function shim($: EngineInterface): Ctx {
88  return {
89    get: async () => withDefaults(await read($, state)),
90    patch: (key, fn) => update($, state, s => ({ ...INITIAL, ...s, [key]: fn({ ...INITIAL, ...s }[key]) })),
91    ui: {
92      log: (text, options) => $.ui.log(text, options),
93      toast: (text, options) => $.ui.toast(text, options),
94      ask: (question, options) => $.ui.ask(question, options),
95      open: pane => $.ui.open(pane),
96      resolve: e => $.ui.resolve(e),
97    },
98    model: { complete: (request, options) => $.model.complete(request, options) },
99    prompt: {
100      fill: input => $.prompt.fill(input),
101      suggest: input => $.prompt.suggest(input),
102      read: () => $.prompt.read(),
103      submit: input => $.prompt.submit(input),
104    },
105    store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) },
106    clock: { after: (ms, fn) => $.clock.after(ms, fn) },
107    config: { list: () => $.config.list(), set: args => $.config.set(args) },
108    session: {
109      usage: args => $.session.usage(args),
110      authorize: () => $.session.authorize(),
111      surface: () => $.session.surface(),
112      messages: () => $.session.messages(),
113      model: () => $.session.model(),
114    },
115  }
116}
117
118async function init($: EngineInterface) {
119  const c = shim($)
120  if (!isCommandRegistered) {
121    isCommandRegistered = true
122    try {
123      await $.command.register({
124        name: 'coach',
125        description: 'Coach: on/off, pane, ask, settings, about',
126        argumentHint: '[help|pane|ask|settings|model [haiku|haiku-5.5|sonnet|opus]|recommend [on|off]|switch [ask|auto|off]|preview [on|off]|why|gallery|about|on|off]',
127        immediate: true,
128      })
129    } catch {
130      isCommandRegistered = false
131    }
132  }
133  if (!(await c.get()).ready) {
134    await c.patch('ready', () => true)
135    await loadPrefs(c)
136    await refreshUsage(c)
137    await refreshSessionModel(c)
138  }
139  if (!(await c.get()).sessionCounted) {
140    await c.patch('sessionCounted', () => true)
141    await savePrefs(c, q => ({ ...q, sessions: q.sessions + 1 }))
142  }
143}
144
145async function scheduleInit($: EngineInterface) {
146  if (isInitScheduled || withDefaults(await read($, state)).ready) return
147  isInitScheduled = true
148  $.clock.after(0, () => void init($))
149}
150
151async function isOn($: EngineInterface) {
152  return withDefaults(await read($, state)).prefs.enabled
153}
154
155async function turnOnIfOff($: EngineInterface) {
156  if (!(await isOn($))) await setEnabled(shim($), true)
157}
158
159async function runCommand($: EngineInterface, args: string) {
160  await init($)
161  const c = shim($)
162  const arg = args.trim().toLowerCase()
163  switch (arg) {
164    case 'help':
165      return { text: HELP }
166    case 'about':
167      return { text: ABOUT }
168    case 'preview':
169    case 'preview on':
170    case 'preview off': {
171      // "on" and "off" set it outright; plain "preview" toggles.
172      const next = arg === 'preview on' ? true : arg === 'preview off' ? false : !(await c.get()).prefs.settings.preview
173      await saveSettings(c, { preview: next })
174      return { text: next ? 'Preview on: every finding shows.' : 'Preview off.' }
175    }
176    case 'model':
177    case 'model haiku':
178    case 'model haiku-5.5':
179    case 'model sonnet':
180    case 'model opus': {
181      const name = arg.split(' ')[1]
182      if (!isCoachModel(name)) {
183        const now = COACH_MODELS[(await c.get()).prefs.settings.model] ?? COACH_MODELS['haiku-5.5']
184        return { text: `Feedback model: ${now.label}. Change it with /coach model haiku, haiku-5.5, sonnet or opus.` }
185      }
186      await saveSettings(c, { model: name })
187      return { text: `Feedback model: ${COACH_MODELS[name].label}.` }
188    }
189    case 'recommend':
190    case 'recommend on':
191    case 'recommend off': {
192      const next = arg === 'recommend on' ? true : arg === 'recommend off' ? false : !(await c.get()).prefs.settings.recommendModel
193      await saveSettings(c, { recommendModel: next })
194      return { text: next ? 'Model recommendations on: the coach names the model the next prompt suits, beside Coach.' : 'Model recommendations off.' }
195    }
196    case 'switch':
197    case 'switch ask':
198    case 'switch auto':
199    case 'switch off': {
200      const mode = arg.split(' ')[1] as CoachModelSwitch | undefined
201      if (!mode) return { text: `Model switching: ${MODEL_SWITCH_LABEL[(await c.get()).prefs.settings.modelSwitch].toLowerCase()}. Change it with /coach switch ask, auto or off.` }
202      await saveSettings(c, { modelSwitch: mode })
203      return { text: `Model switching: ${MODEL_SWITCH_LABEL[mode].toLowerCase()}.` }
204    }
205    case 'gallery':
206      return { text: 'Gallery' }
207    case 'why':
208      return { text: formatTrace((await c.get()).trace) }
209    case 'pane':
210      await turnOnIfOff($)
211      await openPane(c)
212      return { text: 'Coach pane opened.' }
213    case 'ask':
214      await turnOnIfOff($)
215      await openAsk(c)
216      return { text: 'Ask the coach in the pane.' }
217    case 'settings':
218      await c.patch('settingsOpen', () => true)
219      await openPane(c)
220      return { text: 'Coach settings are in the pane.' }
221    case 'on':
222    case 'off':
223      await setEnabled(c, arg === 'on')
224      if (arg === 'on') await resetSession(c)
225      return { text: arg === 'on' ? 'Coach on.' : 'Coach off.' }
226    default:
227      await turnOnIfOff($)
228      await openPane(c)
229      return { text: 'Coach pane opened.' }
230  }
231}
232
233// A failed hook leaves a line in /coach why and the debug log instead of vanishing.
234async function report($: EngineInterface, hook: string, err: unknown) {
235  const message = err instanceof Error ? err.message : String(err)
236  $.ui.log(`${hook} failed: ${message}`, { to: 'debug' })
237  try {
238    await addTrace(shim($), 'error', false, `${hook}: ${message}`)
239  } catch {
240    // Nothing left to report to.
241  }
242}
243
244export const register: Register = on => {
245  on('session.start', async ($, e, next) => {
246    await init($)
247    await dispatch(shim($), { type: 'session_start' })
248    await refreshUsage(shim($))
249    return next(e)
250  })
251
252  on('prompt.submit', async ($, e, next) => {
253    try {
254      await init($)
255      // Settings live in the store, shared by every chat: read them again, so a change made in another chat
256      // (preview, on/off, cadence) applies here from the next message.
257      await loadPrefs(shim($))
258      if ((await isOn($)) && isPersonPrompt(e.origin.kind)) {
259        draftSeq++
260        await refreshSessionModel(shim($))
261        if (!e.text.trim().startsWith('/')) await chooseForTurn(shim($), e.text)
262        await onPrompt(shim($), e.text)
263      }
264    } catch (err) {
265      // The coach never gets in the way of a prompt, but the failure shows in /coach why.
266      await report($, 'prompt.submit', err)
267    }
268    return next(e)
269  }).catch(($, e, next) => next(e))
270
271  // While the person types: drop a recommendation the draft has moved away from, and once typing pauses ask
272  // the smallest model which model the draft suits. The edit itself is never held up.
273  on('prompt.edit', async ($, e, next) => {
274    const box = await next(e)
275    try {
276      const st = withDefaults(await read($, state))
277      const s = st.prefs.settings
278      if (!st.prefs.enabled || !(s.recommendModel || s.modelSwitch !== 'off')) return box
279      if (st.route && !isSameDraft(st.route, box.text)) await update($, state, x => ({ ...x, route: null }))
280      const mine = ++draftSeq
281      $.clock.after(ROUTER.debounceMs, () => {
282        if (mine === draftSeq) void recommend(shim($)).catch(err => report($, 'recommend', err))
283      })
284    } catch (err) {
285      await report($, 'prompt.edit', err)
286    }
287    return box
288  })
289
290  // The coach's model choice applies to the main loop's requests only, never a subagent's.
291  on('turn.step', async function* ($, e, next) {
292    const st = withDefaults(await read($, state))
293    const isMain = st.prefs.enabled && !e.agentId
294    const model = isMain ? stepModel(st.modelChoice?.model) : null
295    const effort = isMain ? stepEffort(st.modelChoice) : null
296    // The session's own effort, read from a step the coach did not change, for the effort bar.
297    if (isMain && !effort) {
298      const own = effortOf(e.effort)
299      if (own !== st.sessionEffort) await update($, state, x => ({ ...x, sessionEffort: own }))
300    }
301    return yield* next(model || effort ? { ...e, ...(model ? { model } : {}), ...(effort ? { effort } : {}) } : e)
302  })
303
304  on('tool.call', async ($, e, next) => {
305    try {
306      await init($)
307      if (await isOn($)) {
308        const name = String(e.tool)
309        const args = e as unknown as Record<string, unknown>
310        await update($, state, st => ({ ...st, tools: [...st.tools, `${name} ${short(args)}`.trim()].slice(-12) }))
311        const kind = name === 'Bash' ? classifyCommand(String(args.command ?? '')) : null
312        if (kind) {
313          const denial = await onPush(shim($), kind, String(args.command ?? ''))
314          if (denial) return { deny: denial }
315        } else if (name === 'ExitPlanMode') {
316          await dispatch(shim($), { type: 'plan_approved' })
317        } else if (EDIT_TOOLS.includes(name)) {
318          const isPlan = PLAN_FILE_RE.test(String(args.file_path ?? ''))
319          await dispatch(shim($), { type: isPlan ? 'plan_written' : 'acting' })
320        }
321      }
322    } catch (err) {
323      // Observation only: a failure here never blocks the call.
324      await report($, 'tool.call', err)
325    }
326    return next(e)
327  }).catch(($, e, next) => next(e))
328
329  on('turn.complete', async ($, e, next) => {
330    try {
331      await init($)
332      if ((await isOn($)) && !e.agentId) await onTurnComplete(shim($), e.answer, e.reason === 'answer')
333    } catch (err) {
334      await report($, 'turn.complete', err)
335    }
336    return next(e)
337  })
338
339  on('command.run', { command: 'coach' }, ($, e) => runCommand($, e.args))
340
341  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
342    await scheduleInit($)
343    return renderAbovePrompt(shim($), e, next as never)
344  })
345
346  on('ui.render', { component: 'CommandOutput', props: { command: 'coach' } }, ($, e) =>
347    e.props.args.trim() === 'gallery' ? renderGallery(shim($), e) : renderCommandOutput(shim($), e, e.props.text),
348  )
349
350  on('ui.render', { component: 'Pane', requestId: 'coach' }, ($, e) => renderPane(shim($), e))
351
352  // isExpanded is not a reason to skip: the desktop app draws every row in full, so it is always true there.
353  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
354    const c = shim($)
355    const prefs = withDefaults(await read($, state)).prefs
356    if (!prefs.enabled || !prefs.settings.labels || !isPersonPrompt(e.props.origin.kind)) {
357      return next(e)
358    }
359    return userLabel(c, withSurface(c.ui.resolve(e), e.surface) as never, { requestId: e.requestId, text: e.props.text }, await next(e))
360  })
361
362  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
363    const c = shim($)
364    const prefs = withDefaults(await read($, state)).prefs
365    if (!prefs.enabled || !prefs.settings.labels || !e.props.isFirstOfReply) return next(e)
366    return assistantLabel(c, withSurface(c.ui.resolve(e), e.surface) as never, { requestId: e.requestId, text: e.props.text }, await next(e))
367  })
368}
369
hooks/actions.ts 291 lines
1import type { ModelUsage } from 'claude-code'
2import type { CoachUserNote, CoachState, CoachBand, CoachModel, CoachPrefs, CoachSettings, CoachStation } from '../types'
3import type { Ctx } from './ctx'
4import { EMPTY_CARD } from './card'
5import { COACH_MODELS, DEFAULT_SETTINGS, FOCUS_TEMPLATE, OWN_PASS_TEXT, STATION_NAME } from './config'
6import { addCost } from './cost'
7import { recordDismissal } from './fade'
8import { type Complete, type TurnInput, writeBand } from './haiku'
9import { makeOwnCheck } from './own'
10import { noteKey } from './notes'
11import { type CoachEvent, isOuter, step } from './state'
12import { staticBand } from './validate'
13
14export type Dollar = Ctx
15
16export const REASON_MS = 4000
17export const PANE_ID = 'coach'
18
19export const set = <K extends keyof CoachState>($: Dollar, key: K, value: CoachState[K]) =>
20  $.patch(key, () => value)
21
22// Every coach call runs on the model chosen in the settings, with that model's time limit.
23export const complete = ($: Dollar): Complete => async req => {
24  const m = COACH_MODELS[(await $.get()).prefs.settings.model] ?? COACH_MODELS['haiku-5.5']
25  return $.model.complete({ ...req, model: m.id, timeoutMs: m.timeoutMs })
26}
27
28export const heads = (s: string) => s.trim().slice(0, 60)
29
30const PERSISTED = ['enabled', 'sessions', 'hintTaps', 'settings', 'fade', 'dismissals', 'silenced'] as const
31
32// Settings stored before the switch dialog said auto-switch on or off.
33const migrate = (s: CoachSettings & { autoSwitch?: boolean }): CoachSettings => {
34  const { autoSwitch, ...rest } = s
35  return autoSwitch === true && rest.modelSwitch === 'ask' ? { ...rest, modelSwitch: 'auto' } : rest
36}
37
38export async function loadPrefs($: Dollar): Promise<CoachPrefs> {
39  const base = (await $.get()).prefs
40  const stored: Partial<CoachPrefs> = {}
41  for (const key of PERSISTED) {
42    const v = await $.store.get(key)
43    if (v !== undefined && v !== null) (stored as Record<string, unknown>)[key] = v
44  }
45  const next: CoachPrefs = {
46    ...base,
47    ...stored,
48    settings: migrate({ ...DEFAULT_SETTINGS, ...(stored.settings ?? {}) }),
49  }
50  await set($, 'prefs', next)
51  return next
52}
53
54export async function savePrefs($: Dollar, fn: (p: CoachPrefs) => CoachPrefs): Promise<CoachPrefs> {
55  const before = (await $.get()).prefs
56  const after = fn(before)
57  await set($, 'prefs', after)
58  for (const key of PERSISTED) {
59    if (after[key] !== before[key]) await $.store.set(key, after[key])
60  }
61  return after
62}
63
64export const saveSettings = ($: Dollar, patch: Partial<CoachSettings>) =>
65  savePrefs($, p => ({ ...p, settings: { ...p.settings, ...patch } }))
66
67export async function recordUsage($: Dollar, usage: ModelUsage | null) {
68  const m = COACH_MODELS[(await $.get()).prefs.settings.model] ?? COACH_MODELS['haiku-5.5']
69  await recordUsageAt($, usage, m.price)
70}
71
72export async function recordUsageAt($: Dollar, usage: ModelUsage | null, price: (typeof COACH_MODELS)[CoachModel]['price']) {
73  if (usage) await $.patch('cost', c => addCost(c, usage, price))
74}
75
76// The user row's data: its move, the ✓ note and an outer step, keyed by the prompt's text.
77export async function upsertUserNote($: Dollar, key: string, patch: Partial<CoachUserNote>) {
78  const turn = (await $.get()).turnIndex
79  await $.patch('rowNotes', r => ({
80    ...r,
81    user: { ...r.user, [key]: { move: 'brief' as const, turn, ...r.user[key], ...patch } },
82  }))
83}
84
85const TRACE_MAX = 8
86
87export async function addTrace($: Dollar, call: string, ok: boolean, detail: string) {
88  const turn = (await $.get()).turnIndex
89  await $.patch('trace', t => [...t, { turn, call, ok, detail: detail.slice(0, 160) }].slice(-TRACE_MAX))
90}
91
92export async function dispatch($: Dollar, ev: CoachEvent, reason = '') {
93  const m = { station: (await $.get()).station, focus: (await $.get()).focus }
94  const t = step(m, ev)
95  if (t.next.station !== m.station) await set($, 'station', t.next.station)
96  if (t.next.focus !== m.focus) await set($, 'focus', t.next.focus)
97  if (t.next.station !== m.station && isOuter(t.next.station)) await outerMove($, t.next.station, reason)
98  return t
99}
100
101const OUTER_REASON: Record<string, string> = { plan: 'plan first', own: 'before it goes out' }
102
103async function outerMove($: Dollar, to: CoachStation, reason: string) {
104  const why = reason || OUTER_REASON[to] || ''
105  await set($, 'stationReason', `Stepped out · ${why}`)
106  $.clock.after(REASON_MS, () => void set($, 'stationReason', ''))
107  const turn = (await $.get()).turnIndex
108  if ((await $.get()).dividerTurn === turn) return
109  await set($, 'dividerTurn', turn)
110  $.ui.log(`${STATION_NAME[to]} · ${why}`, { to: 'debug' })
111  await upsertUserNote($, noteKey((await $.get()).lastPrompt), { outer: `${STATION_NAME[to]} · ${why}` })
112  const prompt = (await $.get()).lastPrompt
113  const answer = (await $.get()).lastAnswer
114  await $.patch('fresh', f => [...f, heads(prompt), heads(answer)].filter(Boolean).slice(-6))
115}
116
117export async function pulseLoop($: Dollar) {
118  await set($, 'pulse', true)
119  $.clock.after(300, () => void set($, 'pulse', false))
120}
121
122export async function openPane($: Dollar) {
123  await $.ui.open({ id: PANE_ID, title: 'Coach' })
124}
125
126export async function setEnabled($: Dollar, enabled: boolean) {
127  await savePrefs($, p => ({ ...p, enabled }))
128  await set($, 'menuOpen', false)
129  await set($, 'pickerOpen', false)
130  if (!enabled) {
131    await setBand($, null)
132    await set($, 'ownCheck', null)
133    await set($, 'focus', null)
134  }
135}
136
137// The row a band is about: the newest reply for Review, the newest prompt for a Brief finding.
138export function rowFor(band: CoachBand): string {
139  if (band.station === 'review') return 'reply'
140  if (band.station === 'brief' && band.source === 'finding') return 'user'
141  return ''
142}
143
144export async function setBand($: Dollar, band: CoachBand | null) {
145  await set($, 'band', band)
146  await set($, 'bandRow', band ? rowFor(band) : '')
147}
148
149// The first Coach press ends the first-run line.
150export async function toggleMenu($: Dollar) {
151  await $.patch('menuOpen', o => !o)
152  if ((await $.get()).prefs.hintTaps === 0) await savePrefs($, p => ({ ...p, hintTaps: p.hintTaps + 1 }))
153}
154
155export async function clearBand($: Dollar) {
156  await setBand($, null)
157  await set($, 'stationNote', '')
158  await set($, 'hiddenBand', null)
159  await set($, 'bandLoading', false)
160  await set($, 'ownCheck', null)
161}
162
163export async function dismissBand($: Dollar) {
164  if ((await $.get()).ownCheck) {
165    await set($, 'ownCheck', null)
166    await dispatch($, { type: 'own_done' })
167  }
168  const band = (await $.get()).band
169  if (band && band.source === 'finding') {
170    await $.patch('sessionDismissals', d => ({ ...d, [band.kind]: (d[band.kind] ?? 0) + 1 }))
171    await savePrefs($, p => recordDismissal(p, band.kind))
172  }
173  // Kept until the next prompt, so the Coach menu can bring the suggestions back.
174  if (band) await set($, 'hiddenBand', band)
175  await setBand($, null)
176  await dispatch($, { type: 'focus_clear' })
177}
178
179export async function restoreBand($: Dollar) {
180  const hidden = (await $.get()).hiddenBand
181  if (!hidden) return
182  await set($, 'hiddenBand', null)
183  await set($, 'menuOpen', false)
184  await setBand($, hidden)
185}
186
187const HISTORY_MESSAGES = 6
188
189// The exchanges before the last one, newest last; the last prompt and answer go in on their own.
190async function history($: Dollar): Promise<TurnInput['history']> {
191  try {
192    const all = (await $.session.messages()).filter(m => m.text.trim() !== '' && !m.text.trim().startsWith('/'))
193    let end = all.length
194    if (all[end - 1]?.role === 'assistant') end--
195    if (all[end - 1]?.role === 'user') end--
196    return all.slice(Math.max(0, end - HISTORY_MESSAGES), end)
197  } catch {
198    return []
199  }
200}
201
202export async function turnInput($: Dollar): Promise<TurnInput> {
203  return {
204    history: await history($),
205    card: (await $.get()).card,
206    user: (await $.get()).lastPrompt,
207    answer: (await $.get()).lastAnswer,
208    tools: (await $.get()).tools,
209    station: (await $.get()).station,
210    focus: (await $.get()).focus,
211  }
212}
213
214// A coach template replaces the previous coach template, never the person's own text.
215export async function fillFromCoach($: Dollar, text: string, isTemplate = false) {
216  const box = (await $.prompt.read()).text
217  const isOurs = box === '' || box === (await $.get()).lastFill
218  if (!isOurs) return isTemplate ? undefined : $.prompt.fill({ text, mode: 'insert' })
219  await set($, 'lastFill', text)
220  return $.prompt.fill({ text, mode: 'replace' })
221}
222
223export async function refreshUsage($: Dollar) {
224  try {
225    const usage = await $.session.usage()
226    const auth = await $.session.authorize()
227    await set($, 'usage', { convUsd: usage.cost?.usd ?? null, isApprox: auth?.kind !== 'api-key' })
228  } catch {
229    // Keep the last known figure.
230  }
231}
232
233// Focus shows the static band at once; the specific one replaces it when Haiku answers.
234export async function focusStation($: Dollar, station: CoachStation) {
235  await set($, 'menuOpen', false)
236  await $.patch('prefs', p => ({ ...p, hintTaps: p.hintTaps + 1 }))
237  await $.store.set('hintTaps', (await $.get()).prefs.hintTaps)
238  await dispatch($, { type: 'focus', station })
239  if (station === 'own') {
240    await setBand($, null)
241    await set($, 'ownCheck', makeOwnCheck((await $.get()).card, '', false))
242    return
243  }
244  await set($, 'ownCheck', null)
245  // The template shows dim in the empty prompt box (Tab takes it) and never overwrites typed text.
246  const template = FOCUS_TEMPLATE[station]
247  if (template) {
248    const r = await $.prompt.suggest({ text: template }).catch(() => undefined)
249    await addTrace($, 'suggest', r !== undefined, `focus ${station}: ${template.slice(0, 60)}`)
250  }
251  await setBand($, staticBand(station))
252  const input = await turnInput($)
253  if (input.user === '' && input.answer === '') return
254  const { band, usage } = await writeBand(complete($), input, station, null, 'focus')
255  await recordUsage($, usage)
256  if ((await $.get()).focus !== station || !band) return
257  await setBand($, band)
258}
259
260export async function showBand($: Dollar, band: CoachBand) {
261  const turn = (await $.get()).turnIndex
262  await setBand($, band)
263  await set($, 'lastBandTurn', turn)
264}
265
266// Ending the checks of a held push lets the next push through once and asks Claude to retry it.
267export async function finishOwn($: Dollar) {
268  const held = (await $.get()).ownCheck?.held === true
269  await set($, 'ownCheck', null)
270  await dispatch($, { type: 'own_done' })
271  await dispatch($, { type: 'focus_clear' })
272  if (!held) return
273  await set($, 'pushPass', true)
274  await $.prompt.submit({ text: OWN_PASS_TEXT })
275}
276
277export async function tickOwn($: Dollar, index: number) {
278  const check = (await $.get()).ownCheck
279  if (!check) return
280  const ticked = check.ticked.includes(index)
281    ? check.ticked.filter(i => i !== index)
282    : [...check.ticked, index]
283  if (ticked.length >= check.checks.length) return finishOwn($)
284  await set($, 'ownCheck', { ...check, ticked })
285}
286
287export async function resetSession($: Dollar) {
288  await set($, 'card', EMPTY_CARD)
289  await clearBand($)
290}
291
hooks/initial.ts 64 lines
1import type { CoachState } from '../types'
2import { EMPTY_CARD } from './card'
3import { DEFAULT_SETTINGS } from './config'
4
5export const INITIAL: CoachState = {
6  ready: false,
7  prefs: {
8    enabled: true,
9    sessions: 0,
10    hintTaps: 0,
11    settings: DEFAULT_SETTINGS,
12    fade: {},
13    dismissals: {},
14    silenced: [],
15  },
16  station: 'plan',
17  focus: null,
18  stationReason: '',
19  pulse: false,
20  card: EMPTY_CARD,
21  band: null,
22  hiddenBand: null,
23  bandLoading: false,
24  noteBusy: '',
25  route: null,
26  modelMenuOpen: false,
27  declined: null,
28  modelChoice: null,
29  sessionModel: '',
30  sessionEffort: null,
31  routeBusy: false,
32  stationNote: '',
33  rowNotes: { user: {}, reply: null },
34  moveNote: '',
35  trace: [],
36  pendingSuggest: null,
37  shownSuggest: null,
38  ownCheck: null,
39  menuOpen: false,
40  pickerOpen: false,
41  settingsOpen: false,
42  meta: [],
43  metaBusy: false,
44  metaContext: '',
45  turnIndex: 0,
46  cost: { tokens: 0, usd: 0 },
47  lastPrompt: '',
48  lastAnswer: '',
49  fresh: [],
50  tools: [],
51  dividerTurn: -1,
52  lastBandTurn: 0,
53  sessionDismissals: {},
54  silencedSession: [],
55  ignoredGaps: {},
56  askedPause: false,
57  sessionCounted: false,
58  pushPass: false,
59  lastFill: '',
60  usage: { convUsd: null, isApprox: true },
61  bandRow: '',
62  rows: { user: '', reply: '', userTurn: 0, replyTurn: 0 },
63}
64
hooks/ctx.ts 19 lines
1import type { EngineInterface } from 'claude-code'
2import type { CoachState } from '../types'
3
4// The engine's `$` cannot cross a file boundary, so register.tsx hands the rest of the
5// mod this narrow set of closures instead. Tests can supply their own.
6export type Ctx = {
7  get: () => Promise<CoachState>
8  patch: <K extends keyof CoachState>(key: K, fn: (value: CoachState[K]) => CoachState[K]) => Promise<unknown>
9  ui: Pick<EngineInterface['ui'], 'log' | 'toast' | 'ask' | 'open' | 'resolve'>
10  model: Pick<EngineInterface['model'], 'complete'>
11  prompt: Pick<EngineInterface['prompt'], 'fill' | 'suggest' | 'read' | 'submit'>
12  store: Pick<EngineInterface['store'], 'get' | 'set'>
13  clock: Pick<EngineInterface['clock'], 'after'>
14  config: Pick<EngineInterface['config'], 'list' | 'set'>
15  session: Pick<EngineInterface['session'], 'usage' | 'authorize' | 'surface' | 'model'> & {
16    messages: () => Promise<{ role: 'user' | 'assistant'; text: string }[]>
17  }
18}
19
hooks/config.ts 221 lines
1import type { CoachSettings, CoachStation, CoachModel, RouteModel } from '../types'
2
3export const STATIONS: readonly CoachStation[] = ['plan', 'brief', 'review', 'own']
4
5export const OUTER: readonly CoachStation[] = ['plan', 'own']
6
7// Claude Code's muted, theme-aware keys (they follow light and dark). If the engine refuses one,
8// swap in STATION_COLOR_FALLBACK (plain ANSI names, no bright variants).
9export const STATION_COLOR: Record<CoachStation, string> = {
10  plan: 'purple_FOR_SUBAGENTS_ONLY',
11  brief: 'cyan_FOR_SUBAGENTS_ONLY',
12  review: 'yellow_FOR_SUBAGENTS_ONLY',
13  own: 'pink_FOR_SUBAGENTS_ONLY',
14}
15
16export const STATION_COLOR_FALLBACK: Record<CoachStation, string> = {
17  plan: 'magenta',
18  brief: 'cyan',
19  review: 'yellow',
20  own: 'red',
21}
22
23// Light-mode tones sampled from the visual refs; an Svg or a remote surface's Text takes raw colours only.
24export const STATION_HEX: Record<CoachStation, string> = {
25  plan: '#8B7FD6',
26  brief: '#2A9D8F',
27  review: '#D08A1E',
28  own: '#D45A85',
29}
30
31export const STATION_WORD_HEX: Record<CoachStation, string> = {
32  plan: '#6E5FC4',
33  brief: '#1F8577',
34  review: '#B06A12',
35  own: '#B83C6A',
36}
37
38export const STATION_NAME: Record<CoachStation, string> = {
39  plan: 'Plan',
40  brief: 'Brief',
41  review: 'Review',
42  own: 'Own',
43}
44
45export const STATION_CAPTION: Record<CoachStation, string> = {
46  plan: 'deciding what Claude should do',
47  brief: 'your next prompt',
48  review: 'checking what Claude gave you',
49  own: 'standing behind what you ship',
50}
51
52export const STATION_TOOLTIP: Record<CoachStation, string> = {
53  plan: 'Decide what Claude should do, and how',
54  brief: 'Your next prompt',
55  review: 'Check what Claude gave you',
56  own: 'Stand behind what you ship',
57}
58
59// Small secondary labels only (never in the main UI).
60export const STATION_MEANING: Record<CoachStation, string> = {
61  plan: 'Decide what Claude should do',
62  brief: 'Say what you need',
63  review: 'Check what came back',
64  own: 'Stand behind what ships',
65}
66
67// One line shown at once when a step is picked, before the specific band is ready.
68export const STATIC_TITLE: Record<CoachStation, string> = {
69  plan: 'Before Claude starts, say what the work is and what must not change.',
70  brief: 'Your next prompt could say who it is for and what done looks like.',
71  review: 'Pick the claims in the last answer that you would check yourself.',
72  own: 'Before this goes out, three quick checks.',
73}
74
75export const STATION_FRAMEWORK: Record<CoachStation, string> = {
76  plan: 'Delegation',
77  brief: 'Description',
78  review: 'Discernment',
79  own: 'Diligence',
80}
81
82export const ATTRIBUTION = 'Based on the AI Fluency Framework by Dakan, Feller and Anthropic, CC BY-NC-SA 4.0.'
83
84export const DEFAULT_SETTINGS: CoachSettings = {
85  model: 'haiku-5.5',
86  cadence: 'every',
87  pausePushes: 'unset',
88  labels: true,
89  hotkeys: false,
90  preview: false,
91  recommendModel: true,
92  modelSwitch: 'ask',
93}
94
95// The models a prompt can be sent with. Prices per million tokens (input / output), from the same page.
96export const ROUTE_MODELS: Record<RouteModel, { id: string; label: string }> = {
97  fable: { id: 'claude-fable-5-1', label: 'Fable 5.1' },
98  opus: { id: 'claude-opus-5-5', label: 'Opus 5.5' },
99  sonnet: { id: 'claude-sonnet-5-5', label: 'Sonnet 5.5' },
100  haiku: { id: 'claude-haiku-5-5', label: 'Haiku 5.5' },
101}
102
103// The recommender runs on the smallest model, whatever writes the feedback: it reads a draft, not a turn.
104// The three modes, as the toggle beside Coach names them.
105export const MODEL_SWITCH_LABEL = { ask: 'Prompt me', auto: 'Auto-select', off: 'Only show' } as const
106
107// After the person keeps their model, the same recommendation is not asked about for this many prompts.
108export const DECLINE_TURNS = 3
109
110export const ROUTER = { model: 'claude-haiku-5-5', effort: 'low', timeoutMs: 5000, maxTokens: 120, debounceMs: 900, minWords: 4 } as const
111
112// The models the feedback can run on, by their full ids so the price is the right one. Prices per million
113// tokens from https://platform.claude.com/docs/en/about-claude/pricing (read 2026-10-08); ids from the models overview.
114export const COACH_MODELS: Record<CoachModel, { id: string; label: string; timeoutMs: number; price: Price }> = {
115  haiku: { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5', timeoutMs: 8000, price: { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 } },
116  // Haiku 5.5 is priced by prompt length; the coach's prompts stay far below the 100,000-token step.
117  'haiku-5.5': { id: 'claude-haiku-5-5', label: 'Haiku 5.5', timeoutMs: 8000, price: { input: 0.1, output: 0.5, cacheWrite: 0.125, cacheRead: 0.01 } },
118  sonnet: { id: 'claude-sonnet-5-5', label: 'Sonnet 5.5', timeoutMs: 15000, price: { input: 2, output: 10, cacheWrite: 2.5, cacheRead: 0.1 } },
119  opus: { id: 'claude-opus-5-5', label: 'Opus 5.5', timeoutMs: 25000, price: { input: 4, output: 20, cacheWrite: 5, cacheRead: 0.2 } },
120}
121
122export const isCoachModel = (v: unknown): v is CoachModel => typeof v === 'string' && v in COACH_MODELS
123
124export const HAIKU = {
125  model: 'haiku',
126  effort: 'low',
127  timeoutMs: 8000,
128  maxTokens: { a: 1600, b: 800, c: 400, meta: 600 },
129} as const
130
131export const LIMITS = {
132  title: 90,
133  chipLabelWords: 5,
134  chipLabelChars: 32,
135  chipFill: 160,
136  chips: 3,
137  minChips: 2,
138  cardField: 120,
139  cardList: 5,
140  noteText: 50,
141  templateText: 120,
142  windowText: 1500,
143  answerText: 2400,
144  captionText: 60,
145  aboutWords: 10,
146  reasonText: 60,
147  evidenceMin: 3,
148} as const
149
150export const BLOCKLIST: readonly string[] = [
151  'who is the audience',
152  'add more context',
153  'be more specific',
154  'check the output',
155  'check the result',
156  'add more detail',
157  'provide more context',
158  'what is the goal',
159  'verify the output',
160  'review the answer',
161]
162
163export const OWN_CHECKS: readonly string[] = ['Tested myself', 'No client data', 'AI assistance noted']
164export const OWN_PASS_TEXT = 'Checks done. Go ahead with the push.'
165export const OWN_DENY_TEXT =
166  "Push paused at the Own check. Wait for the person to finish the checks; don't retry."
167
168// Suggested in the empty prompt box when a station is focused. Slots are named in brackets: [audience].
169export const SLOT_RE = /\[[^\]\n]{2,24}\]|___/
170export const FOCUS_TEMPLATE: Record<CoachStation, string> = {
171  plan: 'The goal is [goal], and what must not change is [limits].',
172  brief: 'This is for [audience], who needs it to [decide or do].',
173  review: 'Which of your assumptions about [topic] should I check first?',
174  own: '',
175}
176export const OWN_CONTINUE = 'Continue anyway'
177
178export const PUSH_RE = /\bgit\s+(?:-C\s+\S+\s+)?push\b/
179export const COMMIT_RE = /\bgit\s+(?:-C\s+\S+\s+)?commit\b/
180export const DEPLOY_RE =
181  /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:deploy|publish|release)|npm\s+publish|vercel(?:\s+\S+)*\s+--prod|vercel\s+deploy|netlify\s+deploy|kubectl\s+(?:apply|rollout)|terraform\s+apply|cdk\s+deploy|serverless\s+deploy|sls\s+deploy|gh\s+release\s+create|docker\s+push|twine\s+upload|cargo\s+publish|fly\s+deploy|firebase\s+deploy)\b/
182
183export const PLAN_FILE_RE = /(^|\/)(\.claude\/plans\/|plan[^/]*\.md$|[^/]*-plan\.md$)/i
184
185export const EDIT_TOOLS: readonly string[] = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
186
187export const CLOSED_LOOP_RE =
188  /\?|\b(?:no|not|wrong|actually|instead|why|sure|source|check|verify|doesn'?t|isn'?t|didn'?t|but|nej|ikke|forkert|hvorfor|kilde)\b/i
189
190export const FOCUS_ONLY_KINDS: readonly string[] = []
191
192export const STATIC_CHIPS: Record<CoachStation, { label: string; fill: string }[]> = {
193  plan: [
194    { label: 'Goal and limits', fill: 'The goal is [goal], and what must not change is [limits].' },
195    { label: 'Should Claude do this?', fill: 'Is this something Claude should do, or should I do part of it myself? Tell me why.' },
196    { label: 'Outline first', fill: 'Before changing anything, outline your approach and what you need from me.' },
197  ],
198  brief: [
199    { label: 'Who it is for', fill: 'This is for [audience], who needs it to [decide or do].' },
200    { label: 'What done looks like', fill: 'It is done when [result].' },
201  ],
202  review: [
203    { label: 'What did you assume?', fill: 'Which assumptions did you make in that answer, and which parts are you least sure about?' },
204    { label: 'How do I verify this?', fill: 'How can I check the main claims in your answer myself?' },
205  ],
206  own: [
207    { label: 'Check before sharing', fill: 'Before this goes out, list what I should check myself and what could be wrong.' },
208    { label: 'Data in it', fill: 'Does this contain client or personal data that should not leave this machine?' },
209  ],
210}
211
212export const STARTER_CHIPS: readonly string[] = [
213  "My result isn't what I wanted",
214  'How do I check this?',
215  'Should Claude do this at all?',
216  'What should I tell the client about AI use?',
217]
218
219// Prices in USD per million tokens; the per-model values live in COACH_MODELS above.
220export type Price = { input: number; output: number; cacheWrite: number; cacheRead: number }
221
hooks/trace.ts 11 lines
1import type { CoachTrace } from '../types'
2
3export const TRACE_TITLE = 'Last analysis'
4
5// One line per call, separated by " | " so the command output can draw it as a table.
6export function formatTrace(trace: CoachTrace[]): string {
7  if (trace.length === 0) return 'Nothing analysed yet. It runs after each of Claude\'s replies.'
8  const rows = trace.map(t => `t${t.turn} | ${t.call} | ${t.ok ? 'ok' : 'no'} | ${t.detail}`)
9  return [TRACE_TITLE, ...rows].join('\n')
10}
11
hooks/flow.ts 365 lines
1import type { CoachBand, CoachStation, CoachSuggestion } from '../types'
2import {
3  type Dollar,
4  addTrace,
5  clearBand,
6  upsertUserNote,
7  complete,
8  dispatch,
9  heads,
10  pulseLoop,
11  refreshUsage,
12  set,
13  recordUsage,
14  savePrefs,
15  showBand,
16  turnInput,
17} from './actions'
18import { COMMIT_RE, DEPLOY_RE, LIMITS, OWN_DENY_TEXT, PUSH_RE } from './config'
19import { makeOwnCheck } from './own'
20import { blockedBecause, cadenceAllows, isFaded, isQuietPhase, recordShown } from './fade'
21import { analyseTurn, headTail, notePrompt, writeBand } from './haiku'
22import { MOVE_CAPTION, classifyMove } from './moves'
23import { freshGaps, gapNames, gapTemplate, missingFrom } from './gaps'
24import { noteKey } from './notes'
25import { cut, isVerbatim, staticBand } from './validate'
26import { endTurnChoice } from './route'
27
28export type Push = 'commit' | 'push' | 'deploy' | null
29
30export function classifyCommand(command: string): Push {
31  if (DEPLOY_RE.test(command)) return 'deploy'
32  if (PUSH_RE.test(command)) return 'push'
33  if (COMMIT_RE.test(command)) return 'commit'
34  return null
35}
36
37const stem = (template: string): string => template.split(/___|\[/)[0]!.trim().slice(0, 12)
38
39export const wasAccepted = (shown: CoachSuggestion, submitted: string): boolean =>
40  submitted.trim().includes(stem(shown.template))
41
42const GAP_REASON: Record<string, string> = {
43  plan: 'This looks like a new task.',
44  own: 'This looks like it is going to someone.',
45}
46
47export async function onPrompt($: Dollar, text: string) {
48  const st = await $.get()
49  const prefs = st.prefs
50  const prev = st.station
51  const mv = classifyMove(text, prev)
52  if (mv.move === 'review' && prev === 'review') await pulseLoop($)
53
54  const shown = st.shownSuggest
55  if (shown) {
56    if (!wasAccepted(shown, text)) {
57      const ignored = (st.ignoredGaps[shown.kind] ?? 0) + 1
58      await $.patch('ignoredGaps', g => ({ ...g, [shown.kind]: ignored }))
59      if (ignored >= 2) await $.patch('silencedSession', s => [...s, shown.kind])
60    }
61    await set($, 'shownSuggest', null)
62  }
63
64  await clearBand($)
65  await $.patch('turnIndex', n => n + 1)
66  const turn = (await $.get()).turnIndex
67  await set($, 'lastPrompt', text.slice(0, LIMITS.windowText))
68  await set($, 'tools', [])
69  await set($, 'pendingSuggest', null)
70  await set($, 'moveNote', mv.caption ?? MOVE_CAPTION[mv.move])
71  // Leaving Review for a new instruction is fine, but worth naming: the chapter line says where they came from.
72  const from = prev === 'review' && mv.move === 'brief' ? prev : undefined
73  await upsertUserNote($, noteKey(text), { move: mv.move, turn, ...(from ? { from } : {}), ...(mv.caption ? { caption: mv.caption } : {}) })
74  await dispatch($, { type: 'prompt_submit', move: mv.move }, mv.caption ?? OUTER_MOVE_REASON[mv.move] ?? '')
75  // Taking a result as final brings up the Own checks; nothing is held, the person ticks or moves on. A push or
76  // send instruction gets its checks when the push itself happens.
77  if (mv.caption === 'taking it as final' && !(await $.get()).ownCheck) {
78    await set($, 'ownCheck', makeOwnCheck((await $.get()).card, '', false))
79  }
80  await addTrace($, 'move', true, `${mv.move} · ${mv.hit ? `"${mv.hit.slice(0, 40)}"` : 'default'} · "${text.slice(0, 40)}"`)
81
82  if (isQuietPhase(prefs)) {
83    await addTrace($, 'gate', false, 'session 1 is quiet: no notes or suggestions (/coach preview shows them)')
84    return
85  }
86  if (mv.note) await applyNote($, text, turn, { text: mv.note, kind: NOTE_KIND[mv.note] ?? null })
87  // A short instruction missing what its kind of request needs gets its note and template at once, by rule;
88  // the note names only what was not already named on an earlier prompt.
89  const missing = mv.move === 'brief' || mv.caption === 'starting new work' ? missingFrom(text) : []
90  const earlier = Object.entries((await $.get()).rowNotes.user)
91    .filter(([k, n]) => k !== noteKey(text) && n.gap)
92    .map(([, n]) => n.gap!)
93  const gaps = freshGaps(missing, earlier)
94  if (gaps.length > 0 && !mv.note) {
95    await upsertUserNote($, noteKey(text), { gap: gapNames(gaps), turn })
96    await set($, 'pendingSuggest', { template: gapTemplate(text, gaps), kind: 'brief_gap' })
97    await addTrace($, 'gaps', true, `missing ${gapNames(gaps)}`)
98  }
99  if (prefs.settings.cadence !== 'focus') {
100    // The row under the message shows a spinner until its feedback is written.
101    await set($, 'noteBusy', noteKey(text))
102    $.clock.after(0, () => void runNote($, text, turn))
103  }
104  else await addTrace($, 'gate', false, 'cadence "on focus only" skips call C')
105}
106
107// "Analyse [question] for [audience]" → "question, audience".
108export const slotNames = (template: string): string =>
109  [...template.matchAll(/\[([^\]\n]{2,24})\]/g)]
110    .map(m => m[1]!.split(/\/| or /)[0]!.trim())
111    .slice(0, 3)
112    .join(', ')
113
114const OUTER_MOVE_REASON: Record<string, string> = { plan: 'asked for a plan', own: 'about to ship' }
115const NOTE_KIND: Record<string, string> = { 'Corrected Claude': 'silent_assumption', 'Questioned the result': 'unchecked_claim' }
116
117// The ✓ note belongs to the user row, found by the prompt's text or, once known, the row's id.
118async function applyNote($: Dollar, text: string, turn: number, note: { text: string; kind: string | null }) {
119  const prefs = (await $.get()).prefs
120  if (note.kind) await savePrefs($, p => ({ ...p, fade: recordShown(p.fade, note.kind!, true) }))
121  if (note.kind && !prefs.settings.preview && isFaded(prefs.fade, note.kind)) return
122  await upsertUserNote($, noteKey(text), { note: note.text, turn })
123  const rows = (await $.get()).rows
124  if (rows.userTurn === turn && rows.user) await upsertUserNote($, rows.user, { note: note.text, turn })
125}
126
127async function runNote($: Dollar, text: string, turn: number) {
128  try {
129    await writeNote($, text, turn)
130  } finally {
131    await $.patch('noteBusy', k => (k === noteKey(text) ? '' : k))
132  }
133}
134
135async function writeNote($: Dollar, text: string, turn: number) {
136  const r = await notePrompt(complete($), text, (await $.get()).card)
137  await recordUsage($, r.usage)
138  if (r.good) await applyNote($, text, turn, r.good)
139  if (r.caption) await upsertUserNote($, noteKey(text), { detail: r.caption })
140  await addTrace(
141    $,
142    'C',
143    r.info.isAnswered,
144    `${r.info.reason} · note ${r.good ? `"${r.good.text.slice(0, 30)}"` : 'none'} · template ${r.suggestion ? 'yes' : 'none'} · move ${r.move ?? 'none'}`,
145  )
146  // Haiku only decides when no rule matched: a rule's Plan, Own or Review stands ("kundebrief" is not a Brief).
147  if (r.move && !classifyMove(text, 'brief').hit && (await $.get()).turnIndex === turn) await overrideMove($, text, r.move)
148  const st = await $.get()
149  if (r.suggestion && !st.silencedSession.includes(r.suggestion.kind) && !st.prefs.silenced.includes(r.suggestion.kind)) {
150    await set($, 'pendingSuggest', r.suggestion)
151    // A ✓ wins the line under the message; otherwise it names what the prompt could still say.
152    const gap = slotNames(r.suggestion.template)
153    const ruled = (await $.get()).rowNotes.user[noteKey(text)]?.gap
154    if (!r.good && gap && !ruled) await upsertUserNote($, noteKey(text), { gap, turn })
155  }
156}
157
158// Haiku's move, with evidence from the prompt, wins over the keyword rule while the turn is still running.
159async function overrideMove($: Dollar, text: string, move: CoachStation) {
160  const st = await $.get()
161  const key = noteKey(text)
162  const ruled = st.rowNotes.user[key]?.move
163  if (!ruled || ruled === move || st.station !== ruled) return
164  await upsertUserNote($, key, { move })
165  await set($, 'moveNote', MOVE_CAPTION[move])
166  await dispatch($, { type: 'move', move }, OUTER_MOVE_REASON[move] ?? '')
167}
168
169export async function showSuggestion($: Dollar, attempt = 0) {
170  const pending = (await $.get()).pendingSuggest
171  if (!pending) return
172  if ((await $.get()).ownCheck || (await $.get()).station === 'own') {
173    await addTrace($, 'suggest', false, 'an Own check is active')
174    return
175  }
176  if ((await $.prompt.read()).text !== '') {
177    await addTrace($, 'suggest', false, attempt === 0 ? 'the prompt box has text; retrying once' : 'the prompt box still has text')
178    if (attempt === 0) $.clock.after(RETRY_MS, () => void showSuggestion($, 1))
179    return
180  }
181  const r = await $.prompt.suggest({ text: pending.template })
182  $.ui.log(`suggest shown=${r.isShown}`, { to: 'debug' })
183  await addTrace($, 'suggest', r.isShown, r.isShown ? `shown "${pending.template.slice(0, 40)}"` : 'the engine did not show it')
184  if (r.isShown) {
185    await set($, 'shownSuggest', pending)
186    await set($, 'pendingSuggest', null)
187  }
188}
189
190const RETRY_MS = 4000
191
192export async function onTurnComplete($: Dollar, answer: string, isAnswer: boolean) {
193  await catchUpOnPrompt($)
194  await dispatch($, { type: 'turn_complete', ownPending: (await $.get()).ownCheck !== null })
195  await set($, 'moveNote', '')
196  await set($, 'lastAnswer', headTail(answer, LIMITS.answerText))
197  const prompt = (await $.get()).lastPrompt
198  await $.patch('fresh', f => [...f, heads(prompt), heads(answer)].filter(Boolean).slice(-6))
199  await endTurnChoice($)
200  if (!isAnswer) return
201  const prefs = (await $.get()).prefs
202  const turn = (await $.get()).turnIndex
203  if (cadenceAllows(prefs.settings.cadence, turn)) {
204    // A spinner holds the band's place while the suggestions are written, so the person sees work going on.
205    const now = await $.get()
206    if (!isQuietPhase(prefs) && !now.focus && !now.ownCheck) await set($, 'bandLoading', true)
207    $.clock.after(0, () => void runAnalysis($, turn))
208  } else await addTrace($, 'gate', false, `cadence "${prefs.settings.cadence}" skips the analysis on turn ${turn}`)
209  $.clock.after(0, () => void showSuggestion($))
210  $.clock.after(0, () => void refreshUsage($))
211}
212
213const MAX_CHECK = 2
214const CHECK_CHARS = 24
215
216// The very first prompt of a chat can reach the engine before the coach has started, so prompt.submit never
217// runs for it. When the reply ends and the latest prompt has no record, read it from the conversation and
218// process it now: its step, feedback and template.
219async function catchUpOnPrompt($: Dollar) {
220  try {
221    // Slash commands and the engine's own markup are no prompts of the person's.
222    const isPrompt = (t: string) => t.trim() !== '' && !t.trim().startsWith('/') && !t.includes('<command-')
223    const last = (await $.session.messages()).filter(m => m.role === 'user' && isPrompt(m.text)).at(-1)
224    if (!last) return
225    const known = (await $.get()).rowNotes.user[noteKey(last.text)]
226    if (known) return
227    await addTrace($, 'move', true, 'caught up on a prompt the coach had not seen')
228    await onPrompt($, last.text)
229  } catch {
230    // Nothing to catch up on.
231  }
232}
233
234export async function runAnalysis($: Dollar, turn: number) {
235  try {
236    await analyse($, turn)
237  } finally {
238    // In preview a turn never ends without suggestions: the step's own when nothing better came back.
239    const st = await $.get()
240    if (st.turnIndex === turn) {
241      await set($, 'bandLoading', false)
242      if (st.prefs.settings.preview && !st.band && !st.focus && !st.ownCheck) {
243        await showBand($, { ...staticBand(st.station), source: 'fallback' })
244        await addTrace($, 'band', true, `preview: ${st.station} suggestions (nothing specific came back)`)
245      }
246    }
247  }
248}
249
250async function analyse($: Dollar, turn: number) {
251  const input = await turnInput($)
252  const isPreview = (await $.get()).prefs.settings.preview
253  const r = await analyseTurn(complete($), input, isPreview)
254  await recordUsage($, r.usage)
255  await set($, 'card', r.card)
256  await addTrace($, 'A', r.info.isAnswered, `${r.info.reason} · finding ${r.rawFinding} → ${r.findingWhy} · ${r.flags.length} flags`)
257  if ((await $.get()).turnIndex !== turn) {
258    await addTrace($, 'gate', false, 'the person had already moved on')
259    return
260  }
261
262  if (r.stepNote) await set($, 'stationNote', r.stepNote)
263  // The model the likely next prompt suits, for the model button; a draft's own recommendation wins.
264  const st0 = await $.get()
265  if (r.nextModel && (st0.prefs.settings.recommendModel || st0.prefs.settings.modelSwitch !== 'off') && !st0.route?.draft) {
266    await set($, 'route', { draft: '', ...r.nextModel })
267  }
268
269  const claims = r.card.unchecked_claims.filter(c => isVerbatim(input.answer, c)).slice(-MAX_CHECK)
270  await set($, 'rowNotes', { ...(await $.get()).rowNotes, reply: { turn, check: claims.map(c => cut(c, CHECK_CHARS)) } })
271
272  let suggest: string | null = null
273  for (const flag of r.flags) {
274    const t = await dispatch($, { type: 'flag', flag: flag.type, hasEvidence: flag.hasEvidence })
275    if (t.suggest && !suggest) suggest = t.suggest
276  }
277  if (!r.finding) {
278    // No finding: the next-step chips, written for this conversation, when the band may show at all.
279    if (r.next) await raise($, turn, input, { station: r.next.station, kind: 'next_step', evidence: r.next.evidence }, suggest, r.next)
280    else await addTrace($, 'band', false, 'no next-step chips survived')
281    return
282  }
283  await raise($, turn, input, r.finding, suggest, r.band ?? r.next)
284}
285
286async function raise(
287  $: Dollar,
288  turn: number,
289  input: Awaited<ReturnType<typeof turnInput>>,
290  finding: NonNullable<Awaited<ReturnType<typeof analyseTurn>>['finding']>,
291  suggest: string | null,
292  ready: CoachBand | null = null,
293) {
294  const st = await $.get()
295  const why = blockedBecause({
296    prefs: st.prefs,
297    kind: finding.kind,
298    sessionDismissals: st.sessionDismissals,
299    silencedSession: st.silencedSession,
300    turnIndex: turn,
301    lastBandTurn: st.lastBandTurn,
302  })
303  if (why || st.focus || st.ownCheck) {
304    await addTrace($, 'gate', false, `${finding.kind} not raised: ${why ?? (st.focus ? 'a focus is active' : 'an Own check is active')}`)
305    return
306  }
307  // Call A usually brings its own chips; only without them is the second call made.
308  let band = ready
309  if (band) await addTrace($, 'B', true, `skipped: call A brought ${band.chips.length} chips`)
310  else {
311    const r = await writeBand(complete($), input, finding.station, finding, 'finding', st.prefs.settings.preview)
312    await recordUsage($, r.usage)
313    const dropped = r.chips.dropped.length ? ` (dropped: ${[...new Set(r.chips.dropped)].join(', ')})` : ''
314    await addTrace($, 'B', r.info.isAnswered, `${r.info.reason} · chips ${r.chips.kept}/${r.chips.returned} kept${dropped} · band ${r.band ? 'yes' : 'no'}`)
315    // A finding never vanishes because call B was unreadable: show the station's own chips with the finding.
316    band = r.band ?? { ...staticBand(finding.station), kind: finding.kind, evidence: finding.evidence.slice(0, 2), source: 'finding' as const }
317    if (!r.band) await addTrace($, 'B', false, `fallback band for ${finding.kind}`)
318  }
319  if ((await $.get()).turnIndex !== turn || (await $.get()).focus) return
320  const withSuggestion =
321    !band.suggestion && suggest
322      ? { ...band, suggestion: { station: suggest as typeof band.station, reason: GAP_REASON[suggest] ?? '' } }
323      : band
324  await showBand($, withSuggestion)
325  // Next steps are not a behaviour to grow out of, so they never fade.
326  if (finding.kind !== 'next_step') await savePrefs($, p => ({ ...p, fade: recordShown(p.fade, finding.kind, false) }))
327}
328
329export async function askPausePreference($: Dollar) {
330  await set($, 'askedPause', true)
331  try {
332    const answer = await $.ui.ask('Pause before a push so there is a moment to check it?', {
333      options: ['Pause pushes', 'Just show the checks'],
334      header: 'Own',
335    })
336    await savePrefs($, p => ({
337      ...p,
338      settings: { ...p.settings, pausePushes: answer === 'Pause pushes' ? 'on' : 'off' },
339    }))
340  } catch {
341    // Dismissed: ask again on a later push.
342    await set($, 'askedPause', false)
343  }
344}
345
346// Returns a denial for a push that must wait for the Own band, else null.
347export async function onPush($: Dollar, kind: Exclude<Push, null>, command = ''): Promise<string | null> {
348  const isInteractive = (await $.session.surface()) !== null
349  const canHold = kind !== 'commit' && isInteractive
350  if (canHold && (await $.get()).pushPass) {
351    await set($, 'pushPass', false)
352    return null
353  }
354  if (canHold && (await $.get()).prefs.settings.pausePushes === 'unset' && !(await $.get()).askedPause) {
355    await askPausePreference($)
356  }
357  const isHeld = canHold && (await $.get()).prefs.settings.pausePushes === 'on'
358  const reason = isHeld ? 'push paused' : kind === 'commit' ? 'git commit' : kind === 'push' ? 'git push' : 'deploy'
359  await dispatch($, { type: 'push' }, reason)
360  const existing = (await $.get()).ownCheck
361  if (!existing) await set($, 'ownCheck', makeOwnCheck((await $.get()).card, command, isHeld))
362  else if (isHeld && !existing.held) await set($, 'ownCheck', { ...existing, held: true })
363  return isHeld ? OWN_DENY_TEXT : null
364}
365
hooks/meta.ts 67 lines
1import { type Dollar, complete, openPane, recordUsage } from './actions'
2import { cardText } from './card'
3import { HAIKU } from './config'
4import { SYSTEM_META } from './prompts'
5
6const PROMPT_REQUEST_RE = /\b(write|draft|give|make|show|skriv|lav)\b[^.?!]{0,40}\b(prompt|template)\b/i
7const NEEDS_SESSION_RE = /\b(this|above|earlier|last|answer|result|output|my prompt|that)\b/i
8const FENCE_RE = /```prompt\s*\n([\s\S]*?)```/
9
10export const countPromptRequests = (userTexts: string[]): number =>
11  userTexts.filter(t => PROMPT_REQUEST_RE.test(t)).length
12
13export const extractPrompt = (reply: string): string | null => FENCE_RE.exec(reply)?.[1]?.trim() || null
14
15async function sessionExcerpt($: Dollar): Promise<string> {
16  try {
17    const rows = (await $.session.messages()).slice(-6)
18    return rows.map(r => `${r.role}: ${r.text.slice(0, 600)}`).join('\n')
19  } catch {
20    return ''
21  }
22}
23
24export async function openAsk($: Dollar, context = '') {
25  await $.patch('metaContext', () => context)
26  await openPane($)
27}
28
29export async function askMeta($: Dollar, text: string) {
30  const question = text.trim()
31  if (!question || (await $.get()).metaBusy) return
32  await $.patch('meta', m => [...m, { role: 'user' as const, text: question }])
33  await $.patch('metaBusy', () => true)
34  const thread = (await $.get()).meta
35  const asked = countPromptRequests(thread.filter(m => m.role === 'user').map(m => m.text))
36  const card = cardText((await $.get()).card)
37  const context = (await $.get()).metaContext
38  const excerpt = NEEDS_SESSION_RE.test(question) ? await sessionExcerpt($) : ''
39  const mode =
40    asked >= 2
41      ? 'The person has asked twice for a ready prompt. Write it now.'
42      : 'Ask one question back before advising.'
43  const prompt = [
44    card && `What the work is about:\n${card}`,
45    context && `The coach band said:\n${context}`,
46    excerpt && `Recent messages:\n${excerpt}`,
47    `Conversation with the coach:\n${thread.map(m => `${m.role}: ${m.text}`).join('\n')}`,
48    mode,
49  ]
50    .filter(Boolean)
51    .join('\n\n')
52  const r = await complete($)({
53    model: HAIKU.model,
54    system: SYSTEM_META,
55    prompt,
56    maxTokens: HAIKU.maxTokens.meta,
57    effort: HAIKU.effort,
58    timeoutMs: HAIKU.timeoutMs,
59  })
60  await recordUsage($, r.usage)
61  const reply = r.isAnswered ? r.text.trim() : 'No answer came back. Try again in a moment.'
62  const template = r.isAnswered ? extractPrompt(reply) : null
63  if (template) await $.prompt.fill({ text: template, mode: 'replace' })
64  await $.patch('meta', m => [...m, { role: 'coach' as const, text: reply }])
65  await $.patch('metaBusy', () => false)
66}
67
hooks/ui/band.tsx 248 lines
1import type { CoachBand, CoachOwnCheck, CoachState } from '../../types'
2import { type Dollar, dismissBand, fillFromCoach, finishOwn, focusStation, openPane, saveSettings, setEnabled, tickOwn, toggleMenu, restoreBand } from '../actions'
3import { OWN_CONTINUE, STATIONS, STATION_NAME } from '../config'
4import { menuCost } from '../cost'
5import { type Ui, checkMark, coachMark, effortBar, glyph, icon, spacer, spinMark, spinner, withSurface } from './glyph'
6import { MODEL_SWITCH_LABEL, ROUTE_MODELS } from '../config'
7import { differs, familyOf, setEffort, switchTo } from '../route'
8import { EFFORTS } from '../parse-route'
9import { focusTarget, hasQuote, targetOf } from '../target'
10import { rail } from './rail'
11
12function dismissButton(ui: Ui, $: Dollar) {
13  return <ui.Button key="dismiss" label="×" role="dismiss" onPress={() => void dismissBand($)} />
14}
15
16// Finding or Own title on one row with the dismiss control at the right end.
17function titleRow(ui: Ui, $: Dollar, title: string) {
18  const { Box, Text } = ui
19  return (
20    <Box flexDirection="row" justifyContent="space-between" gap={1}>
21      <Box flexShrink={1}>
22        <Text>{title}</Text>
23      </Box>
24      {dismissButton(ui, $)}
25    </Box>
26  )
27}
28
29function sourceRow(ui: Ui, quotes: string[], target: string) {
30  const { Box, Text } = ui
31  if (quotes.length === 0) return null
32  return (
33    <Box flexDirection="row" gap={1}>
34      <Text dimColor>“</Text>
35      <Box flexShrink={1}>
36        <Text dimColor italic wrap="truncate">
37          {quotes.join(' · ')}
38        </Text>
39      </Box>
40      {target && <Text dimColor>· {target}</Text>}
41    </Box>
42  )
43}
44
45// Only suggestions: one row of buttons that fill the prompt box, and a dismiss at the end. What the coach
46// noticed about the person's own message is said under that message, not here.
47// The suggestions in the step line's own style: the coach's mark, then each one as plain text behind a small
48// arrow into the prompt box, turning clay under the pointer; lifted a few pixels off the step line.
49function bandView(ui: Ui, $: Dollar, band: CoachBand) {
50  const { Box, Button } = ui
51  return (
52    <Box flexDirection="column">
53      <Box flexDirection="row" flexWrap="wrap" columnGap={3} alignItems="center">
54        {coachMark(ui)}
55        {band.chips.map((chip, i) => (
56          <Box key={`chip-box-${i}`} flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
57            {icon(ui, 'fill')}
58            <Button
59              key={`chip-${i}`}
60              plain
61              label={chip.label}
62              hover={{ scope: `chip-${i}`, color: CLAY }}
63              onPress={() => void fillFromCoach($, chip.fill)}
64            />
65          </Box>
66        ))}
67      </Box>
68      {spacer(ui, 3)}
69    </Box>
70  )
71}
72
73const CLAY = '#D97757'
74
75function ownView(ui: Ui, $: Dollar, check: CoachOwnCheck) {
76  const { Box, Button } = ui
77  return (
78    <Box flexDirection="column">
79      {titleRow(ui, $, check.title)}
80      {sourceRow(ui, check.source, '')}
81      <Box flexDirection="row" flexWrap="wrap" gap={1} marginTop={1}>
82        {check.checks.map((text, i) =>
83          // Open checks are plain buttons; a ticked one turns into a drawn tick and dim text, still pressable.
84          check.ticked.includes(i) ? (
85            <Box key={`own-done-${i}`} flexDirection="row" gap={1} alignItems="center">
86              {checkMark(ui)}
87              <Button key={`own-${i}`} plain dimColor label={text} onPress={() => void tickOwn($, i)} />
88            </Box>
89          ) : (
90            <Button key={`own-${i}`} label={text} onPress={() => void tickOwn($, i)} />
91          ),
92        )}
93        <Button key="own-continue" plain dimColor label={OWN_CONTINUE} onPress={() => void finishOwn($)} />
94      </Box>
95    </Box>
96  )
97}
98
99// The coach's mark for a recommendation, wherever one shows: beside the settings and in the picker.
100export const REC_MARK = '✦'
101// The settings control: the menu mark Claude's own interface uses.
102export const SETTINGS_GLYPH = '⋯'
103
104// Beside the settings, like the model and effort at the foot of the prompt box: the recommendation, marked,
105// which a press switches to; the model in use, which opens the picker; and the mode, which a press cycles:
106// Prompt me → Auto-select → Only show.
107function modelControls(ui: Ui, $: Dollar, st: CoachState) {
108  const { Box, Button } = ui
109  const now = st.modelChoice?.model ?? familyOf(st.sessionModel)
110  const effort = st.modelChoice?.effort ?? st.sessionEffort
111  const rec = differs(st.route, now, effort) ? st.route : null
112  const mode = MODEL_SWITCH_LABEL[st.prefs.settings.modelSwitch] ? st.prefs.settings.modelSwitch : 'ask'
113  const isJudging = st.routeBusy || (st.bandLoading && (st.prefs.settings.recommendModel || mode !== 'off'))
114  const slot = (button: unknown, bar: unknown) => (
115    <Box flexDirection="row" alignItems="center" flexShrink={0}>
116      {icon(ui, 'model')}
117      {button as never}
118      {bar as never}
119    </Box>
120  )
121  return (
122    <Box flexDirection="row" gap={1} flexShrink={0} alignItems="center">
123      {isJudging && !rec
124        ? slot(<Box flexDirection="row" gap={1} alignItems="center">{spinMark(ui)}</Box>, null)
125        : rec
126          ? slot(
127              <Button
128                key="model"
129                plain
130                label={`${ROUTE_MODELS[rec.model].label} · ${rec.reason}`}
131                onPress={() => void switchTo($, rec.model, rec.effort).then(() => $.patch('route', () => null))}
132              />,
133              effortBar(ui, rec.effort, true),
134            )
135          : slot(
136              <Button key="model" plain dimColor label={now ? ROUTE_MODELS[now].label : 'Model'} onPress={() => void $.patch('modelMenuOpen', o => !o)} />,
137              effortBar(ui, effort),
138            )}
139      <Box flexDirection="row" alignItems="center" flexShrink={0}>
140        {icon(ui, mode)}
141        <Button key="switch-mode" plain dimColor label={MODEL_SWITCH_LABEL[mode]} onPress={() => void saveSettings($, { modelSwitch: NEXT_MODE[mode] })} />
142      </Box>
143    </Box>
144  )
145}
146
147// The model picker: the four models, then the five efforts; the ones in use dimmed, the recommended marked.
148function modelMenu(ui: Ui, $: Dollar, st: CoachState) {
149  const { Box, Text, Button } = ui
150  const now = st.modelChoice?.model ?? familyOf(st.sessionModel)
151  const effort = st.modelChoice?.effort ?? st.sessionEffort
152  const close = () => $.patch('modelMenuOpen', () => false)
153  const option = (key: string, label: string, isNow: boolean, isRec: boolean, pick: () => void) => {
154    const shown = isRec && !isNow ? `${label} ${REC_MARK}` : label
155    return isNow ? <Button key={key} plain dimColor label={shown} onPress={pick} /> : <Button key={key} label={shown} onPress={pick} />
156  }
157  return (
158    <Box flexDirection="column">
159      <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
160        <Text dimColor>Run Claude on</Text>
161        {ROUTE_ORDER.map(m => option(`run-${m}`, ROUTE_MODELS[m].label, m === now, m === st.route?.model, () => void switchTo($, m).then(close)))}
162      </Box>
163      <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
164        <Text dimColor>Effort</Text>
165        {EFFORTS.map(x => option(`effort-${x}`, x, x === effort, x === st.route?.effort, () => void setEffort($, x).then(close)))}
166      </Box>
167    </Box>
168  )
169}
170
171const ROUTE_ORDER = ['haiku', 'sonnet', 'opus', 'fable'] as const
172const NEXT_MODE = { ask: 'auto', auto: 'off', off: 'ask' } as const
173
174// One row: "Focus on" and the four steps on the left, cost and Turn off on the right.
175function fold(ui: Ui, $: Dollar, st: CoachState) {
176  const { Box, Text, Button } = ui
177  return (
178    <Box flexDirection="row" justifyContent="space-between" gap={1}>
179      <Box flexDirection="row" gap={1} flexShrink={1}>
180        <Text dimColor>Focus on</Text>
181        {STATIONS.map((s, i) => (
182          <Button
183            key={`pick-${s}`}
184            label={STATION_NAME[s]}
185            hotkey={st.prefs.settings.hotkeys ? String(i + 1) : undefined}
186            onPress={() => void focusStation($, s)}
187          />
188        ))}
189      </Box>
190      <Box flexDirection="row" gap={1} flexShrink={0}>
191        {st.hiddenBand && !st.band && <Button key="menu-restore" label="Show suggestions" onPress={() => void restoreBand($)} />}
192        <Text dimColor>{menuCost(st.cost.usd, st.usage.convUsd)}</Text>
193        <Button key="menu-settings" plain dimColor label="Settings" onPress={() => void $.patch('settingsOpen', () => true).then(() => openPane($))} />
194        <Button key="menu-off" label="Turn off" onPress={() => void setEnabled($, false)} />
195      </Box>
196    </Box>
197  )
198}
199
200export async function renderAbovePrompt(
201  $: Dollar,
202  e: Parameters<Dollar['ui']['resolve']>[0] & { props: { hasSurvey: boolean; isWorking: boolean; bodyColumns: number } },
203  next: (e: never) => Promise<never>,
204) {
205  if (e.props.hasSurvey) return next(e as never)
206  const ui = withSurface($.ui.resolve(e), (e as { surface: string }).surface)
207  const { Box, Button, Text } = ui
208  const st = await $.get()
209
210  if (!st.prefs.enabled) {
211    return (
212      <Box flexDirection="row" justifyContent="flex-end">
213        <Button key="coach-on" plain dimColor label="Coach off" onPress={() => void setEnabled($, true)} />
214      </Box>
215    )
216  }
217
218  // Mid-turn only what the person asked for: a focus, or a held push.
219  const isAsked = st.focus !== null || st.ownCheck?.held === true
220  const content =
221    e.props.isWorking && !isAsked
222      ? null
223      : st.ownCheck
224        ? ownView(ui, $, st.ownCheck)
225        : st.band
226          ? bandView(ui, $, st.band)
227          : null
228  // While the suggestions are written a spinner holds their place.
229  const loading = content === null && st.bandLoading ? spinner(ui, 'Writing suggestions') : null
230  // Suggestions on top, then the status group: the Focus row sits right above the rail it changes, and the
231  // model row last, nearest the draft it is about.
232  return (
233    <Box flexDirection="column">
234      {content ?? loading}
235      {st.menuOpen && fold(ui, $, st)}
236      {st.modelMenuOpen && modelMenu(ui, $, st)}
237      {content === null && !st.menuOpen && st.prefs.hintTaps === 0 && st.prefs.sessions <= 3 && (
238        <Text dimColor>The coach follows your loop with Claude. Press ⋯ to focus on a step.</Text>
239      )}
240      <Box flexDirection="row" gap={1}>
241        {rail(ui, { station: st.station, focus: st.focus, reason: st.stationReason, moveNote: st.moveNote, note: st.stationNote, pulse: st.pulse })}
242        {modelControls(ui, $, st)}
243        <Button key="menu" plain dimColor label={SETTINGS_GLYPH} onPress={() => void toggleMenu($)} />
244      </Box>
245    </Box>
246  )
247}
248
hooks/ui/glyph.tsx 230 lines
1import type { ElementTable } from 'claude-code'
2import type { CoachStation } from '../../types'
3import { STATION_COLOR, STATION_HEX, STATION_NAME, STATION_WORD_HEX } from '../config'
4
5export type Ui = Pick<ElementTable, 'Box' | 'Text' | 'Button'> & { surface?: string }
6
7// The element table plus the surface it belongs to (the glyph is an Svg anywhere but the terminal).
8export const withSurface = (table: unknown, surface: string): Ui => ({ ...(table as object), surface }) as Ui
9
10export const isTerminal = (ui: Ui) => ui.surface === 'terminal'
11
12// Station word: theme keys on the terminal, the light-mode hex everywhere else.
13export const wordColor = (ui: Ui, s: CoachStation) => (isTerminal(ui) ? STATION_COLOR[s] : STATION_WORD_HEX[s])
14
15const ORDER: CoachStation[] = ['plan', 'brief', 'review', 'own']
16const WIDTH: Record<CoachStation, number> = { plan: 10, brief: 15, review: 15, own: 10 }
17const GAP = [4, 2, 4]
18const HEIGHT = 3
19
20type GlyphOptions = { mini?: boolean; pulse?: boolean }
21
22// The four bars as one small drawing: outer bars short, the inner pair long and close.
23export function glyphSvg(active: CoachStation, { mini = false, pulse = false }: GlyphOptions = {}) {
24  const k = mini ? 0.7 : 1
25  let x = 0
26  const bars = ORDER.map((s, i) => {
27    const w = WIDTH[s] * k
28    const isActive = s === active
29    const isInner = s === 'brief' || s === 'review'
30    const fill = isActive ? STATION_HEX[s] : '#888888'
31    const opacity = isActive ? 1 : pulse && isInner ? 0.6 : 0.35
32    const bar = `<rect x="${x.toFixed(1)}" y="0" width="${w.toFixed(1)}" height="${(HEIGHT * k).toFixed(1)}" rx="${(1.5 * k).toFixed(2)}" fill="${fill}" fill-opacity="${opacity}"/>`
33    x += w + (GAP[i] ?? 0) * k
34    return bar
35  })
36  const total = ORDER.reduce((n, s) => n + WIDTH[s], 0) + GAP.reduce((n, g) => n + g, 0)
37  const width = Math.round(total * k * 10) / 10
38  const height = Math.round(HEIGHT * k * 10) / 10
39  return {
40    width,
41    height,
42    source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${bars.join('')}</svg>`,
43  }
44}
45
46// Terminal cells: active bar thick and coloured, the rest thin and dim; the inner pair sits closer.
47const CELLS: Record<CoachStation, { on: string; off: string; gap: string }> = {
48  plan: { on: '━', off: '─', gap: '  ' },
49  brief: { on: '━━', off: '──', gap: ' ' },
50  review: { on: '━━', off: '──', gap: '  ' },
51  own: { on: '━', off: '─', gap: '' },
52}
53
54type SvgFn = (p: { source: string; alt: string; width?: number; height?: number; isInteractive?: boolean }) => never
55
56// Claude's own working mark: a small clay asterisk that turns and breathes. SMIL needs the interactive frame.
57const SPIN_SVG =
58  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12"><g fill="none" stroke="#D97757" stroke-width="1.5" stroke-linecap="round"><path d="M6 1.5V10.5M1.5 6H10.5M2.8 2.8 9.2 9.2M9.2 2.8 2.8 9.2"/><animateTransform attributeName="transform" type="rotate" from="0 6 6" to="360 6 6" dur="3s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;0.4;1" dur="1.5s" repeatCount="indefinite"/></g></svg>'
59
60// A gap of a few pixels: the desktop spaces in whole rows, so a transparent drawing of that height stands in.
61// The terminal has nothing smaller than a row and gets nothing.
62export function spacer(ui: Ui, px: number) {
63  const Svg = (ui as unknown as { Svg?: SvgFn }).Svg
64  if (!Svg || isTerminal(ui)) return null
65  return <Svg source={`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 ${px}" width="1" height="${px}"></svg>`} alt="space" width={1} height={px} />
66}
67
68const MARK_SVG =
69  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="11" height="11"><path d="M6 0.8 7.3 4.7 11.2 6 7.3 7.3 6 11.2 4.7 7.3 0.8 6 4.7 4.7Z" fill="#D97757"/></svg>'
70
71// The coach's mark, the ✦ its recommendations carry: in clay, ahead of the suggestions.
72export function coachMark(ui: Ui) {
73  const Svg = (ui as unknown as { Svg?: SvgFn }).Svg
74  if (Svg && !isTerminal(ui)) return <Svg source={MARK_SVG} alt="Coach suggests" width={11} height={11} />
75  return <ui.Text color="claude">✦</ui.Text>
76}
77
78// Small line icons in the label grey, drawn like the check mark: one beside each control at the step line.
79const LINE = (body: string) =>
80  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 12" width="16" height="12"><g fill="none" stroke="#8A8782" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">${body}</g></svg>`
81const ICONS = {
82  // A chip: the model Claude runs on.
83  model: LINE('<rect x="3" y="3" width="6" height="6" rx="1.3"/><path d="M5 1.2v1.8M7 1.2v1.8M5 9v1.8M7 9v1.8M1.2 5h1.8M1.2 7h1.8M9 5h1.8M9 7h1.8"/>'),
84  // A speech bubble: ask before switching.
85  ask: LINE('<path d="M2.2 3.3a1.3 1.3 0 0 1 1.3-1.3h5a1.3 1.3 0 0 1 1.3 1.3v3.4a1.3 1.3 0 0 1-1.3 1.3H5.4L3.2 10V8h-.0a1.3 1.3 0 0 1-1-1.3Z"/>'),
86  // Two turning arrows: switch by itself.
87  auto: LINE('<path d="M9.6 4.6A3.8 3.8 0 0 0 2.7 4M2.4 7.4A3.8 3.8 0 0 0 9.3 8"/><path d="M2.5 1.8v2.4h2.4M9.5 10.2V7.8H7.1"/>'),
88  // An arrow into the prompt box: a suggestion fills it.
89  fill: LINE('<path d="M2.5 2v3.6a1.6 1.6 0 0 0 1.6 1.6h5.4"/><path d="M7.4 5 9.6 7.2 7.4 9.4"/>'),
90  // An eye: only show.
91  off: LINE('<path d="M1 6s1.8-3.4 5-3.4S11 6 11 6 9.2 9.4 6 9.4 1 6 1 6Z"/><circle cx="6" cy="6" r="1.4"/>'),
92} as const
93
94export type IconName = keyof typeof ICONS | 'mark'
95
96// A drawn icon beside a control; nothing on the terminal, where the words stand alone.
97export function icon(ui: Ui, name: IconName) {
98  const Svg = (ui as unknown as { Svg?: SvgFn }).Svg
99  if (!Svg || isTerminal(ui)) return name === 'mark' ? coachMark(ui) : null
100  if (name === 'mark') return coachMark(ui)
101  return <Svg source={ICONS[name]} alt={name} width={16} height={12} />
102}
103
104const EFFORT_LEVEL = { low: 1, medium: 2, high: 3, xhigh: 4, max: 5 } as const
105
106// Effort as one small vertical bar that fills from the bottom, low to max; clay when it is a recommendation.
107export function effortBar(ui: Ui, effort: keyof typeof EFFORT_LEVEL | null | undefined, isRecommended = false) {
108  if (!effort) return null
109  const Svg = (ui as unknown as { Svg?: SvgFn }).Svg
110  const level = EFFORT_LEVEL[effort]
111  if (!Svg || isTerminal(ui)) return <ui.Text dimColor={!isRecommended}>{' ▁▂▄▆█'[level]}</ui.Text>
112  const fill = isRecommended ? '#D97757' : '#8A8782'
113  const filled = (10 * level) / 5
114  const source = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 12" width="9" height="12"><rect x="3.5" y="1" width="4" height="10" rx="1.2" fill="none" stroke="${fill}" stroke-opacity="0.55" stroke-width="1"/><rect x="3.5" y="${(11 - filled).toFixed(1)}" width="4" height="${filled.toFixed(1)}" rx="1.2" fill="${fill}"/></svg>`
115  return <Svg source={source} alt={`${effort} effort`} width={9} height={12} />
116}
117
118// The working mark alone, for a slot that is still being decided.
119export function spinMark(ui: Ui) {
120  const Svg = (ui as unknown as { Svg?: SvgFn }).Svg
121  if (Svg && !isTerminal(ui)) return <Svg source={SPIN_SVG} alt="working" width={12} height={12} isInteractive />
122  return <ui.Text color="claude">✻</ui.Text>
123}
124
125// A working row: the mark and a dim word, in the voice of Claude's own spinner.
126export function spinner(ui: Ui, text: string) {
127  const { Box, Text } = ui
128  const Svg = (ui as unknown as { Svg?: SvgFn }).Svg
129  return (
130    <Box flexDirection="row" gap={1} alignItems="center">
131      {Svg && !isTerminal(ui) ? (
132        <Svg source={SPIN_SVG} alt="working" width={12} height={12} isInteractive />
133      ) : (
134        <Text color="claude">✻</Text>
135      )}
136      <Text dimColor>{text}…</Text>
137    </Box>
138  )
139}
140
141const CHECK_SVG =
142  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 10" width="12" height="10"><path d="M1.5 5.5 4.5 8.5 10.5 1.5" fill="none" stroke="#8A8782" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>'
143
144const HALF_SVG =
145  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="11" height="11"><circle cx="6" cy="6" r="4.6" fill="none" stroke="#8A8782" stroke-width="1.4"/><path d="M6 1.4 A4.6 4.6 0 0 1 6 10.6 Z" fill="#8A8782"/></svg>'
146const CROSS_SVG =
147  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="10" height="10"><path d="M2.5 2.5 9.5 9.5 M9.5 2.5 2.5 9.5" fill="none" stroke="#8A8782" stroke-width="1.6" stroke-linecap="round"/></svg>'
148
149export type Mark = 'good' | 'comment' | 'issue'
150
151// The three marks on a person's message: ✓ a move worth repeating, ◐ something it could still say, ✕ a problem.
152export function mark(ui: Ui, kind: Mark) {
153  if (kind === 'good') return checkMark(ui)
154  const Svg = (ui as unknown as { Svg?: SvgFn }).Svg
155  if (Svg && !isTerminal(ui)) {
156    return kind === 'comment' ? <Svg source={HALF_SVG} alt="comment" width={11} height={11} /> : <Svg source={CROSS_SVG} alt="issue" width={10} height={10} />
157  }
158  return <ui.Text dimColor>{kind === 'comment' ? '◐' : '✕'}</ui.Text>
159}
160
161// A rule that fills the rest of a row, after a chapter line's caption.
162export function fillRule(ui: Ui) {
163  return pieces(ui, 60)
164}
165
166// A drawn tick in the label grey: the ✓ glyph falls back to another font on remote surfaces.
167export function checkMark(ui: Ui) {
168  const Svg = (ui as unknown as { Svg?: SvgFn }).Svg
169  if (Svg && !isTerminal(ui)) return <Svg source={CHECK_SVG} alt="done" width={12} height={10} />
170  return <ui.Text dimColor>✓</ui.Text>
171}
172
173export function glyph(ui: Ui, active: CoachStation, { mini = false, pulse = false }: GlyphOptions = {}) {
174  const { Box, Text } = ui
175  const Svg = (ui as unknown as { Svg?: SvgFn }).Svg
176  if (Svg && !isTerminal(ui)) {
177    const g = glyphSvg(active, { mini, pulse })
178    return (
179      <Box flexShrink={0}>
180        <Svg source={g.source} alt={STATION_NAME[active]} width={g.width} height={g.height} />
181      </Box>
182    )
183  }
184  return (
185    <Box flexDirection="row" flexShrink={0}>
186      {ORDER.map(s => {
187        const c = CELLS[s]
188        return s === active ? (
189          <Text color={STATION_COLOR[s]}>
190            {c.on}
191            {c.gap}
192          </Text>
193        ) : (
194          <Text dimColor={!(pulse && (s === 'brief' || s === 'review'))}>
195            {c.off}
196            {c.gap}
197          </Text>
198        )
199      })}
200    </Box>
201  )
202}
203
204// One thin rule between the band's content and the rail. It fills the slot's width, is 1px tall (an unset height
205// would scale to a fraction of a pixel), has a blank row above and below, and is never
206// drawn as dashes on a remote surface.
207// A line across the rest of a row, built from short pieces in a box that clips what does not fit. A single
208// long text line wraps to two lines on desktop, and a truncating one ends in "…"; pieces avoid both.
209// The grey of a step bar that is not lit (35% of #888888 over the page), so the rule continues the glyph.
210export const RULE_GREY = '#D1CFCB'
211
212function pieces(ui: Ui, count: number) {
213  const { Box, Text } = ui
214  const tone = isTerminal(ui) ? { dimColor: true } : { color: RULE_GREY }
215  return (
216    <Box flexDirection="row" flexWrap="nowrap" flexGrow={1} flexShrink={1} overflow="hidden" minWidth={2}>
217      {Array.from({ length: count }, (_, i) => (
218        <Box key={`r${i}`} flexShrink={0}>
219          <Text {...tone}>────</Text>
220        </Box>
221      ))}
222    </Box>
223  )
224}
225
226// The band's rule across its width.
227export function hairline(ui: Ui, _columns: number) {
228  return <ui.Box flexDirection="row">{pieces(ui, 60)}</ui.Box>
229}
230
hooks/ui/labels.tsx 156 lines
1import type { CoachStation, CoachState, CoachUserNote } from '../../types'
2import { type Dollar, heads } from '../actions'
3import { labelMode } from '../fade'
4import { noteKey } from '../notes'
5import { STATION_NAME } from '../config'
6import { type Ui, fillRule, glyph, mark, spacer, spinner, wordColor } from './glyph'
7
8export { noteKey }
9
10type Row = { requestId: string; text: string }
11
12const CHAPTER_CAPTION: Record<CoachStation, string> = {
13  plan: 'asking for a plan',
14  brief: 'a new instruction',
15  review: "checking Claude's result",
16  own: 'about to ship',
17}
18
19
20// The person's move for this row: found by the prompt's text, the row's id, or (newest row) this turn.
21export function userNoteFor(st: CoachState, row: Row, isNewest: boolean): CoachUserNote | undefined {
22  const all = st.rowNotes.user
23  return all[noteKey(row.text)] ?? all[row.requestId] ?? (isNewest ? Object.values(all).find(n => n.turn === st.turnIndex) : undefined)
24}
25
26export const chapterCaption = (n: CoachUserNote | undefined): string => {
27  if (!n) return CHAPTER_CAPTION.brief
28  if (n.from) return n.detail ? `moved on from ${STATION_NAME[n.from]} · ${n.detail}` : `moved on from ${STATION_NAME[n.from]}`
29  if (n.detail) return n.detail
30  if (n.caption) return n.caption
31  if (n.outer) return n.outer.split(' · ').slice(1).join(' · ') || n.outer
32  return CHAPTER_CAPTION[n.move]
33}
34
35// A chapter line opens each exchange: glyph, the move in its colour, a dim caption that never wraps, the ✓ note
36// when there is one, then a rule across the rest of the row.
37// One line above the person's message: the move in its colour, a caption, and a rule across the rest.
38// Under the person's message, right-aligned: ✓ a move worth repeating, ◐ what it could still say. It is
39// placed over the empty space the engine leaves under the bubble (absolute, bottom right), so it sits right
40// under the message and takes no room of its own.
41// "audience, goal" → "Could say who it is for and what it should achieve".
42const GAP_WORDS: Record<string, string> = {
43  question: 'the question to answer',
44  'which data': 'which data to use',
45  audience: 'who it is for',
46  goal: 'what it should achieve',
47  output: 'the form you want back',
48  deadline: 'when it is due',
49}
50export const couldAdd = (gap: string): string => {
51  const parts = gap.split(', ').map(g => GAP_WORDS[g] ?? g)
52  const list = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}` : parts[0]
53  return `Could say ${list}`
54}
55
56function feedbackRow(ui: Ui, key: string, n: CoachUserNote | undefined, isShown: boolean, isBusy: boolean) {
57  const { Box, Text } = ui
58  if (isBusy) {
59    return (
60      <Box position="absolute" bottom={0} right={0}>
61        {spinner(ui, 'Reading your prompt')}
62      </Box>
63    )
64  }
65  const feedback = n?.note ? { kind: 'good' as const, text: n.note } : n?.gap ? { kind: 'comment' as const, text: couldAdd(n.gap) } : null
66  if (!feedback) return null
67  const reveal = isShown ? {} : { display: 'none' as const, hover: { scope: key, display: 'flex' as const } }
68  return (
69    <Box position="absolute" bottom={0} right={0} flexDirection="row" gap={1} alignItems="center" {...reveal}>
70      {mark(ui, feedback.kind)}
71      <Text dimColor>{feedback.text}</Text>
72    </Box>
73  )
74}
75
76function chapterLine(ui: Ui, n: CoachUserNote | undefined, isBusy: boolean) {
77  const { Box, Text } = ui
78  const move = n?.move ?? 'brief'
79  // While the step is still being decided, the working mark stands where the step will be.
80  if (isBusy) {
81    return (
82      <Box flexDirection="row" gap={1} marginTop={1} alignItems="center">
83        <Box flexShrink={0}>{spinner(ui, 'Reading the step')}</Box>
84        {fillRule(ui)}
85      </Box>
86    )
87  }
88  return (
89    <Box flexDirection="row" gap={1} marginTop={1} alignItems="center">
90      {glyph(ui, move, { mini: true })}
91      <Box flexShrink={0}>
92        <Text color={wordColor(ui, move)}>{STATION_NAME[move]}</Text>
93      </Box>
94      <Box flexShrink={0}>
95        <Text dimColor wrap="truncate">· {chapterCaption(n)}</Text>
96      </Box>
97      {fillRule(ui)}
98    </Box>
99  )
100}
101
102// Render hooks never write, so the first unseen row of a turn is recorded from a timer.
103async function isNewest($: Dollar, id: string, kind: 'user' | 'reply', matches: boolean) {
104  const st = await $.get()
105  const turnKey = kind === 'user' ? 'userTurn' : 'replyTurn'
106  const isKnown = st.rows[kind] === id
107  // With a known newest text, the text decides; recording by id is only the fallback when there is none.
108  const hasReference = (kind === 'user' ? st.lastPrompt : st.lastAnswer) !== ''
109  const isNew = !isKnown && !hasReference && st.rows[turnKey] < st.turnIndex
110  if (isNew) {
111    $.clock.after(0, () => {
112      void $.patch('rows', r => (r[turnKey] < st.turnIndex ? { ...r, [kind]: id, [turnKey]: st.turnIndex } : r))
113    })
114  }
115  return isKnown || isNew || matches
116}
117
118// The chapter line sits above the bubble with a small gap, like the feedback's distance below it; the feedback
119// is placed over the space the engine leaves under the bubble. No width on anything that holds the row.
120const LINE_GAP_PX = 6
121export async function userLabel($: Dollar, ui: Ui, e: Row, inner: unknown) {
122  const { Box } = ui
123  const st = await $.get()
124  const matches = heads(e.text) === heads(st.lastPrompt) || st.fresh.includes(heads(e.text))
125  const isFresh = await isNewest($, e.requestId, 'user', matches)
126  const n = userNoteFor(st, e, isFresh)
127  const key = `row-${e.requestId}`
128  const isBusy = st.noteBusy !== '' && st.noteBusy === noteKey(e.text)
129  const isShown = labelMode(st.prefs.sessions) === 'all' || isFresh
130  return (
131    <Box key={key} hover={{ scope: key }} flexDirection="column">
132      {chapterLine(ui, n, isBusy)}
133      {spacer(ui, LINE_GAP_PX)}
134      {inner as never}
135      {feedbackRow(ui, key, n, isShown, isBusy)}
136    </Box>
137  )
138}
139
140// A reply is never a step: only what is worth checking in it, dim, when the analysis found something.
141export async function assistantLabel($: Dollar, ui: Ui, e: Row, inner: unknown) {
142  const { Box, Text } = ui
143  const st = await $.get()
144  const head = heads(e.text)
145  const matches = head !== '' && (st.lastAnswer.includes(head) || st.fresh.includes(head))
146  const isFresh = await isNewest($, e.requestId, 'reply', matches)
147  const check = isFresh && st.rowNotes.reply?.turn === st.turnIndex ? st.rowNotes.reply.check : []
148  if (check.length === 0) return inner as never
149  return (
150    <Box key={`row-${e.requestId}`} flexDirection="column">
151      {inner as never}
152      <Text dimColor>Worth checking: {check.join(' · ')}</Text>
153    </Box>
154  )
155}
156
hooks/ui/gallery.tsx 100 lines
1import type { Dollar } from '../actions'
2import { STATION_HEX } from '../config'
3import { type Ui, checkMark, coachMark, glyph, icon, spinner, withSurface, wordColor } from './glyph'
4
5// `/coach gallery`: every candidate drawing technique side by side, numbered, inside a transcript row,
6// so one screenshot shows which ones the surface paints the way the tests assume.
7
8type SvgFn = (p: { source: string; alt: string; width?: number; height?: number }) => never
9
10const rule = (w: number, h: number, y: number) =>
11  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}"><rect x="0" y="${y}" width="${w}" height="1" fill="#8A8782" fill-opacity="0.45"/></svg>`
12const ruleNoSize = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 1" preserveAspectRatio="none"><rect x="0" y="0" width="100" height="1" fill="#8A8782" fill-opacity="0.45"/></svg>'
13const DASHES = '─'.repeat(200)
14
15function row(ui: Ui, label: string, body: unknown) {
16  const { Box, Text } = ui
17  return (
18    <Box flexDirection="row" gap={1} marginTop={1}>
19      <Box width={4} flexShrink={0}>
20        <Text dimColor>{label}</Text>
21      </Box>
22      <Box flexDirection="row" flexGrow={1} flexShrink={1} gap={1} alignItems="center">
23        {body as never}
24      </Box>
25    </Box>
26  )
27}
28
29export async function renderGallery($: Dollar, e: Parameters<Dollar['ui']['resolve']>[0]) {
30  const ui = withSurface($.ui.resolve(e), (e as { surface: string }).surface)
31  const { Box, Text, Button, Markdown } = ui as Ui & { Markdown: (p: { text: string; dimColor?: boolean }) => never }
32  const Svg = (ui as unknown as { Svg?: SvgFn }).Svg
33  const svg = (source: string, width?: number, height?: number) =>
34    Svg ? <Svg source={source} alt="" width={width} height={height} /> : <Text dimColor>(no Svg here)</Text>
35  const word = (
36    <>
37      {glyph(ui, 'brief', { mini: true })}
38      <Text color={wordColor(ui, 'brief')}>Brief</Text>
39      <Box flexShrink={0}>
40        <Text dimColor>· a new instruction</Text>
41      </Box>
42    </>
43  )
44  return (
45    <Box flexDirection="column">
46      <Text>Coach · gallery. Which numbers look right?</Text>
47
48      <Text dimColor>Rules after a chapter line</Text>
49      {row(ui, 'R1', <>{word}<Box flexGrow={1} flexShrink={1} overflow="hidden" height={1}><Text dimColor>{DASHES}</Text></Box></>)}
50      {row(ui, 'R2', <>{word}<Box flexGrow={1} flexShrink={1}><Text dimColor wrap="truncate">{DASHES}</Text></Box></>)}
51      {row(ui, 'R3', <>{word}<Box flexGrow={1} flexShrink={1} overflow="hidden" height={1}>{svg(rule(2400, 1, 0), 2400, 1)}</Box></>)}
52      {row(ui, 'R4', <>{word}<Box flexGrow={1} flexShrink={1}>{svg(ruleNoSize, undefined, 1)}</Box></>)}
53      {row(ui, 'R5', <>{word}{svg(rule(400, 1, 0), 400, 1)}</>)}
54      {row(ui, 'R6', <>{word}<Box flexGrow={1} flexShrink={1}><Markdown text="---" dimColor /></Box></>)}
55      {row(ui, 'R7', <>{word}<Box flexGrow={1} flexShrink={1} borderStyle="single" borderDimColor height={1} /></>)}
56      {row(ui, 'R8', <>{word}<Text dimColor>{'─'.repeat(40)}</Text></>)}
57
58      <Text dimColor>Separators across the band width</Text>
59      {row(ui, 'S1', <Box flexGrow={1} height={1} overflow="hidden">{svg(rule(2400, 9, 4), 2400, 9)}</Box>)}
60      {row(ui, 'S2', <Box flexGrow={1}>{svg(ruleNoSize, undefined, 1)}</Box>)}
61      {row(ui, 'S3', <Box flexGrow={1}><Markdown text="---" dimColor /></Box>)}
62      {row(ui, 'S4', <Box flexGrow={1} overflow="hidden" height={1}><Text dimColor>{DASHES}</Text></Box>)}
63
64      <Text dimColor>Check marks</Text>
65      {row(ui, 'K1', <>{checkMark(ui)}<Text dimColor>Audience is named</Text></>)}
66      {row(ui, 'K2', <Text dimColor>✓ Audience is named</Text>)}
67      {row(ui, 'K3', <Text dimColor>✔ Audience is named</Text>)}
68
69      <Text dimColor>Suggestion styles (which looks best?)</Text>
70      {row(ui, 'B1', <>{['Check the 12%', 'Compare delivery terms'].map(l => <Button key={`b1-${l}`} label={l} onPress={() => {}} />)}</>)}
71      {row(ui, 'B2', <>{[coachMark(ui), ...['Check the 12%', 'Compare delivery terms'].map(l => <Button key={`b2-${l}`} variant="secondary" label={l} onPress={() => {}} />)]}</>)}
72      {row(ui, 'B3', <>{[coachMark(ui), ...['Check the 12%', 'Compare delivery terms'].map(l => <Button key={`b3-${l}`} variant="primary" label={l} onPress={() => {}} />)]}</>)}
73      {row(ui, 'B4', <>{[coachMark(ui), ...['Check the 12%', 'Compare delivery terms'].map(l => <Button key={`b4-${l}`} plain label={`${l} ↗`} onPress={() => {}} />)]}</>)}
74      {row(ui, 'B5', <>{['Check the 12%', 'Compare delivery terms'].map(l => <Button key={`b5-${l}`} plain dimColor label={`→ ${l}`} onPress={() => {}} />)}</>)}
75
76      <Text dimColor>Icons beside the step line, and the settings control</Text>
77      {row(ui, 'I1', <>{icon(ui, 'mark') as never}<Text>Opus 5.5 · planning</Text>{icon(ui, 'model') as never}<Text dimColor>Sonnet 5.5</Text>{icon(ui, 'ask') as never}<Text dimColor>Prompt me</Text>{icon(ui, 'auto') as never}<Text dimColor>Auto-select</Text>{icon(ui, 'off') as never}<Text dimColor>Only show</Text></>)}
78      {row(ui, 'I2', <>{['⋯', '•••', '☰', 'Settings'].map(l => <Button key={`i2-${l}`} plain dimColor label={l} onPress={() => {}} />)}</>)}
79
80      <Text dimColor>Working spinner (should turn and breathe)</Text>
81      {row(ui, 'W1', spinner(ui, 'Finding next steps'))}
82
83      <Text dimColor>Station colours (word and bar)</Text>
84      {row(
85        ui,
86        'C1',
87        <>
88          {(['plan', 'brief', 'review', 'own'] as const).map(s => (
89            <Box flexDirection="row" gap={1} flexShrink={0}>
90              {glyph(ui, s, { mini: true })}
91              <Text color={wordColor(ui, s)}>{s}</Text>
92              <Text color={STATION_HEX[s]}>bar hex</Text>
93            </Box>
94          ))}
95        </>,
96      )}
97    </Box>
98  )
99}
100