Choice UI tool (select, multi, rank, compare matrix, wizard, regenerate), approve-plan tool, in-thread charts, plus skills ask-dont-guess, show-dont-tell…

Mod + skills for decisions.
Tool mcp__your-call__ask (Claude calls it instead of guessing; a system rule tells it when):
showIf makes a step conditional on an earlier answer; changing that answer resets the steps that depended on it. The review screen's lines jump back to a step.recommended: true on an option preselects it (with the step's one-line why); your last pick for the same question is marked "last time"; a text box takes your own answer; in a single-step pane picking an option confirms it.audience tally from ask-the-audience is charted in the pane; compare scores are labelled as estimates unless measured; a toast reminds you after idleMinutes (5), and Claude is told there was no answer after giveUpMinutes (30)./decisions lists this session and /decisions <n> puts a reopen request in the prompt box.autoConfirm, rememberPicks, idleMinutes, giveUpMinutes, confidence, timeoutPick (use the recommended option if you never answer).viz: bars, quadrant, tree or flow, drawn as text charts that mark your pick.In-thread charts: fenced viz JSON blocks in Claude's replies are drawn as charts in the thread (text in the terminal, SVG on desktop, VS Code and mobile).
Skills
ask-dont-guess: list assumptions, ask before non-trivial or irreversible actions, show what will run, report what happened.show-dont-tell: picks a visual route (in-thread chart, side-panel file, text fallback) for complex answers.phone-a-friend: independent second opinion from a fresh subagent; disagreements go to you.ask-the-audience: 3-5 independent subagents vote; split votes go to you.Approve-plan tool mcp__your-call__approve: before doing several things the user has not seen, Claude lists them (label, command, risk low/medium/high, action read/edit/ship/delete/other). A pane shows each with a tick (low and medium start ticked, high-risk and deletes start unticked), an optional note, and Approve selected / Approve all / Reject all / Skip. Claude does only what comes back in approved.
Simple asks need only {question, options: ["A","B"], recommended: "A", why} or {question, yesno: true}.
With /lgtm (looks-good-to-me): questions whose options include a recommended one are answered for you, and plan items the variant covers come back pre-approved (shown as "auto-approved" in the pane). Everything it approves is recorded and listed by /lgtm log. Without that plugin nothing changes.
Mobile has no text inputs, so the boxes become buttons there (None fit).
hooks/register.tsx 917 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Approval, PlanRow, PlanView, Step, View } from '../types'
5
6import { cardLines } from './card'
7import { defaultTicks, gatePermitsItem, normalizePlan } from './plan'
8import { CONFIDENCE, OTHER, initialPicks, isComplete, lastKey, nextVisible, prevVisible, recommendedFor, redoOf, resetDependents, resumeFrom, toggleVeto, undoOf, visibleIdx, weightKey, weightOf, weightedTotals, withUndo } from './flow'
9import { normalizeSpec } from './spec'
10import { answerKey, keyedLines, splitViz, svgFor, textLines } from './viz'
11
12const PANE = 'your-call'
13
14/** The `last` store after these decisions: the picks to offer again next time, per question. */
15const nextLast = (saved: Record<string, string[]>, decisions: { question: string; picks: string[] }[]): Record<string, string[]> => {
16 const next = { ...saved }
17 for (const d of decisions) {
18 const p = d.picks.filter(x => x !== OTHER)
19 if (p.length) next[lastKey(d.question)] = p
20 }
21 return next
22}
23
24const viewFrom = (title: string, steps: Step[], resumed: ReturnType<typeof resumeFrom>, saved: Record<string, string[]>): View => ({
25 title,
26 steps,
27 idx: 0,
28 picks: { ...initialPicks(steps), ...resumed.picks },
29 notes: resumed.notes ?? {},
30 phase: 'step',
31 regen: '',
32 weights: resumed.weights ?? {},
33 fromReview: false,
34 others: resumed.others ?? {},
35 last: Object.fromEntries(steps.map(s => [s.id, saved[lastKey(s.question)] ?? []])),
36 vetoed: resumed.vetoed ?? {},
37 confidence: resumed.confidence ?? {},
38 undo: [],
39 redo: [],
40 showAll: {},
41})
42
43const LIMIT = 6
44
45type PickGate = { phase?: string; allow?: { pick?: boolean } | null } | null
46
47const pickPreApproved = (gate: PickGate): boolean => gate?.phase === 'active' && !!gate.allow?.pick
48
49/** The outcome of a one-question ask: the option the label names, or the person's own words. */
50const simpleOutcome = (s: Step, label: string): Outcome => {
51 const hit = s.options.find(o => o.label === label || `${o.label}${REC}` === label)
52 return { decisions: [{ step: s.id, question: s.question, picks: [hit?.id ?? OTHER], labels: [hit?.label ?? `Other: ${label}`], other: hit ? undefined : label }] }
53}
54
55const weightedFor = (step: Step, weights: Record<string, number>) => (step.kind === 'compare' && step.criteria ? weightedTotals(step, weights) : undefined)
56
57const nextLabelOf = (isLast: boolean, fromReview: boolean, many: boolean): string => {
58 if (!isLast && !fromReview) return 'Next'
59 return many ? 'Review' : 'Confirm'
60}
61
62const isAutoConfirm = (autoConfirm: boolean, stepCount: number, kind: Step['kind']): boolean => autoConfirm && stepCount === 1 && kind === 'one'
63
64/** The options a step shows: all of them, or the first few plus anything already picked, until "show more". */
65const shownOptions = (step: Step, showAll: boolean, picks: string[]): Step['options'] =>
66 showAll || step.options.length <= LIMIT + 1 ? step.options : step.options.filter((o, i) => i < LIMIT || picks.includes(o.id))
67
68const toggled = (list: string[], id: string): string[] => (list.includes(id) ? list.filter(x => x !== id) : [...list, id])
69
70/** `list` with `id` moved `d` places; the same list when it would fall off either end. */
71const moved = (list: string[], id: string, d: number): string[] => {
72 const i = list.indexOf(id)
73 const j = i + d
74 if (i < 0 || j < 0 || j >= list.length) return list
75 const out = [...list]
76 const held = out[i]
77 out[i] = out[j]
78 out[j] = held
79 return out
80}
81
82const riskTone = (r: PlanRow['risk']): string => {
83 if (r === 'high') return 'red'
84 return r === 'medium' ? 'yellow' : 'green'
85}
86
87const markOf = (many: boolean, on: boolean): string => {
88 if (many) return on ? '☑' : '☐'
89 return on ? '◉' : '○'
90}
91
92const progressTone = (here: boolean, done: boolean): string | undefined => {
93 if (here) return 'cyan'
94 return done ? 'green' : undefined
95}
96const TOOL = 'mcp__your-call__ask'
97const view = atom({ plugin: 'your-call', key: 'view' } as const, null)
98const plan = atom({ plugin: 'your-call', key: 'plan' } as const, null)
99const approvals = atom({ plugin: 'your-call', key: 'approvals' } as const, [])
100const ready = atom({ plugin: 'your-call', key: 'ready' } as const, false)
101const PLAN_PANE = 'your-call-plan'
102const APPROVE = 'mcp__your-call__approve'
103
104const RULE = `When a request is ambiguous, has several viable approaches, or needs the user's preference on a non-trivial choice, do NOT guess: call the \`${TOOL}\` tool (use it instead of the built-in AskUserQuestion, never both). For one simple question send just {question, options: ["A","B"], recommended: "A", why} or {question, yesno: true}; use steps for anything richer. Give 2-6 options with short labels, a one-line detail, and pros/cons when they differ. Use kind "many" for multi-select, "rank" to prioritise, "compare" with criteria (scores 1-5) for tradeoffs; use several steps for a dependent multi-part problem, and showIf ({step, picked?, notPicked?}, naming an earlier step) to ask a step only when an earlier answer calls for it. In compare steps give each criterion an optional weight 0-10 (default 5); the user can change weights live and the result returns the final weights and the leader. The answer returns the user's picks, per-option notes, or {regenerate: guidance} meaning propose different options. Add a \`viz\` (bars, quadrant, tree, flow) to a step only when a picture clarifies a complex tradeoff or structure, never as decoration. Mark the option you would pick with recommended: true and give the step a one-line why. If you polled ask-the-audience, pass its tally as audience: {votes: {optionId: count}}. Set measured: true on a compare step only when its scores are real measurements, not your estimates. The user may also type their own answer (returned as other). Other results: {explain: {step, option, label}, state} means explain that option in a sentence or two, then call this tool again with the same steps plus resume: <the state you got>; {deferred: true, state} means carry on with a safe, reversible default, say what you assumed, and re-ask later; {regenerate, vetoed} means propose new options and never offer a vetoed one again; decisions may carry confidence (low/medium/high) and vetoed. Before doing several things the user has not seen (commands to run, files to change, a push), call \`${APPROVE}\` with items [{label, command?, detail?, risk: low|medium|high, action: read|edit|ship|delete|other}] and do only what comes back in approved. Skip the tools for trivial or clearly-specified work.`
105
106const SCHEMA = {
107 type: 'object',
108 properties: {
109 title: { type: 'string' },
110 question: { type: 'string', description: 'Shorthand for one question: use with options (strings), or yesno: true' },
111 options: { type: 'array', items: { type: 'string' }, description: 'Shorthand: the choices for `question`' },
112 yesno: { type: 'boolean', description: 'Shorthand: a Yes/No question' },
113 multi: { type: 'boolean', description: 'Shorthand: allow several answers' },
114 recommended: { type: 'string', description: 'Shorthand: the option you would pick (label)' },
115 why: { type: 'string', description: 'Shorthand: one line on why the recommended option' },
116 resume: { type: 'object', description: 'The state from a previous explain/defer/regenerate result, to continue where the user left off' },
117 steps: {
118 type: 'array',
119 items: {
120 type: 'object',
121 required: ['id', 'question', 'options'],
122 properties: {
123 id: { type: 'string' },
124 question: { type: 'string' },
125 kind: { type: 'string', enum: ['one', 'many', 'rank', 'compare'] },
126 options: {
127 type: 'array',
128 items: {
129 type: 'object',
130 required: ['id', 'label'],
131 properties: {
132 id: { type: 'string' },
133 label: { type: 'string' },
134 detail: { type: 'string' },
135 pros: { type: 'array', items: { type: 'string' } },
136 cons: { type: 'array', items: { type: 'string' } },
137 recommended: { type: 'boolean', description: 'The option you would pick; it starts selected' },
138 },
139 },
140 },
141 viz: {
142 type: 'object',
143 description:
144 'Optional chart, only when a picture clarifies a complex tradeoff or structure. {type:"bars",values:{optId:n}} | {type:"quadrant",x,y,points:[{id,x:0-10,y:0-10}]} | {type:"tree",nodes:[{label,children}]} | {type:"flow",lanes:[[stepA,stepB],...]}',
145 },
146 why: { type: 'string', description: 'One line: why the recommended option' },
147 measured: { type: 'boolean', description: 'true only if compare scores are measurements, not estimates' },
148 audience: {
149 type: 'object',
150 description: 'Tally from ask-the-audience: {votes: {optionId: count}}',
151 properties: { votes: { type: 'object' } },
152 },
153 showIf: {
154 type: 'object',
155 description: 'Show this step only if an earlier step (by id) had one of `picked` options chosen, or none of `notPicked`.',
156 properties: {
157 step: { type: 'string' },
158 picked: { type: 'array', items: { type: 'string' } },
159 notPicked: { type: 'array', items: { type: 'string' } },
160 },
161 },
162 criteria: {
163 type: 'array',
164 items: {
165 type: 'object',
166 required: ['name', 'scores'],
167 properties: { name: { type: 'string' }, scores: { type: 'object' }, weight: { type: 'number', description: '0-10, default 5' } },
168 },
169 },
170 },
171 },
172 },
173 },
174}
175
176const APPROVE_SCHEMA = {
177 type: 'object',
178 required: ['items'],
179 properties: {
180 title: { type: 'string' },
181 why: { type: 'string', description: 'One line: why this plan' },
182 items: {
183 type: 'array',
184 items: {
185 type: 'object',
186 required: ['label'],
187 properties: {
188 id: { type: 'string' },
189 label: { type: 'string', description: 'What will happen, in a few words' },
190 command: { type: 'string', description: 'The exact command, if there is one' },
191 detail: { type: 'string' },
192 risk: { type: 'string', enum: ['low', 'medium', 'high'] },
193 action: { type: 'string', enum: ['read', 'edit', 'ship', 'delete', 'other'] },
194 },
195 },
196 },
197 },
198}
199
200type Spec = { title?: string; steps?: Step[] }
201type State = Pick<View, 'picks' | 'notes' | 'weights' | 'vetoed' | 'others' | 'confidence'>
202type Outcome =
203 | { decisions: unknown[]; usedDefault?: boolean; autoApproved?: boolean }
204 | { regenerate: string; vetoed?: Record<string, string[]>; state?: State }
205 | { cancelled: true; timedOut?: boolean }
206 | { deferred: true; state: State }
207 | { explain: { step: string; option: string; label: string }; state: State }
208
209export const stateOf = (v: View): State => ({
210 picks: v.picks,
211 notes: v.notes,
212 weights: v.weights,
213 vetoed: v.vetoed,
214 others: v.others,
215 confidence: v.confidence,
216})
217
218type Logged = { title: string; at: number; steps: Step[]; decisions: unknown[]; state: State }
219
220const isSimple = (s: Step[]) =>
221 s.length === 1 &&
222 s[0].kind === 'one' &&
223 s[0].options.length <= 4 &&
224 !s[0].criteria &&
225 !s[0].viz &&
226 !s[0].why &&
227 !s[0].audience &&
228 s[0].options.every(o => !o.detail && !o.pros?.length && !o.cons?.length)
229
230const REC = ' (Recommended)'
231
232const labelsFor = (s: Step, picks: string[], others: Record<string, string>): string[] =>
233 picks.map(id => (id === OTHER ? `Other: ${others[s.id] ?? ''}` : (s.options.find(o => o.id === id)?.label ?? id)))
234
235/** What the model gets back: the visible steps with picks, labels, notes, other text, weights and leader. */
236export const buildDecisions = (v: View, picks: Record<string, string[]> = v.picks) =>
237 visibleIdx(v.steps, picks).map(i => {
238 const s = v.steps[i]
239 const p = picks[s.id] ?? []
240 const base = {
241 step: s.id,
242 question: s.question,
243 picks: p,
244 labels: labelsFor(s, p, v.others),
245 notes: Object.fromEntries(
246 Object.entries(v.notes).filter(([k]) => k.startsWith(`${s.id}:`)).map(([k, t]) => [k.slice(s.id.length + 1), t]),
247 ),
248 other: p.includes(OTHER) ? v.others[s.id] : undefined,
249 vetoed: v.vetoed[s.id]?.length ? v.vetoed[s.id] : undefined,
250 confidence: v.confidence[s.id],
251 }
252 if (s.kind !== 'compare' || !s.criteria) return base
253 return {
254 ...base,
255 weights: Object.fromEntries(s.criteria.map(c => [c.name, weightOf(s.id, c, v.weights)])),
256 leader: weightedTotals(s, v.weights).leader,
257 }
258 })
259
260const RULE_LINE = '─'.repeat(40)
261// Toasts show 3 lines of 40 columns (see too-long-didnt-read/hooks/toast.ts).
262export const TOASTS = { idle: 'your-call: still waiting on your answer', unplaced: 'your-call: widen the terminal or reopen to answer' }
263
264/** A fresh view of a spec: nothing resumed, recommended options preselected. */
265const blankView = (title: string, steps: Step[], picks: Record<string, string[]>): View => ({
266 title,
267 steps,
268 idx: 0,
269 picks,
270 notes: {},
271 phase: 'step',
272 regen: '',
273 weights: {},
274 fromReview: false,
275 others: {},
276 last: {},
277 vetoed: {},
278 confidence: {},
279 undo: [],
280 redo: [],
281 showAll: {},
282})
283
284const short = (q: string, n = 16) => (q.length > n ? `${q.slice(0, n - 1)}…` : q)
285const num = (v: unknown, d: number) => (typeof v === 'number' && v >= 0 ? v : d)
286
287export const register: Register = (on, options) => {
288 let finish: ((o: Outcome) => void) | null = null
289 let planFinish: ((o: object) => void) | null = null
290 const idleMs = num(options.idleMinutes, 5) * 60000
291 const giveUpMs = num(options.giveUpMinutes, 30) * 60000
292 const autoConfirm = options.autoConfirm !== false
293 const remember = options.rememberPicks !== false
294 const askConfidence = options.confidence !== false
295 const timeoutPick = options.timeoutPick === true
296 const history: Logged[] = []
297
298 on('session.start', async ($, e, next) => {
299 // Plugins that depend on this one (too-long-didnt-read) read this to know the charts will be drawn.
300 await update($, ready, () => true)
301 await $.tool.register({
302 name: 'ask',
303 description:
304 'Ask the user to choose or refine via rich UI (select, multi, rank, compare matrix, multi-step wizard). Blocks until answered.',
305 inputSchema: SCHEMA,
306 })
307 await $.tool.register({
308 name: 'approve',
309 description:
310 'Show the user a list of things you are about to do (commands, file changes, a push) and let them approve some or all. Blocks until answered; do only what comes back in approved.',
311 inputSchema: APPROVE_SCHEMA,
312 })
313 await $.command.register({ name: 'decisions', description: 'List this session\'s decisions; /decisions <n> reopens one' })
314 return next(e)
315 })
316
317 on('command.run', { command: 'decisions' }, async ($, e) => {
318 const arg = e.args.trim()
319 if (history.length === 0) return { text: 'No decisions yet this session.' }
320 if (!arg) {
321 return {
322 text: history
323 .map((h, i) => {
324 const picks = (h.decisions as { question?: string; labels?: string[] }[]).map(d => `${d.question ?? '?'} → ${(d.labels ?? []).join(', ') || '-'}`).join('; ')
325 return `${i + 1}. ${h.title}: ${picks}`
326 })
327 .join('\n'),
328 }
329 }
330 const h = history[Number(arg) - 1]
331 if (!h) return { text: `No decision ${arg}. Use /decisions to list them.` }
332 await $.prompt.fill({
333 text: `Reopen my earlier decision "${h.title}": call ${TOOL} again with these steps and resume state so I can change my answers.\nsteps: ${JSON.stringify(h.steps)}\nresume: ${JSON.stringify(h.state)}`,
334 })
335 return { text: `Decision ${arg} is in the prompt box: press Enter to reopen it.` }
336 })
337
338 on('prompt.compose', async ($, e, next) => {
339 const r = await next(e)
340 return { sections: [...r.sections, { id: 'your-call:rule', text: RULE, scope: 'session' as const }] }
341 })
342
343 // The tool only asks the user a question, so it needs no extra permission prompt on top of its own pane.
344 on('tool.check', { tool: TOOL }, () => ({ decision: 'allow' as const, reason: 'your-call only asks you a question' }))
345 on('tool.check', { tool: APPROVE }, () => ({ decision: 'allow' as const, reason: 'your-call only asks you to approve' }))
346
347 let chain: Promise<unknown> = Promise.resolve()
348
349 // Calls run one at a time: a second ask waits for the first pane to finish instead of hanging.
350 on('tool.call', { tool: TOOL }, ($, e) => {
351 const run = chain.then(async () => {
352 const norm = normalizeSpec(e)
353
354 if (!norm.ok) return { result: JSON.stringify({ error: norm.error }) }
355
356 const { steps, title, warnings } = norm
357 const reply = (out: Outcome) => ({ result: JSON.stringify(warnings.length ? { ...out, warnings } : out) })
358 const saved = remember ? (((await $.store.get('last')) as Record<string, string[]> | undefined) ?? {}) : {}
359 const remembered = (out: Outcome) => (remember && 'decisions' in out ? $.store.set('last', nextLast(saved, out.decisions as { question: string; picks: string[] }[])) : undefined)
360
361 // /lgtm: pre-approved by the gate, so take the recommended option(s) without asking.
362 const gate = ((await $.state.get({ plugin: 'looks-good-to-me', key: 'gate' })).value ?? null) as { phase?: string; mode?: string; allow?: { pick?: boolean } | null } | null
363 if (pickPreApproved(gate)) {
364 const rec = recommendedFor(steps)
365 if (rec) {
366 const decisions = buildDecisions(blankView(title, steps, rec), rec)
367 const at = await $.clock.now()
368 const entries = decisions.map(d => ({ at, kind: 'pick', detail: d.question }))
369 await update($, approvals, (l: Approval[]) => [...l, ...entries].slice(-200))
370 history.push({ title, at, steps, decisions, state: stateOf(blankView(title, steps, rec)) })
371 return reply({ decisions, autoApproved: true })
372 }
373 }
374
375 if (isSimple(steps)) {
376 const s = steps[0]
377 try {
378 const label = await $.ui.ask(s.question, s.options.map(o => (o.recommended ? `${o.label}${REC}` : o.label)))
379 const out = simpleOutcome(s, label)
380 await remembered(out)
381 return reply(out)
382 } catch {
383 return reply({ cancelled: true })
384 }
385 }
386
387 const resumed = resumeFrom((e as unknown as { resume?: unknown }).resume, steps)
388 const v = viewFrom(title, steps, resumed, saved)
389 await update($, view, () => v)
390
391 const outcome = new Promise<Outcome>(resolve => {
392 finish = resolve
393 })
394 const timers = [
395 idleMs > 0 ? $.clock.after(idleMs, () => $.ui.toast(TOASTS.idle)) : undefined,
396 giveUpMs > 0
397 ? $.clock.after(giveUpMs, async () => {
398 const cur = await read($, view)
399 const rec = timeoutPick && cur ? recommendedFor(cur.steps) : undefined
400 finish?.(cur && rec ? { decisions: buildDecisions(cur, rec), usedDefault: true } : { cancelled: true, timedOut: true })
401 })
402 : undefined,
403 ]
404 const opened = await $.ui.open({ id: PANE, title: v.title, focus: true, closeOnEscape: true, rows: 20 })
405
406 if (!opened.isPlaced) {
407 finish?.({ cancelled: true })
408 $.ui.toast(TOASTS.unplaced)
409 }
410
411 const out = await outcome
412 const fin = await read($, view)
413 for (const t of timers) t?.cancel()
414 finish = null
415 await update($, view, () => null)
416 await $.ui.close({ id: PANE })
417 await remembered(out)
418 if ('decisions' in out && !('usedDefault' in out && out.usedDefault)) {
419 history.push({ title: v.title, at: await $.clock.now(), steps, decisions: out.decisions, state: stateOf(fin ?? v) })
420 }
421 return reply(out)
422 })
423 chain = run.then(() => undefined, () => undefined)
424 return run
425 })
426
427 // Approve-plan: the user ticks which of the things Claude is about to do may go ahead.
428 on('tool.call', { tool: APPROVE }, ($, e) => {
429 const run = chain.then(async () => {
430 const norm = normalizePlan(e)
431
432 if (!norm.ok) return { result: JSON.stringify({ error: norm.error }) }
433
434 const { items, title, why, warnings } = norm
435 const reply = (out: object) => ({ result: JSON.stringify(warnings.length ? { ...out, warnings } : out) })
436 const g = ((await $.state.get({ plugin: 'looks-good-to-me', key: 'gate' })).value ?? null) as { phase?: string; allow?: { plan: { medium: boolean; high: boolean; ship: boolean; delete: boolean } } | null } | null
437 const flags = g?.phase === 'active' ? g.allow : null
438 const auto = items.filter(i => gatePermitsItem(flags, i))
439 const rest = items.filter(i => !auto.includes(i))
440
441 const logAuto = async () => {
442 if (auto.length === 0) return
443 const at = await $.clock.now()
444 const entries = auto.map(i => ({ at, kind: 'plan', detail: i.label }))
445 await update($, approvals, (l: Approval[]) => [...l, ...entries].slice(-200))
446 }
447
448 if (rest.length === 0) {
449 await logAuto()
450 return reply({ approved: items.map(i => i.id), rejected: [], autoApproved: auto.map(i => i.id) })
451 }
452
453 const rows: PlanRow[] = items.map(i => ({ ...i, auto: auto.includes(i) }))
454 await update($, plan, () => ({ title, why, rows, ticked: defaultTicks(rest), note: '' }))
455 const outcome = new Promise<object>(resolve => {
456 planFinish = resolve
457 })
458 const opened = await $.ui.open({ id: PLAN_PANE, title, focus: true, closeOnEscape: true, rows: 20 })
459
460 if (!opened.isPlaced) {
461 planFinish?.({ cancelled: true })
462 $.ui.toast(TOASTS.unplaced)
463 }
464
465 const out = await outcome
466 planFinish = null
467 await update($, plan, () => null)
468 await $.ui.close({ id: PLAN_PANE })
469 if ('approved' in out) await logAuto()
470 return reply(out)
471 })
472 chain = run.then(() => undefined, () => undefined)
473 return run
474 })
475
476 on('ui.close', ($, e, next) => {
477 if (e.origin === 'person') {
478 finish?.({ cancelled: true })
479 planFinish?.({ cancelled: true })
480 }
481 return next(e)
482 })
483
484 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
485 if (e.props.tool !== TOOL && e.props.tool !== APPROVE) return next(e)
486 const { Text } = $.ui.resolve(e)
487 const title = (e.props.input as { title?: string } | undefined)?.title
488 return (
489 <Text dimColor>
490 ◆ {e.props.tool === APPROVE ? 'plan' : 'your call'}: {title ?? (e.props.tool === APPROVE ? 'for approval' : 'a decision')}
491 {e.props.isRunning ? ' (waiting for you)' : ''}
492 </Text>
493 )
494 })
495
496 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
497 if (e.props.tool !== TOOL && e.props.tool !== APPROVE) return next(e)
498 const lines = cardLines(e.props.output)
499 if (!lines) return next(e)
500 const { Box, Text } = $.ui.resolve(e)
501 return (
502 <Box flexDirection="column">
503 {keyedLines(lines).map(({ key, line }, i) => (
504 <Text key={`c-${key}`} color={i === 0 ? 'cyan' : undefined} bold={i === 0}>
505 {line}
506 </Text>
507 ))}
508 </Box>
509 )
510 })
511
512 // Charts in answers (```viz blocks), and the summary chart too-long-didnt-read published for this very answer.
513 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
514 const text = e.props.text
515 const key = answerKey(text)
516 const card = ((await $.state.get({ plugin: 'too-long-didnt-read', key: 'cards' })).value ?? []).findLast(c => c.key === key)
517 if (!text.includes('```viz') && !card) return next(e)
518
519 const { Box, Text, Markdown, Svg } = $.ui.resolve(e)
520 const hasSvg = e.surface !== 'terminal'
521 const chart = (viz: Parameters<typeof svgFor>[0], k: string) => {
522 const svg = hasSvg ? svgFor(viz) : undefined
523 if (svg && Svg) return <Svg key={`v${k}`} source={svg} alt={viz.title ?? 'chart'} />
524 return (
525 <Box key={`v${k}`} flexDirection="column">
526 {textLines(viz).map((l, j) => (
527 <Text key={`l${k}-${j}`}>{l}</Text>
528 ))}
529 </Box>
530 )
531 }
532 const segments = splitViz(text)
533 const body = text.includes('```viz') ? (
534 <Box flexDirection="column">
535 {keyedLines(segments.map(s => (s.kind === 'text' ? `t:${s.text}` : s.kind))).map(({ key }, i) => {
536 const s = segments[i]
537 if (s.kind === 'text') return <Markdown key={`t${key}`} text={s.text} />
538 if (s.kind === 'pending') return <Text key={`p${key}`} dimColor>drawing chart…</Text>
539 return chart(s.viz, key)
540 })}
541 </Box>
542 ) : (
543 await next(e)
544 )
545
546 return (
547 <Box flexDirection="column">
548 {body}
549 {card && (
550 <Box key="tldr-card" flexDirection="column">
551 <Text bold color="cyan">≡ TL;DR</Text>
552 {chart(card.viz, 'card')}
553 </Box>
554 )}
555 </Box>
556 )
557 })
558
559 on('ui.render', { component: 'Pane', requestId: PLAN_PANE }, async ($, e) => {
560 const { Box, Text, Button, Input: InputEl } = $.ui.resolve(e)
561 // Mobile has no text inputs; they resolve to something that draws nothing there.
562 const Input = e.surface === 'mobile' ? undefined : InputEl
563 const pv = await read($, plan)
564
565 if (!pv) return <Text dimColor>No plan pending.</Text>
566
567 const setPlan = (fn: (x: PlanView) => PlanView) => update($, plan, x => (x ? fn(x) : x))
568 const pending = pv.rows.filter(r => !r.auto)
569 const autoIds = pv.rows.filter(r => r.auto).map(r => r.id)
570 const ticked = pv.ticked.filter(t => pending.some(r => r.id === t))
571 const done = (ids: string[]) =>
572 planFinish?.({ approved: ids, rejected: pv.rows.map(r => r.id).filter(id => !ids.includes(id)), autoApproved: autoIds.length ? autoIds : undefined, note: pv.note || undefined })
573 const toggle = (id: string) => setPlan(x => ({ ...x, ticked: toggled(x.ticked, id) }))
574 const tone = riskTone
575
576 return (
577 <Box flexDirection="column">
578 <Text bold color="cyan">◆ {pv.title}</Text>
579 <Text dimColor>{RULE_LINE}</Text>
580 {pv.why && <Text> {pv.why}</Text>}
581 {pv.rows.map((r, i) => (
582 <Box key={`pr-${r.id}`} flexDirection="column">
583 {i > 0 && <Text> </Text>}
584 {r.auto ? (
585 <Box>
586 <Text color="green">✓ {r.label} </Text>
587 <Text dimColor>auto-approved (lgtm)</Text>
588 </Box>
589 ) : (
590 <Box>
591 <Button key={`t-${r.id}`} label={`${pv.ticked.includes(r.id) ? '☑' : '☐'} ${r.label}`} onPress={() => toggle(r.id)} />
592 <Text color={tone(r.risk)}> {r.risk}</Text>
593 <Text dimColor> · {r.action}</Text>
594 </Box>
595 )}
596 {r.command && <Text dimColor> $ {r.command}</Text>}
597 {r.detail && <Text dimColor> {r.detail}</Text>}
598 </Box>
599 ))}
600 <Text> </Text>
601 {Input && (
602 <Input
603 key="plan-note"
604 label="note for Claude:"
605 placeholder="optional"
606 value={pv.note}
607 submitLabel="save"
608 onInput={t => setPlan(x => ({ ...x, note: t }))}
609 onSubmit={() => undefined}
610 />
611 )}
612 <Text dimColor>{RULE_LINE}</Text>
613 <Box>
614 <Button key="ok" label={`Approve selected (${ticked.length + autoIds.length}/${pv.rows.length})`} onPress={() => done([...autoIds, ...ticked])} />
615 <Button key="all" label="Approve all" onPress={() => done(pv.rows.map(r => r.id))} />
616 <Button key="none" label="Reject all" onPress={() => done(autoIds)} />
617 <Button key="skip" label="Skip" onPress={() => planFinish?.({ cancelled: true })} />
618 </Box>
619 <Text dimColor>Esc skips · nothing runs that is not ticked</Text>
620 </Box>
621 )
622 })
623
624 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
625 const { Box, Text, Button, Input: InputEl } = $.ui.resolve(e)
626 // Mobile has no text inputs; they resolve to something that draws nothing there.
627 const Input = e.surface === 'mobile' ? undefined : InputEl
628 const v = await read($, view)
629
630 if (!v) return <Text dimColor>No decision pending.</Text>
631
632 const set = (fn: (x: View) => View) => update($, view, x => (x ? fn(x) : x))
633 // A change you may want to take back: remembered for Undo, and it clears Redo.
634 const commit = (fn: (x: View) => View) => update($, view, x => (x ? withUndo(x, fn(x)) : x))
635 const step = v.steps[v.idx]
636 const picks = v.picks[step.id] ?? []
637 const vis = visibleIdx(v.steps, v.picks)
638 const auto = isAutoConfirm(autoConfirm, v.steps.length, step.kind)
639
640 const setPicks = (stepId: string, next_: string[]) =>
641 commit(x => {
642 const reset = resetDependents(x.steps, { ...x.picks, [stepId]: next_ }, x.notes, stepId)
643 return { ...x, picks: reset.picks, notes: reset.notes }
644 })
645
646 const toggle = (id: string) => {
647 if ((v.vetoed[step.id] ?? []).includes(id)) return
648 if (auto) return finish?.({ decisions: buildDecisions(v, { ...v.picks, [step.id]: [id] }) })
649 const cur = v.picks[step.id] ?? []
650 return setPicks(step.id, step.kind === 'many' ? toggled(cur, id) : [id])
651 }
652
653 const move = (id: string, d: number) => setPicks(step.id, moved(v.picks[step.id] ?? [], id, d))
654
655 const bump = (stepId: string, name: string, current: number, d: number) =>
656 commit(x => ({ ...x, weights: { ...x.weights, [weightKey(stepId, name)]: Math.max(0, Math.min(10, current + d)) } }))
657
658 const typeOwn = (text: string) => {
659 const t = text.trim()
660 if (!t) return
661 const others = { ...v.others, [step.id]: t }
662 const cur = v.picks[step.id] ?? []
663 const next_ = step.kind === 'many' ? [...cur.filter(c => c !== OTHER), OTHER] : [OTHER]
664 if (auto) return finish?.({ decisions: buildDecisions({ ...v, others }, { ...v.picks, [step.id]: next_ }) })
665 return commit(x => {
666 const reset = resetDependents(x.steps, { ...x.picks, [step.id]: next_ }, x.notes, step.id)
667 return { ...x, others, picks: reset.picks, notes: reset.notes }
668 })
669 }
670
671 const veto = (id: string) => commit(x => toggleVeto(x, step.id, id))
672 const explain = (o: { id: string; label: string }) => finish?.({ explain: { step: step.id, option: o.id, label: o.label }, state: stateOf(v) })
673 const defer = () => finish?.({ deferred: true, state: stateOf(v) })
674 const vetoedHere = v.vetoed[step.id] ?? []
675 const anyVeto = Object.values(v.vetoed).some(a => a.length > 0)
676 const proposeOthers = () =>
677 finish?.({ regenerate: 'propose different options', vetoed: Object.fromEntries(Object.entries(v.vetoed).filter(([, a]) => a.length)), state: stateOf(v) })
678
679 if (v.phase === 'review') {
680 const open = vis.filter(i => !isComplete(v.steps[i], v.picks))
681 return (
682 <Box flexDirection="column">
683 <Text bold color="cyan">◆ {v.title}: review</Text>
684 <Text dimColor>{RULE_LINE}</Text>
685 {vis.map(i => {
686 const s = v.steps[i]
687 const done = isComplete(s, v.picks)
688 const label = labelsFor(s, v.picks[s.id] ?? [], v.others).join(', ')
689 return (
690 <Box key={`rl-${s.id}`}>
691 <Text color={done ? 'green' : 'yellow'}>{done ? '✓ ' : '○ '}</Text>
692 <Button
693 key={`rev-${s.id}`}
694 label={`${s.question} -> ${label || '(unanswered)'}`}
695 onPress={() => set(x => ({ ...x, idx: i, phase: 'step', fromReview: true }))}
696 />
697 </Box>
698 )
699 })}
700 <Text dimColor>{open.length > 0 ? `${open.length} unanswered: click one to answer it.` : 'Click a line to change it.'}</Text>
701 <Box>
702 <Button key="back" label="Back" onPress={() => set(x => ({ ...x, phase: 'step', idx: vis.at(-1) ?? x.idx, fromReview: false }))} />
703 <Button key="confirm" label="Confirm" onPress={() => (open.length === 0 ? finish?.({ decisions: buildDecisions(v) }) : undefined)} />
704 </Box>
705 <Text dimColor>Esc skips</Text>
706 </Box>
707 )
708 }
709
710 const ready = isComplete(step, v.picks)
711 const nxt = nextVisible(v.steps, v.picks, v.idx)
712 const prv = prevVisible(v.steps, v.picks, v.idx)
713 const isLast = nxt === undefined
714 const wt = weightedFor(step, v.weights)
715 const many = vis.length > 1
716 const nextLabel = nextLabelOf(isLast, v.fromReview, many)
717 const last = v.last[step.id] ?? []
718 const recommended = step.options.find(o => o.recommended)
719 const shown = shownOptions(step, !!v.showAll[step.id], picks)
720 const hiddenCount = step.options.length - shown.length
721
722 return (
723 <Box flexDirection="column">
724 <Box>
725 <Text bold color="cyan">◆ {v.title}{' '}</Text>
726 {many &&
727 vis.map(i => {
728 const s = v.steps[i]
729 const here = i === v.idx
730 const done = isComplete(s, v.picks)
731 return (
732 <Text key={`pg-${s.id}`} color={progressTone(here, done)} bold={here} dimColor={!here && !done}>
733 {here || done ? '● ' : '○ '}
734 {short(s.question)}
735 {' '}
736 </Text>
737 )
738 })}
739 </Box>
740 <Text dimColor>{RULE_LINE}</Text>
741
742 <Text bold> {step.question}</Text>
743 <Text> </Text>
744 {step.why && recommended && <Text color="green">★ {step.why}</Text>}
745
746 {step.audience && (
747 <Box flexDirection="column">
748 {keyedLines(textLines({ type: 'bars', title: 'The audience says', values: step.audience.votes }, step.options, picks)).map(({ key, line }) => (
749 <Text key={`au-${key}`} dimColor>{line}</Text>
750 ))}
751 </Box>
752 )}
753
754 {step.viz && (
755 <Box flexDirection="column">
756 {keyedLines(textLines(step.viz, step.options, picks)).map(({ key, line }) => (
757 <Text key={`v-${key}`} dimColor>{line}</Text>
758 ))}
759 </Box>
760 )}
761
762 {step.kind === 'compare' && step.criteria && wt && (
763 <Box flexDirection="column">
764 {step.criteria.map(c => {
765 const w = weightOf(step.id, c, v.weights)
766 return (
767 <Box key={`c-${c.name}`}>
768 <Text>{c.name} </Text>
769 <Button key={`wm-${c.name}`} label="-" onPress={() => bump(step.id, c.name, w, -1)} />
770 <Text> weight {w} </Text>
771 <Button key={`wp-${c.name}`} label="+" onPress={() => bump(step.id, c.name, w, 1)} />
772 <Text dimColor> {step.options.map(o => `${o.label} ${c.scores[o.id] ?? '-'}`).join(' | ')}</Text>
773 </Box>
774 )
775 })}
776 <Text color="yellow">
777 weighted: {step.options.map(o => `${o.id === wt.leader ? '★ ' : ''}${o.label} ${wt.totals[o.id]}`).join(' | ')}
778 </Text>
779 {!step.measured && <Text dimColor>scores are Claude's estimates, not measurements</Text>}
780 </Box>
781 )}
782
783 {step.kind === 'rank'
784 ? picks.map((id, i) => {
785 const o = step.options.find(x => x.id === id)
786 return (
787 <Box key={`k-${id}`}>
788 <Text>
789 {i + 1}. {o?.label ?? id}{' '}
790 </Text>
791 <Button key={`u-${id}`} label="Up" onPress={() => move(id, -1)} />
792 <Button key={`d-${id}`} label="Down" onPress={() => move(id, 1)} />
793 {o?.recommended && <Text color="green"> ★ recommended</Text>}
794 </Box>
795 )
796 })
797 : shown.map((o, oi) => {
798 const on_ = picks.includes(o.id)
799 const mark = markOf(step.kind === 'many', on_)
800 const isVetoed = vetoedHere.includes(o.id)
801 return (
802 <Box key={`o-${o.id}`} flexDirection="column">
803 {oi > 0 && <Text> </Text>}
804 {isVetoed ? (
805 <Box>
806 <Text dimColor>✕ {o.label} (vetoed) </Text>
807 <Button key={`x-${o.id}`} label="restore" onPress={() => veto(o.id)} />
808 </Box>
809 ) : (
810 <Box>
811 <Button key={`p-${o.id}`} label={`${mark} ${o.label}${wt?.leader === o.id ? ' ★' : ''}`} onPress={() => toggle(o.id)} />
812 {o.recommended && <Text color="green"> ★ recommended</Text>}
813 {last.includes(o.id) && <Text dimColor> last time</Text>}
814 <Text> </Text>
815 <Button key={`q-${o.id}`} label="?" onPress={() => explain(o)} />
816 <Button key={`x-${o.id}`} label="✕" onPress={() => veto(o.id)} />
817 </Box>
818 )}
819 {!isVetoed && o.detail && <Text dimColor> {o.detail}</Text>}
820 {!isVetoed && o.pros?.map(p => <Text key={`+${o.id}${p}`} color="green"> + {p}</Text>)}
821 {!isVetoed && o.cons?.map(c => <Text key={`-${o.id}${c}`} color="red"> - {c}</Text>)}
822 {on_ && !auto && Input && (
823 <Input
824 key={`n-${step.id}-${o.id}`}
825 label=" tweak:"
826 placeholder="optional note"
827 value={v.notes[`${step.id}:${o.id}`] ?? ''}
828 submitLabel="save"
829 onInput={t => set(x => ({ ...x, notes: { ...x.notes, [`${step.id}:${o.id}`]: t } }))}
830 onSubmit={() => undefined}
831 />
832 )}
833 </Box>
834 )
835 })}
836 {step.kind !== 'rank' && step.options.length > LIMIT + 1 && (
837 <Button
838 key={`more-${step.id}`}
839 label={v.showAll[step.id] ? 'Show fewer' : `Show ${hiddenCount} more`}
840 onPress={() => set(x => ({ ...x, showAll: { ...x.showAll, [step.id]: !x.showAll[step.id] } }))}
841 />
842 )}
843
844 {(step.kind === 'one' || step.kind === 'many') && Input && (
845 <Input
846 key={`other-${step.id}`}
847 label="or type your own:"
848 placeholder="your answer, then Enter"
849 value={v.others[step.id] ?? ''}
850 submitLabel="use this"
851 onInput={t => set(x => ({ ...x, others: { ...x.others, [step.id]: t } }))}
852 onSubmit={t => typeOwn(t)}
853 />
854 )}
855
856 {askConfidence && !auto && step.kind !== 'rank' && (
857 <Box>
858 <Text dimColor>how sure? </Text>
859 {CONFIDENCE.map(c => (
860 <Button
861 key={`cf-${c}`}
862 label={v.confidence[step.id] === c ? `● ${c}` : c}
863 onPress={() =>
864 commit(x => {
865 const { [step.id]: _drop, ...rest } = x.confidence
866 return { ...x, confidence: x.confidence[step.id] === c ? rest : { ...x.confidence, [step.id]: c } }
867 })
868 }
869 />
870 ))}
871 </Box>
872 )}
873
874 {Input ? (
875 <Input
876 key="regen"
877 label="none fit? say what you want instead:"
878 placeholder="then Enter to regenerate"
879 value={v.regen}
880 submitLabel="regenerate"
881 onInput={t => set(x => ({ ...x, regen: t }))}
882 onSubmit={t => finish?.({ regenerate: t.trim() || 'propose different options' })}
883 />
884 ) : (
885 <Button key="regen" label="None fit" onPress={() => finish?.({ regenerate: 'propose different options' })} />
886 )}
887
888 {!ready && <Text color="yellow">{step.kind === 'many' ? 'Pick at least one to continue.' : 'Pick an option to continue.'}</Text>}
889
890 <Text dimColor>{RULE_LINE}</Text>
891 <Box>
892 {prv !== undefined && <Button key="prev" label="Back" onPress={() => set(x => ({ ...x, idx: prv }))} />}
893 <Button
894 key="next"
895 label={nextLabel}
896 onPress={() => {
897 if (!ready) return
898 if (v.fromReview) return set(x => ({ ...x, phase: 'review', fromReview: false }))
899 if (nxt !== undefined) return set(x => ({ ...x, idx: nxt }))
900 if (many) return set(x => ({ ...x, phase: 'review' }))
901 finish?.({ decisions: buildDecisions(v) })
902 }}
903 />
904 <Button key="skip" label="Skip" onPress={() => finish?.({ cancelled: true })} />
905 </Box>
906 <Box>
907 {v.undo.length > 0 && <Button key="undo" label="Undo" onPress={() => set(undoOf)} />}
908 {v.redo.length > 0 && <Button key="redo" label="Redo" onPress={() => set(redoOf)} />}
909 <Button key="defer" label="Decide later" onPress={defer} />
910 {anyVeto && <Button key="others" label="Propose others" onPress={proposeOthers} />}
911 </Box>
912 <Text dimColor>Esc skips · Enter in a field saves it{auto ? ' · picking an option confirms it' : ''}</Text>
913 </Box>
914 )
915 })
916}
917hooks/card.ts 77 lines1type Decision = { question?: string; step?: string; labels?: string[]; picks?: string[]; notes?: Record<string, string>; other?: string; leader?: string | null; confidence?: string; vetoed?: string[] }
2
3const asObject = (output: unknown): Record<string, unknown> | undefined => {
4 let v = output
5 if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
6 const r = v as Record<string, unknown>
7 if (typeof r.text === 'string') v = r.text
8 else if (typeof r.result === 'string') v = r.result
9 else if (r.decisions || r.cancelled || r.regenerate || r.error || r.deferred || r.explain || r.approved) return r
10 }
11 if (typeof v === 'string') {
12 try {
13 const j = JSON.parse(v)
14 return typeof j === 'object' && j !== null ? (j as Record<string, unknown>) : undefined
15 } catch {
16 return undefined
17 }
18 }
19 return undefined
20}
21
22type Raw = Record<string, unknown>
23type Card = (o: Raw) => string[] | undefined
24
25const count = (v: unknown): number => (Array.isArray(v) ? v.length : 0)
26
27const errorCard: Card = o => (typeof o.error === 'string' ? [`◆ your call: could not ask (${o.error})`] : undefined)
28
29const cancelledCard: Card = o => (o.cancelled ? [o.timedOut ? '◆ your call: no answer, timed out' : '◆ your call: skipped'] : undefined)
30
31const planCard: Card = o => {
32 if (!Array.isArray(o.approved)) return undefined
33 const auto = count(o.autoApproved)
34 const autoText = auto ? `, ${auto} auto (lgtm)` : ''
35 const note = typeof o.note === 'string' && o.note ? ` (${o.note})` : ''
36 return [`◆ plan: approved ${o.approved.length}, skipped ${count(o.rejected)}${autoText}${note}`]
37}
38
39const deferredCard: Card = o => (o.deferred ? ['◆ your call: decide later'] : undefined)
40
41const explainCard: Card = o =>
42 o.explain && typeof o.explain === 'object' ? [`◆ your call: asked about "${(o.explain as { label?: string }).label ?? '?'}"`] : undefined
43
44const regenerateCard: Card = o => {
45 if (typeof o.regenerate !== 'string') return undefined
46 const vetoed = Object.values((o.vetoed as Record<string, string[]> | undefined) ?? {}).flat()
47 const rejected = vetoed.length ? ` (rejected: ${vetoed.join(', ')})` : ''
48 return ['◆ your call: none of these fit', ` ↻ ${o.regenerate}${rejected}`]
49}
50
51const decisionLine = (d: Decision): string => {
52 const picked = (d.labels?.length ? d.labels : (d.picks ?? [])).join(', ') || '(none)'
53 const notes = Object.values(d.notes ?? {}).filter(Boolean)
54 const extra = [d.confidence ? `${d.confidence} confidence` : '', ...notes, d.vetoed?.length ? `vetoed ${d.vetoed.length}` : ''].filter(Boolean)
55 const extraText = extra.length ? ` (${extra.join('; ')})` : ''
56 return ` ${d.question ?? d.step ?? '?'} → ${picked}${extraText}`
57}
58
59const decisionsCard: Card = o => {
60 if (!Array.isArray(o.decisions)) return undefined
61 const head = o.usedDefault ? '◆ your call (no answer: recommended option used)' : '◆ your call'
62 return [head, ...(o.decisions as Decision[]).map(decisionLine)]
63}
64
65const CARDS: Card[] = [errorCard, cancelledCard, planCard, deferredCard, explainCard, regenerateCard, decisionsCard]
66
67/** Lines for the card that replaces the raw JSON in the thread; undefined when it is not our result. */
68export const cardLines = (output: unknown): string[] | undefined => {
69 const o = asObject(output)
70 if (!o) return undefined
71 for (const card of CARDS) {
72 const lines = card(o)
73 if (lines) return lines
74 }
75 return undefined
76}
77hooks/plan.ts 57 lines1export type Risk = 'low' | 'medium' | 'high'
2export type Action = 'read' | 'edit' | 'ship' | 'delete' | 'other'
3export type PlanItem = { id: string; label: string; detail?: string; command?: string; risk: Risk; action: Action }
4
5export const MAX_ITEMS = 12
6const RISKS: Risk[] = ['low', 'medium', 'high']
7const ACTIONS: Action[] = ['read', 'edit', 'ship', 'delete', 'other']
8
9type Raw = Record<string, unknown>
10const isObj = (v: unknown): v is Raw => typeof v === 'object' && v !== null && !Array.isArray(v)
11const str = (v: unknown, max = 200): string | undefined => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : undefined)
12
13export type NormalizedPlan = { ok: true; title: string; why?: string; items: PlanItem[]; warnings: string[] } | { ok: false; error: string }
14
15const toItem = (r: unknown, i: number, seen: Set<string>): PlanItem | undefined => {
16 const o: Raw = isObj(r) ? r : { label: r }
17 const label = str(o.label) ?? str(o.command)
18 if (!label) return undefined
19 let id = str(o.id, 40) ?? `i${i + 1}`
20 if (seen.has(id)) id = `${id}-${i + 1}`
21 seen.add(id)
22 return {
23 id,
24 label,
25 detail: str(o.detail, 300),
26 command: str(o.command, 300),
27 risk: (RISKS as unknown[]).includes(o.risk) ? (o.risk as Risk) : 'medium',
28 action: (ACTIONS as unknown[]).includes(o.action) ? (o.action as Action) : 'other',
29 }
30}
31
32/** Clean up an approve-plan request so the pane never throws. */
33export const normalizePlan = (raw: unknown): NormalizedPlan => {
34 const spec: Raw = isObj(raw) ? raw : {}
35 const rawItems = Array.isArray(spec.items) ? spec.items : []
36 if (rawItems.length === 0) return { ok: false, error: 'items must be a non-empty array' }
37
38 const warnings: string[] = []
39 const seen = new Set<string>()
40 const items = rawItems.flatMap((r, i) => toItem(r, i, seen) ?? [])
41 if (items.length === 0) return { ok: false, error: 'every item needs a label' }
42 if (items.length > MAX_ITEMS) {
43 warnings.push(`showing the first ${MAX_ITEMS} of ${items.length} items`)
44 items.length = MAX_ITEMS
45 }
46 return { ok: true, title: str(spec.title, 80) ?? 'Plan', why: str(spec.why, 200), items, warnings }
47}
48
49/** Ticked by default: low and medium risk, nothing that deletes. The rest you opt into. */
50export const defaultTicks = (items: PlanItem[]): string[] =>
51 items.filter(i => i.risk !== 'high' && i.action !== 'delete').map(i => i.id)
52
53/** The gate's published flags (looks-good-to-me); the same rule as its `permits` for plan items. */
54export type Flags = { plan: { medium: boolean; high: boolean; ship: boolean; delete: boolean } }
55export const gatePermitsItem = (flags: Flags | null | undefined, item: PlanItem): boolean =>
56 !!flags && flags.plan.medium && (item.risk !== 'high' || flags.plan.high) && (item.action !== 'ship' || flags.plan.ship) && (item.action !== 'delete' || flags.plan.delete)
57hooks/flow.ts 167 lines1import type { Criterion, Snap, Step, View } from '../types'
2
3export const DEFAULT_WEIGHT = 5
4export const MAX_WEIGHT = 10
5
6type Picks = Record<string, string[]>
7
8export const isComplete = (step: Step, picks: Picks): boolean => step.kind === 'rank' || (picks[step.id] ?? []).length > 0
9
10/** A step is shown when its showIf holds against the picks so far (no showIf: always). */
11export const isVisible = (step: Step, picks: Picks): boolean => {
12 const c = step.showIf
13 if (!c) return true
14 const chosen = picks[c.step] ?? []
15 const yes = !c.picked || c.picked.some(id => chosen.includes(id))
16 const no = !c.notPicked?.some(id => chosen.includes(id))
17 return yes && no && (chosen.length > 0 || !!c.notPicked)
18}
19
20export const visibleIdx = (steps: Step[], picks: Picks): number[] =>
21 steps.flatMap((s, i) => (isVisible(s, picks) ? [i] : []))
22
23export const nextVisible = (steps: Step[], picks: Picks, from: number): number | undefined =>
24 visibleIdx(steps, picks).find(i => i > from)
25
26export const prevVisible = (steps: Step[], picks: Picks, from: number): number | undefined =>
27 [...visibleIdx(steps, picks)].reverse().find(i => i < from)
28
29/** Steps whose visibility depends, directly or through others, on `id`. */
30export const dependents = (steps: Step[], id: string): Set<string> => {
31 const out = new Set<string>()
32 let grew = true
33 while (grew) {
34 grew = false
35 for (const s of steps) {
36 const on = s.showIf?.step
37 if (on && (on === id || out.has(on)) && !out.has(s.id)) {
38 out.add(s.id)
39 grew = true
40 }
41 }
42 }
43 return out
44}
45
46/** After the answer to `changed` moves, forget the answers of everything that depended on it. */
47export const resetDependents = (
48 steps: Step[],
49 picks: Picks,
50 notes: Record<string, string>,
51 changed: string,
52): { picks: Picks; notes: Record<string, string> } => {
53 const dep = dependents(steps, changed)
54 if (dep.size === 0) return { picks, notes }
55 const nextPicks = { ...picks }
56 for (const s of steps) if (dep.has(s.id)) nextPicks[s.id] = s.kind === 'rank' ? s.options.map(o => o.id) : []
57 const nextNotes = Object.fromEntries(Object.entries(notes).filter(([k]) => ![...dep].some(id => k.startsWith(`${id}:`))))
58 return { picks: nextPicks, notes: nextNotes }
59}
60
61export const weightKey = (stepId: string, name: string): string => `${stepId}:${name}`
62
63export const clampWeight = (n: number): number => Math.max(0, Math.min(MAX_WEIGHT, Math.round(n)))
64
65export const weightOf = (stepId: string, c: Criterion, weights: Record<string, number>): number =>
66 clampWeight(weights[weightKey(stepId, c.name)] ?? c.weight ?? DEFAULT_WEIGHT)
67
68/** Weighted totals per option, and the leader (null when nothing scores or the top is tied). */
69export const weightedTotals = (step: Step, weights: Record<string, number>): { totals: Record<string, number>; leader: string | null } => {
70 const totals: Record<string, number> = {}
71 for (const o of step.options) {
72 totals[o.id] = (step.criteria ?? []).reduce((sum, c) => sum + (c.scores[o.id] ?? 0) * weightOf(step.id, c, weights), 0)
73 }
74 const sorted = Object.entries(totals).sort((a, b) => b[1] - a[1])
75 const leader = sorted.length > 0 && sorted[0][1] > 0 && (sorted.length === 1 || sorted[0][1] > sorted[1][1]) ? sorted[0][0] : null
76 return { totals, leader }
77}
78
79export const OTHER = '__other'
80
81/** Starting picks: recommended options preselected (one/compare: the first; many: all), rank in given order. */
82export const initialPicks = (steps: Step[]): Picks =>
83 Object.fromEntries(
84 steps.map(s => {
85 if (s.kind === 'rank') return [s.id, s.options.map(o => o.id)]
86 const rec = s.options.filter(o => o.recommended).map(o => o.id)
87 return [s.id, s.kind === 'many' ? rec : rec.slice(0, 1)]
88 }),
89 )
90
91export const lastKey = (question: string): string => question.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 60)
92
93export const UNDO_LIMIT = 20
94export const CONFIDENCE = ['low', 'medium', 'high'] as const
95
96export const snapOf = (v: View): Snap => ({
97 picks: v.picks,
98 notes: v.notes,
99 others: v.others,
100 weights: v.weights,
101 vetoed: v.vetoed,
102 confidence: v.confidence,
103 idx: v.idx,
104 phase: v.phase,
105})
106
107/** Record the state before a user change so Undo can return to it; a new change clears Redo. */
108export const withUndo = (before: View, after: View): View => ({
109 ...after,
110 undo: [...before.undo, snapOf(before)].slice(-UNDO_LIMIT),
111 redo: [],
112})
113
114export const undoOf = (v: View): View => {
115 const prev = v.undo.at(-1)
116 return prev ? { ...v, ...prev, undo: v.undo.slice(0, -1), redo: [...v.redo, snapOf(v)] } : v
117}
118
119export const redoOf = (v: View): View => {
120 const next = v.redo.at(-1)
121 return next ? { ...v, ...next, redo: v.redo.slice(0, -1), undo: [...v.undo, snapOf(v)] } : v
122}
123
124/** Toggle an option as vetoed; a vetoed option can no longer be picked, so it leaves the picks. */
125export const toggleVeto = (v: View, stepId: string, optionId: string): View => {
126 const cur = v.vetoed[stepId] ?? []
127 const on = cur.includes(optionId)
128 const vetoed = { ...v.vetoed, [stepId]: on ? cur.filter(x => x !== optionId) : [...cur, optionId] }
129 const picks = on ? v.picks : { ...v.picks, [stepId]: (v.picks[stepId] ?? []).filter(x => x !== optionId) }
130 return { ...v, vetoed, picks }
131}
132
133/** Starting state for a re-ask: only keys that still match the new steps and options survive. */
134export const resumeFrom = (raw: unknown, steps: Step[]): Partial<Pick<View, 'picks' | 'notes' | 'weights' | 'vetoed' | 'others' | 'confidence'>> => {
135 if (typeof raw !== 'object' || raw === null) return {}
136 const r = raw as Record<string, unknown>
137 const byId = new Map(steps.map(s => [s.id, s]))
138 const keep = (m: unknown, fn: (s: Step, v: unknown) => unknown): Record<string, unknown> => {
139 const out: Record<string, unknown> = {}
140 if (typeof m !== 'object' || m === null) return out
141 for (const [k, v] of Object.entries(m as Record<string, unknown>)) {
142 const s = byId.get(k.split(':')[0])
143 const val = s ? fn(s, v) : undefined
144 if (val !== undefined) out[k] = val
145 }
146 return out
147 }
148 const ids = (s: Step, v: unknown) => {
149 const a = Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && (x === OTHER || s.options.some(o => o.id === x))) : []
150 return a.length ? a : undefined
151 }
152 return {
153 picks: keep(r.picks, ids) as Record<string, string[]>,
154 vetoed: keep(r.vetoed, (s, v) => (Array.isArray(v) ? v.filter((x): x is string => s.options.some(o => o.id === x)) : undefined)) as Record<string, string[]>,
155 notes: keep(r.notes, (_s, v) => (typeof v === 'string' ? v.slice(0, 300) : undefined)) as Record<string, string>,
156 others: keep(r.others, (_s, v) => (typeof v === 'string' ? v.slice(0, 300) : undefined)) as Record<string, string>,
157 weights: keep(r.weights, (_s, v) => (typeof v === 'number' ? clampWeight(v) : undefined)) as Record<string, number>,
158 confidence: keep(r.confidence, (_s, v) => ((CONFIDENCE as readonly unknown[]).includes(v) ? v : undefined)) as Record<string, string>,
159 }
160}
161
162/** The recommended pick per visible step, or undefined when any visible step has none (so a timeout cannot guess). */
163export const recommendedFor = (steps: Step[]): Picks | undefined => {
164 const picks = initialPicks(steps)
165 return visibleIdx(steps, picks).every(i => steps[i].kind === 'rank' || picks[steps[i].id].length > 0) ? picks : undefined
166}
167hooks/spec.ts 145 lines1import type { Criterion, Option, ShowIf, Step, Viz } from '../types'
2
3import { OTHER, clampWeight } from './flow'
4
5export const MAX_OPTIONS = 12
6export const MAX_STEPS = 6
7const KINDS = ['one', 'many', 'rank', 'compare'] as const
8
9type Raw = Record<string, unknown>
10
11const isObj = (v: unknown): v is Raw => typeof v === 'object' && v !== null && !Array.isArray(v)
12const str = (v: unknown, max = 200): string | undefined => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : undefined)
13const strs = (v: unknown, n = 4): string[] | undefined => {
14 const a = Array.isArray(v) ? v.map(x => str(x, 120)).filter((x): x is string => !!x).slice(0, n) : []
15 return a.length ? a : undefined
16}
17
18const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'x'
19
20const normOptions = (raw: unknown): Option[] => {
21 const seen = new Set<string>()
22 const out: Option[] = []
23 for (const [i, r] of (Array.isArray(raw) ? raw : []).entries()) {
24 const o: Raw = isObj(r) ? r : { label: r }
25 const label = str(o.label) ?? str(o.id)
26 if (!label) continue
27 let id = str(o.id, 40) ?? slug(label)
28 if (seen.has(id)) id = `${id}-${i + 1}`
29 seen.add(id)
30 out.push({ id: id === OTHER ? `${id}-${i + 1}` : id, label, detail: str(o.detail, 300), pros: strs(o.pros), cons: strs(o.cons), recommended: o.recommended === true ? true : undefined })
31 }
32 return out
33}
34
35const normCriteria = (raw: unknown): Criterion[] | undefined => {
36 const out: Criterion[] = []
37 for (const c of Array.isArray(raw) ? raw : []) {
38 if (!isObj(c) || !str(c.name) || !isObj(c.scores)) continue
39 const scores: Record<string, number> = {}
40 for (const [k, v] of Object.entries(c.scores)) if (typeof v === 'number' && Number.isFinite(v)) scores[k] = v
41 out.push({ name: str(c.name) as string, scores, weight: typeof c.weight === 'number' && Number.isFinite(c.weight) ? clampWeight(c.weight) : undefined })
42 }
43 return out.length ? out : undefined
44}
45
46const normAudience = (raw: unknown, options: Option[]): { votes: Record<string, number> } | undefined => {
47 if (!isObj(raw) || !isObj(raw.votes)) return undefined
48 const ids = new Set(options.map(o => o.id))
49 const votes: Record<string, number> = {}
50 for (const [k, v] of Object.entries(raw.votes)) if (ids.has(k) && typeof v === 'number' && v > 0 && Number.isFinite(v)) votes[k] = Math.round(v)
51 return Object.keys(votes).length ? { votes } : undefined
52}
53
54const normShowIf = (raw: unknown, earlier: string[], id: string, warnings: string[]): ShowIf | undefined => {
55 if (!isObj(raw)) return undefined
56 const step = str(raw.step, 40)
57 if (!step || !earlier.includes(step)) {
58 warnings.push(`step "${id}": showIf must name an earlier step; ignored`)
59 return undefined
60 }
61 const picked = strs(raw.picked, 8)
62 const notPicked = strs(raw.notPicked, 8)
63 if (!picked && !notPicked) {
64 warnings.push(`step "${id}": showIf needs picked or notPicked; ignored`)
65 return undefined
66 }
67 return { step, picked, notPicked }
68}
69
70/** Shorthand for simple asks: {question, options?, recommended?, why?, multi?, yesno?} becomes a one-step spec. */
71export const expandShorthand = (raw: unknown): unknown => {
72 if (!isObj(raw) || Array.isArray(raw.steps) || !str(raw.question)) return raw
73 const options = raw.yesno === true ? ['Yes', 'No'] : raw.options
74 const rec = str(raw.recommended, 80)?.toLowerCase()
75 const marked = (Array.isArray(options) ? options : []).map(o => {
76 const base: Raw = isObj(o) ? { ...o } : { label: o }
77 const label = str(base.label)?.toLowerCase()
78 const id = str(base.id)?.toLowerCase()
79 return rec && (label === rec || id === rec) ? { ...base, recommended: true } : base
80 })
81 return {
82 title: raw.title,
83 steps: [{ id: 'q', question: raw.question, kind: raw.multi === true ? 'many' : 'one', options: marked, why: raw.why, viz: raw.viz }],
84 }
85}
86
87export type Normalized = { ok: true; title: string; steps: Step[]; warnings: string[] } | { ok: false; error: string }
88
89/** Cap, de-duplicate the recommendation of, and reorder one step's options (recommended first, except for rank). */
90const tidyOptions = (list: Option[], id: string, kind: Step['kind'], warnings: string[]): Option[] => {
91 let options = list
92 if (options.length > MAX_OPTIONS) {
93 warnings.push(`step "${id}": showing the first ${MAX_OPTIONS} of ${options.length} options`)
94 options = options.slice(0, MAX_OPTIONS)
95 }
96 const seenRec = options.filter(o => o.recommended)
97 if (seenRec.length > 1 && kind !== 'many') {
98 options = options.map(o => (o === seenRec[0] ? o : { ...o, recommended: undefined }))
99 warnings.push(`step "${id}": only one option can be recommended; kept "${seenRec[0].label}"`)
100 }
101 if (kind !== 'rank' && options.some(o => o.recommended)) options = [...options.filter(o => o.recommended), ...options.filter(o => !o.recommended)]
102 return options
103}
104
105/** One step, cleaned up; the error text when it cannot be used. */
106const normStep = (r: unknown, i: number, ids: Set<string>, before: Step[], warnings: string[]): Step | string => {
107 const s: Raw = isObj(r) ? r : {}
108 let id = str(s.id, 40) ?? `s${i + 1}`
109 if (ids.has(id)) id = `${id}-${i + 1}`
110 ids.add(id)
111
112 const question = str(s.question, 300) ?? `Question ${i + 1}`
113 const kind = (KINDS as readonly unknown[]).includes(s.kind) ? (s.kind as Step['kind']) : 'one'
114 const parsed = normOptions(s.options)
115 if (parsed.length < 2) return `step "${id}" needs at least 2 options`
116
117 const options = tidyOptions(parsed, id, kind, warnings)
118 const audience = normAudience(s.audience, options)
119 const showIf = normShowIf(s.showIf, before.map(p => p.id), id, warnings)
120 const viz = isObj(s.viz) && typeof s.viz.type === 'string' ? (s.viz as unknown as Viz) : undefined
121 return { id, question, kind, options, criteria: normCriteria(s.criteria), viz, showIf, why: str(s.why, 200), audience, measured: s.measured === true ? true : undefined }
122}
123
124/** Clean up whatever the model sent so the pane never throws; say what is wrong when it cannot. */
125export const normalizeSpec = (input: unknown): Normalized => {
126 const raw = expandShorthand(input)
127 const spec: Raw = isObj(raw) ? raw : {}
128 const rawSteps = Array.isArray(spec.steps) ? spec.steps : []
129 if (rawSteps.length === 0) return { ok: false, error: 'steps must be a non-empty array' }
130
131 const warnings: string[] = []
132 const steps: Step[] = []
133 const ids = new Set<string>()
134
135 for (const [i, r] of rawSteps.slice(0, MAX_STEPS).entries()) {
136 const step = normStep(r, i, ids, steps, warnings)
137 if (typeof step === 'string') return { ok: false, error: step }
138 steps.push(step)
139 }
140
141 if (rawSteps.length > MAX_STEPS) warnings.push(`only the first ${MAX_STEPS} steps are shown`)
142
143 return { ok: true, title: str(spec.title, 80) ?? 'Decision', steps, warnings }
144}
145hooks/viz.ts 135 lines1import type { Option, TreeNode, Viz } from '../types'
2
3export type Segment = { kind: 'text'; text: string } | { kind: 'viz'; viz: Viz } | { kind: 'pending' }
4
5const FENCE = /```viz[ \t]*\n([\s\S]*?)```/g
6const BAR = 10
7const W = 21
8const H = 9
9
10const labelOf = (opts: Option[], id: string) => opts.find(o => o.id === id)?.label ?? id
11
12const pointLabel = (opts: Option[], p: { id?: string; label?: string }) => p.label ?? labelOf(opts, p.id ?? '?')
13
14const treeLines = (nodes: TreeNode[], prefix = ''): string[] =>
15 nodes.flatMap((n, i) => {
16 const last = i === nodes.length - 1
17 return [`${prefix}${last ? '└─ ' : '├─ '}${n.label}`, ...treeLines(n.children ?? [], prefix + (last ? ' ' : '│ '))]
18 })
19
20export const splitViz = (text: string): Segment[] => {
21 const out: Segment[] = []
22 let at = 0
23 for (const m of text.matchAll(FENCE)) {
24 const i = m.index ?? 0
25 if (i > at) out.push({ kind: 'text', text: text.slice(at, i) })
26 try {
27 out.push({ kind: 'viz', viz: JSON.parse(m[1]) as Viz })
28 } catch {
29 out.push({ kind: 'text', text: m[0] })
30 }
31 at = i + m[0].length
32 }
33 const rest = text.slice(at)
34 const open = rest.search(/```viz[ \t]*\n?/)
35 if (open >= 0) {
36 if (open > 0) out.push({ kind: 'text', text: rest.slice(0, open) })
37 out.push({ kind: 'pending' })
38 } else if (rest) out.push({ kind: 'text', text: rest })
39 return out
40}
41
42/** Text chart. `opts` resolves option ids to labels; `picked` (your-call pane) marks chosen ids with `*`. */
43export const textLines = (v: Viz, opts: Option[] = [], picked?: string[]): string[] => {
44 const head = v.title ? [v.title] : []
45 const mark = (id: string): string => {
46 if (picked === undefined) return ''
47 return picked.includes(id) ? '* ' : ' '
48 }
49
50 if (v.type === 'bars') {
51 const max = v.max ?? Math.max(1, ...Object.values(v.values))
52 const w = Math.max(...Object.keys(v.values).map(k => labelOf(opts, k).length))
53 return [
54 ...head,
55 ...Object.entries(v.values).map(([k, n]) => {
56 const f = Math.max(0, Math.min(BAR, Math.round((n / max) * BAR)))
57 return `${mark(k)}${labelOf(opts, k).padEnd(w)} ${'█'.repeat(f)}${'░'.repeat(BAR - f)} ${n}`
58 }),
59 ]
60 }
61
62 if (v.type === 'quadrant') {
63 const grid = Array.from({ length: H }, () => new Array<string>(W).fill(' '))
64 v.points.forEach((p, i) => {
65 const c = Math.max(0, Math.min(W - 1, Math.round((p.x / 10) * (W - 1))))
66 const r = H - 1 - Math.max(0, Math.min(H - 1, Math.round((p.y / 10) * (H - 1))))
67 grid[r][c] = String.fromCodePoint(65 + i)
68 })
69 return [
70 ...head,
71 `${v.y} ↑`,
72 ...grid.map(row => `│${row.join('')}`),
73 `└${'─'.repeat(W)}→ ${v.x}`,
74 v.points
75 .map((p, i) => `${String.fromCodePoint(65 + i)}=${pointLabel(opts, p)}${picked?.includes(p.id ?? '') ? '*' : ''}`)
76 .join(' '),
77 ]
78 }
79
80 if (v.type === 'tree') return [...head, ...treeLines(v.nodes)]
81
82 return [...head, ...v.lanes.map(l => l.join(' ──▶ '))]
83}
84
85const esc = (s: string) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
86
87/** SVG for bars and quadrant; undefined for types that stay text. */
88export const svgFor = (v: Viz, opts: Option[] = []): string | undefined => {
89 const ink = '#7d7d7d'
90 const font = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
91
92 if (v.type === 'bars') {
93 const rows = Object.entries(v.values)
94 const max = v.max ?? Math.max(1, ...rows.map(r => r[1]))
95 const body = rows
96 .map(([k, n], i) => {
97 const y = 28 + i * 26
98 const w = Math.max(2, Math.round((n / max) * 180))
99 return `<text x="0" y="${y + 12}" font-size="12" fill="${ink}">${esc(labelOf(opts, k))}</text><rect x="110" y="${y}" width="${w}" height="16" rx="4" fill="#4a90d9"/><text x="${116 + w}" y="${y + 12}" font-size="12" fill="${ink}">${n}</text>`
100 })
101 .join('')
102 return `<svg xmlns="http://www.w3.org/2000/svg" ${font} viewBox="0 0 340 ${40 + rows.length * 26}"><text x="0" y="14" font-size="13" font-weight="600" fill="${ink}">${esc(v.title ?? '')}</text>${body}</svg>`
103 }
104
105 if (v.type === 'quadrant') {
106 const dots = v.points
107 .map(p => {
108 const cx = 40 + (p.x / 10) * 240
109 const cy = 150 - (p.y / 10) * 130
110 const left = cx > 200
111 return `<circle cx="${cx}" cy="${cy}" r="7" fill="#4a90d9"/><text x="${left ? cx - 11 : cx + 11}" y="${cy + 4}" ${left ? 'text-anchor="end" ' : ''}font-size="12" fill="${ink}">${esc(pointLabel(opts, p))}</text>`
112 })
113 .join('')
114 return `<svg xmlns="http://www.w3.org/2000/svg" ${font} viewBox="0 0 320 190"><text x="0" y="12" font-size="13" font-weight="600" fill="${ink}">${esc(v.title ?? '')}</text><rect x="40" y="20" width="240" height="130" fill="none" stroke="${ink}" opacity=".5"/><line x1="160" y1="20" x2="160" y2="150" stroke="${ink}" opacity=".25"/><line x1="40" y1="85" x2="280" y2="85" stroke="${ink}" opacity=".25"/>${dots}<text x="160" y="176" font-size="11" text-anchor="middle" fill="${ink}">${esc(v.x)} →</text><text x="10" y="85" font-size="11" text-anchor="middle" fill="${ink}" transform="rotate(-90 10 85)">${esc(v.y)} →</text></svg>`
115 }
116
117 return undefined
118}
119
120/** Identifies an answer's text for matching a summary chart to its message; the same function is in too-long-didnt-read. */
121export const answerKey = (text: string): string => {
122 const t = text.trim()
123 return `${t.length}:${t.slice(0, 60)}`
124}
125
126/** Lines with a key that stays unique when a line repeats; for React keys without using an array index. */
127export const keyedLines = (lines: string[]): { key: string; line: string }[] => {
128 const seen = new Map<string, number>()
129 return lines.map(line => {
130 const n = (seen.get(line) ?? 0) + 1
131 seen.set(line, n)
132 return { key: `${line}#${n}`, line }
133 })
134}
135types/index.d.ts 72 lines1export type Option = { id: string; label: string; detail?: string; pros?: string[]; cons?: string[]; recommended?: boolean }
2export type Criterion = { name: string; scores: Record<string, number>; weight?: number }
3export type ShowIf = { step: string; picked?: string[]; notPicked?: string[] }
4export type Viz =
5 | { type: 'bars'; title?: string; values: Record<string, number>; max?: number }
6 | { type: 'quadrant'; title?: string; x: string; y: string; points: { id?: string; label?: string; x: number; y: number }[] }
7 | { type: 'tree'; title?: string; nodes: TreeNode[] }
8 | { type: 'flow'; title?: string; lanes: string[][] }
9export type TreeNode = { label: string; children?: TreeNode[] }
10export type Step = {
11 id: string
12 question: string
13 kind: 'one' | 'many' | 'rank' | 'compare'
14 options: Option[]
15 criteria?: Criterion[]
16 viz?: Viz
17 showIf?: ShowIf
18 why?: string
19 audience?: { votes: Record<string, number> }
20 measured?: boolean
21}
22export type Snap = {
23 picks: Record<string, string[]>
24 notes: Record<string, string>
25 others: Record<string, string>
26 weights: Record<string, number>
27 vetoed: Record<string, string[]>
28 confidence: Record<string, string>
29 idx: number
30 phase: 'step' | 'review'
31}
32export type View = {
33 title: string
34 steps: Step[]
35 idx: number
36 picks: Record<string, string[]>
37 notes: Record<string, string>
38 phase: 'step' | 'review'
39 regen: string
40 weights: Record<string, number>
41 fromReview: boolean
42 others: Record<string, string>
43 last: Record<string, string[]>
44 vetoed: Record<string, string[]>
45 confidence: Record<string, string>
46 undo: Snap[]
47 redo: Snap[]
48 showAll: Record<string, boolean>
49}
50
51export type PlanRow = { id: string; label: string; detail?: string; command?: string; risk: 'low' | 'medium' | 'high'; action: 'read' | 'edit' | 'ship' | 'delete' | 'other'; auto: boolean }
52export type PlanView = { title: string; why?: string; rows: PlanRow[]; ticked: string[]; note: string }
53export type SummaryCard = { id: number; key: string; viz: Viz }
54export type Approval = { at: number; kind: string; detail: string }
55export type GateFlags = {
56 pick: boolean
57 plan: { medium: boolean; high: boolean; ship: boolean; delete: boolean }
58 danger: boolean
59 sensitive: boolean
60}
61export type GateState = { mode: string | null; phase: 'off' | 'armed' | 'active'; since: number; allow: GateFlags | null }
62
63declare module 'claude-code' {
64 interface PluginState {
65 'your-call': { view: View | null; plan: PlanView | null; approvals: Approval[]; ready: boolean }
66 // Published by looks-good-to-me; only read here.
67 'looks-good-to-me': { gate: GateState }
68 // Published by too-long-didnt-read: summary charts for this plugin to draw under the answer they belong to.
69 'too-long-didnt-read': { cards: SummaryCard[] }
70 }
71}
72