Signal-only replies in Claude Code: the model writes the substance (verdict, decision, done, read), a themed renderer draws the form — folded details, hidden…

Claude writes the substance. The mod draws the form.
Claude Code replies are messing with everyone's brains: too much noise for a small portion of signal, a wall of text impossible to scan.
Claude Code just released mods (function hooks, experimental): a way to redraw the reply and cut the cognitive load. This is one.
A Claude Code mod that turns every reply into a card you can read in three seconds: the verdict first, the one decision you must take with its default, what was done with its proof, what you must read — and everything else folded away. Observer reports disappear behind a small green check. Narration between tool calls folds into one dim line. A theoretical cost sits at the foot of the card.

Built on Claude Code function hooks — TypeScript that runs inside Claude Code's own process and redraws the transcript. Early access: it needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 and Claude Code 2.1.273 or later, in the terminal.
Before After (theme: gmorel)
⏺ ▎ 👉 For you ⏺ ┃ Decide: patch the copy so quoted ✓
▎ 🟡 Decide: do I patch the copy? ┃ fences draw too? ─ default: leave it
▎ If you say nothing: nothing. ┃ [ yes ] [ no ]
▎ 🟢 Copy in ~/.claude/skills/… ┃ Two ui.render hooks would fire if the
▎ 🟢 12 tests pass. ┃ CI badge had stayed red.
▎ ⚠️ Two ui.render hooks if the badge… ✓ Copy in ~/.claude/skills/claude-mermaid
▎ ▫️ renamed config.yml to config.yaml. ✓ 12 tests pass
▎ ℹ️ The « ❯ … » seen on screen is a… · renamed config.yml to config.yaml
▎ ℹ️ Clone and node_modules are throwaway. · 2 skippable · [ show ]
⏺ Ran 5 stop hooks
⎿ Stop hook error: final reply does not (no Stop hook: the form is not a rule any more)
match the card format — rewrite it …
Three things a prompt cannot fix, measured on one week of one user's sessions:
The substance stays with the model: which marker a fact deserves, "verified" only when checked, one decision with its default. Those are the tokens worth spending.
Function hooks on, once, in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
Then one of three:
# as a plugin, from this marketplace
claude plugin marketplace add gmorel/claude-code-signal-only-ui
claude plugin install signal-only-ui@claude-code-signal-only-ui
# for one session, from a clone
git clone https://github.com/gmorel/claude-code-signal-only-ui
cd claude-code-signal-only-ui && claude --plugin-dir .
# as your own, editable copy: a clone (or a symlink to it) under ~/.claude/skills/ auto-loads as signal-only-ui@skills-dir
ln -s "$PWD" ~/.claude/skills/signal-only-ui
Install a released tag rather than main, and do not symlink a clone you are actively editing — every session start loads the working tree as it is at that moment.
Then ask Claude anything. A reply written with the six markers (🟢 done · 🟡 decide · 🔴 blocked · ⚠️ read · ▫️ note · ℹ️ skippable) is drawn as a card. A reply without markers is left alone. Teach the markers to your model in CLAUDE.md or an output style; the mod ships none, the form is yours.

The GIF is a real Claude Code session with the API mocked, recorded by npm run demo — not a mock-up.
| theme | in one line | card | question |
|---|---|---|---|
gmorel | Answer first: the verdict in a box bordered by its own marker colour, then only the counters that are not zero, then plain marker lines with lists indented under them; ℹ️ fold and API-price footer, and 3–4 options get numbered rows with estimate, risk and cost, the recommended one marked by a thin yellow rule rather than a fill. | ![]() | ![]() |
receipt | Framed till roll, ASCII, dotted leaders to [OK] [!!] [??]; the question as an order form. | ![]() | ![]() |
kanban | Sticky notes: empty columns dropped, widths follow the text, a darker header band and an offset shadow on every note; chooser uses OPTIONS / DECIDED / WHY I ASK. | ![]() | ![]() |
| yours | Design on a canvas, transcribe in one file — skills/theme-builder, artboards in design/. | /signal theme <id> |
Every still in docs/ is drawn by npm run capture from a real scripted tmux session (Chrome required). Nothing is a mockup.
| command | what it does | |
|---|---|---|
/signal | current theme, observer mark, folding and audit, as one line | |
/signal theme <id> | switch theme, redraw the cards on screen, remember it | |
/signal themes | the installed themes and their taglines | |
| `/signal observer on\ | off` | hide observer reports and draw the witness check |
| `/signal fold on\ | off` | fold narration between tool calls and task/peer notifications |
| `/signal audit on\ | off` | after each turn, compare the raw reply with what the theme drew and report anything dropped (off by default; spends a model call) |
/signal reset | restore theme, observer mark, folding and audit to the plugin defaults | |
/signal observers | the hidden observer reports of this session, newest first; the ⚑ on a card counts reports hidden since the previous card was drawn | |
/signal agents | what $.agent.list() calls the agents of this session — the names to put in the observerTypes option |
Buttons on a card fold and unfold sections; a decision drawn with options puts the option you press into the prompt box — you still press Enter. Nothing is sent on your behalf.
Set in ~/.claude/settings.json under pluginConfigs, keyed by the plugin's full name — signal-only-ui@claude-code-signal-only-ui when installed from the marketplace, signal-only-ui@skills-dir for a copy under ~/.claude/skills/:
{ "pluginConfigs": { "signal-only-ui@skills-dir": { "options": { "theme": "gmorel", "observerTypes": "claim-sentinel" } } } }
| option | default | meaning |
|---|---|---|
theme | gmorel | which renderer draws the card (/signal theme overrides it for the session and remembers) |
observerMark | true | hide observer reports, draw the witness ✓ while an observer runs |
observerTypes | claim-sentinel | comma-separated agent types counted as observers (/signal agents lists the names) |
foldNarration | true | fold assistant text followed by a tool call in the same turn into one dim line |
foldNotifications | true | fold task, peer and scheduled-trigger notifications into one dim line |
audit | false | after every turn, hand the raw reply and the drawn card to a claude -p --model haiku child and log what it says went missing; costs one model call and about 30 s per turn |
prices | built-in | API list prices for the cost footer, USD per million tokens, family:input/output — e.g. opus:15/75,sonnet:3/15 |
hooks/card.ts reads a reply as data: markers, the bold verdict, the decision and its default clause, continuation lines, fenced code. Cards with or without the > wrapper are accepted.hooks/register.ts hooks ui.render on AssistantMessage (draw the card through the theme) and UserMessage (hide origin.kind === 'observer', fold task and peer notifications), tracks turns and tool calls to fold narration, refreshes the witness from $.agent.list() at turn boundaries and mid-turn when stale, and prices the turn's usage for the footer.hooks/themes/<id>.ts is a pure function from the parsed card to a Box/Text/Button/Code tree. Themes never touch the engine.hooks/audit.ts (only when audit is on) flattens the drawn tree back to plain text and compares it with the raw reply through a claude -p --model haiku child started by $.process.run. The child runs with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=0, so it cannot load this plugin and recurse. The turn never waits for it: findings arrive later as $.ui.log lines.claude -p, other machines and JSONL readers see the plain card.claude update, run /plugin-types in the repo and read the diff.receipt is the most monochrome theme and the best fallback.Svg, and a Desktop build older than Claude Code 2.1.273 runs no function hook at all, so an SVG theme had no surface to draw on: the one that existed was removed on 2026-09-17 and stays recoverable at 297fdbe.prices option. Cache reads are priced at 10% of input and cache writes at 125%, per Anthropic's published cache multipliers.$.agent.list, $.command.register, $.prompt.fill, $.session.cwd, $.store, $.ui.* and — only while audit is on — $.process.run, which starts one claude -p child and nothing else. It opens no socket and reads no file — claude plugin validate .claude-plugin/plugin.json prints the list (claude plugin validate . checks the marketplace manifest, not the function-hooks call list).bun test tests/unit # parser, folding, themes, pricing — no dependencies
bunx -p typescript tsc -p . # types against .claude/types (run /plugin-types in the repo first)
npm run test:e2e # real Claude Code in tmux, API mocked (needs tmux and claude)
npm run capture # redraw docs/*.png from a real session (needs Chrome); --theme <id> for one
npm run demo # record docs/demo.gif from a real session (needs asciinema, agg, tmux)
claude plugin validate .claude-plugin/plugin.json # the static scan Claude Code runs before loading a mod
test:e2e, capture and demo run from a scratch mirror of the repository under $TMPDIR, with a throwaway CLAUDE_CONFIG_DIR: no node_modules is ever written next to the source, and no test or recording opens your own ~/.claude.
The e2e harness (a real interactive Claude Code in a tmux pane, replies scripted by aimock) follows the one in galElmalah/claude-mods, whose idioms this mod learned from — see LICENSE.upstream-claude-mods. The themes were designed on a Claude Design canvas; the artboards are in design/. MIT, © 2026 Guillaume Morel.
hooks/register.ts 453 lines1import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
2import { parseCard } from './card.ts'
3import {
4 foldsNotification,
5 isObserverOrigin,
6 notificationLooksFailed,
7 narrationFallbackOnBlock,
8 narrationMarkedOf,
9 narrationOf,
10 narrationSummary,
11 narrationTextsOfChunk,
12 notificationSummary,
13 reportKey,
14 stableIdOf,
15} from './fold.ts'
16import { auditArgv, auditRunInit, parseAuditAnswer, renderedText } from './audit.ts'
17import { computeUsage, parsePrices, type UsageSummary } from './cost.ts'
18import { themeOf, themes } from './themes/index.ts'
19import type { CardState, ThemeCtx, Witness } from './theme.ts'
20
21// The model writes the substance as marker lines; this draws the form. A card goes through the configured theme, narration and notifications fold to one dim line, an observer's report is hidden and leaves a witness check behind. Display only: the stored transcript is never rewritten.
22
23const COMMAND = 'signal'
24const PREFS_KEY = 'prefs'
25/** the transcript's gutter and margins, off the width a card may draw into */
26const GUTTER = 4
27/** narrow enough that a card still reads; below it the terminal is unusable anyway */
28const MIN_COLUMNS = 24
29/** how many hidden reports `/signal observers` lists */
30const LISTED = 20
31/** the session's caps: a transcript is long and only the drawn messages matter */
32const KEPT_REPORTS = 200
33const KEPT_STATES = 400
34
35type Prefs = { theme: string; observerMark: boolean; foldNarration: boolean; foldNotifications: boolean; audit: boolean }
36
37const DEFAULT_PREFS: Prefs = { theme: 'gmorel', observerMark: true, foldNarration: true, foldNotifications: true, audit: false }
38const DEFAULT_OBSERVER_TYPES = ['claim-sentinel']
39
40/** the two message drawings this module hooks; both resolve an element table */
41type MessageRender = RenderInput<'AssistantMessage'> | RenderInput<'UserMessage'>
42
43/** userConfig as `register(on, options)` received it: the defaults the store layers over */
44let base: Prefs = DEFAULT_PREFS
45let prefs: Prefs = DEFAULT_PREFS
46let observerTypes: readonly string[] = DEFAULT_OBSERVER_TYPES
47
48/** one fold state per drawn message */
49const states = new Map<string, CardState>()
50/** blocks drawn in the running turn: id → turnId; only the current turn's ids are marked narration */
51let waiting = new Map<string, string>()
52/** the turn a message id was first drawn in, so a redraw of an old block is not narration */
53const drawnInTurn = new Map<string, string>()
54/** blocks a tool call turned into narration */
55const narrated = new Set<string>()
56/** texts turn.step saw before a tool chunk; chunks carry no requestId matching the render hook */
57const narrationByText = new Set<string>()
58/** tool.call ran with nothing in waiting yet: the next non-card block is decided when a later one draws */
59let narrationPending = false
60let narrationCandidate: string | undefined
61/** the running main-loop turn, so a redraw of old blocks while idle marks nothing */
62let turnId: string | undefined
63const witness: Witness = { observerRunning: false, hiddenReports: 0 }
64/** the observer reports hidden this session, oldest first, deduped on text */
65const reports = new Map<string, string>()
66/** report keys counted since the last card was drawn, so a redraw does not count twice */
67let turnReports = new Set<string>()
68/** the last assistant card drawn; kept across turn.complete for between-turn witness redraws */
69let lastDrawnCardId: string | undefined
70/** whether a card has been drawn in the running turn yet */
71let cardDrawnThisTurn = false
72/** USD per million tokens, keyed by model family; overridable via userConfig `prices` */
73let prices = parsePrices()
74/** the last assistant message drawn in the running turn: `turn.complete`'s usage attaches here */
75let lastAssistantId: string | undefined
76/** the last card of the running turn, for the post-turn haiku audit */
77let lastCardForAudit: { raw: string; rendered: string } | undefined
78let auditInFlight = false
79/** per-message usage from the last completed turn, for the cost footer */
80const usageById = new Map<string, UsageSummary>()
81/** when `refresh` last called `$.agent.list()` */
82let witnessRefreshedAt = 0
83/** at most one `$.agent.list()` per turn step's render passes */
84let witnessRefreshedThisStep = false
85const WITNESS_STALE_MS = 2000
86
87const isPrefs = (value: unknown): value is Partial<Prefs> => typeof value === 'object' && value !== null
88
89const boolOf = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback)
90
91const onOff = (word: string): boolean | undefined => (word === 'on' || word === 'true' ? true : word === 'off' || word === 'false' ? false : undefined)
92
93const trim = (store: { size: number; keys: () => IterableIterator<string>; delete: (key: string) => boolean }, max: number) => {
94 while (store.size > max) {
95 const oldest = store.keys().next().value
96 if (oldest === undefined) return
97 store.delete(oldest)
98 }
99}
100
101const stateOf = (id: string): CardState => {
102 const known = states.get(id)
103 if (known) return known
104 const fresh: CardState = { open: {}, answered: null }
105 states.set(id, fresh)
106 trim(states, KEPT_STATES)
107 return fresh
108}
109
110/** one dim line and the way in */
111const summary = ($: EngineInterface, e: MessageRender, key: string, text: string, onPress: () => void): RenderElement => {
112 const { Box, Text, Button } = $.ui.resolve(e)
113 return Box({
114 key,
115 flexDirection: 'row',
116 gap: 1,
117 children: [Text({ dimColor: true, wrap: 'truncate', children: text }), Button({ key: `${key}:show`, label: 'show', dimColor: true, onPress })],
118 })
119}
120
121/** the block as the engine draws it, and the way back */
122const opened = ($: EngineInterface, e: MessageRender, body: RenderElement, key: string, onPress: () => void): RenderElement => {
123 const { Box, Button } = $.ui.resolve(e)
124 return Box({ key, flexDirection: 'column', children: [body, Button({ key: `${key}:hide`, label: 'hide', dimColor: true, onPress })] })
125}
126
127const ctxOf = ($: EngineInterface, e: RenderInput<'AssistantMessage'>, id: string, state: CardState): ThemeCtx => ({
128 el: $.ui.resolve(e),
129 columns: Math.max(MIN_COLUMNS, (e.viewport?.columns ?? 80) - GUTTER),
130 surface: e.surface,
131 state,
132 witness,
133 id,
134 usage: usageById.get(id),
135 actions: {
136 toggle: section => {
137 state.open[section] = state.open[section] !== true
138 $.ui.invalidate('ui.render')
139 },
140 fill: text => {
141 // the prompt box, never the send: nothing leaves on the reader's behalf
142 state.answered = text
143 $.ui.invalidate('ui.render')
144 void $.prompt
145 .fill({ text })
146 .then(({ isFilled }) => {
147 if (isFilled) return
148 // a dialog held the keys: the card must not claim an answer the box never took
149 state.answered = null
150 $.ui.log('signal: the prompt box is busy · the answer was not filled in')
151 $.ui.invalidate('ui.render')
152 })
153 .catch(err => $.ui.log(`signal: fill failed: ${err}`))
154 },
155 },
156})
157
158// `$.agent.list()` is a host call: memoise per turn step so a render pass with many cards asks once.
159const refresh = async ($: EngineInterface) => {
160 witnessRefreshedAt = Date.now()
161 witnessRefreshedThisStep = true
162 const agents = await $.agent.list().catch(() => [])
163 const running = agents.some(agent => agent.status === 'running' && observerTypes.includes(agent.type))
164 if (running === witness.observerRunning) return
165 witness.observerRunning = running
166 $.ui.invalidate('ui.render')
167}
168
169const ids = () => Object.keys(themes)
170
171const status = () =>
172 `signal: theme ${prefs.theme} · observer ${prefs.observerMark ? 'on' : 'off'} · fold ${prefs.foldNarration || prefs.foldNotifications ? 'on' : 'off'} · audit ${prefs.audit ? 'on' : 'off'} · ${reports.size} report${reports.size === 1 ? '' : 's'} hidden · ${witness.observerRunning ? 'an observer is running ✓' : 'no observer running'}`
173
174export const register: Register = (on, options) => {
175 witness.hiddenReports = 0
176 witness.observerRunning = false
177 turnReports = new Set()
178 lastDrawnCardId = undefined
179 cardDrawnThisTurn = false
180 base = {
181 theme: typeof options.theme === 'string' ? options.theme : DEFAULT_PREFS.theme,
182 observerMark: boolOf(options.observerMark, DEFAULT_PREFS.observerMark),
183 foldNarration: boolOf(options.foldNarration, DEFAULT_PREFS.foldNarration),
184 foldNotifications: boolOf(options.foldNotifications, DEFAULT_PREFS.foldNotifications),
185 audit: boolOf(options.audit, DEFAULT_PREFS.audit),
186 }
187 prefs = base
188 // SPEC §3.3: which agent types count as an observer for the witness check.
189 // userConfig has no list type, so plugin.json declares a comma-separated string; a raw option may still be an array
190 const configured = options.observerTypes
191 const listed = Array.isArray(configured)
192 ? configured.filter((type): type is string => typeof type === 'string')
193 : typeof configured === 'string'
194 ? configured.split(',').map(type => type.trim()).filter(type => type !== '')
195 : []
196 observerTypes = listed.length > 0 ? listed : DEFAULT_OBSERVER_TYPES
197 prices = parsePrices(typeof options.prices === 'string' ? options.prices : undefined)
198
199 on('session.start', async ($, e, next) => {
200 const r = await next(e)
201 // the store is the reader's own choice and wins over userConfig, key by key
202 const saved = await $.store.get(PREFS_KEY).catch(() => undefined)
203 if (isPrefs(saved)) {
204 prefs = {
205 theme: typeof saved.theme === 'string' ? saved.theme : base.theme,
206 observerMark: boolOf(saved.observerMark, base.observerMark),
207 foldNarration: boolOf(saved.foldNarration, base.foldNarration),
208 foldNotifications: boolOf(saved.foldNotifications, base.foldNotifications),
209 audit: boolOf(saved.audit, base.audit),
210 }
211 }
212 await $.command
213 .register({
214 name: COMMAND,
215 description: 'The reply card: theme <id>, themes, observer on|off, fold on|off, audit on|off, reset, observers, agents (signal-only-ui)',
216 argumentHint: '[theme <id> | themes | observer on|off | fold on|off | audit on|off | reset | observers | agents]',
217 // the card is read while a turn streams, so its switches must answer then too
218 immediate: true,
219 })
220 .catch(err => $.ui.log(`signal: /${COMMAND} not registered: ${err}`))
221 return r
222 })
223
224 // `turn.start` is the main loop's alone: a subagent's run raises none, so there is no agentId to check here.
225 on('turn.start', async ($, e, next) => {
226 const r = await next(e)
227 turnId = e.turnId
228 waiting = new Map()
229 narrationByText.clear()
230 narrationPending = false
231 narrationCandidate = undefined
232 cardDrawnThisTurn = false
233 lastAssistantId = undefined
234 lastCardForAudit = undefined
235 witnessRefreshedThisStep = false
236 await refresh($)
237 return r
238 })
239
240 // Narration is text-then-tool in one turn. TurnStepChunk has block index but no requestId, so we
241 // watch turn.step (pass chunks through unchanged) and match narration by text at render time.
242 // When tool.call still beats the render hook, narrationPending + narrationFallbackOnBlock decide
243 // at the later block so the final block of a turn is never marked.
244 on('turn.step', async function* ($, e, next) {
245 witnessRefreshedThisStep = false
246 let blocks = new Map<number, string>()
247 for await (const chunk of next(e)) {
248 if (!e.agentId) {
249 const step = narrationTextsOfChunk(blocks, chunk)
250 blocks = step.blocks
251 if (step.texts.length > 0) {
252 for (const text of step.texts) {
253 narrationByText.add(text)
254 narrated.add(stableIdOf(undefined, text))
255 }
256 trim(narrated, KEPT_STATES)
257 if (prefs.foldNarration) $.ui.invalidate('ui.render')
258 }
259 }
260 yield chunk
261 }
262 })
263
264 on('turn.complete', async ($, e, next) => {
265 const r = await next(e)
266 // a subagent's turn ends inside the session's own and is not the main loop's
267 if (e.agentId) return r
268 // usage rides on the last assistant block this turn drew — the same id `waiting` held
269 if (e.usage && lastAssistantId) {
270 usageById.set(lastAssistantId, computeUsage(e.usage, prices))
271 trim(usageById, KEPT_STATES)
272 $.ui.invalidate('ui.render')
273 }
274 turnId = undefined
275 lastAssistantId = undefined
276 // the last block of a turn has no tool call after it, so what is still waiting or pending is never narration
277 waiting.clear()
278 narrationPending = false
279 narrationCandidate = undefined
280 if (prefs.audit && lastCardForAudit && !auditInFlight) {
281 const card = lastCardForAudit
282 lastCardForAudit = undefined
283 auditInFlight = true
284 void (async () => {
285 try {
286 const dir = await $.session.cwd().catch(() => '/tmp')
287 const { exitCode, stdout } = await $.process.run(auditArgv(card.raw, card.rendered), auditRunInit(dir))
288 if (exitCode !== 0 || stdout.trim() === '') return
289 const findings = parseAuditAnswer(stdout)
290 for (const finding of findings) $.ui.log(`signal audit: dropped — ${finding}`)
291 if (findings.length > 0) $.ui.invalidate('ui.render')
292 } catch {
293 // audit must never disturb the session
294 } finally {
295 auditInFlight = false
296 }
297 })()
298 }
299 await refresh($)
300 return r
301 })
302
303 // The narration rule: a block followed by a tool call of the same main-loop turn was the model talking while it worked.
304 on('tool.call', async ($, e, next) => {
305 if (e.agentId) return next(e)
306 let touched = false
307 if (waiting.size > 0) {
308 for (const [id, tid] of waiting) {
309 if (tid === turnId) narrated.add(id)
310 }
311 waiting.clear()
312 touched = true
313 } else narrationPending = true
314 trim(narrated, KEPT_STATES)
315 if (touched && prefs.foldNarration) $.ui.invalidate('ui.render')
316 return next(e)
317 })
318
319 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
320 const id = stableIdOf(e.requestId, e.props.text)
321 const model = parseCard(e.props.text)
322 let marked = narrationMarkedOf(id, e.props.text, narrated, narrationByText)
323 // only while a turn runs: a resize redraws the whole transcript, and old blocks are not this turn's narration
324 if (turnId && !marked) {
325 if (narrationPending) {
326 const fallback = narrationFallbackOnBlock(narrationCandidate, id, model !== null)
327 narrationCandidate = fallback.candidate
328 for (const confirmed of fallback.confirmed) narrated.add(confirmed)
329 if (fallback.confirmed.length > 0) {
330 trim(narrated, KEPT_STATES)
331 marked = narrationMarkedOf(id, e.props.text, narrated, narrationByText)
332 }
333 }
334 if (drawnInTurn.get(id) === undefined) drawnInTurn.set(id, turnId)
335 if (drawnInTurn.get(id) === turnId) {
336 waiting.set(id, turnId)
337 lastAssistantId = id
338 }
339 if (!witnessRefreshedThisStep && Date.now() - witnessRefreshedAt > WITNESS_STALE_MS) await refresh($)
340 }
341 // read without creating: most blocks never fold, and the map is the session's
342 const how = narrationOf({ fold: prefs.foldNarration, marked, open: states.get(id)?.open.narration === true, isCard: model !== null })
343 const press = () => {
344 const state = stateOf(id)
345 state.open.narration = state.open.narration !== true
346 $.ui.invalidate('ui.render')
347 }
348 if (how === 'folded') return summary($, e, `narration:${id}`, narrationSummary(e.props.text), press)
349 if (model === null) return prefs.foldNarration && marked ? opened($, e, await next(e), `narration:${id}`, press) : next(e)
350 const firstCardThisTurn = turnId !== undefined && !cardDrawnThisTurn
351 if (turnId !== undefined) {
352 lastDrawnCardId = id
353 cardDrawnThisTurn = true
354 }
355 const drawn = themeOf(prefs.theme).render(model, ctxOf($, e, id, stateOf(id)))
356 if (prefs.audit && turnId !== undefined) lastCardForAudit = { raw: e.props.text, rendered: renderedText(drawn) }
357 if (firstCardThisTurn) {
358 witness.hiddenReports = 0
359 turnReports = new Set()
360 }
361 return drawn
362 })
363
364 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
365 const kind = e.props.origin.kind
366 if (prefs.observerMark && isObserverOrigin(kind)) {
367 const key = reportKey(e.props.text)
368 if (!turnReports.has(key)) {
369 turnReports.add(key)
370 witness.hiddenReports++
371 if (lastDrawnCardId && (!turnId || cardDrawnThisTurn)) $.ui.invalidate('ui.render')
372 }
373 if (!reports.has(key)) {
374 reports.set(key, e.props.text)
375 trim(reports, KEPT_REPORTS)
376 }
377 // hidden, not dropped: `/signal observers` still reads them back
378 return $.ui.resolve(e).Box({ display: 'none' })
379 }
380 if (!prefs.foldNotifications || !foldsNotification(kind)) return next(e)
381 if (notificationLooksFailed(e.props.text)) return next(e)
382 const id = stableIdOf(e.requestId, e.props.text)
383 const state = stateOf(id)
384 // one fold, the same word as a narration's: `[show]` means the block whole
385 const press = () => {
386 state.open.narration = state.open.narration !== true
387 $.ui.invalidate('ui.render')
388 }
389 return state.open.narration === true
390 ? opened($, e, await next(e), `notice:${id}`, press)
391 : summary($, e, `notice:${id}`, notificationSummary(kind), press)
392 })
393
394 on('command.run', { command: COMMAND }, async ($, e) => {
395 const [word = '', value = ''] = e.args.trim().split(/\s+/)
396 const save = async () => {
397 await $.store.set(PREFS_KEY, prefs).catch(err => $.ui.log(`signal: store write failed: ${err}`))
398 $.ui.invalidate('ui.render')
399 }
400 if (word === '') return { text: status() }
401 if (word === 'themes') return { text: Object.values(themes).map(theme => `${theme.id === prefs.theme ? '·' : ' '} ${theme.id} — ${theme.tagline}`).join('\n') }
402 if (word === 'theme') {
403 if (value === '') return { text: `signal: /${COMMAND} theme <${ids().join('|')}> · now ${prefs.theme}` }
404 const picked = themes[value]
405 if (!picked) return { text: `signal: no theme ${value} · ${ids().join(', ')}` }
406 prefs = { ...prefs, theme: value }
407 await save()
408 return { text: `signal: theme ${value} · ${picked.tagline}` }
409 }
410 if (word === 'observer') {
411 const flag = onOff(value)
412 prefs = { ...prefs, observerMark: flag ?? !prefs.observerMark }
413 await save()
414 return { text: `signal: observer ${prefs.observerMark ? 'on · reports hidden, the card carries the witness check' : 'off · reports drawn as they come'}` }
415 }
416 if (word === 'fold') {
417 const flag = onOff(value)
418 const next = flag ?? !(prefs.foldNarration || prefs.foldNotifications)
419 // one word for both folds: the reader asked for quiet, not for a matrix of switches
420 prefs = { ...prefs, foldNarration: next, foldNotifications: next }
421 await save()
422 return { text: `signal: fold ${next ? 'on · narration and notifications are one dim line each' : 'off · every block drawn whole'}` }
423 }
424 if (word === 'audit') {
425 const flag = onOff(value)
426 prefs = { ...prefs, audit: flag ?? !prefs.audit }
427 await save()
428 return {
429 text: `signal: audit ${prefs.audit ? 'on · every card is compared with a haiku claude -p (≈30 s, costs tokens)' : 'off'}`,
430 }
431 }
432 if (word === 'reset') {
433 prefs = { ...base }
434 await save()
435 return { text: `signal: reset · ${status()}` }
436 }
437 if (word === 'agents') {
438 // ground truth for observerTypes: what $.agent.list() calls the agents of this session
439 const agents = await $.agent.list().catch(() => [])
440 if (agents.length === 0) return { text: 'signal: no agent listed for this session' }
441 const rows = agents.map(agent => `${observerTypes.includes(agent.type) ? '*' : ' '} ${agent.type} · ${agent.status} · ${agent.id}`)
442 return { text: [`signal: ${agents.length} agent${agents.length === 1 ? '' : 's'} · * = counted as observer (${observerTypes.join(', ')})`, ...rows].join('\n') }
443 }
444 if (word === 'observers') {
445 if (reports.size === 0) return { text: 'signal: no observer report hidden this session' }
446 const listed = [...reports.values()].reverse().slice(0, LISTED)
447 const head = `signal: ${reports.size} observer report${reports.size === 1 ? '' : 's'} hidden this session · newest first${reports.size > LISTED ? ` · ${LISTED} shown` : ''}`
448 return { text: [head, ...listed.map(text => `\n— ${text.trim()}`)].join('\n') }
449 }
450 return { text: `${status()} · /${COMMAND} theme <id> · themes · observer on|off · fold on|off · audit on|off · reset · observers · agents` }
451 })
452}
453hooks/card.ts 283 lines1import { MARKER_GLYPHS, type CardItem, type CardModel, type Decision, type DecisionOption, type Marker } from './theme.ts'
2
3// The card grammar, SPEC §2: a pure reader of the model's own text. No `$`, no state, no I/O — the render hook calls it once per drawing, so it stays O(lines).
4
5/** up to three spaces then `>` and one optional space: the blockquote wrapper a card may or may not wear */
6const QUOTE = /^[ \t]{0,3}>[ \t]?/
7
8/** `**👉 For you**`, bold optional, either language; it opens the card and is not an item */
9const HEADER = /^(?:\*\*)?\s*👉\s*(?:for you|pour toi)\s*(?:\*\*)?$/iu
10
11/** the whole text in stars */
12const WHOLE_BOLD = /^\*\*([\s\S]+?)\*\*$/
13
14/** a short label, then the rest in stars: `À décider : **…**` */
15const LABEL_BOLD = /^([^*]{1,40}?:\s*)\*\*([\s\S]+?)\*\*$/
16
17/** the label a decision line may open with, in either language */
18const DECIDE_LABEL = /^(?:à\s+décider|a\s+décider|decide|decision)\s*:\s*/iu
19
20/** what happens if the reader says nothing */
21const DEFAULT_CLAUSE = /\b(?:if you say nothing|sans réponse(?:\s+de ta part)?|default|par défaut)\s*:\s*(.+)$/iu
22
23/** interrogative stems stripped before an option is labelled */
24const OPTION_STEM = /^(?:should i|do i|shall we|dois-je|faut-il|est-ce que)\s+/iu
25
26const wordCount = (text: string) => text.trim().split(/\s+/u).filter(Boolean).length
27
28/** an `A or B?` / `A ou B ?` question, split at the first conjunction */
29const OPTIONS = /^(.+?)\s+(or|ou)\s+(.+?)\s*\?$/u
30
31/** `A, B ou C ?` / `A, B, C or D?` — comma-separated items, the last joined by `ou`/`or` */
32const MULTI_OPTIONS = /^([^?,]+),\s*([^?,]+?)(?:,\s*([^?,]+?))?\s+(ou|or)\s+([^?,]+?)\s*\?(?:\s*(.*))?$/iu
33
34/** `A = …`, `B) …`, `1 : …` inline option titles in the decision text */
35const INLINE_OPTION = /\b([A-D]|[1-4])\s*(?:=|:|\))\s*([^.;]+)(?:\.\s*)?/gi
36
37/** a ▫️ line keyed to an option: `A — detail` */
38const OPTION_DETAIL = /^([A-D]|[1-4])\s*[—\-:=]\s*(.+)$/iu
39
40/** `~2h`, `≈ 45 min`, `1 day` */
41const ESTIMATE = /(?:~|≈)?\s*(\d+\s*(?:min|mins|minutes?|h|hrs?|hours?|day|days))/iu
42
43/** `Risk: high` / `risque : élevé` */
44const RISK = /\b(?:risk|risque)\s*:\s*(high|medium|low|élevé|moyen|faible)/iu
45
46/** `Cost: …` / `Coût : …` up to the next sentence-ending punctuation */
47const COST = /\b(?:cost|coût)\s*:\s*([^.;!?]+)/iu
48
49/** an option longer than this is a sentence, not a button */
50const OPTION_MAX = 40
51
52/** longest glyph first, so `⚠️` is read before `⚠` and no variation selector is left in the text */
53const GLYPHS: readonly (readonly [string, Marker])[] = (Object.entries(MARKER_GLYPHS) as [Marker, readonly string[]][])
54 .flatMap(([marker, glyphs]) => glyphs.map(glyph => [glyph, marker] as const))
55 .sort((a, b) => b[0].length - a[0].length)
56
57const fenceOpen = (line: string): { ticks: number; lang: string } | null => {
58 const trimmed = line.trimStart()
59 const match = /^(`{3,})(.*)$/.exec(trimmed)
60 if (!match) return null
61 return { ticks: match[1]!.length, lang: match[2]!.trim() }
62}
63
64const fenceClose = (line: string, ticks: number): boolean => {
65 const trimmed = line.trimStart()
66 const match = /^(`+)(.*)$/.exec(trimmed)
67 return Boolean(match && match[1]!.length >= ticks && match[2]!.trim() === '')
68}
69
70const riskOf = (word: string): DecisionOption['risk'] => {
71 const lower = word.toLowerCase()
72 if (lower === 'high' || lower === 'élevé') return 'high'
73 if (lower === 'medium' || lower === 'moyen') return 'medium'
74 return 'low'
75}
76
77/** estimate, risk and cost, all optional */
78export const parseOptionDetail = (detail: string): Pick<DecisionOption, 'estimate' | 'risk' | 'cost'> => {
79 const estimate = ESTIMATE.exec(detail)?.[1]?.trim()
80 const riskMatch = RISK.exec(detail)
81 const risk = riskMatch ? riskOf(riskMatch[1]!) : undefined
82 const cost = COST.exec(detail)?.[1]?.trim()
83 return { ...(estimate ? { estimate } : {}), ...(risk ? { risk } : {}), ...(cost ? { cost } : {}) }
84}
85
86const inlineLabels = (text: string): Map<string, string> => {
87 const labels = new Map<string, string>()
88 for (const match of text.matchAll(INLINE_OPTION)) labels.set(match[1]!.toUpperCase(), match[2]!.trim())
89 return labels
90}
91
92const stripInlineDefs = (text: string): string =>
93 text
94 .replace(/\b([A-D]|[1-4])\s*(?:=|:|\))\s*[^.;]+(?:\.\s*)?/gi, '')
95 .replace(/\s{2,}/g, ' ')
96 .replace(/\s+\./g, '.')
97 .trim()
98
99const optionFromKeys = (keys: string[], labels: Map<string, string>): DecisionOption[] =>
100 keys.map(key => {
101 const upper = key.toUpperCase()
102 const label = labels.get(upper) ?? key
103 return label.length <= OPTION_MAX ? { key: upper, label } : { key: upper, label }
104 })
105
106const optionLabel = (side: string): string => side.replace(OPTION_STEM, '').trim()
107
108const twoOptions = (left: string, right: string): DecisionOption[] => {
109 const a = optionLabel(left)
110 const b = optionLabel(right)
111 const wa = wordCount(a)
112 const wb = wordCount(b)
113 if (wa === 0 || wb === 0) return []
114 if (wa > wb * 2 || wb > wa * 2) return []
115 const short = a.length <= OPTION_MAX && b.length <= OPTION_MAX
116 return short ? [{ label: a }, { label: b }] : []
117}
118
119/** the question, its default clause and its short answers, read off a 🟡 line and its continuations */
120export const parseDecision = (raw: string, lines: readonly string[] = []): Decision => {
121 const body = [raw, ...lines].join(' ').replace(DECIDE_LABEL, '').trim()
122 const clause = DEFAULT_CLAUSE.exec(body)
123 const withoutDefault = (clause ? body.slice(0, clause.index) : body).trim()
124 const defaultText = clause ? clause[1]!.trim() : null
125
126 const labels = inlineLabels(withoutDefault)
127 const multi = MULTI_OPTIONS.exec(withoutDefault)
128 if (multi) {
129 const keys = [multi[1]!, multi[2]!, multi[3], multi[5]!].filter((part): part is string => Boolean(part?.trim())).map(part => part.trim())
130 if (keys.length >= 2 && keys.length <= 4) {
131 const rest = multi[6]?.trim() ?? ''
132 const question = stripInlineDefs([keys.join(', ') + '?', rest].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim())
133 const options = optionFromKeys(keys, labels)
134 const defaultKey = defaultText && options.some(option => option.key === defaultText.toUpperCase()) ? defaultText.toUpperCase() : null
135 return { question, defaultText, defaultKey, options }
136 }
137 }
138
139 const parts = OPTIONS.exec(withoutDefault)
140 const left = parts?.[1]?.trim() ?? ''
141 const right = parts?.[3]?.trim() ?? ''
142 const question = stripInlineDefs(withoutDefault)
143 const options = twoOptions(left, right)
144 const defaultKey =
145 defaultText && options.some(option => option.label === defaultText || option.key === defaultText.toUpperCase())
146 ? (options.find(option => option.label === defaultText)?.key ?? defaultText.toUpperCase())
147 : null
148 return { question, defaultText, defaultKey, options }
149}
150
151const attachOptionDetails = (decision: Decision, items: CardItem[]): CardItem[] => {
152 if (decision.options.length === 0 || !decision.options.some(option => option.key)) return items
153 const keyed = new Map(decision.options.filter(option => option.key).map(option => [option.key!, option]))
154 const kept: CardItem[] = []
155 for (const item of items) {
156 if (item.marker !== 'info') {
157 kept.push(item)
158 continue
159 }
160 const match = OPTION_DETAIL.exec(item.text.trim())
161 if (!match) {
162 kept.push(item)
163 continue
164 }
165 const option = keyed.get(match[1]!.toUpperCase())
166 if (!option) {
167 kept.push(item)
168 continue
169 }
170 const detail = match[2]!.trim()
171 option.detail = detail
172 Object.assign(option, parseOptionDetail(detail))
173 }
174 return kept.filter(item => {
175 if (item.marker !== 'info') return true
176 const match = OPTION_DETAIL.exec(item.text.trim())
177 return !match || !keyed.has(match[1]!.toUpperCase())
178 })
179}
180
181/** a reply is a card when it has the header, two distinct markers, or markers are the majority of its lines */
182const isCard = (header: boolean, items: CardItem[], lines: readonly string[]): boolean => {
183 if (header) return items.length > 0
184 const markers = new Set(items.map(item => item.marker))
185 if (markers.size >= 2) return true
186 const nonEmpty = lines.filter(line => line.trim() !== '').length
187 const marked = items.reduce((n, item) => n + 1 + item.lines.length, 0)
188 const unmarked = nonEmpty - marked - (header ? 1 : 0)
189 return marked > 0 && marked > unmarked
190}
191
192/** a block is a card when it passes the threshold; anything else is passed through untouched */
193export const parseCard = (text: string): CardModel | null => {
194 const { lines, quoted } = unquote(text)
195 const preamble: string[] = []
196 const items: CardItem[] = []
197 let header = false
198 let i = 0
199 while (i < lines.length) {
200 const line = lines[i]!
201 const open = fenceOpen(line)
202 if (open) {
203 const body: string[] = []
204 i++
205 while (i < lines.length && !fenceClose(lines[i]!, open.ticks)) {
206 body.push(lines[i]!)
207 i++
208 }
209 if (i < lines.length) i++
210 if (items.length > 0) items[items.length - 1]!.code.push({ lang: open.lang, source: body.join('\n') })
211 else preamble.push(line.trim())
212 continue
213 }
214 i++
215 if (line.trim() === '') continue
216 if (isHeader(line)) {
217 header = true
218 continue
219 }
220 const marker = markerOf(line)
221 if (marker) {
222 const { text: body, bold } = unbold(marker.text)
223 items.push({ marker: marker.marker, text: body, bold, lines: [], code: [] })
224 continue
225 }
226 if (items.length > 0) items[items.length - 1]!.lines.push(line.trim())
227 else preamble.push(line.trim())
228 }
229 if (!isCard(header, items, lines)) return null
230 const decide = items.find(item => item.marker === 'decide')
231 const decision = decide ? parseDecision(decide.text, decide.lines) : null
232 const finalItems = decision ? attachOptionDetails(decision, items) : items
233 const counts: Record<Marker, number> = { done: 0, decide: 0, blocked: 0, read: 0, info: 0, skip: 0 }
234 for (const item of finalItems) counts[item.marker]++
235 return {
236 preamble,
237 header,
238 verdict: finalItems.find(item => item.marker === 'decide' && item.bold) ?? finalItems.find(item => item.bold) ?? null,
239 decision,
240 items: finalItems,
241 counts,
242 quoted,
243 raw: text,
244 }
245}
246
247/** the lines with one level of `>` off them, and whether every non-empty line wore one */
248export const unquote = (text: string): { lines: string[]; quoted: boolean } => {
249 let seen = 0
250 let stripped = 0
251 const lines = text.split('\n').map(line => {
252 if (line.trim() === '') return ''
253 seen++
254 const match = QUOTE.exec(line)
255 if (!match) return line
256 stripped++
257 return line.slice(match[0].length)
258 })
259 return { lines, quoted: seen > 0 && stripped === seen }
260}
261
262/** the marker a line opens with, and the text after it; null when the line opens an item of no kind */
263export const markerOf = (line: string): { marker: Marker; text: string } | null => {
264 const trimmed = line.trimStart()
265 for (const [glyph, marker] of GLYPHS) {
266 if (trimmed.startsWith(glyph)) return { marker, text: trimmed.slice(glyph.length).trim() }
267 }
268 return null
269}
270
271export const isHeader = (line: string): boolean => HEADER.test(line.trim())
272
273/** the text with its stars removed, and whether they were there; a label before them is kept */
274export const unbold = (text: string): { text: string; bold: boolean } => {
275 if (text.split('**').length === 3) {
276 const whole = WHOLE_BOLD.exec(text)
277 if (whole) return { text: whole[1]!.trim(), bold: true }
278 const labelled = LABEL_BOLD.exec(text)
279 if (labelled) return { text: `${labelled[1]!}${labelled[2]!}`.trim(), bold: true }
280 }
281 return { text: text.replace(/\*\*([^*]+)\*\*/g, '$1'), bold: false }
282}
283hooks/fold.ts 133 lines1// The folding decisions of SPEC §3.2–3.4, pure so they can be read and tested without an engine. The hooks in register.ts hold the state; these functions hold the rules.
2
3/** the origin kinds a notification fold collapses; every other kind is the reader's own traffic and passes through */
4export const FOLDED_KINDS = ['task-notification', 'peer', 'peer-send-message', 'scheduled-trigger', 'projects-relay'] as const
5
6/** what each folded kind is called on its one dim line */
7const KIND_LABELS: Readonly<Record<string, string>> = {
8 'task-notification': 'background task',
9 peer: 'another session',
10 'peer-send-message': 'message from a session',
11 'scheduled-trigger': 'scheduled trigger',
12 'projects-relay': 'relayed message',
13 observer: 'observer report',
14}
15
16export type NarrationInput = {
17 /** the `foldNarration` preference */
18 fold: boolean
19 /** a tool call of this main-loop turn followed the block */
20 marked: boolean
21 /** the reader pressed `[show]` on it */
22 open: boolean
23 /** the block parsed as a card */
24 isCard: boolean
25}
26
27/** whether an assistant block draws whole or as one dim line; a card is the signal, so a tool call after it never makes it noise */
28export const narrationOf = ({ fold, marked, open, isCard }: NarrationInput): 'full' | 'folded' =>
29 fold && marked && !open && !isCard ? 'folded' : 'full'
30
31/** a `turn.step` chunk narrowed to the kinds narration tracking reads */
32export type StepChunk =
33 | { kind: 'text'; index: number; text: string }
34 | { kind: 'tool'; index: number }
35 | { kind: string; index?: number; text?: string }
36
37/** whether a drawn block was marked narration by id or by its text (TurnStepChunk has no requestId) */
38export const narrationMarkedOf = (id: string, text: string, narrated: ReadonlySet<string>, byText: ReadonlySet<string>): boolean =>
39 narrated.has(id) || byText.has(text)
40
41/**
42 * One `turn.step` chunk: accumulate text per block index; when a tool chunk arrives, every
43 * earlier non-empty text block in this step was narration. Chunks carry no message id, so
44 * the render hook matches on text (and stableIdOf(undefined, text) in narrated).
45 */
46export const narrationTextsOfChunk = (
47 blocks: ReadonlyMap<number, string>,
48 chunk: StepChunk,
49): { blocks: Map<number, string>; texts: string[] } => {
50 if (chunk.kind === 'text' && typeof chunk.index === 'number' && typeof chunk.text === 'string') {
51 const next = new Map(blocks)
52 next.set(chunk.index, (next.get(chunk.index) ?? '') + chunk.text)
53 return { blocks: next, texts: [] }
54 }
55 if (chunk.kind === 'tool' && typeof chunk.index === 'number') {
56 const texts: string[] = []
57 for (const [idx, text] of blocks) {
58 if (idx < chunk.index && text.trim() !== '') texts.push(text)
59 }
60 return { blocks: new Map(blocks), texts }
61 }
62 return { blocks: new Map(blocks), texts: [] }
63}
64
65/**
66 * When `tool.call` ran before any AssistantMessage drew: the next non-card block becomes a
67 * candidate; it is marked only if another block follows before `turn.complete` (final block rule).
68 */
69export const narrationFallbackOnBlock = (
70 candidate: string | undefined,
71 id: string,
72 isCard: boolean,
73): { candidate: string | undefined; confirmed: string[] } => {
74 if (isCard) return { candidate, confirmed: [] }
75 const confirmed: string[] = []
76 if (candidate !== undefined && candidate !== id) confirmed.push(candidate)
77 return { candidate: id, confirmed }
78}
79
80/** the one dim line a folded block draws in place of its text */
81export const narrationSummary = (text: string): string => {
82 const n = text.split('\n').filter(line => line.trim() !== '').length
83 return `· narration · ${n} ${n === 1 ? 'line' : 'lines'}`
84}
85
86export const foldsNotification = (kind: string): boolean => (FOLDED_KINDS as readonly string[]).includes(kind)
87
88const FAIL_WORD = /\b(?:fail(?:ed|ing)?|error|blocked|exception|timed?\s*out|timeout)\b/i
89const FAIL_EXIT = /\bexit\s+(?:code|status)\s+(\d+)\b/i
90const FAIL_EXITED = /\bexited\s+with\s+(\d+)\b/i
91
92/** a folded notification whose text smells like a failure stays whole so the reader sees it */
93export const notificationLooksFailed = (text: string): boolean => {
94 if (FAIL_WORD.test(text)) return true
95 const code = FAIL_EXIT.exec(text)
96 if (code && code[1] !== '0') return true
97 const exited = FAIL_EXITED.exec(text)
98 return exited !== null && exited[1] !== '0'
99}
100
101/** the kind in the reader's words; an unknown kind keeps its own name rather than being guessed at */
102export const notificationKindLabel = (kind: string): string => KIND_LABELS[kind] ?? kind
103
104/** the one dim line a folded notification draws */
105export const notificationSummary = (kind: string): string => `· ${notificationKindLabel(kind)} ·`
106
107/** a background observer's report to the agent it watches: hidden, never folded */
108export const isObserverOrigin = (kind: string): boolean => kind === 'observer'
109
110/** FNV-1a, base36: short, stable across a session, and enough to tell two texts apart */
111export const hashOf = (text: string): string => {
112 let h = 0x811c9dc5
113 for (let i = 0; i < text.length; i++) {
114 h ^= text.charCodeAt(i)
115 h = Math.imul(h, 0x01000193) >>> 0
116 }
117 return h.toString(36)
118}
119
120/**
121 * One drawing of one message: the engine's instance id where there is one, the
122 * text where there is not. The `requestId` path is what the render hook always
123 * gets (`RenderInputOf.requestId` is a non-empty string); the text-hash fallback
124 * is dead at render time and exists only for turn.step narration matching.
125 */
126export const stableIdOf = (requestId: string | undefined, text: string): string => requestId ?? `t${hashOf(text)}`
127
128/** a report is redrawn on every frame, so it is counted once under a key that ignores its spacing */
129export const reportKey = (text: string): string => {
130 const normalized = text.replace(/\s+/gu, ' ').trim()
131 return `${normalized.length}:${hashOf(normalized)}`
132}
133hooks/audit.ts 109 lines1import type { ProcessRunInit, RenderElement, RenderNode } from 'claude-code'
2
3const CAP = 4000
4const ELLIPSIS = ' …'
5const SYSTEM =
6 'Compare RAW and RENDERED. For each fact, number, name, warning or default present in RAW but absent from RENDERED, print one line starting with MISSING: . If nothing is missing print OK. Print nothing else.'
7
8const trimLine = (line: string) => line.trim()
9
10const collapse = (lines: string[]) => {
11 const out: string[] = []
12 for (const line of lines.map(trimLine)) {
13 if (line === '' && out[out.length - 1] === '') continue
14 out.push(line)
15 }
16 while (out[0] === '') out.shift()
17 while (out[out.length - 1] === '') out.pop()
18 return out
19}
20
21const walkChild = (child: unknown, lines: string[]): void => {
22 if (child === undefined || child === null || typeof child === 'boolean' || typeof child === 'number') return
23 walk(child as RenderElement | RenderNode | readonly RenderNode[], lines)
24}
25
26const walk = (el: RenderElement | RenderNode | readonly RenderNode[] | undefined, lines: string[]): void => {
27 if (el === undefined) return
28 if (typeof el === 'string') {
29 lines.push(el)
30 return
31 }
32 if (Array.isArray(el)) {
33 for (const child of el) walkChild(child, lines)
34 return
35 }
36 if (typeof el !== 'object' || el === null || !('type' in el) || !('props' in el)) return
37 const props = el.props
38 if (!props || typeof props !== 'object') return
39 if (el.type === 'Text') {
40 walkChild('children' in props ? props.children : undefined, lines)
41 return
42 }
43 if (el.type === 'Button' && 'label' in props && typeof props.label === 'string') {
44 lines.push(`[ ${props.label} ]`)
45 return
46 }
47 if (el.type === 'Box') walkChild('children' in props ? props.children : undefined, lines)
48}
49
50export const renderedText = (el: RenderElement | RenderNode | readonly RenderNode[] | undefined): string => {
51 const lines: string[] = []
52 walk(el, lines)
53 return collapse(lines).join('\n')
54}
55
56const cap = (text: string) => (text.length <= CAP ? text : text.slice(0, CAP) + ELLIPSIS)
57
58export const auditPrompt = (raw: string, rendered: string): string =>
59 `RAW:\n${cap(raw)}\n\nRENDERED:\n${cap(rendered)}`
60
61const stripLine = (line: string) =>
62 line
63 .replace(/^\s*>\s?/, '')
64 .replace(/^\s*[-*]\s+/, '')
65 .replace(/\*\*/g, '')
66 .trim()
67
68const markerOnly = /^[-*•·▪▫️ℹ️🔍✅❌⚠️🟢🟡🔴👉▫\s]+$/u
69
70export const parseAuditAnswer = (stdout: string): string[] => {
71 const surviving = stdout
72 .split('\n')
73 .map(stripLine)
74 .filter(line => line !== '' && !markerOnly.test(line))
75
76 const missing = surviving
77 .filter(line => /^missing:/i.test(line))
78 .map(line => line.replace(/^missing:\s*/i, '').trim())
79 .filter(line => line !== '')
80
81 if (missing.length > 0) return missing
82
83 if (/\bOK\b/.test(stdout) && !/missing:/i.test(stdout)) return []
84
85 return surviving.slice(0, 3).map(line => (line.length <= 200 ? line : line.slice(0, 200)))
86}
87
88export const auditArgv = (raw: string, rendered: string) => [
89 'claude',
90 '-p',
91 '--model',
92 'haiku',
93 '--settings',
94 '{"outputStyle":"default","hooks":{}}',
95 '--system-prompt',
96 SYSTEM,
97 auditPrompt(raw, rendered),
98]
99
100export const auditRunInit = (cwd: string): ProcessRunInit => ({
101 cwd: cwd || '/tmp',
102 timeoutMs: 120000,
103 stdin: '',
104 env: {
105 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: '0',
106 CLAUDE_CODE_EXPERIMENTAL_OBSERVER_AGENTS: '0',
107 },
108})
109hooks/cost.ts 73 lines1import type { TurnUsage } from 'claude-code'
2
3/** USD per million tokens, input and output, by model family. */
4export type PriceTier = { input: number; output: number }
5
6export const DEFAULT_PRICES: Record<string, PriceTier> = {
7 opus: { input: 15, output: 75 },
8 sonnet: { input: 3, output: 15 },
9 haiku: { input: 0.8, output: 4 },
10}
11
12export type UsageSummary = { inputTokens: number; outputTokens: number; costUsd: number }
13
14/** Anthropic's published cache pricing: cache reads cost 10% of base input. */
15const CACHE_READ_MULTIPLIER = 0.1
16/** Anthropic's published cache pricing: cache writes cost 125% of base input. */
17const CACHE_CREATION_MULTIPLIER = 1.25
18
19/** `opus:15/75,sonnet:3/15` — only listed families override the defaults. */
20export const parsePrices = (config?: string): Record<string, PriceTier> => {
21 const prices = { ...DEFAULT_PRICES }
22 if (!config) return prices
23 for (const part of config.split(',')) {
24 const [family, rates] = part.trim().split(':')
25 if (!family || !rates) continue
26 const [rawIn, rawOut] = rates.split('/')
27 const input = Number(rawIn)
28 const output = Number(rawOut)
29 if (Number.isFinite(input) && Number.isFinite(output)) prices[family.toLowerCase()] = { input, output }
30 }
31 return prices
32}
33
34/** Substring match on the API model id; unknown families fall back to sonnet pricing. */
35export const modelFamily = (model: string): string => {
36 const lower = model.toLowerCase()
37 if (lower.includes('opus')) return 'opus'
38 if (lower.includes('sonnet')) return 'sonnet'
39 if (lower.includes('haiku')) return 'haiku'
40 return 'sonnet'
41}
42
43/**
44 * Regular input at the tier rate; cache reads at 10% and cache writes at 125%
45 * of that input rate (Anthropic's published cache multipliers).
46 */
47export const computeUsage = (usage: TurnUsage, prices: Record<string, PriceTier> = DEFAULT_PRICES): UsageSummary => {
48 const tier = prices[modelFamily(usage.model)] ?? { input: 3, output: 15 }
49 const inputTokens = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
50 const outputTokens = usage.output_tokens
51 const costUsd =
52 (usage.input_tokens * tier.input +
53 usage.cache_read_input_tokens * tier.input * CACHE_READ_MULTIPLIER +
54 usage.cache_creation_input_tokens * tier.input * CACHE_CREATION_MULTIPLIER +
55 outputTokens * tier.output) /
56 1_000_000
57 return { inputTokens, outputTokens, costUsd }
58}
59
60/** `≈ $0.09` — `< $0.01` when nonzero but rounds to zero; `$0.00` only when truly free. */
61export const formatCost = (costUsd: number): string => {
62 if (costUsd === 0) return '$0.00'
63 if (costUsd > 0 && costUsd < 0.005) return '< $0.01'
64 return `$${costUsd.toFixed(2)}`
65}
66
67/** `22k tokens` below 1M; `1.2m tokens` above; exact count below 1000. */
68export const formatTokens = (total: number): string => {
69 if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}m tokens`
70 if (total >= 1000) return `${Math.round(total / 1000)}k tokens`
71 return `${total} tokens`
72}
73hooks/themes/index.ts 16 lines1import type { Theme } from '../theme.ts'
2import { theme as kanban } from './kanban.ts'
3import { theme as gmorel } from './gmorel.ts'
4import { theme as receipt } from './receipt.ts'
5
6// The only place a theme is listed (SPEC §4): a new file under hooks/themes/ becomes a `/signal theme <id>` word by being imported here and nowhere else.
7
8export const themes: Record<string, Theme> = {
9 [receipt.id]: receipt,
10 [kanban.id]: kanban,
11 [gmorel.id]: gmorel,
12}
13
14/** the theme a preference names; an id no file answers falls back rather than leaving the card undrawn */
15export const themeOf = (id: string, fallback = 'gmorel'): Theme => themes[id] ?? themes[fallback] ?? gmorel
16hooks/theme.ts 120 lines1import type { EngineInterface, RenderElement, RenderInput, RenderSurface } from 'claude-code'
2
3// The contract between the core (hooks/register.ts, hooks/card.ts) and a theme
4// (hooks/themes/*.ts). A theme is a pure function from a parsed card and a
5// small context to a render tree. It never touches `$` directly: everything it
6// may do is on `ctx`.
7
8/** The six markers of a card line, named by what the reader does with them. */
9export type Marker = 'done' | 'decide' | 'blocked' | 'read' | 'info' | 'skip'
10
11/** The glyph each marker is written with in the model's text. */
12export const MARKER_GLYPHS: Readonly<Record<Marker, readonly string[]>> = {
13 done: ['🟢'],
14 decide: ['🟡'],
15 blocked: ['🔴'],
16 read: ['⚠️', '⚠'],
17 info: ['▫️', '▫'],
18 skip: ['ℹ️', 'ℹ'],
19}
20
21export type CodeBlock = { lang: string; source: string }
22
23export type CardItem = {
24 marker: Marker
25 /** the line's text, marker removed, `**bold**` stripped (see `bold`) */
26 text: string
27 /** true when the line was the card's bold line */
28 bold: boolean
29 /** continuation lines that followed without a marker, prefix stripped */
30 lines: string[]
31 /** fenced code that followed the line inside the card, verbatim */
32 code: CodeBlock[]
33}
34
35export type DecisionOption = {
36 key?: string
37 label: string
38 detail?: string
39 estimate?: string
40 risk?: 'low' | 'medium' | 'high'
41 cost?: string
42}
43
44export type Decision = {
45 /** the question, without its leading label (`À décider :`, `Decide:`) and without the default clause */
46 question: string
47 /** what happens if the reader says nothing; null when the line has no default clause */
48 defaultText: string | null
49 /** when the default clause names an option key (`Sans réponse de ta part : B`), the matching key */
50 defaultKey: string | null
51 /** short answers parsed from the question; empty when none could be read */
52 options: DecisionOption[]
53}
54
55export type CardModel = {
56 /** plain lines before the first marker; every theme draws them above the card */
57 preamble: string[]
58 /** the card opened with `👉 For you` / `👉 Pour toi` */
59 header: boolean
60 /** the bold line: the 🟡 when there is one, else the first bold line */
61 verdict: CardItem | null
62 /** the first 🟡 line, parsed */
63 decision: Decision | null
64 /** every marker line, in order, the verdict included */
65 items: CardItem[]
66 counts: Record<Marker, number>
67 /** true when every non-empty line started with `>` */
68 quoted: boolean
69 raw: string
70}
71
72/** Which folds are open for one drawn card; the theme reads it, the core owns it. */
73export type CardState = {
74 open: Partial<Record<'done' | 'read' | 'info' | 'skip' | 'narration', boolean>>
75 /** the answer a decision button put in the prompt box, if any */
76 answered: string | null
77}
78
79export type Witness = {
80 /** an agent listed by `$.agent.list()` matches the configured observer types and is running */
81 observerRunning: boolean
82 /** observer reports hidden since the last card was drawn */
83 hiddenReports: number
84}
85
86export type ThemeActions = {
87 /** flip a fold and redraw */
88 toggle(section: keyof CardState['open']): void
89 /** put text in the prompt box for the reader to send (never sends) */
90 fill(text: string): void
91}
92
93/** The element table for this render, from `$.ui.resolve(e)`. */
94export type Elements = ReturnType<EngineInterface['ui']['resolve']>
95
96export type ThemeCtx = {
97 el: Elements
98 /** columns available to the card, gutter already removed */
99 columns: number
100 surface: RenderSurface
101 state: CardState
102 actions: ThemeActions
103 witness: Witness
104 /** the message's stable id, for `key`s */
105 id: string
106 /** last completed turn's API usage, when the engine reported it for this message */
107 usage?: { inputTokens: number; outputTokens: number; costUsd: number }
108}
109
110export type Theme = {
111 /** file stem under hooks/themes/, also the `/signal theme <id>` word */
112 id: string
113 name: string
114 /** one line for `/signal` and the README */
115 tagline: string
116 render(model: CardModel, ctx: ThemeCtx): RenderElement
117}
118
119export type AssistantRender = RenderInput<'AssistantMessage'>
120hooks/themes/kanban.ts 747 lines1import type { RenderElement } from 'claude-code'
2import type { CardItem, CardModel, Decision, DecisionOption, Marker, Theme, ThemeCtx } from '../theme.ts'
3import {
4 cleanText,
5 codeBlocks,
6 countLabel,
7 decisionIndex,
8 defaultLine,
9 foldRow,
10 grouped,
11 isOpen,
12 needsFoldRow,
13 optionFill,
14 preambleLines,
15 room,
16 sectionOf,
17 softBreak,
18 verdictIndex,
19 witnessMark,
20 type Group,
21 type Section,
22} from './_shared.ts'
23
24// Sticky kanban (design/artboards/DirectionH.dc.html, H2 for questions): three
25// columns of filled stickies — to decide, done, to read and notes.
26
27const WIDE = 84
28const GAP = 2
29
30const FILL: Record<'decide' | 'done' | 'read' | 'note', string> = {
31 decide: '#F2C14E',
32 done: '#8EE0A8',
33 read: '#F0A050',
34 note: '#D9DEE5',
35}
36
37const TAPE: Record<'decide' | 'done' | 'read' | 'note', string> = {
38 decide: '#C89B2E',
39 done: '#5CC48A',
40 read: '#D89040',
41 note: '#B8BEC8',
42}
43
44const MIN_COL = 18
45const DECIDE_MIN = 28
46const SHADOW = '#0E0E0E'
47
48/** Break text into rows of at most `width` columns on word boundaries, the way Ink would. */
49export const wrapRows = (text: string, width: number): string[] => {
50 if (width < 1) return text === '' ? [] : [text]
51 if (text === '') return []
52
53 const words = text.trim().split(/\s+/).filter(w => w.length > 0)
54 if (words.length === 0) return []
55
56 const rows: string[] = []
57 let current = ''
58
59 for (const word of words) {
60 if (word.length > width) {
61 if (current) {
62 rows.push(current)
63 current = ''
64 }
65 rows.push(word)
66 continue
67 }
68 if (current === '') {
69 current = word
70 continue
71 }
72 const candidate = `${current} ${word}`
73 if (candidate.length <= width) current = candidate
74 else {
75 rows.push(current)
76 current = word
77 }
78 }
79 if (current) rows.push(current)
80 return rows
81}
82
83const HEADER: Record<'decide' | 'done' | 'read' | 'note', string> = {
84 decide: 'yellow',
85 done: 'green',
86 read: 'yellow',
87 note: 'gray',
88}
89
90/** skip closed (SPEC §4); chooser mode closes done/read/info. */
91const DEFAULT_OPEN: Record<Section, boolean> = { done: true, read: true, info: true, skip: false }
92
93const sectionOpen = (ctx: ThemeCtx, section: Section, chooser: boolean): boolean =>
94 isOpen(ctx.state, section, chooser && section !== 'skip' ? false : DEFAULT_OPEN[section])
95
96const restFolded = (ctx: ThemeCtx, chooser: boolean): boolean =>
97 chooser && !sectionOpen(ctx, 'done', true) && !sectionOpen(ctx, 'read', true) && !sectionOpen(ctx, 'info', true)
98
99const openRest = (ctx: ThemeCtx): void => {
100 for (const section of ['done', 'read', 'info'] as const) {
101 if (!sectionOpen(ctx, section, true)) ctx.actions.toggle(section)
102 }
103}
104
105const boardSummary = (model: CardModel): string =>
106 `Already on the kanban: ${model.counts.done} done, ${model.counts.read} read, ${model.counts.info} notes`
107
108const decisionContext = (item: CardItem | undefined): string | null => {
109 if (!item || item.lines.length === 0) return null
110 return item.lines.map(cleanText).join(' ')
111}
112
113const chooserDefaultLine = (decision: Decision, defNum: number): string | null => {
114 if (decision.defaultKey !== null) return `if nothing → note ${decision.defaultKey}`
115 if (decision.defaultText !== null) return `if nothing → note ${defNum}`
116 return null
117}
118
119const defaultNumber = (decision: Decision): number => {
120 if (decision.defaultKey !== null) {
121 const i = decision.options.findIndex(o => o.key === decision.defaultKey)
122 if (i >= 0) return i + 1
123 }
124 const i = decision.options.findIndex(o => o.label === decision.defaultText)
125 return i >= 0 ? i + 1 : 1
126}
127
128const itemWeight = (item: CardItem): number => item.text.length + item.lines.reduce((n, line) => n + line.length, 0)
129
130const evenColumnSplit = (available: number, count: number): number =>
131 Math.max(8, Math.floor(available / count))
132
133/** Proportional widths with min/max clamps; falls back to even split only when clamps are infeasible. */
134export const columnWidths = (weights: number[], available: number, cardColumns: number, mins?: number[]): number[] => {
135 const n = weights.length
136 if (n === 0) return []
137 if (n === 1) return [available]
138
139 const colMins = mins ?? weights.map(() => MIN_COL)
140 const maxW = Math.floor(cardColumns * 0.55)
141 const sumMins = colMins.reduce((a, b) => a + b, 0)
142 if (maxW < Math.min(...colMins) || sumMins > available || n * maxW < available) {
143 return weights.map(() => evenColumnSplit(available, n))
144 }
145
146 const sumW = weights.reduce((a, b) => a + b, 0) || 1
147 const clamp = (w: number, i: number) => Math.min(maxW, Math.max(colMins[i]!, w))
148 let widths = weights.map((w, i) => clamp(Math.floor((w / sumW) * available), i))
149
150 for (let pass = 0; pass < 16; pass++) {
151 widths = widths.map((w, i) => clamp(w, i))
152 const sum = widths.reduce((a, b) => a + b, 0)
153 const diff = available - sum
154 if (diff === 0) break
155
156 if (diff > 0) {
157 const movable = weights
158 .map((wt, i) => ({ i, wt, room: maxW - widths[i]! }))
159 .filter(c => c.room > 0)
160 if (movable.length === 0) break
161 const totalWt = movable.reduce((a, c) => a + c.wt, 0)
162 for (const c of movable) {
163 widths[c.i]! += Math.min(c.room, Math.floor((diff * c.wt) / totalWt))
164 }
165 } else {
166 const excess = -diff
167 const movable = weights
168 .map((wt, i) => ({ i, wt, room: widths[i]! - colMins[i]! }))
169 .filter(c => c.room > 0)
170 if (movable.length === 0) break
171 const totalWt = movable.reduce((a, c) => a + c.wt, 0)
172 for (const c of movable) {
173 widths[c.i]! -= Math.min(c.room, Math.floor((excess * c.wt) / totalWt))
174 }
175 }
176 }
177
178 let left = available - widths.reduce((a, b) => a + b, 0)
179 while (left > 0) {
180 let best = -1
181 let bestWt = -1
182 for (let i = 0; i < n; i++) {
183 if (widths[i]! < maxW && weights[i]! > bestWt) {
184 bestWt = weights[i]!
185 best = i
186 }
187 }
188 if (best < 0) break
189 widths[best]! += 1
190 left -= 1
191 }
192
193 while (left < 0) {
194 let best = -1
195 let bestWt = Infinity
196 for (let i = 0; i < n; i++) {
197 if (widths[i]! > colMins[i]! && weights[i]! < bestWt) {
198 bestWt = weights[i]!
199 best = i
200 }
201 }
202 if (best < 0) break
203 widths[best]! -= 1
204 left += 1
205 }
206
207 return widths
208}
209
210const stickyShadow = (ctx: ThemeCtx, key: string, width: number): RenderElement => {
211 const { Box } = ctx.el
212 return Box({
213 key: `${key}:shadow-row`,
214 flexDirection: 'row',
215 width,
216 flexShrink: 0,
217 children: [
218 Box({ key: `${key}:shadow-spacer`, width: 1 }),
219 Box({
220 key: `${key}:shadow`,
221 width: width - 1,
222 height: 1,
223 flexShrink: 0,
224 backgroundColor: SHADOW,
225 }),
226 ],
227 })
228}
229
230const stickyTitle = (
231 ctx: ThemeCtx,
232 inner: number,
233 verdict: boolean,
234 firstRow: string,
235 witness: RenderElement[],
236): RenderElement => {
237 const { Box, Text } = ctx.el
238 return Box({
239 flexDirection: 'row',
240 gap: 1,
241 children: [
242 Box({
243 width: room(inner, witness.length > 0 ? 2 : 0),
244 children: Text({ bold: verdict, color: 'black', wrap: 'truncate', children: firstRow }),
245 }),
246 ...witness,
247 ],
248 })
249}
250
251/** A sticky: tape, fill + black text, shadow — continuations and fences inside the fill. */
252const sticky = (
253 ctx: ThemeCtx,
254 item: CardItem,
255 verdict: boolean,
256 index: number,
257 width: number,
258 fill: string,
259 tape: string,
260): RenderElement => {
261 const { Box, Text } = ctx.el
262 const inner = room(width, 4)
263 const witness = verdict ? witnessMark(ctx) : []
264 const titleRows = wrapRows(softBreak(cleanText(item.text), inner), inner)
265 const titleRest = titleRows.slice(1)
266 const hasBody = titleRest.length > 0 || item.lines.length > 0 || item.code.length > 0
267 const key = `${ctx.id}:sticky:${index}`
268 return Box({
269 key,
270 flexDirection: 'column',
271 width,
272 marginBottom: 1,
273 children: [
274 Box({
275 key: `${key}:tape`,
276 width,
277 paddingX: 1,
278 backgroundColor: tape,
279 children: [stickyTitle(ctx, inner, verdict, titleRows[0] ?? '', witness)],
280 }),
281 ...(hasBody
282 ? [
283 Box({
284 key: `${key}:body`,
285 flexDirection: 'column',
286 width,
287 paddingX: 1,
288 backgroundColor: fill,
289 children: [
290 ...titleRest.map((line, i) =>
291 Box({
292 key: `${key}:title:${i}`,
293 width: inner,
294 children: Text({ color: 'black', wrap: 'wrap', children: line }),
295 }),
296 ),
297 ...item.lines.map((line, i) =>
298 Box({
299 key: `${key}:line:${i}`,
300 width: inner,
301 children: Text({ color: 'black', wrap: 'wrap', children: softBreak(cleanText(line), inner) }),
302 }),
303 ),
304 ...codeBlocks(ctx, item),
305 ],
306 }),
307 ]
308 : []),
309 stickyShadow(ctx, key, width),
310 ],
311 })
312}
313
314const itemSticky = (ctx: ThemeCtx, item: CardItem, verdict: boolean, index: number, width: number): RenderElement => {
315 if (item.marker === 'done') return sticky(ctx, item, verdict, index, width, FILL.done, TAPE.done)
316 if (item.marker === 'read') return sticky(ctx, item, verdict, index, width, FILL.read, TAPE.read)
317 return sticky(ctx, item, verdict, index, width, FILL.note, TAPE.note)
318}
319
320const buttonCells = (label: string): number => label.length + 4
321
322const optionsFitRow = (options: DecisionOption[], width: number): boolean =>
323 options.reduce((sum, o) => sum + buttonCells(o.label), 0) + (options.length - 1) <= width
324
325/** Two short options: question on the sticky, buttons below. */
326const decisionSticky = (ctx: ThemeCtx, model: CardModel, index: number, verdict: boolean, width: number): RenderElement[] => {
327 const { Box, Text, Button } = ctx.el
328 const decision = model.decision!
329 const item = model.items[index]
330 const clause = defaultLine(decision)
331 const inner = room(width, 4)
332 const witness = verdict ? witnessMark(ctx) : []
333 const questionRows = wrapRows(softBreak(cleanText(decision.question), inner), inner)
334 const questionRest = questionRows.slice(1)
335 const hasBody = questionRest.length > 0 || Boolean(clause) || (item !== undefined && item.code.length > 0)
336 const key = `${ctx.id}:decision`
337 const rows: RenderElement[] = [
338 Box({
339 key,
340 flexDirection: 'column',
341 width,
342 marginBottom: 1,
343 children: [
344 Box({
345 key: `${key}:tape`,
346 width,
347 paddingX: 1,
348 backgroundColor: TAPE.decide,
349 children: [stickyTitle(ctx, inner, verdict, questionRows[0] ?? '', witness)],
350 }),
351 ...(hasBody
352 ? [
353 Box({
354 key: `${key}:body`,
355 flexDirection: 'column',
356 width,
357 paddingX: 1,
358 backgroundColor: FILL.decide,
359 children: [
360 ...questionRest.map((line, i) =>
361 Box({
362 key: `${key}:question:${i}`,
363 width: inner,
364 children: Text({ color: 'black', wrap: 'wrap', children: line }),
365 }),
366 ),
367 ...(clause ? [Text({ color: 'black', wrap: 'wrap', children: clause })] : []),
368 ...(item ? codeBlocks(ctx, item) : []),
369 ],
370 }),
371 ]
372 : []),
373 stickyShadow(ctx, key, width),
374 ],
375 }),
376 ]
377 if (decision.options.length > 0) {
378 const fitRow = optionsFitRow(decision.options, width)
379 rows.push(
380 Box({
381 key: `${ctx.id}:decision:options`,
382 flexDirection: fitRow ? 'row' : 'column',
383 width,
384 flexShrink: 0,
385 ...(fitRow ? { gap: 1 } : {}),
386 children: decision.options.map((option, i) => {
387 const label =
388 decision.options.length === 1 && buttonCells(option.label) > width
389 ? option.label.slice(0, width - 5) + '…'
390 : option.label
391 return Button({
392 key: `${ctx.id}:option:${i}`,
393 label,
394 onPress: () => ctx.actions.fill(optionFill(option)),
395 })
396 }),
397 }),
398 )
399 }
400 return rows
401}
402
403const optionSticky = (ctx: ThemeCtx, option: DecisionOption, index: number, isDefault: boolean, width: number): RenderElement => {
404 const { Box, Text } = ctx.el
405 const num = index + 1
406 const inner = room(width, 2)
407 const title = `${num} · ${cleanText(option.label)}`
408 const badge = 'DEFAULT'
409 const badgeOnTitle = isDefault && title.length + 1 + badge.length <= inner
410 const meta: RenderElement[] = []
411 if (option.estimate) meta.push(Text({ color: 'black', children: `effort ${option.estimate}` }))
412 if (option.risk) meta.push(Text({ color: 'black', children: `risk ${option.risk}` }))
413 const titleBlock: RenderElement = badgeOnTitle
414 ? Text({ bold: true, color: 'black', wrap: 'wrap', children: `${title} ${badge}` })
415 : Box({
416 flexDirection: 'column',
417 children: [
418 Box({ width: inner, children: Text({ bold: true, color: 'black', wrap: 'wrap', children: title }) }),
419 ...(isDefault ? [Box({ width: inner, children: Text({ color: 'black', wrap: 'truncate', children: badge }) })] : []),
420 ],
421 })
422 return Box({
423 key: `${ctx.id}:option:${index}`,
424 flexDirection: 'column',
425 width,
426 flexShrink: 0,
427 paddingX: 1,
428 marginBottom: 1,
429 backgroundColor: FILL.decide,
430 children: [
431 titleBlock,
432 ...(option.detail
433 ? [Box({ width: inner, children: Text({ color: 'black', wrap: 'wrap', children: softBreak(cleanText(option.detail), inner) }) })]
434 : []),
435 ...(meta.length > 0 ? [Box({ flexDirection: 'column', children: meta })] : []),
436 ],
437 })
438}
439
440const decisionChooser = (ctx: ThemeCtx, model: CardModel, index: number, verdict: number, width: number): RenderElement => {
441 const { Box, Text, Button } = ctx.el
442 const decision = model.decision!
443 const item = model.items[index]
444 const defNum = defaultNumber(decision)
445 const clause = chooserDefaultLine(decision, defNum)
446 const context = decisionContext(item)
447 const hasWhy = Boolean(context)
448 const wide = ctx.columns >= WIDE
449 const optionsW = wide
450 ? hasWhy
451 ? Math.max(8, Math.floor((width - GAP * 2) / 3))
452 : Math.max(8, Math.floor(((width - GAP) * 2) / 3))
453 : width
454 const decidedW = wide ? (hasWhy ? optionsW : width - GAP - optionsW) : width
455 const whyW = wide && hasWhy ? optionsW : width
456
457 const optionsCol: RenderElement[] = [
458 Text({ bold: true, color: HEADER.decide, wrap: 'truncate', children: 'OPTIONS' }),
459 ...decision.options.map((option, i) => optionSticky(ctx, option, i, decision.defaultKey === option.key, optionsW)),
460 ]
461
462 const summarySticky = (width: number) =>
463 Box({
464 key: `${ctx.id}:kanban-summary`,
465 flexDirection: 'column',
466 width,
467 paddingX: 1,
468 backgroundColor: FILL.note,
469 children: [
470 Text({ color: 'black', wrap: 'wrap', children: boardSummary(model) }),
471 Button({ key: `${ctx.id}:rest:show`, label: 'show', onPress: () => openRest(ctx) }),
472 ],
473 })
474
475 const decidedCol: RenderElement[] = [
476 Text({ bold: true, color: HEADER.done, wrap: 'truncate', children: 'DECIDED' }),
477 Box({
478 key: `${ctx.id}:decided`,
479 flexDirection: 'column',
480 width: decidedW,
481 flexShrink: 0,
482 borderStyle: 'single',
483 borderDimColor: true,
484 paddingX: 1,
485 paddingY: 1,
486 gap: 1,
487 children: [
488 Box({
489 flexDirection: 'row',
490 gap: 1,
491 flexWrap: 'wrap',
492 children: decision.options.map((option, i) =>
493 Button({
494 key: `${ctx.id}:option:${i}`,
495 label: String(i + 1),
496 onPress: () => ctx.actions.fill(optionFill(option)),
497 }),
498 ),
499 }),
500 ...(clause ? [Text({ dimColor: true, wrap: 'wrap', children: clause })] : []),
501 ...(!hasWhy ? [summarySticky(room(decidedW, 4))] : []),
502 ],
503 }),
504 ]
505
506 const whyCol: RenderElement[] = hasWhy
507 ? [
508 Text({ bold: true, color: HEADER.read, wrap: 'truncate', children: 'WHY I ASK' }),
509 Box({
510 key: `${ctx.id}:why`,
511 flexDirection: 'column',
512 width: whyW,
513 flexShrink: 0,
514 paddingX: 1,
515 marginBottom: 1,
516 backgroundColor: FILL.read,
517 children: [Text({ color: 'black', wrap: 'wrap', children: context! })],
518 }),
519 summarySticky(whyW),
520 ]
521 : []
522
523 const colWidths = hasWhy ? [optionsW, decidedW, whyW] : [optionsW, decidedW]
524 const flatCols = hasWhy ? [optionsCol, decidedCol, whyCol] : [optionsCol, decidedCol]
525
526 return Box({
527 key: `${ctx.id}:chooser`,
528 flexDirection: 'column',
529 width: ctx.columns,
530 gap: 1,
531 children: [
532 Text({ bold: true, wrap: 'wrap', children: cleanText(decision.question) }),
533 Box({
534 key: `${ctx.id}:chooser-cols`,
535 flexDirection: wide ? 'row' : 'column',
536 gap: wide ? GAP : 1,
537 width: ctx.columns,
538 children: flatCols.map((col, i) =>
539 Box({
540 key: `${ctx.id}:chooser-col:${i}`,
541 flexDirection: 'column',
542 width: colWidths[i],
543 flexShrink: 0,
544 children: col,
545 }),
546 ),
547 }),
548 ...(item ? codeBlocks(ctx, item) : []),
549 ],
550 })
551}
552
553type Column = { id: string; title: string; tint: string; markers: Marker[] }
554
555const COLUMNS: readonly Column[] = [
556 { id: 'decide', title: 'TO DECIDE', tint: 'yellow', markers: ['decide', 'blocked'] },
557 { id: 'done', title: 'DONE', tint: 'green', markers: ['done'] },
558 { id: 'read', title: 'TO READ', tint: 'yellow', markers: ['read'] },
559]
560
561const columnHasContent = (
562 column: Column,
563 groups: Group[],
564 decision: number,
565 infoGroup: Group | undefined,
566 skipGroup: Group | undefined,
567): boolean => {
568 if (column.id === 'decide') {
569 const mine = groups.filter(group => column.markers.includes(group.marker))
570 return mine.some(group => group.items.length > 0) || decision >= 0
571 }
572 if (column.id === 'done') return groups.some(group => group.marker === 'done' && group.items.length > 0)
573 if (column.id === 'read') {
574 return (
575 groups.some(group => group.marker === 'read' && group.items.length > 0) ||
576 (infoGroup !== undefined && infoGroup.items.length > 0) ||
577 (skipGroup !== undefined && skipGroup.items.length > 0)
578 )
579 }
580 return false
581}
582
583const columnWeight = (
584 column: Column,
585 groups: Group[],
586 model: CardModel,
587 decision: number,
588 infoGroup: Group | undefined,
589 skipGroup: Group | undefined,
590): number => {
591 let weight = 0
592 if (column.id === 'decide') {
593 for (const group of groups.filter(g => column.markers.includes(g.marker))) {
594 for (const placed of group.items) weight += itemWeight(placed.item)
595 }
596 if (decision >= 0 && model.decision) weight += model.decision.question.length
597 } else if (column.id === 'done') {
598 for (const placed of groups.find(g => g.marker === 'done')?.items ?? []) weight += itemWeight(placed.item)
599 } else if (column.id === 'read') {
600 for (const placed of groups.find(g => g.marker === 'read')?.items ?? []) weight += itemWeight(placed.item)
601 if (infoGroup) for (const placed of infoGroup.items) weight += itemWeight(placed.item)
602 if (skipGroup) for (const placed of skipGroup.items) weight += itemWeight(placed.item)
603 }
604 return weight
605}
606
607const columnOf = (
608 ctx: ThemeCtx,
609 model: CardModel,
610 column: Column,
611 groups: Group[],
612 decision: number,
613 verdict: number,
614 width: number,
615 notes: Group | undefined,
616): RenderElement => {
617 const { Box, Text } = ctx.el
618 const mine = groups.filter(group => column.markers.includes(group.marker))
619 const count = mine.reduce((n, group) => n + group.items.length, 0) + (column.id === 'decide' && decision >= 0 ? 1 : 0)
620 const children: RenderElement[] = [Text({ bold: true, color: column.tint, wrap: 'truncate', children: `${column.title} · ${count}` })]
621
622 if (column.id === 'decide' && decision >= 0) {
623 children.push(...decisionSticky(ctx, model, decision, verdict === decision, width))
624 }
625 for (const group of mine) {
626 const section = sectionOf(group.marker)
627 const open = section === null ? true : sectionOpen(ctx, section, false)
628 if (section !== null && needsFoldRow(open, DEFAULT_OPEN[section])) {
629 children.push(foldRow(ctx, section, group.items.length, open))
630 }
631 if (!open) continue
632 for (const placed of group.items) {
633 children.push(itemSticky(ctx, placed.item, placed.index === verdict, placed.index, width))
634 }
635 }
636
637 if (column.id === 'read' && notes) {
638 children.push(Text({ bold: true, color: 'gray', wrap: 'truncate', children: `NOTES · ${notes.items.length}` }))
639 const infoOpen = sectionOpen(ctx, 'info', false)
640 if (needsFoldRow(infoOpen, DEFAULT_OPEN.info)) {
641 children.push(foldRow(ctx, 'info', notes.items.length, infoOpen))
642 }
643 if (infoOpen) {
644 for (const placed of notes.items) {
645 children.push(itemSticky(ctx, placed.item, placed.index === verdict, placed.index, width))
646 }
647 }
648 const skipGroup = groups.find(g => g.marker === 'skip')
649 if (skipGroup && skipGroup.items.length > 0) {
650 const skipOpen = sectionOpen(ctx, 'skip', false)
651 if (needsFoldRow(skipOpen, DEFAULT_OPEN.skip)) {
652 children.push(
653 Box({
654 key: `${ctx.id}:scrap-fold`,
655 flexDirection: 'row',
656 gap: 1,
657 children: [
658 Text({ dimColor: true, wrap: 'truncate', children: `+${skipGroup.items.length} scrap folded` }),
659 ctx.el.Button({
660 key: `${ctx.id}:toggle:skip`,
661 label: skipOpen ? 'hide' : 'show',
662 dimColor: true,
663 onPress: () => ctx.actions.toggle('skip'),
664 }),
665 ],
666 }),
667 )
668 }
669 if (skipOpen) {
670 for (const placed of skipGroup.items) {
671 children.push(itemSticky(ctx, placed.item, false, placed.index, width))
672 }
673 }
674 }
675 }
676
677 return Box({ key: `${ctx.id}:column:${column.id}`, flexDirection: 'column', width, children })
678}
679
680const renderNormal = (model: CardModel, ctx: ThemeCtx, decision: number, verdict: number): RenderElement => {
681 const { Box } = ctx.el
682 const groups = grouped(model, decision)
683 const infoGroup = groups.find(g => g.marker === 'info')
684 const skipGroup = groups.find(g => g.marker === 'skip')
685 const wide = ctx.columns >= WIDE
686 const survivors = COLUMNS.filter(column => columnHasContent(column, groups, decision, infoGroup, skipGroup))
687 const weights = survivors.map(column => columnWeight(column, groups, model, decision, infoGroup, skipGroup))
688 const available = wide ? ctx.columns - GAP * Math.max(0, survivors.length - 1) : ctx.columns
689 const colMins = survivors.map(column => (column.id === 'decide' ? DECIDE_MIN : MIN_COL))
690 const widths = wide ? columnWidths(weights, available, ctx.columns, colMins) : survivors.map(() => ctx.columns)
691 const columns = survivors.map((column, i) =>
692 columnOf(ctx, model, column, groups, decision, verdict, widths[i]!, infoGroup),
693 )
694 return Box({
695 key: `${ctx.id}:columns`,
696 flexDirection: wide ? 'row' : 'column',
697 gap: wide ? GAP : 1,
698 width: ctx.columns,
699 children: columns,
700 })
701}
702
703const renderRest = (model: CardModel, ctx: ThemeCtx, decision: number, verdict: number): RenderElement | null => {
704 const groups = grouped(model, decision)
705 const rows: RenderElement[] = []
706 for (const group of groups) {
707 if (group.marker === 'decide' || group.marker === 'blocked') continue
708 const section = sectionOf(group.marker)
709 const open = section === null ? true : sectionOpen(ctx, section, true)
710 if (!open) continue
711 const width = ctx.columns
712 for (const placed of group.items) {
713 rows.push(itemSticky(ctx, placed.item, placed.index === verdict, placed.index, width))
714 }
715 }
716 if (rows.length === 0) return null
717 return ctx.el.Box({ key: `${ctx.id}:rest`, flexDirection: 'column', width: ctx.columns, children: rows })
718}
719
720const render = (model: CardModel, ctx: ThemeCtx): RenderElement => {
721 const { Box, Text } = ctx.el
722 const verdict = verdictIndex(model)
723 const decision = decisionIndex(model)
724 const chooser = decision >= 0 && model.decision!.options.length >= 3
725 const children: RenderElement[] = [...preambleLines(ctx, model)]
726
727 if (chooser) {
728 children.push(decisionChooser(ctx, model, decision, verdict, ctx.columns))
729 if (!restFolded(ctx, chooser)) {
730 const rest = renderRest(model, ctx, decision, verdict)
731 if (rest) children.push(rest)
732 }
733 } else {
734 if (model.header) children.push(Text({ dimColor: true, wrap: 'truncate', children: 'FOR YOU' }))
735 children.push(renderNormal(model, ctx, decision, verdict))
736 }
737
738 return Box({ key: `${ctx.id}:card`, flexDirection: 'column', width: ctx.columns, children })
739}
740
741export const theme: Theme = {
742 id: 'kanban',
743 name: 'Sticky kanban',
744 tagline: 'sticky notes with real fills, three columns from 84 cols',
745 render,
746}
747hooks/themes/gmorel.ts 623 lines1import type { RenderElement } from 'claude-code'
2import type { CardItem, CardModel, CardState, Marker, Theme, ThemeCtx } from '../theme.ts'
3import { formatCost, formatTokens } from '../cost.ts'
4import {
5 cleanText,
6 codeBlocks,
7 decisionIndex,
8 defaultLine,
9 isOpen,
10 needsFoldRow,
11 optionButtons,
12 optionFill,
13 preambleLines,
14 room,
15 verdictIndex,
16 witnessMark,
17} from './_shared.ts'
18import type { DecisionOption } from '../theme.ts'
19
20// K · gmorel-1.0.0 (design/artboards/DirectionA2, K3–K7): header, counter
21// tiles, boxed decision and blocked, plain marker lines, one skip fold, optional
22// cost footer. Named colours only — no hex, no gradients.
23
24const WIDE = 80
25
26const RISK_COLOR: Record<NonNullable<DecisionOption['risk']>, string> = {
27 low: 'green',
28 medium: 'yellow',
29 high: 'red',
30}
31
32const GLYPH: Record<Marker, string> = {
33 done: '☑',
34 decide: '🟡',
35 blocked: '🔴',
36 read: '⚠️',
37 info: '▫️',
38 skip: 'ℹ️',
39}
40
41/** skip closed (same default as the other themes). */
42const SKIP_OPEN = false
43
44/** per-item summary/detail folds closed by default. */
45const ITEM_FOLD_OPEN = false
46
47/** Runtime toggle keys are `item:N:summary` / `item:N:detail`; cast for the narrow CardState type. */
48const itemFoldKey = (index: number, layer: 'summary' | 'detail'): keyof CardState['open'] =>
49 `item:${index}:${layer}` as keyof CardState['open']
50
51const itemFoldOpen = (ctx: ThemeCtx, index: number, layer: 'summary' | 'detail'): boolean =>
52 ctx.state.open[itemFoldKey(index, layer)] ?? ITEM_FOLD_OPEN
53
54const itemFoldRow = (
55 ctx: ThemeCtx,
56 index: number,
57 layer: 'summary' | 'detail',
58 open: boolean,
59 width: number,
60): RenderElement => {
61 const { Box, Text, Button } = ctx.el
62 const key = itemFoldKey(index, layer)
63 return Box({
64 key: `${ctx.id}:fold:${key}`,
65 flexDirection: 'row',
66 gap: 1,
67 width,
68 paddingLeft: 2,
69 children: [
70 Text({ dimColor: true, wrap: 'truncate', children: open ? layer : `+ ${layer}` }),
71 Button({
72 key: `${ctx.id}:toggle:${key}`,
73 label: open ? 'hide' : 'show',
74 dimColor: true,
75 onPress: () => ctx.actions.toggle(key),
76 }),
77 ],
78 })
79}
80
81const isPureAnswer = (model: CardModel): boolean =>
82 model.items.every(item => item.marker === 'info' || item.marker === 'read' || item.marker === 'skip')
83
84const isOneLine = (model: CardModel): boolean => {
85 if (model.items.length !== 1) return false
86 const marker = model.items[0]!.marker
87 return marker === 'done' || marker === 'read' || marker === 'info' || marker === 'skip'
88}
89
90const skipFoldLabel = (n: number): string => `ℹ️ ${n} skippable detail${n === 1 ? '' : 's'} hidden`
91
92const skipFoldRow = (ctx: ThemeCtx, count: number, open: boolean): RenderElement => {
93 const { Box, Text, Button } = ctx.el
94 return Box({
95 key: `${ctx.id}:fold:skip`,
96 flexDirection: 'row',
97 gap: 1,
98 children: [
99 Text({ dimColor: true, wrap: 'truncate', children: skipFoldLabel(count) }),
100 Button({
101 key: `${ctx.id}:toggle:skip`,
102 label: open ? 'hide' : 'show',
103 dimColor: true,
104 onPress: () => ctx.actions.toggle('skip'),
105 }),
106 ],
107 })
108}
109
110const costLine = (ctx: ThemeCtx): RenderElement | null => {
111 if (!ctx.usage) return null
112 if (ctx.usage.inputTokens === 0 && ctx.usage.outputTokens === 0) return null
113 const total = ctx.usage.inputTokens + ctx.usage.outputTokens
114 return ctx.el.Text({
115 dimColor: true,
116 children: `≈ ${formatCost(ctx.usage.costUsd)} · ${formatTokens(total)} · API price`,
117 })
118}
119
120const headerRow = (ctx: ThemeCtx, pure: boolean): RenderElement | null => {
121 const { Box, Text } = ctx.el
122 if (pure) {
123 return Box({
124 key: `${ctx.id}:header`,
125 flexDirection: 'row',
126 justifyContent: 'space-between',
127 width: ctx.columns,
128 children: [Text({ bold: true, children: '▫️ Answer' }), Text({ dimColor: true, children: 'nothing to do' })],
129 })
130 }
131 const witness = witnessMark(ctx)
132 if (witness.length === 0) return null
133 return Box({
134 key: `${ctx.id}:header`,
135 flexDirection: 'row',
136 justifyContent: 'flex-end',
137 width: ctx.columns,
138 children: witness,
139 })
140}
141
142const counterTile = (
143 ctx: ThemeCtx,
144 value: string,
145 label: string,
146 color: string,
147 active: boolean,
148 key: string,
149 width?: number,
150): RenderElement => {
151 const { Box, Text } = ctx.el
152 return Box({
153 key,
154 flexDirection: 'column',
155 borderStyle: 'round',
156 ...(active ? { borderColor: color } : { borderDimColor: true }),
157 ...(width ? { width } : {}),
158 paddingX: 1,
159 paddingY: 1,
160 children: [
161 Text({ bold: true, ...(active ? { color } : { dimColor: true }), children: value }),
162 Text({ dimColor: true, children: label }),
163 ],
164 })
165}
166
167const countersRow = (ctx: ThemeCtx, model: CardModel): RenderElement | null => {
168 const { Box, Text } = ctx.el
169 const { counts } = model
170 const tiles = [
171 { value: counts.decide, label: 'decision', color: 'yellow', key: `${ctx.id}:count:decide` },
172 { value: counts.done, label: 'done', color: 'green', key: `${ctx.id}:count:done` },
173 { value: counts.read, label: 'to read', color: 'yellow', key: `${ctx.id}:count:read` },
174 { value: counts.blocked, label: 'blocked', color: 'red', key: `${ctx.id}:count:blocked` },
175 ].filter(t => t.value > 0)
176 if (tiles.length === 0) return null
177 if (ctx.columns < WIDE) {
178 const narrow = tiles.map(t => `${t.value} ${t.label}`).join(' · ')
179 return Box({ key: `${ctx.id}:counters`, children: Text({ dimColor: true, wrap: 'wrap', children: narrow }) })
180 }
181 const n = tiles.length
182 const tileW = Math.min(28, Math.max(8, Math.floor((ctx.columns - (n - 1) * 2) / n)))
183 return Box({
184 key: `${ctx.id}:counters`,
185 flexDirection: 'row',
186 gap: 2,
187 width: ctx.columns,
188 children: tiles.map(t => counterTile(ctx, String(t.value), t.label, t.color, true, t.key, tileW)),
189 })
190}
191
192const META_W = 22
193const WARN_COLOR = '#D98324'
194
195/** the verdict sits in a round box like the ⚠️ one, bordered in the colour of its own marker. */
196const VERDICT_BORDER: Record<Marker, string> = {
197 done: 'green',
198 decide: 'yellow',
199 blocked: 'red',
200 read: WARN_COLOR,
201 info: 'green',
202 skip: 'green',
203}
204
205/** the recommended option wears a thin yellow rule, never a fill: a background makes the text fight the terminal's own colours. */
206const RULE = '▌'
207const RULE_COLOR = 'yellow'
208
209const optionSeparator = (ctx: ThemeCtx, width: number): RenderElement =>
210 ctx.el.Text({ dimColor: true, children: '─'.repeat(Math.max(8, width)) })
211
212const chooserRow = (ctx: ThemeCtx, option: DecisionOption, index: number, isDefault: boolean): RenderElement => {
213 const { Box, Text, Button } = ctx.el
214 const wide = ctx.columns >= WIDE
215 const inner = room(ctx.columns, 4)
216 const leftW = wide ? room(inner, META_W + 1) : inner
217 const onFill = isDefault ? {} : { dimColor: true as const }
218
219 const titleRow = Box({
220 flexDirection: 'row',
221 gap: 1,
222 children: [
223 Text({ ...onFill, bold: isDefault, children: `${index + 1} ·` }),
224 Button({
225 key: `${ctx.id}:option:${index}`,
226 label: option.label,
227 onPress: () => ctx.actions.fill(optionFill(option)),
228 }),
229 ...(isDefault ? [Text({ dimColor: true, children: 'default' })] : []),
230 ],
231 })
232
233 const leftCol: RenderElement[] = [
234 titleRow,
235 ...(option.detail
236 ? [
237 Box({
238 width: leftW,
239 children: Text({ wrap: 'wrap', children: cleanText(option.detail) }),
240 }),
241 ]
242 : []),
243 ]
244
245 const meta: RenderElement[] = []
246 if (option.estimate) meta.push(Text({ ...onFill, children: `≈ ${option.estimate}` }))
247 if (option.risk) {
248 meta.push(
249 Text({
250 ...(isDefault ? { color: RISK_COLOR[option.risk] } : { dimColor: true, color: RISK_COLOR[option.risk] }),
251 children: `risk: ${option.risk}`,
252 }),
253 )
254 }
255 if (option.cost) meta.push(Text({ ...onFill, children: option.cost }))
256
257 const rowProps = { key: `${ctx.id}:option-row:${index}` }
258 // one column of rule, one of air: every row pays for them so the labels stay on one grid
259 const rule = Text({ ...(isDefault ? { color: RULE_COLOR } : {}), children: isDefault ? RULE : ' ' })
260
261 if (wide) {
262 return Box({
263 ...rowProps,
264 flexDirection: 'row',
265 gap: 1,
266 width: inner,
267 children: [
268 rule,
269 Box({ flexDirection: 'column', width: leftW - 2, children: leftCol }),
270 ...(meta.length > 0 ? [Box({ flexDirection: 'column', width: META_W, alignItems: 'flex-end', children: meta })] : []),
271 ],
272 })
273 }
274
275 return Box({
276 ...rowProps,
277 flexDirection: 'row',
278 gap: 1,
279 width: inner,
280 children: [rule, Box({ flexDirection: 'column', width: inner - 2, children: [...leftCol, ...meta] })],
281 })
282}
283
284const chooserBlock = (ctx: ThemeCtx, decision: NonNullable<CardModel['decision']>): RenderElement => {
285 const { Box } = ctx.el
286 const inner = room(ctx.columns, 4)
287 const rows: RenderElement[] = []
288 for (let i = 0; i < decision.options.length; i++) {
289 if (i > 0) rows.push(optionSeparator(ctx, inner))
290 rows.push(chooserRow(ctx, decision.options[i]!, i, decision.defaultKey === decision.options[i]!.key))
291 }
292 return Box({ key: `${ctx.id}:options`, flexDirection: 'column', children: rows })
293}
294
295const decisionBox = (ctx: ThemeCtx, model: CardModel, index: number, verdict: boolean): RenderElement => {
296 const { Box, Text } = ctx.el
297 const decision = model.decision!
298 const item = model.items[index]
299 const clause = decision.options.length === 0 ? defaultLine(decision) : null
300 const inner = room(ctx.columns, 4)
301
302 const options: RenderElement[] =
303 decision.options.length >= 3
304 ? [chooserBlock(ctx, decision)]
305 : decision.options.length > 0
306 ? [Box({ flexDirection: 'row', gap: 1, children: optionButtons(ctx, decision) })]
307 : []
308
309 return Box({
310 key: `${ctx.id}:decision`,
311 flexDirection: 'column',
312 width: ctx.columns,
313 borderStyle: 'round',
314 borderColor: 'yellow',
315 paddingX: 1,
316 paddingY: 1,
317 gap: 1,
318 children: [
319 Box({
320 flexDirection: 'row',
321 justifyContent: 'space-between',
322 gap: 1,
323 children: [
324 Text({ color: 'yellow', children: '🟡 DECISION' }),
325 ...(clause ? [Text({ dimColor: true, wrap: 'truncate', children: `default ${clause}` })] : []),
326 ],
327 }),
328 Text({ bold: verdict, wrap: 'wrap', children: cleanText(decision.question) }),
329 ...options,
330 ...(item ? itemTail(ctx, item, index, inner) : []),
331 ],
332 })
333}
334
335const blockedBox = (ctx: ThemeCtx, item: CardItem, index: number): RenderElement => {
336 const { Box, Text } = ctx.el
337 const inner = room(ctx.columns, 4)
338 return Box({
339 key: `${ctx.id}:blocked:${index}`,
340 flexDirection: 'column',
341 width: ctx.columns,
342 borderStyle: 'round',
343 borderColor: 'red',
344 paddingX: 1,
345 paddingY: 1,
346 gap: 1,
347 children: [
348 Box({
349 flexDirection: 'row',
350 justifyContent: 'space-between',
351 gap: 1,
352 children: [Text({ color: 'red', children: '🔴 BLOCKED' }), Text({ dimColor: true, children: 'only you can unblock' })],
353 }),
354 Text({ bold: true, wrap: 'wrap', children: cleanText(item.text) }),
355 ...itemTail(ctx, item, index, inner),
356 ],
357 })
358}
359
360/** Continuations and fences under a marker line; `index` only for keys. */
361const itemTail = (ctx: ThemeCtx, item: CardItem, index: number, width: number): RenderElement[] => {
362 const blocks = codeBlocks(ctx, item)
363 return [
364 ...item.lines.map((line, i) =>
365 ctx.el.Box({
366 key: `${ctx.id}:item:${index}:line:${i}`,
367 width,
368 paddingLeft: 2,
369 children: ctx.el.Text({ dimColor: true, wrap: 'wrap', children: cleanText(line) }),
370 }),
371 ),
372 ...(blocks.length > 0
373 ? [ctx.el.Box({ key: `${ctx.id}:item:${index}:code`, paddingLeft: 2, width, children: blocks })]
374 : []),
375 ]
376}
377
378/**
379 * Inverted-pyramid fold for marker lines and the read box. With no `lines`, code
380 * blocks stay visible (themes.test.ts); folds appear only once `lines` is non-empty.
381 */
382const foldingItemTail = (ctx: ThemeCtx, item: CardItem, index: number, width: number): RenderElement[] => {
383 const hasCode = item.code.length > 0
384 const hasLines = item.lines.length > 0
385 if (!hasLines && !hasCode) return []
386 if (!hasLines) {
387 const blocks = codeBlocks(ctx, item)
388 return blocks.length > 0
389 ? [ctx.el.Box({ key: `${ctx.id}:item:${index}:code`, paddingLeft: 2, width, children: blocks })]
390 : []
391 }
392
393 const summaryOpen = itemFoldOpen(ctx, index, 'summary')
394 const detailOpen = itemFoldOpen(ctx, index, 'detail')
395 const rows: RenderElement[] = []
396
397 if (needsFoldRow(summaryOpen, ITEM_FOLD_OPEN)) rows.push(itemFoldRow(ctx, index, 'summary', summaryOpen, width))
398 if (summaryOpen) {
399 rows.push(
400 ctx.el.Box({
401 key: `${ctx.id}:item:${index}:line:0`,
402 width,
403 paddingLeft: 2,
404 children: ctx.el.Text({ dimColor: true, wrap: 'wrap', children: cleanText(item.lines[0]!) }),
405 }),
406 )
407 const hasDetail = item.lines.length > 1 || hasCode
408 if (hasDetail) {
409 if (needsFoldRow(detailOpen, ITEM_FOLD_OPEN)) rows.push(itemFoldRow(ctx, index, 'detail', detailOpen, width))
410 if (detailOpen) {
411 for (let i = 1; i < item.lines.length; i++) {
412 rows.push(
413 ctx.el.Box({
414 key: `${ctx.id}:item:${index}:line:${i}`,
415 width,
416 paddingLeft: 2,
417 children: ctx.el.Text({ dimColor: true, wrap: 'wrap', children: cleanText(item.lines[i]!) }),
418 }),
419 )
420 }
421 const blocks = codeBlocks(ctx, item)
422 if (blocks.length > 0) {
423 rows.push(ctx.el.Box({ key: `${ctx.id}:item:${index}:code`, paddingLeft: 2, width, children: blocks }))
424 }
425 }
426 }
427 }
428 return rows
429}
430
431const readBox = (ctx: ThemeCtx, item: CardItem, verdict: boolean, index: number): RenderElement => {
432 const { Box, Text } = ctx.el
433 const width = room(ctx.columns, 4)
434 return Box({
435 key: `${ctx.id}:read:${index}`,
436 flexDirection: 'column',
437 width: ctx.columns,
438 borderStyle: 'round',
439 borderColor: WARN_COLOR,
440 paddingX: 1,
441 children: [
442 Box({
443 flexDirection: 'row',
444 gap: 1,
445 children: [
446 Text({ children: GLYPH.read }),
447 Box({ width, children: Text({ bold: verdict, wrap: 'wrap', children: cleanText(item.text) }) }),
448 ],
449 }),
450 ...foldingItemTail(ctx, item, index, width),
451 ],
452 })
453}
454
455const markerLine = (ctx: ThemeCtx, item: CardItem, verdict: boolean, index: number): RenderElement => {
456 const { Box, Text } = ctx.el
457 const width = room(ctx.columns, 4)
458 const glyphProps = item.marker === 'done' ? { color: 'green' as const } : {}
459 return Box({
460 key: `${ctx.id}:marker:${index}`,
461 flexDirection: 'column',
462 width: ctx.columns,
463 children: [
464 Box({
465 flexDirection: 'row',
466 gap: 1,
467 children: [
468 Text({ children: GLYPH[item.marker], ...glyphProps }),
469 Box({ width, children: Text({ bold: verdict, wrap: 'wrap', children: cleanText(item.text) }) }),
470 ],
471 }),
472 ...foldingItemTail(ctx, item, index, width),
473 ],
474 })
475}
476
477const verdictBox = (ctx: ThemeCtx, item: CardItem, index: number): RenderElement => {
478 const { Box, Text } = ctx.el
479 const inner = room(ctx.columns, 3)
480 return Box({
481 key: `${ctx.id}:verdict`,
482 flexDirection: 'row',
483 width: ctx.columns,
484 borderStyle: 'round',
485 borderColor: VERDICT_BORDER[item.marker],
486 paddingX: 1,
487 children: [
488 Box({
489 flexDirection: 'column',
490 width: inner,
491 gap: 1,
492 children: [
493 Text({ bold: true, wrap: 'wrap', children: `${GLYPH[item.marker]} ${cleanText(item.text)}` }),
494 ...item.lines.map((line, i) =>
495 Box({ key: `${ctx.id}:verdict:line:${i}`, children: Text({ wrap: 'wrap', children: line }) }),
496 ),
497 ...codeBlocks(ctx, item),
498 ],
499 }),
500 ],
501 })
502}
503
504const oneLineCard = (ctx: ThemeCtx, item: CardItem): RenderElement => {
505 const { Box, Text } = ctx.el
506 const width = room(ctx.columns, 6)
507 return Box({
508 key: `${ctx.id}:oneline`,
509 flexDirection: 'row',
510 justifyContent: 'space-between',
511 width: ctx.columns,
512 borderStyle: 'round',
513 paddingX: 1,
514 children: [
515 Box({
516 flexDirection: 'row',
517 gap: 1,
518 children: [
519 Text({ children: GLYPH[item.marker], ...(item.marker === 'done' ? { color: 'green' } : {}) }),
520 Box({ width, children: Text({ wrap: 'wrap', children: cleanText(item.text) }) }),
521 ],
522 }),
523 ...witnessMark(ctx),
524 ],
525 })
526}
527
528const skipSection = (ctx: ThemeCtx, model: CardModel): RenderElement[] => {
529 const skipItems = model.items.filter(item => item.marker === 'skip')
530 if (skipItems.length === 0) return []
531 const open = isOpen(ctx.state, 'skip', SKIP_OPEN)
532 const rows: RenderElement[] = []
533 if (needsFoldRow(open, SKIP_OPEN)) rows.push(skipFoldRow(ctx, skipItems.length, open))
534 if (!open) return rows
535 for (const item of skipItems) {
536 const index = model.items.indexOf(item)
537 rows.push(markerLine(ctx, item, false, index))
538 }
539 return rows
540}
541
542const render = (model: CardModel, ctx: ThemeCtx): RenderElement => {
543 const { Box } = ctx.el
544 const rows: RenderElement[] = []
545
546 if (isOneLine(model)) {
547 rows.push(oneLineCard(ctx, model.items[0]!))
548 const cost = costLine(ctx)
549 if (cost) rows.push(cost)
550 return Box({
551 key: `${ctx.id}:card`,
552 flexDirection: 'column',
553 width: ctx.columns,
554 gap: 1,
555 children: [...preambleLines(ctx, model), ...rows],
556 })
557 }
558
559 const pure = isPureAnswer(model)
560 const verdict = verdictIndex(model)
561 const decision = decisionIndex(model)
562
563 const header = headerRow(ctx, pure)
564 if (header) rows.push(header)
565
566 const skipDrawn = new Set<number>()
567 if (decision >= 0) skipDrawn.add(decision)
568 for (let i = 0; i < model.items.length; i++) {
569 if (model.items[i]!.marker === 'blocked') skipDrawn.add(i)
570 }
571 if (verdict >= 0 && !pure && model.items[verdict]!.marker === 'read') skipDrawn.add(verdict)
572 if (verdict >= 0 && !skipDrawn.has(verdict)) {
573 rows.push(verdictBox(ctx, model.items[verdict]!, verdict))
574 skipDrawn.add(verdict)
575 }
576 if (!pure) {
577 const counters = countersRow(ctx, model)
578 if (counters) rows.push(counters)
579 }
580
581 if (decision >= 0) {
582 rows.push(decisionBox(ctx, model, decision, verdict === decision))
583 skipDrawn.add(decision)
584 }
585 for (let i = 0; i < model.items.length; i++) {
586 const item = model.items[i]!
587 if (item.marker === 'blocked') {
588 rows.push(blockedBox(ctx, item, i))
589 skipDrawn.add(i)
590 } else if (item.marker === 'decide' && i !== decision) {
591 rows.push(markerLine(ctx, item, i === verdict, i))
592 skipDrawn.add(i)
593 }
594 }
595
596 for (let i = 0; i < model.items.length; i++) {
597 const item = model.items[i]!
598 if (item.marker !== 'done' && item.marker !== 'read' && item.marker !== 'info') continue
599 if (skipDrawn.has(i)) continue
600 if (item.marker === 'read') rows.push(readBox(ctx, item, i === verdict, i))
601 else rows.push(markerLine(ctx, item, i === verdict, i))
602 }
603
604 rows.push(...skipSection(ctx, model))
605 const cost = costLine(ctx)
606 if (cost) rows.push(cost)
607
608 return Box({
609 key: `${ctx.id}:card`,
610 flexDirection: 'column',
611 width: ctx.columns,
612 gap: 1,
613 children: [...preambleLines(ctx, model), ...rows],
614 })
615}
616
617export const theme: Theme = {
618 id: 'gmorel',
619 name: 'gmorel 1.1.0',
620 tagline: 'K layout: counters, boxed decision, plain lines, cost footer',
621 render,
622}
623hooks/themes/receipt.ts 394 lines1import type { RenderElement, TextProps } from 'claude-code'
2import type { CardItem, CardModel, Decision, DecisionOption, Marker, Theme, ThemeCtx } from '../theme.ts'
3import {
4 cleanText,
5 codeBlocks,
6 decisionIndex,
7 grouped,
8 isOpen,
9 needsFoldRow,
10 optionFill,
11 preambleLines,
12 room,
13 sectionOf,
14 verdictIndex,
15 witnessMark,
16 type Group,
17 type Section,
18} from './_shared.ts'
19
20// Receipt (design/artboards/DirectionF.dc.html, F2 for questions): one colour,
21// ASCII only — a classic frame, dotted leaders, the question as an order form.
22
23const INK = 'yellow'
24const WIDE = 80
25/** classic border (2) + paddingX 1 (2) on the till-roll frame */
26const FRAME_SPENT = 4
27
28const ink = (ctx: ThemeCtx, props: TextProps & { children: string }): RenderElement =>
29 ctx.el.Text({ color: INK, ...props })
30
31const TAG: Record<Marker, string> = {
32 decide: '[??]',
33 blocked: '[!!]',
34 read: '[!!]',
35 done: '[OK]',
36 info: '',
37 skip: '',
38}
39
40/** skip closed (SPEC §4); chooser mode closes done/read/info. */
41const DEFAULT_OPEN: Record<Section, boolean> = { done: true, read: true, info: true, skip: false }
42
43const sectionOpen = (ctx: ThemeCtx, section: Section, chooser: boolean): boolean =>
44 isOpen(ctx.state, section, chooser && section !== 'skip' ? false : DEFAULT_OPEN[section])
45
46const restFolded = (ctx: ThemeCtx, chooser: boolean): boolean =>
47 chooser && !sectionOpen(ctx, 'done', true) && !sectionOpen(ctx, 'read', true) && !sectionOpen(ctx, 'info', true)
48
49const openRest = (ctx: ThemeCtx): void => {
50 for (const section of ['done', 'read', 'info'] as const) {
51 if (!sectionOpen(ctx, section, true)) ctx.actions.toggle(section)
52 }
53}
54
55const centred = (text: string, width: number): string => {
56 if (text.length >= width) return text.slice(0, width)
57 const left = Math.floor((width - text.length) / 2)
58 return ' '.repeat(left) + text + ' '.repeat(width - text.length - left)
59}
60
61const decisionContext = (item: CardItem | undefined): string | null => {
62 if (!item || item.lines.length === 0) return null
63 return item.lines.map(cleanText).join(' ')
64}
65
66const defaultClause = (decision: Decision): string | null =>
67 decision.defaultText === null ? null : `default: ${decision.defaultText}`
68
69const defaultNumber = (decision: Decision): number => {
70 if (decision.defaultKey !== null) {
71 const i = decision.options.findIndex(o => o.key === decision.defaultKey)
72 if (i >= 0) return i + 1
73 }
74 const i = decision.options.findIndex(o => o.label === decision.defaultText)
75 return i >= 0 ? i + 1 : 1
76}
77
78const foldFooter = (model: CardModel): string =>
79 `below the fold: ${model.counts.done} done [OK] · ${model.counts.read} read [!!] · ${model.counts.info} notes`
80
81const COL_NUM = 3
82const COL_GAP = 2
83const COL_TIME = 8
84const COL_RISK = 7
85const TABLE_FIXED = COL_NUM + COL_GAP + COL_GAP + COL_TIME + COL_GAP + COL_RISK
86
87const itemColWidth = (innerW: number): number => innerW - TABLE_FIXED
88
89const padCell = (text: string, width: number, align: 'left' | 'right' = 'left'): string => {
90 if (text.length >= width) return text.slice(0, width)
91 return align === 'right' ? ' '.repeat(width - text.length) + text : text + ' '.repeat(width - text.length)
92}
93
94const numCell = (num: string, isDefault: boolean): string => padCell(num + (isDefault ? '*' : ''), COL_NUM)
95
96const itemCell = (title: string, width: number): string => {
97 const upper = title.toUpperCase()
98 if (upper.length >= width) return upper.slice(0, width)
99 return upper + '.'.repeat(width - upper.length)
100}
101
102const tableLine = (
103 innerW: number,
104 num: string,
105 isDefault: boolean,
106 item: string,
107 time: string,
108 risk: string,
109): string => {
110 const iw = itemColWidth(innerW)
111 return (
112 numCell(num, isDefault) +
113 ' '.repeat(COL_GAP) +
114 itemCell(item, iw) +
115 ' '.repeat(COL_GAP) +
116 padCell(time.toUpperCase(), COL_TIME, 'right') +
117 ' '.repeat(COL_GAP) +
118 padCell(risk.toUpperCase(), COL_RISK, 'right')
119 )
120}
121
122const tableHeader = (innerW: number): string => {
123 const iw = itemColWidth(innerW)
124 return (
125 numCell('#', false) +
126 ' '.repeat(COL_GAP) +
127 padCell('ITEM', iw) +
128 ' '.repeat(COL_GAP) +
129 padCell('TIME', COL_TIME, 'right') +
130 ' '.repeat(COL_GAP) +
131 padCell('RISK', COL_RISK, 'right')
132 )
133}
134
135/** A row of the bill: item on the left, dotted leaders, stamp on the right. */
136const row = (ctx: ThemeCtx, item: CardItem, verdict: boolean, index: number, innerW: number): RenderElement => {
137 const { Box } = ctx.el
138 const tag = TAG[item.marker]
139 const witness = verdict ? witnessMark(ctx) : []
140 const width = room(innerW, (tag ? tag.length + 1 : 0) + (witness.length > 0 ? 2 : 0))
141 const text = item.text
142 const head: RenderElement[] =
143 tag && text.length + 3 <= width
144 ? [
145 ink(ctx, { bold: verdict, children: cleanText(text) }),
146 ink(ctx, { dimColor: true, children: ' ' + '.'.repeat(width - text.length - 1) }),
147 ...witness,
148 ink(ctx, { children: ' ' + tag }),
149 ]
150 : [
151 Box({ width, children: ink(ctx, { bold: verdict, wrap: 'wrap', children: cleanText(text) }) }),
152 ...witness,
153 ...(tag ? [ink(ctx, { children: ' ' + tag })] : []),
154 ]
155 return Box({
156 key: `${ctx.id}:row:${index}`,
157 flexDirection: 'column',
158 width: innerW,
159 children: [
160 Box({ flexDirection: 'row', children: head }),
161 ...item.lines.map((line, i) =>
162 Box({
163 key: `${ctx.id}:row:${index}:line:${i}`,
164 width: innerW,
165 paddingLeft: 2,
166 children: ink(ctx, { dimColor: true, wrap: 'wrap', children: cleanText(line) }),
167 }),
168 ),
169 ...codeBlocks(ctx, item),
170 ],
171 })
172}
173
174const decisionTwo = (ctx: ThemeCtx, model: CardModel, index: number, verdict: boolean, innerW: number): RenderElement[] => {
175 const { Box } = ctx.el
176 const decision = model.decision!
177 const item = model.items[index]
178 const witness = verdict ? witnessMark(ctx) : []
179 const tag = TAG.decide
180 const width = room(innerW, tag.length + 1 + (witness.length > 0 ? 2 : 0))
181 const clause = defaultClause(decision)
182 return [
183 Box({
184 key: `${ctx.id}:decision`,
185 flexDirection: 'row',
186 width: innerW,
187 children: [
188 Box({ width, children: ink(ctx, { bold: verdict, wrap: 'wrap', children: cleanText(decision.question).toUpperCase() }) }),
189 ...witness,
190 ink(ctx, { children: ' ' + tag }),
191 ],
192 }),
193 ...(clause ? [Box({ key: `${ctx.id}:decision:default`, width: innerW, children: ink(ctx, { dimColor: true, wrap: 'wrap', children: clause }) })] : []),
194 ...(decision.options.length > 0
195 ? [
196 Box({
197 key: `${ctx.id}:decision:options`,
198 flexDirection: 'row',
199 gap: 1,
200 children: decision.options.map((option, i) =>
201 ctx.el.Button({
202 key: `${ctx.id}:option:${i}`,
203 label: option.label,
204 onPress: () => ctx.actions.fill(optionFill(option)),
205 }),
206 ),
207 }),
208 ]
209 : []),
210 ...(item ? codeBlocks(ctx, item) : []),
211 ]
212}
213
214const tableRow = (
215 ctx: ThemeCtx,
216 option: DecisionOption,
217 index: number,
218 isDefault: boolean,
219 wide: boolean,
220 innerW: number,
221): RenderElement[] => {
222 const { Box } = ctx.el
223 const num = String(index + 1)
224 const title = cleanText(option.label)
225 const estimate = option.estimate?.toUpperCase() ?? ''
226 const risk = option.risk?.toUpperCase() ?? ''
227 const rows: RenderElement[] = []
228 if (wide) {
229 rows.push(
230 Box({
231 key: `${ctx.id}:table:${index}`,
232 width: innerW,
233 children: ink(ctx, {
234 bold: true,
235 children: tableLine(innerW, num, isDefault, title, estimate, risk),
236 }),
237 }),
238 )
239 } else {
240 rows.push(
241 Box({
242 key: `${ctx.id}:table:${index}`,
243 flexDirection: 'column',
244 width: innerW,
245 children: [
246 ink(ctx, { bold: true, children: `${num}${isDefault ? '*' : ''} ${title.toUpperCase()}` }),
247 ...(estimate || risk
248 ? [ink(ctx, { dimColor: true, children: [estimate, risk].filter(Boolean).join(' · ') })]
249 : []),
250 ],
251 }),
252 )
253 }
254 if (option.detail) {
255 rows.push(
256 Box({
257 key: `${ctx.id}:table:${index}:detail`,
258 width: innerW,
259 paddingLeft: wide ? 4 : 2,
260 children: ink(ctx, { dimColor: true, wrap: 'wrap', children: cleanText(option.detail) }),
261 }),
262 )
263 }
264 return rows
265}
266
267const decisionChooser = (ctx: ThemeCtx, model: CardModel, index: number, verdict: boolean, innerW: number): RenderElement[] => {
268 const { Box, Button } = ctx.el
269 const decision = model.decision!
270 const item = model.items[index]
271 const defNum = defaultNumber(decision)
272 const rows: RenderElement[] = [
273 ink(ctx, { inverse: true, children: centred('YOUR ORDER, PLEASE', innerW) }),
274 ink(ctx, { dimColor: true, children: centred(`1 question · pick one · default #${defNum}`, innerW) }),
275 Box({ width: innerW, children: ink(ctx, { bold: verdict, wrap: 'wrap', children: cleanText(decision.question).toUpperCase() }) }),
276 ...(() => {
277 const context = decisionContext(item)
278 return context ? [Box({ width: innerW, children: ink(ctx, { dimColor: true, wrap: 'wrap', children: context }) })] : []
279 })(),
280 ink(ctx, { dimColor: true, children: tableHeader(innerW) }),
281 ...decision.options.flatMap((option, i) => tableRow(ctx, option, i, decision.defaultKey === option.key, ctx.columns >= WIDE, innerW)),
282 ink(ctx, { dimColor: true, children: '* = default if you say nothing' }),
283 Box({
284 key: `${ctx.id}:decision:options`,
285 flexDirection: 'row',
286 gap: 1,
287 children: decision.options.map((option, i) =>
288 ctx.el.Button({
289 key: `${ctx.id}:option:${i}`,
290 label: String(i + 1),
291 onPress: () => ctx.actions.fill(optionFill(option)),
292 }),
293 ),
294 }),
295 Box({
296 key: `${ctx.id}:rest-fold`,
297 flexDirection: 'row',
298 gap: 1,
299 children: [
300 ink(ctx, { dimColor: true, wrap: 'truncate', children: foldFooter(model) }),
301 Button({ key: `${ctx.id}:rest:show`, label: 'show', dimColor: true, onPress: () => openRest(ctx) }),
302 ],
303 }),
304 ...(item ? codeBlocks(ctx, item) : []),
305 ]
306 return rows
307}
308
309const renderGroups = (ctx: ThemeCtx, groups: Group[], verdict: number, chooser: boolean, innerW: number): RenderElement[] => {
310 const { Box, Button } = ctx.el
311 const rows: RenderElement[] = []
312 for (const group of groups) {
313 const section = sectionOf(group.marker)
314 const open = section === null ? true : sectionOpen(ctx, section, chooser)
315 if (section !== null && needsFoldRow(open, chooser && section !== 'skip' ? false : DEFAULT_OPEN[section])) {
316 rows.push(
317 Box({
318 key: `${ctx.id}:fold:${section}`,
319 flexDirection: 'row',
320 gap: 1,
321 children: [
322 ink(ctx, {
323 dimColor: true,
324 wrap: 'truncate',
325 children: section === 'skip' ? `${group.items.length} skippable line${group.items.length === 1 ? '' : 's'}` : `${group.items.length} ${section}`,
326 }),
327 Button({
328 key: `${ctx.id}:toggle:${section}`,
329 label: open ? 'hide' : 'show',
330 dimColor: true,
331 onPress: () => ctx.actions.toggle(section),
332 }),
333 ],
334 }),
335 )
336 }
337 if (!open) continue
338 for (const placed of group.items) rows.push(row(ctx, placed.item, placed.index === verdict, placed.index, innerW))
339 }
340 return rows
341}
342
343const render = (model: CardModel, ctx: ThemeCtx): RenderElement => {
344 const { Box } = ctx.el
345 const verdict = verdictIndex(model)
346 const decision = decisionIndex(model)
347 const chooser = decision >= 0 && model.decision!.options.length >= 3
348 const groups = grouped(model, decision)
349 const innerW = room(ctx.columns, FRAME_SPENT)
350 const inner: RenderElement[] = []
351
352 if (chooser) {
353 inner.push(...decisionChooser(ctx, model, decision, verdict === decision, innerW))
354 } else {
355 inner.push(ink(ctx, { inverse: true, children: centred('FOR YOU', innerW) }))
356 const tally = [
357 model.counts.decide > 0 ? `${model.counts.decide} decision` : '',
358 model.counts.done > 0 ? `${model.counts.done} done` : '',
359 model.counts.read + model.counts.blocked > 0 ? `${model.counts.read + model.counts.blocked} read` : '',
360 ].filter(Boolean)
361 if (tally.length > 0) inner.push(ink(ctx, { dimColor: true, children: centred(tally.join(' · '), innerW) }))
362 if (decision >= 0) inner.push(...decisionTwo(ctx, model, decision, verdict === decision, innerW))
363 }
364
365 if (!chooser || !restFolded(ctx, chooser)) {
366 inner.push(...renderGroups(ctx, groups, verdict, chooser, innerW))
367 }
368
369 return Box({
370 key: `${ctx.id}:card`,
371 flexDirection: 'column',
372 width: ctx.columns,
373 children: [
374 ...preambleLines(ctx, model),
375 Box({
376 key: `${ctx.id}:frame`,
377 flexDirection: 'column',
378 width: ctx.columns,
379 borderStyle: 'classic',
380 borderDimColor: true,
381 paddingX: 1,
382 children: inner,
383 }),
384 ],
385 })
386}
387
388export const theme: Theme = {
389 id: 'receipt',
390 name: 'Receipt',
391 tagline: 'framed till roll, ASCII, the question as an order form',
392 render,
393}
394hooks/themes/_shared.ts 185 lines1import type { RenderElement } from 'claude-code'
2import type { CardItem, CardModel, CardState, Decision, DecisionOption, Marker, ThemeCtx } from '../theme.ts'
3
4// What the three themes of v0.1 agree on, so the agreement lives in one file
5// and not in three: the order the markers are drawn in, the fold arithmetic,
6// the single bold line and the witness mark. Pure: no `$`, no module state.
7
8/** A group of items that can be folded away. `decide` and `blocked` have no fold: they are why the card was drawn. */
9export type Section = 'done' | 'read' | 'info' | 'skip'
10
11/**
12 * The order SPEC §4 fixes: what you must answer, then what could bite you,
13 * then what happened, then the rest. `blocked` is not in §4's list; it rides
14 * with the head of the card because it, like `decide`, waits on the reader.
15 */
16export const ORDER: readonly Marker[] = ['decide', 'blocked', 'read', 'done', 'info', 'skip']
17
18export const sectionOf = (marker: Marker): Section | null =>
19 marker === 'done' || marker === 'read' || marker === 'info' || marker === 'skip' ? marker : null
20
21/** The reader's choice when they made one, the theme's default when they did not. */
22export const isOpen = (state: CardState, section: Section, byDefault: boolean): boolean =>
23 state.open[section] ?? byDefault
24
25/**
26 * Every fold must be reversible, but a section that is open because the theme
27 * opens it by default needs no line of chrome above it — the mockups show a
28 * summary only where something is hidden. So: draw the summary when the
29 * section is closed, or when the reader had to open it themselves.
30 */
31export const needsFoldRow = (open: boolean, byDefault: boolean): boolean => !open || !byDefault
32
33/** How a folded section counts itself. Plural by hand: these are four fixed words, not a grammar. */
34export const countLabel = (section: Section, n: number): string => {
35 if (section === 'skip') return `${n} skippable line${n === 1 ? '' : 's'}`
36 if (section === 'info') return `${n} note${n === 1 ? '' : 's'}`
37 if (section === 'read') return `${n} to read`
38 return `${n} done`
39}
40
41/** One item with the index it has in `model.items`, so a theme can name the verdict by position and not by equality. */
42export type Placed = { item: CardItem; index: number }
43export type Group = { marker: Marker; items: Placed[] }
44
45/** The card's items in §4's order, grouped by marker; `skipIndex` drops the item a theme draws elsewhere (the decision). */
46export const grouped = (model: CardModel, skipIndex: number): Group[] => {
47 const placed: Placed[] = model.items.map((item, index) => ({ item, index }))
48 return ORDER.map(marker => ({
49 marker,
50 items: placed.filter(p => p.item.marker === marker && p.index !== skipIndex),
51 })).filter(group => group.items.length > 0)
52}
53
54/**
55 * Where the one bold line sits. `model.verdict` is normally the very object
56 * the parser put in `items`; the marker+text fallback is there so a parser
57 * that copies instead of aliasing still marks exactly one line, never two.
58 */
59export const verdictIndex = (model: CardModel): number => {
60 const verdict = model.verdict
61 if (!verdict) return -1
62 const byIdentity = model.items.indexOf(verdict)
63 if (byIdentity >= 0) return byIdentity
64 return model.items.findIndex(item => item.marker === verdict.marker && item.text === verdict.text)
65}
66
67/** The index of the item the decision was parsed from, or -1: the theme draws that one as the decision and not as an ordinary line. */
68export const decisionIndex = (model: CardModel): number =>
69 model.decision === null ? -1 : model.items.findIndex(item => item.marker === 'decide')
70
71export const WITNESS = '✓'
72
73/** control characters except tab and newline, stripped before Text and Code */
74export const cleanText = (text: string): string => text.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
75
76/**
77 * The mod's own mark, never the model's (SPEC §3.3). Hidden observer reports
78 * show an amber flag; a running observer with none hidden shows dim green ✓.
79 */
80export const witnessMark = (ctx: ThemeCtx): RenderElement[] => {
81 const { hiddenReports, observerRunning } = ctx.witness
82 if (hiddenReports > 0) {
83 const label = `⚑ ${hiddenReports} observer report${hiddenReports === 1 ? '' : 's'}`
84 return [ctx.el.Text({ dimColor: true, color: 'yellow', children: label })]
85 }
86 if (observerRunning) return [ctx.el.Text({ dimColor: true, color: 'green', children: WITNESS })]
87 return []
88}
89
90/** plain preamble lines above the card */
91export const preambleLines = (ctx: ThemeCtx, model: CardModel): RenderElement[] =>
92 model.preamble.map((line, i) =>
93 ctx.el.Box({
94 key: `${ctx.id}:preamble:${i}`,
95 width: ctx.columns,
96 children: ctx.el.Text({ wrap: 'wrap', children: cleanText(line) }),
97 }),
98 )
99
100/** The fold's summary line: a count and the one control that reverses the fold. */
101export const foldRow = (
102 ctx: ThemeCtx,
103 section: Section,
104 count: number,
105 open: boolean,
106 style: { color?: string } = {},
107): RenderElement => {
108 const { Box, Text, Button } = ctx.el
109 return Box({
110 key: `${ctx.id}:fold:${section}`,
111 flexDirection: 'row',
112 gap: 1,
113 children: [
114 // truncate here and only here: a summary is chrome, and chrome that
115 // wraps onto a second line costs more than the line it hides
116 Text({ dimColor: true, ...style, wrap: 'truncate', children: countLabel(section, count) }),
117 Button({
118 key: `${ctx.id}:toggle:${section}`,
119 label: open ? 'hide' : 'show',
120 dimColor: true,
121 onPress: () => ctx.actions.toggle(section),
122 }),
123 ],
124 })
125}
126
127/** CodeProps caps `source`; a longer fence is cut rather than failing the whole tree's validation. */
128const MAX_CODE = 10000
129
130/** The fences that followed an item, drawn by the engine's own highlighter. */
131export const codeBlocks = (ctx: ThemeCtx, item: CardItem): RenderElement[] =>
132 item.code.map(block => {
133 const source = cleanText(block.source.length > MAX_CODE ? block.source.slice(0, MAX_CODE) : block.source)
134 return ctx.el.Code({ source, ...(block.lang ? { language: block.lang } : {}) })
135 })
136
137/** what a decision button puts in the prompt box */
138export const optionFill = (option: DecisionOption): string =>
139 option.label.length <= 40 ? option.label : (option.key ?? option.label)
140
141/**
142 * The decision's answers, one Button each. A press fills the prompt box and
143 * stops there: nothing is ever sent on the reader's behalf (SPEC §1).
144 */
145export const optionButtons = (ctx: ThemeCtx, decision: Decision): RenderElement[] =>
146 decision.options.map((option, i) =>
147 ctx.el.Button({
148 key: `${ctx.id}:option:${i}`,
149 label: option.label,
150 onPress: () => ctx.actions.fill(optionFill(option)),
151 }),
152 )
153
154/** The default clause, said the same way in all three themes. */
155export const defaultLine = (decision: Decision): string | null =>
156 decision.defaultText === null ? null : `if you say nothing: ${decision.defaultText}`
157
158/** Room for a text column once the fixed chrome around it is paid for; never below 8, never above what the site gave us. */
159export const room = (columns: number, spent: number): number => Math.max(8, columns - spent)
160
161/** inserts a space after the last separator at or before width inside long runs so Ink can wrap with minimal breaks */
162export const softBreak = (text: string, width: number): string => {
163 if (width < 1 || text.length <= width) return text
164 return text.replace(/\S+/g, run => {
165 if (run.length <= width) return run
166 let out = ''
167 let segStart = 0
168 let lastSep = -1
169 for (let i = 0; i < run.length; i++) {
170 if (/[/.\-_]/.test(run[i]!)) lastSep = i
171 const segLen = i - segStart + 1
172 if (segLen >= width) {
173 if (lastSep >= segStart && lastSep !== run.length - 1) {
174 out += run.slice(segStart, lastSep + 1) + ' '
175 segStart = lastSep + 1
176 lastSep = -1
177 i = segStart - 1
178 }
179 }
180 }
181 out += run.slice(segStart)
182 return out
183 })
184}
185