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.

<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.
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.
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 message | Step |
|---|---|
| "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 instruction | Brief · 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.
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….✓ 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….[audience] and [deadline]. Tab takes it.✦, 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.⋯, 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.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.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./coach): the four steps, what the conversation is about, a Stuck? chat, settings and attribution.The coach recommends the model a prompt suits, and how hard it should think (effort: low, medium, high, xhigh or max):
| Model | Suits |
|---|---|
| Haiku 5.5 | Quick, small or mechanical work: a lookup, a rename, a short reply |
| Sonnet 5.5 | Everyday work with a clear instruction: a draft, routine code, carrying out an agreed plan |
| Opus 5.5 | Work that needs judgment: planning, open questions, reviewing important work, tricky debugging |
| Fable 5.1 | The hardest work: long multi-step tasks, large changes, strategy with many moving parts |
⋯, marked ✦.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.⋯./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.
| Command | Does |
|---|---|
/coach help | List every command |
/coach | Open the pane |
/coach on · /coach off | Turn the coach on or off, for every chat |
/coach preview on · off | Show every finding from the first session (for testing and demos); plain /coach preview toggles |
/coach model haiku · haiku-5.5 · sonnet · opus | Pick the model that writes the feedback |
/coach model | Show which model runs now |
/coach recommend on · off | Show the model suggestion beside Coach (on by default) |
/coach switch ask · auto · off | When a prompt suits another model: prompt me (default), auto-select, or only show |
/coach why | The last analysis: which calls ran, what came back and why anything was dropped |
/coach settings | Open the pane at the settings |
/coach ask | Open the Stuck? chat |
/coach about | What the coach reads and stores |
/coach gallery | A drawing test for the surface you are on |
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:
| Model | Input | Output | Cache write | Cache 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.
In the pane (/coach):
/coach preview on.Settings are shared by every chat and read again with each message.
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.
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.
Based on the AI Fluency Framework by Dakan, Feller and Anthropic, CC BY-NC-SA 4.0.
hooks/register.tsx 369 lines1import { 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}
369hooks/actions.ts 291 lines1import 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}
291hooks/initial.ts 64 lines1import 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}
64hooks/ctx.ts 19 lines1import 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}
19hooks/config.ts 221 lines1import 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 }
221hooks/trace.ts 11 lines1import 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}
11hooks/flow.ts 365 lines1import 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}
365hooks/meta.ts 67 lines1import { 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}
67hooks/ui/band.tsx 248 lines1import 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}
248hooks/ui/glyph.tsx 230 lines1import 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}
230hooks/ui/labels.tsx 156 lines1import 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}
156hooks/ui/gallery.tsx 100 lines1import 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