SLOPSHOPPER

signal-only-ui

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

newrowsguardcommandprocess
v0.8.0MITupdated 2026-09-18gmorel/claude-code-signal-only-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · signal-only-ui
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /signal ⎿ signal-only-ui: signal: theme gmorel · observer on · fold on · audit off · 0 reports hidden · no observer running ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

signal-only-ui

Claude writes the substance. The mod draws the form.

Pain point

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.

MIT Claude Code 2.1.273+ function hooks · early access tests

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.

the gmorel theme, captured from a real session

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 …

Why

Three things a prompt cannot fix, measured on one week of one user's sessions:

  • 20 replies generated twice. A Stop hook re-ran the model every time a reply was not wrapped in a blockquote — all 20 blocks were that one formatting rule, six of them on "waiting for the agent" one-liners and two on a lone ```mermaid block. The mod frames the card itself, so the rule can go.
  • 353 soft formatting faults nobody read. "One bold only", "🟢 line too long", "header missing" — logged, never acted on. The renderer makes them moot: it bolds the verdict once, folds the long lines, draws the header.
  • Observer reports in the transcript. Background observer agents (claim-sentinel and friends) talk to the model, not to you. Their reports are hidden; the card shows a dim ✓ while an observer is running.

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.

Install

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.

Themes

animated demo: a real Claude Code session, API mocked

The GIF is a real Claude Code session with the API mocked, recorded by npm run demo — not a mock-up.

themein one linecardquestion
gmorelAnswer 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.gmorelgmorel question
receiptFramed till roll, ASCII, dotted leaders to [OK] [!!] [??]; the question as an order form.receiptreceipt question
kanbanSticky 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.kanbankanban question
yoursDesign 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.

Use

commandwhat it does
/signalcurrent theme, observer mark, folding and audit, as one line
/signal theme <id>switch theme, redraw the cards on screen, remember it
/signal themesthe 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 resetrestore theme, observer mark, folding and audit to the plugin defaults
/signal observersthe hidden observer reports of this session, newest first; the ⚑ on a card counts reports hidden since the previous card was drawn
/signal agentswhat $.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.

Options

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" } } } }
optiondefaultmeaning
themegmorelwhich renderer draws the card (/signal theme overrides it for the session and remembers)
observerMarktruehide observer reports, draw the witness ✓ while an observer runs
observerTypesclaim-sentinelcomma-separated agent types counted as observers (/signal agents lists the names)
foldNarrationtruefold assistant text followed by a tool call in the same turn into one dim line
foldNotificationstruefold task, peer and scheduled-trigger notifications into one dim line
auditfalseafter 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
pricesbuilt-inAPI list prices for the cost footer, USD per million tokens, family:input/output — e.g. opus:15/75,sonnet:3/15

How it works

  • 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.
  • Display only. The transcript keeps the model's text untouched; claude -p, other machines and JSONL readers see the plain card.

Limits

  • Early access API: a Claude Code update can change what a hook receives. If a card stops drawing after claude update, run /plugin-types in the repo and read the diff.
  • Themes are designed for a dark, truecolor terminal; on a 16-colour terminal the hex tints degrade to bright approximations and the dim greys are hard to read on a light background — receipt is the most monochrome theme and the best fallback.
  • There is no vector path. The terminal's element table has no 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.
  • Terminal and Claude Desktop are both drawn by the same tree; Desktop gets no ANSI colour beyond what the element props express, and only a Desktop build shipping Claude Code 2.1.273+ runs function hooks.
  • ```mermaid fences inside a card are drawn as code, not as box art; claude-mermaid draws top-level fences.
  • The ✓ means an observer was running and had nothing hidden since the previous card.
  • The cost footer is the API list price of the turn's usage, not what a subscription plan bills — hence "API price". The price table is hardcoded and dated (rates taken 2026-09); the figure shown is an estimate — override with the prices option. Cache reads are priced at 10% of input and cache writes at 125%, per Anthropic's published cache multipliers.
  • A mod runs in-process with Claude Code's own credentials. Read what you install; this one calls $.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).

Develop

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.

Credits and licence

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.

Source 11 files
hooks/register.ts 453 lines
1import 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}
453
hooks/card.ts 283 lines
1import { 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}
283
hooks/fold.ts 133 lines
1// 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}
133
hooks/audit.ts 109 lines
1import 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})
109
hooks/cost.ts 73 lines
1import 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}
73
hooks/themes/index.ts 16 lines
1import 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
16
hooks/theme.ts 120 lines
1import 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'>
120
hooks/themes/kanban.ts 747 lines
1import 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}
747
hooks/themes/gmorel.ts 623 lines
1import 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}
623
hooks/themes/receipt.ts 394 lines
1import 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}
394
hooks/themes/_shared.ts 185 lines
1import 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