Safety and review of Claude's output: blast-radius, secret-sentry, reflect, assumption-tracker, self-critique

Claude Code mods (needs Claude Code 2.1.287+). Inspired by savvy-progress, claude-code-filetree, cache-tax, claude-reflect and the "9 Claude Code Mods" article.
/plugin marketplace add bharath-gp/ai-harness
/plugin install guardrails@ai-harness
/plugin install cockpit@ai-harness
/plugin install toolbox@ai-harness
toolbox holds general-purpose skills, not mods. Today: github-repo-setup, which sets up or hardens a GitHub repo (new or existing) with branch protection, CI, CodeQL, Dependabot, secret scanning and optional signed commits via gh.
The other two plugins are bundles of mods. Every mod is on by default; turn individual mods off in the config menu (each is a boolean userConfig field, e.g. cacheKeeper) and the plugin reloads. Mods are not sandboxed: read the source first.
| Bundle | Mod | What it does |
|---|---|---|
guardrails | blast-radius | Asks before destructive shell commands run |
secret-sentry | Blocks API keys and private keys from being written into files | |
reflect | Spots corrections in your prompts and saves them as rules in CLAUDE.md | |
assumption-tracker | Lists the assumptions Claude made and sends fixes for the ones you mark wrong | |
self-critique | /critique panel that reviews Claude's last answer, with one-click fixes | |
cockpit | agents-progress | Live subagent panel and a running-agents band above the prompt |
cache-keeper | Cache countdown band (shown in the last 5 minutes or when cold), /keepwarm pings and a cold-send guard with cost estimate | |
context-health | Context fill band with warnings, /ctx-handoff and /autohandoff | |
usage-limits | Always-on band: session (5h) and weekly (7d) limit usage, reset countdown, pace | |
filetree | File tree pane with git status and highlights for files Claude reads and writes | |
next-steps | Suggests three next prompts after each turn |
The toolbox skills are plain SKILL.md folders, so the open skills installer puts them into Cursor, Codex, Gemini CLI, GitHub Copilot, OpenCode and other agents. Claude Code users keep using /plugin install above.
npx skills add bharath-gp/ai-harness # pick skills and agents interactively
npx skills add bharath-gp/ai-harness --skill github-repo-setup --agent codex cursor -g
Only skills install this way; the guardrails and cockpit mods are Claude Code only.
MIT, see LICENSE. Security reports: SECURITY.md.
./scripts/sync-ui.sh # copy shared/ into each plugins/<bundle>/hooks/ (commit the copies)
./scripts/dev-sync.sh <mods-folder> # rsync plugins/ into a hot-reloaded mods folder
claude plugin validate plugins/<bundle>
claude plugin test plugins/<bundle>
claude --plugin-dir plugins/<bundle>
Each bundle is plugins/<bundle>/{.claude-plugin/plugin.json, hooks/hooks.json, hooks/register.tsx}. register.tsx holds every mod as a section of named function declarations (the engine follows $ only into declarations in the same file, which is what the desktop app's bundled CLI requires) and wires them: one on(...) per event+matcher (a plugin may not register one twice), chaining the mods that share it and skipping any whose userConfig flag is false. To add a mod: add a section to register.tsx, add a boolean field to plugin.json, and wire its hooks. Edit shared UI only in shared/ (ui.ts palette, band.tsx band/pane header/item, kit.tsx pills/badges/tiles), then run sync-ui.sh. The palette is dark-only for now.
Install reflect (in guardrails) or the Python claude-reflect plugin, not both.
hooks/register.tsx 559 lines1import type { Register } from 'claude-code'
2import { band, beginPass, endPass, paneHeader } from './band'
3import { folderIcon, iconTile, textCard } from './kit'
4import { P, type Tone, bar, below, fit, isRich, svg } from './ui'
5
6// ===== blast-radius =====
7// Destructive shell patterns worth a human look: [pattern, what it does].
8const RISKY: [RegExp, string][] = [
9 [/\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+/, 'delete'],
10 [/\bgit\s+push\b[^\n;&|]*(--force\b|--force-with-lease\b|\s-f\b)/, 'force-push'],
11 [/\bgit\s+reset\s+--hard\b/, 'discard'],
12 [/\bgit\s+clean\s+-[a-zA-Z]*f/, 'discard'],
13 [/\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/, 'discard'],
14 [/\bgit\s+branch\s+-D\b/, 'force-delete a branch'],
15 [/\b(mkfs|dd\s+if=)/, 'overwrite a device'],
16 [/\bfind\b[^\n]*\s-delete\b/, 'delete'],
17 [/\bDROP\s+(TABLE|DATABASE)\b/i, 'drop a database object'],
18]
19
20type File = { path: string; size: number; tracked: boolean }
21type Impact = { cmd: string; verb: string; project: string; files: File[]; total: number; untracked: number; note: string }
22
23let pending: Impact | undefined
24
25const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB` : `${(n / 1048576).toFixed(1)} MB`)
26const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
27
28// Every file under `path`, with sizes (capped: a node_modules rm should not take a minute).
29async function collect($: any, path: string, out: File[]) {
30 const st = await $.fs.stat(path).catch(() => undefined)
31 if (!st) return
32 if (st.kind !== 'dir') {
33 out.push({ path, size: st.size, tracked: false })
34 return
35 }
36 for (const x of await $.fs.list(path).catch(() => [])) {
37 if (out.length >= 3000) return
38 if (x.kind === 'dir') await collect($, `${path}/${x.name}`, out)
39 else out.push({ path: `${path}/${x.name}`, size: x.size, tracked: false })
40 }
41}
42
43// `rm -rf build dist` -> ['build', 'dist']; no globbing or quoting tricks, just the plain case.
44const rmTargets = (cmd: string) => {
45 const m = /\brm\s+(?:-[a-zA-Z-]+\s+)+([^;&|\n]+)/.exec(cmd)
46 return (m?.[1] ?? '').split(/\s+/).map(t => t.replace(/^['"]|['"]$/g, '')).filter(t => t && !t.startsWith('-'))
47}
48
49async function assess($: any, cmd: string, verb: string) {
50 const cwd: string = await $.session.cwd()
51 const impact: Impact = { cmd, verb, project: cwd.slice(cwd.lastIndexOf('/') + 1), files: [], total: 0, untracked: 0, note: '' }
52 if (verb === 'delete' && /\brm\b/.test(cmd)) {
53 for (const t of rmTargets(cmd)) await collect($, t.startsWith('/') ? t : `${cwd}/${t}`, impact.files)
54 const tracked = await $.process.run(['git', 'ls-files', '-z'], { cwd, timeoutMs: 8000 }).catch(() => undefined)
55 const set = new Set<string>((tracked?.stdout ?? '').split('\0').filter(Boolean).map((p: string) => `${cwd}/${p}`))
56 for (const f of impact.files) f.tracked = set.has(f.path)
57 } else if (verb === 'discard') {
58 const st = await $.process.run(['git', 'status', '--short'], { cwd, timeoutMs: 5000 }).catch(() => undefined)
59 for (const l of (st?.stdout ?? '').split('\n').filter(Boolean)) {
60 const p = `${cwd}/${l.slice(3)}`
61 const s = await $.fs.stat(p).catch(() => undefined)
62 impact.files.push({ path: p, size: s?.size ?? 0, tracked: false }) // uncommitted work is not saved anywhere
63 }
64 }
65 impact.total = impact.files.reduce((n, f) => n + f.size, 0)
66 impact.untracked = impact.files.filter(f => !f.tracked).length
67 impact.note =
68 impact.files.length === 0
69 ? verb === 'force-push' ? 'This rewrites history on the remote. Others who pulled it will have to recover.' : 'This cannot be undone from inside the session.'
70 : impact.untracked === 0
71 ? 'All of these are tracked by git: you can restore them with git checkout.'
72 : impact.untracked === impact.files.length
73 ? "None of these are tracked by git, so they can't be restored."
74 : `${plural(impact.untracked, 'file')} of these are not tracked by git, so they can't be restored.`
75 return impact
76}
77
78// Group files by folder, biggest folder first.
79const groups = (files: File[], project: string) => {
80 const by = new Map<string, File[]>()
81 for (const f of files) {
82 const dir = f.path.slice(0, f.path.lastIndexOf('/'))
83 by.set(dir, [...(by.get(dir) ?? []), f])
84 }
85 const rel = (d: string) => (d.includes(`/${project}/`) ? d.slice(d.indexOf(`/${project}/`) + project.length + 2) : d)
86 return [...by.entries()]
87 .map(([dir, fs]) => ({ dir: `${rel(dir)}/`, files: fs.sort((a, b) => b.size - a.size), size: fs.reduce((n, f) => n + f.size, 0) }))
88 .sort((a, b) => b.size - a.size)
89}
90
91const drawCard = (ui: any, e: any, i: Impact) => {
92 const { Box, Text } = ui
93 const rich = isRich(ui, e)
94 const n = i.files.length
95 const g = groups(i.files, i.project)
96 const top = g.slice(0, 2)
97 const shownFiles = top.reduce((c, x) => c + Math.min(5, x.files.length), 0)
98 const hiddenFiles = n - shownFiles
99 const hiddenSize = i.total - top.reduce((s, x) => s + x.files.slice(0, 5).reduce((a, f) => a + f.size, 0), 0)
100 const W = 90
101 const meter = (pct: number) => (rich ? <ui.Svg source={svg(W, 8, bar(0, 1, W, 6, pct, '#9aa0aa'))} alt="" width={W} height={8} /> : <Text color={P.muted}>{'▰'.repeat(Math.max(1, Math.round(pct / 10)))}</Text>)
102 const act = i.verb === 'delete' ? 'will delete' : i.verb === 'discard' ? 'will discard' : `will ${i.verb}`
103
104 return (
105 <Box flexDirection="column" borderStyle="round" borderColor="#4a3030" paddingX={2} paddingY={1} gap={1}>
106 <Box flexDirection="row" gap={2} alignItems="center">
107 {iconTile(ui, e, i.verb === 'delete' ? '🗑' : '⚠', P.err, 52)}
108 <Box flexDirection="column">
109 <Box flexDirection="row" gap={1} flexWrap="wrap">
110 <Text bold>{i.cmd.length > 60 ? `${i.cmd.slice(0, 59)}…` : i.cmd}</Text>
111 <Text color={P.muted}>{act}</Text>
112 {n > 0 ? (
113 <Box borderStyle="round" borderColor="#f97316" paddingX={1}>
114 <Text bold color="#fb923c">{`${plural(n, 'file')} · ${fmtSize(i.total)}`}</Text>
115 </Box>
116 ) : null}
117 </Box>
118 <Text color={P.muted}>{`in ${i.project}`}</Text>
119 </Box>
120 </Box>
121 <Box flexDirection="row" gap={1} borderStyle="round" borderColor="#6a3a3a" backgroundColor="#33201f" paddingX={1}>
122 <Text color="#f0a0a0">{`⛨ ${i.note}`}</Text>
123 </Box>
124 {top.map(grp => (
125 <Box key={grp.dir} flexDirection="column">
126 <Box flexDirection="row" gap={1} alignItems="center">
127 {folderIcon(ui, e, '#9aa0aa')}
128 <Text bold>{grp.dir}</Text>
129 <Box flexGrow={1} />
130 <Text color={P.muted}>{plural(grp.files.length, 'file')}</Text>
131 <Text bold>{fmtSize(grp.size)}</Text>
132 {meter(i.total ? (grp.size / i.total) * 100 : 0)}
133 </Box>
134 {grp.files.slice(0, 5).map(f => (
135 <Box key={f.path} flexDirection="row" gap={1} paddingLeft={3} alignItems="center">
136 <Text color={P.muted}>▫</Text>
137 <Text backgroundColor="#5a3a1a" color="#f3d2b0">{` ${f.path.slice(f.path.lastIndexOf('/') + 1)} `}</Text>
138 <Box flexGrow={1} />
139 <Text color={P.muted}>{fmtSize(f.size)}</Text>
140 {meter(i.total ? (f.size / i.total) * 100 : 0)}
141 </Box>
142 ))}
143 </Box>
144 ))}
145 {hiddenFiles > 0 ? <Text color={P.muted}>{`+ ${plural(hiddenFiles, 'more file')} · ${fmtSize(Math.max(0, hiddenSize))}`}</Text> : null}
146 </Box>
147 )
148}
149
150export const blastRadius: Register = on => {
151 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
152 const hit = RISKY.find(([re]) => re.test(e.command))
153 if (!hit) return next(e)
154
155 pending = await assess($, e.command.trim(), hit[1])
156 const n = pending.files.length
157 const proceed = n > 0 ? `${hit[1] === 'delete' ? 'Delete' : 'Discard'} ${plural(n, 'file')}` : 'Proceed'
158 let answer: string
159 try {
160 answer = await $.ui.ask(`Blast Radius is holding this command: ${e.command.slice(0, 200)}`, { header: 'Blast Radius', options: [proceed, 'Cancel'] })
161 } finally {
162 pending = undefined
163 }
164 return answer === proceed ? next(e) : { deny: `${$.plugin.name}: user cancelled (${hit[1]}).` }
165 }).catch(($, e, next) =>
166 // ask rejects when nobody can answer (-p run): refuse rather than run it
167 next.called ? next(e) : { deny: `${$.plugin.name}: could not confirm a destructive command.` },
168 )
169
170 // The impact card goes above the engine's own dialog, which keeps its Delete / Cancel buttons.
171 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
172 const q = (e.props.questions as { header?: string }[] | undefined)?.[0]
173 if (!pending || q?.header !== 'Blast Radius') return next(e)
174 const { Box } = $.ui.resolve(e)
175 return (
176 <Box flexDirection="column" gap={1}>
177 {drawCard($.ui.resolve(e), e, pending)}
178 {await next(e)}
179 </Box>
180 )
181 })
182}
183
184// ===== secret-sentry =====
185const SECRETS: [RegExp, string][] = [
186 [/AKIA[0-9A-Z]{16}/, 'AWS access key'],
187 [/sk-ant-[A-Za-z0-9_-]{20,}/, 'Anthropic API key'],
188 [/sk-[A-Za-z0-9]{32,}/, 'OpenAI-style API key'],
189 [/gh[pousr]_[A-Za-z0-9]{36,}/, 'GitHub token'],
190 [/xox[abprs]-[A-Za-z0-9-]{10,}/, 'Slack token'],
191 [/-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/, 'private key'],
192 [/(api[_-]?key|secret|token|passwd|password)["']?\s*[:=]\s*["'][A-Za-z0-9/+_=-]{20,}["']/i, 'hard-coded credential'],
193]
194
195// .env files are where secrets belong; example files hold placeholders.
196const ALLOWED_PATH = /(^|\/)\.env(\.|$)|\.example$|\.sample$/
197
198const scan = (text: string) => SECRETS.find(([re]) => re.test(text))?.[1]
199
200let blocked = 0
201
202function guard($: any, path: string, text: string) {
203 if (ALLOWED_PATH.test(path)) return undefined
204 const kind = scan(text)
205 if (!kind) return undefined
206 blocked += 1
207 $.ui.status(`🛡 Secret Sentry · ${blocked} blocked`)
208 $.ui.toast(`Secret Sentry blocked a ${kind} in ${path.split('/').pop()}`)
209 return { deny: `${$.plugin.name}: refused to write a ${kind} into ${path}. Read it from an env var instead.` }
210}
211
212export const secretSentry: Register = on => {
213 on('tool.call', { tool: 'Write' }, ($, e, next) => guard($, e.file_path, e.content) ?? next(e)).catch(
214 ($, e, next) => (next.called ? next(e) : { deny: `${$.plugin.name}: guard failed.` }),
215 )
216
217 on('tool.call', { tool: 'Edit' }, ($, e, next) => guard($, e.file_path, e.new_string) ?? next(e)).catch(
218 ($, e, next) => (next.called ? next(e) : { deny: `${$.plugin.name}: guard failed.` }),
219 )
220}
221
222// ===== reflect =====
223const START = '<!-- reflect:start -->'
224const END = '<!-- reflect:end -->'
225const SKIP = /^(ok|okay|yes|no|y|n|continue|go on|thanks|thank you|go|sure|proceed)\W*$/i
226
227const reflectPROMPT = (text: string) =>
228 `The user sent this message to a coding assistant. Does it state a reusable, durable preference or correction (a rule to follow in future sessions, e.g. "use pnpm not npm", "never mock the db")? One-off task instructions are NOT rules. Any language. Reply with only JSON: {"rule":"<imperative rule in English>"|null,"scope":"global"|"repo"}.\n\n<message>\n${text}\n</message>`
229
230type Found = { rule: string; scope: 'global' | 'repo'; count: number }
231
232const reflectParse = (text: string): { rule: string; scope: 'global' | 'repo' } | undefined => {
233 try {
234 const j = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1))
235 return typeof j.rule === 'string' && j.rule ? { rule: j.rule, scope: j.scope === 'global' ? 'global' : 'repo' } : undefined
236 } catch {
237 return undefined
238 }
239}
240
241let queue: Found[] = []
242let paused = false
243
244function add($: any, rule: string, scope: Found['scope']) {
245 const hit = queue.find(q => q.rule.toLowerCase() === rule.toLowerCase())
246 if (hit) hit.count += 1
247 else queue = [...queue, { rule, scope, count: 1 }]
248 $.ui.invalidate('ui.render')
249}
250
251async function save($: any, f: Found, scope: Found['scope']) {
252 const path = scope === 'global' ? '~/.claude/CLAUDE.md' : `${await $.session.root()}/CLAUDE.md`
253 const file = scope === 'global' ? `${await $.env.get('HOME')}/.claude/CLAUDE.md` : path
254 const old: string = (await $.fs.exists(file)) ? await $.fs.read(file) : ''
255 const bullet = `- ${f.rule}`
256 let next: string
257 if (old.includes(START) && old.includes(END)) next = old.replace(END, `${bullet}\n${END}`)
258 else next = `${old.trimEnd()}${old ? '\n\n' : ''}## Learned rules\n${START}\n${bullet}\n${END}\n`
259 await $.fs.write(file, next)
260 queue = queue.filter(q => q !== f)
261 $.ui.invalidate('ui.render')
262 $.ui.toast(`Saved to ${scope === 'global' ? '~/.claude/CLAUDE.md' : './CLAUDE.md'}`)
263}
264
265export async function reflectSessionStart($: any, e: any, next: any) {
266 await $.command.register({ name: 'reflect-queue', description: 'List rules reflect found in your prompts' })
267 await $.command.register({ name: 'reflect-pause', description: 'Pause or resume reflect' })
268 return next(e)
269}
270
271export function reflectCommandRunReflectQueue() {
272 return { text: queue.length ? queue.map(q => `- ${q.rule} (${q.scope}, x${q.count})`).join('\n') : 'Nothing queued.' }
273}
274
275export function reflectCommandRunReflectPause() {
276 paused = !paused
277 return { text: paused ? 'reflect paused.' : 'reflect resumed.' }
278}
279
280export async function reflectPromptSubmit($: any, e: any, next: any) {
281 const t = e.text.trim()
282 if (e.origin.kind === 'plugin' || paused || t.startsWith('/')) return next(e)
283
284 const explicit = /^remember:\s*(.+)$/is.exec(t)
285 if (explicit) add($, explicit[1].trim(), 'repo')
286 else if (t.length <= 500 && !SKIP.test(t)) {
287 void $.model
288 .complete({ model: 'haiku', prompt: reflectPROMPT(t), maxTokens: 150, effort: 'low', timeoutMs: 15_000 })
289 .then(r => {
290 const found = r.isAnswered ? reflectParse(r.text) : undefined
291 if (found) add($, found.rule, found.scope)
292 })
293 }
294 return next(e)
295}
296
297export async function reflectUiRenderAbovePrompt($: any, e: any, next: any) {
298 if (e.props.hasSurvey || queue.length === 0) return next(e)
299 const f = queue[0]
300 const other = f.scope === 'global' ? 'repo' : 'global'
301 const more = queue.length > 1 ? [`+${queue.length - 1} more`] : []
302 return band($.ui.resolve(e), e, await below(next, e), {
303 title: 'Rule found',
304 tone: 'purple',
305 detail: f.rule,
306 chips: [f.scope, ...(f.count > 1 ? [`×${f.count}`] : []), ...more],
307 buttons: [
308 { key: 'save', label: 'Save', onPress: () => save($, f, f.scope) },
309 { key: 'other', label: other === 'global' ? 'Global instead' : 'This repo instead', onPress: () => save($, f, other) },
310 { key: 'skip', label: 'Skip', plain: true, onPress: () => { queue = queue.filter(q => q !== f); $.ui.invalidate('ui.render') } },
311 ],
312 })
313}
314
315// ===== assumption-tracker =====
316type Item = { text: string; verdict: 'right' | 'wrong' | undefined }
317
318const assumptionTrackerPROMPT = (task: string, answer: string) =>
319 `List the assumptions the assistant made that the user did not explicitly specify (defaults chosen, ambiguities resolved silently, scope decisions). Max 8, one short sentence each. Reply with only a JSON array of strings, [] if none.\n\n<user_request>\n${task.slice(0, 2000)}\n</user_request>\n<assistant_reply>\n${answer.slice(0, 6000)}\n</assistant_reply>`
320
321const assumptionTrackerParse = (text: string): string[] => {
322 try {
323 const arr = JSON.parse(text.slice(text.indexOf('['), text.lastIndexOf(']') + 1))
324 return Array.isArray(arr) ? arr.filter((s): s is string => typeof s === 'string').slice(0, 8) : []
325 } catch {
326 return []
327 }
328}
329
330let items: Item[] = []
331let task = ''
332
333function redraw($: any) { return $.ui.invalidate('ui.render') }
334
335export async function assumptionTrackerSessionStart($: any, e: any, next: any) {
336 await $.command.register({ name: 'assumptions', description: 'Review the assumptions Claude made last turn' })
337 return next(e)
338}
339
340export async function assumptionTrackerCommandRunAssumptions($: any) {
341 if (items.length === 0) return { text: 'No assumptions recorded for the last turn.' }
342 await $.ui.open({ id: 'assumptions', title: 'Assumptions', focus: true, closeOnEscape: true })
343 return { text: 'Assumptions opened.' }
344}
345
346export function assumptionTrackerPromptSubmit($: any, e: any, next: any) {
347 if (e.origin.kind !== 'plugin') task = e.text // fix prompts we send must not overwrite the task
348 return next(e)
349}
350
351export async function assumptionTrackerTurnComplete($: any, e: any, next: any) {
352 if (e.agentId === undefined && e.reason === 'answer' && task) {
353 void $.model
354 .complete({ model: 'haiku', prompt: assumptionTrackerPROMPT(task, e.answer), maxTokens: 600, effort: 'low', timeoutMs: 20_000 })
355 .then(r => {
356 items = r.isAnswered ? assumptionTrackerParse(r.text).map(text => ({ text, verdict: undefined })) : []
357 redraw($)
358 })
359 }
360 return next(e)
361}
362
363export async function assumptionTrackerUiRenderAbovePrompt($: any, e: any, next: any) {
364 if (e.props.hasSurvey || items.length === 0) return next(e)
365 const reviewed = items.filter(i => i.verdict).length
366 return band($.ui.resolve(e), e, await below(next, e), {
367 title: `Claude made ${items.length} assumption${items.length > 1 ? 's' : ''}`,
368 tone: 'warn',
369 detail: `${reviewed}/${items.length} reviewed`,
370 pct: (reviewed / items.length) * 100,
371 buttons: [
372 { key: 'review', label: 'Review', hotkey: '0', onPress: () => $.ui.open({ id: 'assumptions', title: 'Assumptions', focus: true, closeOnEscape: true }) },
373 { key: 'skip', label: '×', plain: true, onPress: () => { items = []; redraw($) } },
374 ],
375 })
376}
377
378export function assumptionTrackerUiRenderPaneAssumptions($: any, e: any) {
379 const ui = $.ui.resolve(e)
380 const { Box, Text, Button } = ui
381 const wrong = items.filter(i => i.verdict === 'wrong')
382 const set = (i: number, v: Item['verdict']) => {
383 items = items.map((it, j) => (j === i ? { ...it, verdict: v } : it))
384 redraw($)
385 }
386 const reviewed = items.filter(i => i.verdict).length
387 return (
388 <Box flexDirection="column" gap={1}>
389 {paneHeader(ui, e, { title: 'Assumptions', sub: `${reviewed}/${items.length} reviewed · ${wrong.length} wrong`, pct: items.length ? (reviewed / items.length) * 100 : 0, tone: 'warn' })}
390 {items.length === 0 && <Text color="#8b8f98">Nothing to review.</Text>}
391 {items.map((it, i) => {
392 const color = it.verdict === 'wrong' ? P.err : it.verdict === 'right' ? P.ok : P.warn // yellow until reviewed
393 return textCard(ui, {
394 key: `r${i}`,
395 color,
396 text: it.text,
397 children: (
398 <Box flexDirection="row" gap={1}>
399 <Button key={`ok${i}`} label="Right" variant={it.verdict === 'right' ? 'primary' : 'secondary'} onPress={() => set(i, 'right')} />
400 <Button key={`no${i}`} label="Wrong" variant={it.verdict === 'wrong' ? 'primary' : 'secondary'} onPress={() => set(i, 'wrong')} />
401 </Box>
402 ),
403 })
404 })}
405 {wrong.length > 0 && (
406 <Button
407 key="send"
408 variant="primary"
409 label={`Fix ${wrong.length} wrong`}
410 onPress={async () => {
411 const keep = items.filter(i => i.verdict === 'right').map(i => `- ${i.text}`)
412 const text =
413 `These assumptions you made were wrong. Fix only the parts that depend on them:\n${wrong.map(i => `- ${i.text}`).join('\n')}` +
414 (keep.length ? `\n\nThese were right, keep them:\n${keep.join('\n')}` : '')
415 items = []
416 await $.ui.close({ id: 'assumptions' })
417 void $.prompt.submit({ text })
418 }}
419 />
420 )}
421 </Box>
422 )
423}
424
425// ===== self-critique =====
426type Critique = {
427 verdict: 'strong' | 'mixed' | 'weak'
428 summary: string
429 strengths: string[]
430 flaws: { issue: string; fix: string }[]
431}
432
433const MODELS = ['session', 'opus', 'sonnet', 'haiku'] as const
434
435const selfCritiquePROMPT = (answer: string) =>
436 `Critique the assistant reply below. Reply with only JSON: {"verdict":"strong|mixed|weak","summary":"one sentence","strengths":["..."],"flaws":[{"issue":"...","fix":"one-line instruction to repair it"}]}. Be specific; at most 4 strengths and 5 flaws.\n\n<reply>\n${answer.slice(0, 12000)}\n</reply>`
437
438const selfCritiqueParse = (text: string): Critique | undefined => {
439 try {
440 const c = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1))
441 return Array.isArray(c.flaws) && Array.isArray(c.strengths) ? c : undefined
442 } catch {
443 return undefined
444 }
445}
446
447let result: Critique | undefined
448let status = 'No critique yet. Run /critique.'
449let model: (typeof MODELS)[number] = 'session'
450let auto = false
451let lastAnswer = ''
452
453async function run($: any, answer: string) {
454 if (!answer) {
455 status = 'Nothing to critique yet.'
456 return
457 }
458 status = 'Critiquing…'
459 $.ui.invalidate('ui.render')
460 const m = model === 'session' ? await $.session.model() : model
461 const r = await $.model.complete({ model: m, prompt: selfCritiquePROMPT(answer), maxTokens: 1500, timeoutMs: 60_000 })
462 result = r.isAnswered ? selfCritiqueParse(r.text) : undefined
463 status = result ? '' : `Critique failed${r.isAnswered ? ' (unparseable reply)' : `: ${r.reason}`}`
464 $.ui.invalidate('ui.render')
465}
466
467export async function selfCritiqueSessionStart($: any, e: any, next: any) {
468 await $.command.register({ name: 'critique', description: 'Critique the last answer: /critique [auto|opus|sonnet|haiku|session]' })
469 return next(e)
470}
471
472export async function selfCritiqueCommandRunCritique($: any, e: any) {
473 const arg = (e.args ?? "").trim()
474 if (arg === 'auto') {
475 auto = !auto
476 return { text: `Auto-critique ${auto ? 'on' : 'off'}.` }
477 }
478 if ((MODELS as readonly string[]).includes(arg)) model = arg as typeof model
479 await $.ui.open({ id: 'critique', title: 'Critique' })
480 void run($, lastAnswer)
481 return { text: `Critiquing with ${model}.` }
482}
483
484export async function selfCritiqueTurnComplete($: any, e: any, next: any) {
485 if (e.agentId === undefined && e.reason === 'answer') {
486 lastAnswer = e.answer
487 if (auto) {
488 await $.ui.open({ id: 'critique', title: 'Critique' })
489 void run($, e.answer)
490 }
491 }
492 return next(e)
493}
494
495export function selfCritiqueUiRenderPaneCritique($: any, e: any) {
496 const ui = $.ui.resolve(e)
497 const { Box, Text, Button } = ui
498 const tone: Tone = result?.verdict === 'strong' ? 'ok' : result?.verdict === 'weak' ? 'err' : 'warn'
499 const score = result ? { strong: 90, mixed: 55, weak: 20 }[result.verdict] : undefined
500 return (
501 <Box flexDirection="column" gap={1}>
502 {paneHeader(ui, e, { title: 'Critique', sub: status || `${result?.flaws.length ?? 0} flaws · model: ${model}`, pct: score, tone: result ? tone : 'purple' })}
503 <Box flexDirection="row" gap={1}>
504 <Button key="rerun" label="Rerun" variant="primary" onPress={() => void run($, lastAnswer)} />
505 <Button key="auto" label={`Auto ${auto ? 'on' : 'off'}`} onPress={() => { auto = !auto; $.ui.invalidate('ui.render') }} />
506 <Button key="model" label={`Model: ${model}`} onPress={() => { model = MODELS[(MODELS.indexOf(model) + 1) % MODELS.length]; $.ui.invalidate('ui.render') }} />
507 </Box>
508 {result && (
509 <>
510 {textCard(ui, { key: 'verdict', color: P[tone], text: `${result.verdict.toUpperCase()}: ${result.summary}` })}
511 {result.strengths.map((s, i) => textCard(ui, { key: `s${i}`, color: P.ok, text: s }))}
512 {result.flaws.map((f, i) => (
513 <Box key={`f${i}`} flexDirection="column">
514 {textCard(ui, { key: `fc${i}`, color: P.err, text: f.issue, sub: `fix: ${f.fix}` })}
515 <Button key={`fix${i}`} label="Make this fix" onPress={() => void $.prompt.submit({ text: `Fix this issue in your last answer: ${f.issue}. ${f.fix}` })} />
516 </Box>
517 ))}
518 </>
519 )}
520 </Box>
521 )
522}
523
524// Each mod is on unless its userConfig flag is false (toggle in the config menu; the module reloads).
525export const register: Register = (on, options) => {
526 if (options.blastRadius !== false) blastRadius(on)
527 if (options.secretSentry !== false) secretSentry(on)
528 on('session.start', ($, e, next) => {
529 const n3 = (x: any) => next(x)
530 const n2 = (x: any) => (options.selfCritique === false ? n3(x) : selfCritiqueSessionStart($, x, n3))
531 const n1 = (x: any) => (options.assumptionTracker === false ? n2(x) : assumptionTrackerSessionStart($, x, n2))
532 return options.reflect === false ? n1(e) : reflectSessionStart($, e, n1)
533 })
534 if (options.reflect !== false) on('command.run', { command: 'reflect-queue' }, reflectCommandRunReflectQueue)
535 if (options.reflect !== false) on('command.run', { command: 'reflect-pause' }, reflectCommandRunReflectPause)
536 on('prompt.submit', ($, e, next) => {
537 const n2 = (x: any) => next(x)
538 const n1 = (x: any) => (options.assumptionTracker === false ? n2(x) : assumptionTrackerPromptSubmit($, x, n2))
539 return options.reflect === false ? n1(e) : reflectPromptSubmit($, e, n1)
540 })
541 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
542 beginPass()
543 const n2 = (x: any) => next(x)
544 const n1 = (x: any) => (options.assumptionTracker === false ? n2(x) : assumptionTrackerUiRenderAbovePrompt($, x, n2))
545 const drawn = await (options.reflect === false ? n1(e) : reflectUiRenderAbovePrompt($, e, n1))
546 if (endPass()) $.ui.invalidate('ui.render')
547 return drawn
548 })
549 if (options.assumptionTracker !== false) on('command.run', { command: 'assumptions' }, assumptionTrackerCommandRunAssumptions)
550 on('turn.complete', ($, e, next) => {
551 const n2 = (x: any) => next(x)
552 const n1 = (x: any) => (options.selfCritique === false ? n2(x) : selfCritiqueTurnComplete($, x, n2))
553 return options.assumptionTracker === false ? n1(e) : assumptionTrackerTurnComplete($, e, n1)
554 })
555 if (options.assumptionTracker !== false) on('ui.render', { component: 'Pane', requestId: 'assumptions' }, assumptionTrackerUiRenderPaneAssumptions)
556 if (options.selfCritique !== false) on('command.run', { command: 'critique' }, selfCritiqueCommandRunCritique)
557 if (options.selfCritique !== false) on('ui.render', { component: 'Pane', requestId: 'critique' }, selfCritiqueUiRenderPaneCritique)
558}
559hooks/band.tsx 176 lines1// Shared band and pane pieces, so every mod looks the same. Single source: scripts/sync-ui.sh copies it to plugins/*/hooks/band.tsx.
2import { bar, chip, dot, fit, hbar, isRich, P, ring, svg, svgWidth, text, tw, type Tone } from './ui'
3
4export type Btn = { key: string; label: string; onPress: () => unknown; hotkey?: string; plain?: boolean }
5export type RowBtn = { key: string; label: string; hint?: string; hotkey?: string; onPress: () => unknown }
6export type BandSpec = {
7 title: string
8 tone: Tone
9 detail?: string
10 pct?: number // 0-100: a ring (ring: true) or a pill bar
11 bars?: { pct: number; label: string; tone?: Tone }[] // several pill bars in one band (label inside the pill), instead of pct
12 pctLabel?: string // text inside the pill bar, e.g. "Review 3/4"
13 ring?: boolean
14 busy?: boolean // pulse the dot
15 chips?: string[]
16 right?: string
17 buttons?: Btn[]
18 rows?: RowBtn[] // one clickable line each, under the card
19 maxW?: number
20}
21
22const H = 36
23// Button column: as wide as the widest button set in the last pass (never under MIN_ACTION_COLS, so it matches across plugins),
24// the same in every band so the buttons line up. A render pass is `beginPass()` ... `endPass()` around the AbovePrompt chain.
25const MIN_ACTION_COLS = 13
26let actionCols = MIN_ACTION_COLS
27let needs: Record<string, number> = {}
28const widest = () => Math.max(MIN_ACTION_COLS, ...Object.values(needs))
29export const beginPass = () => {
30 actionCols = widest()
31 needs = {}
32}
33// true when this pass found a different width: the caller redraws once so every band uses it
34export const endPass = () => widest() !== actionCols
35const RIGHT_PX = 250 // fixed right region (bar, chips, side text): titles truncate at the same x in every band
36
37// The band above the prompt: one card, same anatomy in every mod. `nested` is `await below(next, e)` (the bands beneath),
38// `ui` is `$.ui.resolve(e)`: `$` never crosses an import, so the mod passes both in.
39export const band = (ui: any, e: any, nested: unknown, s: BandSpec) => {
40 const { Box, Text, Button } = ui
41 const color = P[s.tone]
42 const bars = s.bars ?? (s.pct !== undefined && !s.ring ? [{ pct: s.pct, label: s.pctLabel ?? '', tone: undefined }] : [])
43 const bs = s.buttons ?? []
44 needs[s.title] = bs.reduce((n, b) => n + b.label.length + (b.plain ? 1 : 2) + (b.hotkey ? 2 : 0) + 1, 1)
45 const ACTION_COLS = actionCols
46 const buttons = (s.buttons ?? []).map(b => <Button key={b.key} label={b.label} hotkey={b.hotkey} plain={b.plain} onPress={b.onPress} />)
47 const rows = (s.rows ?? []).map(r => (
48 <Button key={r.key} plain hotkey={r.hotkey} onPress={r.onPress}>
49 <Text color={P.muted}>{r.hotkey ? `${r.hotkey} ` : '› '}</Text>
50 <Text>{r.label}</Text>
51 <Text color={P.muted}>{r.hint ? ` ${r.hint}` : ''}</Text>
52 </Button>
53 ))
54
55 if (isRich(ui, e)) {
56 const { Svg } = ui
57 const W = Math.min(s.maxW ?? 1600, svgWidth(e.props.bodyColumns, ACTION_COLS * 8 + 24)) // every band the same width
58 let right = W - 12
59 let body = `<rect class="c" x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="9"/>`
60 for (const c of [...(s.chips ?? [])].reverse()) {
61 const k = chip(0, 9, c, color)
62 right -= k.w
63 body += chip(right, 9, c, color).svg
64 right -= 6
65 }
66 if (s.right) {
67 body += text(right - 2, 22, s.right, { size: 11, cls: 'm', anchor: 'end' })
68 right -= tw(s.right, 11) + 14
69 }
70 const tx = 38
71 body += s.ring && s.pct !== undefined ? ring(20, 18, 9, s.pct, color, s.busy) : dot(20, 18, color, s.busy)
72 const barW = bars.length > 1 ? 110 : bars.length ? 170 : 0
73 const hasRight = Boolean(bars.length || s.chips?.length || s.right)
74 const textW = Math.max(120, W - tx - 16 - (bars.length > 1 ? bars.length * (barW + 56) + 10 : hasRight ? RIGHT_PX : 0))
75 body += text(tx, s.detail ? 16 : 22, fit(s.title, textW, 13), { size: 13, weight: 600 })
76 if (s.detail) body += text(tx, 29, fit(s.detail, textW, 11), { size: 11, cls: 'm' })
77 for (const b of [...bars].reverse()) {
78 // pill bar with its label inside, as in savvy-progress, then the percent
79 const bc = b.tone ? P[b.tone] : color
80 const bx = right - barW - 44
81 const fillW = Math.max(26, (Math.min(100, b.pct) / 100) * barW)
82 body += `<defs><clipPath id="pb${bx}"><rect x="${bx}" y="9" width="${barW}" height="18" rx="9"/></clipPath></defs><rect class="k" x="${bx}" y="9" width="${barW}" height="18" rx="9"/><g clip-path="url(#pb${bx})"><rect${s.busy ? ' class="live"' : ''} x="${bx}" y="9" width="${fillW}" height="18" rx="9" fill="${bc}"/></g>`
83 if (b.label) body += text(bx + fillW / 2, 22, fit(b.label, fillW - 8, 11), { size: 11, weight: 700, anchor: 'middle', fill: '#1b1d22' })
84 body += text(bx + barW + 8, 22, `${Math.round(b.pct)}%`, { size: 12, cls: 'm' })
85 right = bx - 12
86 }
87 return (
88 <Box flexDirection="column">
89 <Box flexDirection="row" alignItems="center" gap={1}>
90 <Svg source={svg(W, H, body)} alt={[s.title, s.detail].filter(Boolean).join(': ')} width={W} height={H} />
91 <Box width={ACTION_COLS} flexDirection="row" alignItems="center" gap={1}>
92 {buttons}
93 </Box>
94 </Box>
95 {rows}
96 {nested}
97 </Box>
98 )
99 }
100
101 return (
102 <Box flexDirection="column">
103 <Box flexDirection="row" gap={1}>
104 <Text color={color} bold>● {s.title}</Text>
105 {bars.map(b => (
106 <Text key={b.label || 'bar'} color={b.tone ? P[b.tone] : color}>{b.label ? `${b.label} ` : ''}{hbar(b.pct, 10)} {Math.round(b.pct)}%</Text>
107 ))}
108 {s.detail && <Text color={P.muted}>{s.detail}</Text>}
109 {(s.chips ?? []).map(c => (
110 <Text key={c} color={color}>[{c}]</Text>
111 ))}
112 {s.right && <Text color={P.muted}>{s.right}</Text>}
113 {buttons}
114 </Box>
115 {rows}
116 {nested}
117 </Box>
118 )
119}
120
121// Pane header: title, a muted subtitle, an optional bar on the right.
122export const paneHeader = (ui: any, e: any, o: { title: string; sub?: string; pct?: number; tone?: Tone; right?: string }) => {
123 const { Box, Text } = ui
124 const color = P[o.tone ?? 'accent']
125 if (isRich(ui, e)) {
126 const { Svg } = ui
127 const W = svgWidth(e.props.bodyColumns, 24)
128 let body = text(2, 17, o.title, { size: 15, weight: 600 })
129 if (o.sub) body += text(2, 33, fit(o.sub, W - 160, 11.5), { size: 11.5, cls: 'm' })
130 if (o.pct !== undefined) body += bar(W - 120, 12, 118, 6, o.pct, color)
131 if (o.right) body += text(W - 2, 33, o.right, { size: 11, cls: 'm', anchor: 'end' })
132 return <Svg source={svg(W, 40, body)} alt={`${o.title}${o.sub ? `: ${o.sub}` : ''}`} width={W} height={40} />
133 }
134 return (
135 <Box flexDirection="row" gap={1}>
136 <Text bold color={color}>{o.title}</Text>
137 <Text color={P.muted}>{o.sub ?? ''}</Text>
138 </Box>
139 )
140}
141
142const GLYPH: Record<Tone, string> = { accent: '●', ok: '✓', warn: '◐', err: '✗', info: '●', purple: '●' }
143
144// One card in a pane: status dot, title, muted line, a chip or text on the right, optional bar (undefined pct = sliding).
145export const item = (ui: any, e: any, o: { key: string; tone: Tone; title: string; sub?: string; subColor?: string; chip?: string; meta?: string; avatar?: string; pct?: number | 'busy'; busy?: boolean }) => {
146 const { Box, Text } = ui
147 const color = P[o.tone]
148 if (isRich(ui, e)) {
149 const { Svg } = ui
150 const W = svgWidth(e.props.bodyColumns, 24)
151 const rowH = o.pct !== undefined ? 64 : 48
152 const ax = o.avatar ? 50 : 0 // room for the avatar tile
153 let body = `<rect class="c" x="0.5" y="0.5" width="${W - 1}" height="${rowH - 1}" rx="10"/>`
154 if (o.avatar) body += `<rect x="10" y="8" width="34" height="34" rx="10" fill="${color}" fill-opacity=".18"/>` + text(27, 31, o.avatar, { size: 15, weight: 700, anchor: 'middle', fill: color })
155 else body += `<rect x="0.5" y="6" width="3.5" height="${rowH - 12}" rx="1.75" fill="${color}"/>${dot(20, 18, color, o.busy)}`
156 const tx = o.avatar ? ax + 8 : 32
157 let right = W - 14
158 if (o.chip) {
159 const k = chip(0, 8, o.chip, color)
160 right -= k.w
161 body += chip(right, 8, o.chip, color).svg
162 }
163 if (o.meta) body += text(W - 14, 38, o.meta, { size: 10.5, cls: 'm', anchor: 'end' })
164 body += text(tx, 22, fit(o.title, right - tx - 8, 13), { size: 13, weight: 600 })
165 if (o.sub) body += text(tx, 38, fit(o.sub, W - tx - (o.meta ? tw(o.meta, 10.5) + 30 : 20), 11), { size: 11, ...(o.subColor ? { fill: o.subColor } : { cls: 'm' as const }) })
166 if (o.pct !== undefined) body += bar(tx, 50, W - tx - 14, 4, o.pct === 'busy' ? undefined : o.pct, color)
167 return <Svg key={o.key} source={svg(W, rowH, body)} alt={`${o.title}${o.sub ? `: ${o.sub}` : ''}`} width={W} height={rowH} />
168 }
169 return (
170 <Box key={o.key} flexDirection="column">
171 <Text color={color}>{GLYPH[o.tone]} {o.title}<Text color={P.muted}>{o.chip ? ` ${o.chip}` : ''}</Text></Text>
172 <Text color={P.muted}>{o.sub ? ` ${o.sub}` : ''}</Text>
173 </Box>
174 )
175}
176hooks/kit.tsx 93 lines1// Card pieces shared by the pane mods: pills, file badges, folder icons, stat tiles, icon tiles, section labels.
2// Single source: scripts/sync-ui.sh copies it to plugins/*/hooks/kit.tsx. `$` never crosses an import, so every
3// piece takes `ui` (= $.ui.resolve(e)) and `e`.
4import { esc, FONT, isRich, P, svg, svgBare, text, tw } from './ui'
5
6const EXT: Record<string, [string, string]> = {
7 js: ['JS', '#e5c07b'], jsx: ['JSX', '#e5c07b'], mjs: ['JS', '#e5c07b'], ts: ['TS', '#61afef'], tsx: ['TSX', '#61afef'],
8 json: ['JSON', '#e5c07b'], md: ['MD', '#8aa0b8'], pdf: ['PDF', '#e06c75'], csv: ['CSV', '#98c379'], py: ['PY', '#98c379'],
9 sh: ['SH', '#d19a66'], html: ['HTML', '#e06c75'], css: ['CSS', '#c678dd'], yml: ['YML', '#c678dd'], yaml: ['YML', '#c678dd'],
10 go: ['GO', '#56b6c2'], rs: ['RS', '#d19a66'], txt: ['TXT', P.muted], png: ['PNG', '#56b6c2'], jpg: ['JPG', '#56b6c2'],
11 svg: ['SVG', '#56b6c2'], map: ['MAP', P.muted], lock: ['LOCK', P.muted], toml: ['TOML', '#d19a66'],
12}
13
14// "client.test.js" -> ["JS", colour]; no extension -> a plain file mark.
15export const extBadge = (name: string): [string, string] => {
16 const dot = name.lastIndexOf('.')
17 const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : name.startsWith('.') ? name.slice(1).toLowerCase() : ''
18 return EXT[ext] ?? [ext ? ext.slice(0, 4).toUpperCase() : 'FILE', P.muted]
19}
20
21const badgeCache = new Map<string, string>()
22const badgeSvg = (label: string, color: string) => {
23 const k = `${label}${color}`
24 let s = badgeCache.get(k)
25 if (!s) {
26 const W = Math.max(24, Math.ceil(tw(label, 8.5)) + 10)
27 s = svgBare(W, 16, `<rect x="0.5" y="0.5" width="${W - 1}" height="15" rx="4" fill="${color}" fill-opacity=".16" stroke="${color}" stroke-opacity=".45"/>${text(W / 2, 11.5, label, { size: 8.5, weight: 700, anchor: 'middle', fill: color })}`)
28 badgeCache.set(k, s)
29 }
30 return { s, W: Math.max(24, Math.ceil(tw(label, 8.5)) + 10) }
31}
32
33export const fileBadge = (ui: any, e: any, name: string) => {
34 const [label, color] = extBadge(name)
35 if (!isRich(ui, e)) return <ui.Text color={color}>{`${label} `}</ui.Text>
36 const b = badgeSvg(label, color)
37 return <ui.Svg source={b.s} alt={label} width={b.W} height={16} />
38}
39
40const FOLDER = (color: string) => svgBare(18, 16, `<path d="M1.5 3.5h5l1.6 1.8h8.4v8.2a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1z" fill="none" stroke="${color}" stroke-width="1.4" stroke-linejoin="round"/>`)
41export const folderIcon = (ui: any, e: any, color: string = '#61afef') =>
42 isRich(ui, e) ? <ui.Svg source={FOLDER(color)} alt="folder" width={18} height={16} /> : <ui.Text color={color}>{'■ '}</ui.Text>
43
44// A small outlined pill: "Reading", "Edited", "committed".
45export const pill = (ui: any, e: any, label: string, color: string) => {
46 if (!isRich(ui, e)) return <ui.Text color={color} bold>{` ${label} `}</ui.Text>
47 const W = Math.ceil(tw(label, 11)) + 20
48 const body = `<rect x="0.5" y="0.5" width="${W - 1}" height="21" rx="11" fill="${color}" fill-opacity=".16" stroke="${color}" stroke-opacity=".35"/>${text(W / 2, 15, label, { size: 11, weight: 700, anchor: 'middle', fill: color })}`
49 return <ui.Svg source={svgBare(W, 22, body)} alt={label} width={W} height={22} />
50}
51
52// Rounded tile with a glyph, the "RIGHT NOW" / trash icon.
53export const iconTile = (ui: any, e: any, glyph: string, color: string, size = 48) => {
54 if (!isRich(ui, e)) return <ui.Text color={color} bold>{`${glyph} `}</ui.Text>
55 const body = `<rect width="${size}" height="${size}" rx="${size * 0.3}" fill="${color}" fill-opacity=".16"/><text x="${size / 2}" y="${size * 0.65}" text-anchor="middle" font-family="${FONT}" font-size="${size * 0.46}" fill="${color}">${esc(glyph)}</text>`
56 return <ui.Svg source={svgBare(size, size, body)} alt="" width={size} height={size} />
57}
58
59// Stat tile: a muted label over a bold value (Cost, Tokens, Time). Themed, so it uses the CSS-carrying svg().
60export const tile = (ui: any, e: any, label: string, value: string, W = 118) => {
61 if (!isRich(ui, e)) return <ui.Text>{`${label} `}<ui.Text bold>{value}</ui.Text></ui.Text>
62 const body = `<rect class="c" x="0.5" y="0.5" width="${W - 1}" height="45" rx="9"/>${text(12, 18, label, { size: 11, cls: 'm' })}${text(12, 37, value, { size: 15, weight: 700 })}`
63 return <ui.Svg source={svg(W, 46, body)} alt={`${label} ${value}`} width={W} height={46} />
64}
65
66// "CLAUDE TOUCHED 1 FILE": small caps label left, muted count right.
67export const sectionLabel = (ui: any, left: string, right?: string) => (
68 <ui.Box flexDirection="row">
69 <ui.Text bold color={P.muted}>{left.toUpperCase()}</ui.Text>
70 <ui.Box flexGrow={1} />
71 <ui.Text bold color={P.muted}>{right ? right.toUpperCase() : ''}</ui.Text>
72 </ui.Box>
73)
74
75// Rounded bordered panel.
76export const panel = (ui: any, children: unknown, o: { color?: string; bg?: string } = {}) => (
77 <ui.Box flexDirection="column" borderStyle="round" borderColor={o.color ?? '#30343c'} backgroundColor={o.bg} paddingX={1}>
78 {children}
79 </ui.Box>
80)
81
82// A card whose text wraps in full (no truncation): a coloured stripe on the left, text, optional muted line and actions.
83export const textCard = (ui: any, o: { key: string; color: string; text: string; sub?: string; children?: unknown }) => (
84 <ui.Box key={o.key} flexDirection="row" borderStyle="round" borderColor="#30343c" backgroundColor="#212226">
85 <ui.Box width={1} backgroundColor={o.color} />
86 <ui.Box flexDirection="column" paddingX={1} flexGrow={1} flexShrink={1}>
87 <ui.Text wrap="wrap">{o.text}</ui.Text>
88 {o.sub ? <ui.Text wrap="wrap" color={P.muted}>{o.sub}</ui.Text> : null}
89 {o.children}
90 </ui.Box>
91 </ui.Box>
92)
93hooks/ui.ts 131 lines1// Shared drawing kit. Single source: scripts/sync-ui.sh copies it to plugins/*/hooks/ui.ts.
2// Desktop draws Svg (animated, themed through prefers-color-scheme); the terminal gets text.
3
4// One palette for every mod (the filetree and savvy-progress look: One Dark accents on a dark surface).
5export const P = {
6 accent: '#d97757', ok: '#98c379', warn: '#e5c07b', err: '#e06c75', info: '#61afef', purple: '#c084fc', teal: '#2dd4bf',
7 muted: '#8b8f98', selection: '#2c313a', hover: '#232831',
8}
9export type Tone = 'accent' | 'ok' | 'warn' | 'err' | 'info' | 'purple'
10export const C = P // svg and text read the same colours
11export const T = P
12
13// Shimmer ramps, dim to bright, from claude-code-filetree.
14export const RAMP: Record<string, string[]> = {
15 orange: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'],
16 green: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'],
17 purple: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'],
18 blue: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'],
19 red: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'],
20}
21
22export const FONT = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
23
24const CSS = `<style>
25.t{fill:#1f1f1f}.m{fill:#6f6f6f}.k{fill:#e4e4e2}.c{fill:#f4f4f2;stroke:#dcdcd8}
26@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.c{fill:#262625;stroke:#3a3a38}}
27.live{animation:pulse 1.6s ease-in-out infinite}.slide{animation:slide 1.5s linear infinite}
28@keyframes pulse{50%{opacity:.3}}@keyframes slide{from{transform:translateX(-45%)}to{transform:translateX(100%)}}
29@media (prefers-reduced-motion: reduce){.live,.slide{animation:none}}
30</style>`
31
32export const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] as string)
33
34// Rough text width in px: CJK and emoji advance about 1em, the rest about 0.58em.
35export const tw = (s: string, size: number) => [...s].reduce((w, ch) => w + (ch.charCodeAt(0) > 0x2e7f ? size : size * 0.58), 0)
36
37export const fit = (s: string, px: number, size: number) => {
38 if (tw(s, size) <= px) return s
39 let out = ''
40 for (const ch of s) {
41 if (tw(out + ch + '…', size) > px) break
42 out += ch
43 }
44 return `${out}…`
45}
46
47export const fmtTok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`)
48export const fmtCost = (usd: number) => (usd < 0.01 ? '<$0.01' : usd < 10 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`)
49export const fmtDur = (ms: number) => {
50 const s = Math.max(0, Math.round(ms / 1000))
51 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
52}
53
54// Green below `warnAt`, amber below `errAt`, red above (percent scales).
55export const zone = (pct: number, warnAt = 60, errAt = 85) => (pct >= errAt ? P.err : pct >= warnAt ? P.warn : P.ok)
56export const zoneT = zone
57export const zoneTone = (pct: number, warnAt = 60, errAt = 85): Tone => (pct >= errAt ? 'err' : pct >= warnAt ? 'warn' : 'ok')
58
59// ---- terminal text ----
60const EIGHTHS = ' ▏▎▍▌▋▊▉█'
61export const hbar = (pct: number, width: number) => {
62 const cells = (Math.max(0, Math.min(100, pct)) / 100) * width
63 const full = Math.floor(cells)
64 const part = EIGHTHS[Math.round((cells - full) * 8)] ?? ''
65 return ('█'.repeat(full) + (full < width ? part.trim() : '')).padEnd(width, '░').slice(0, width)
66}
67export const spark = (vs: number[]) => {
68 const max = Math.max(...vs, 1)
69 return vs.map(v => '▁▂▃▄▅▆▇█'[Math.min(7, Math.floor((v / max) * 7.99))]).join('')
70}
71
72// ---- svg ----
73let uid = 0
74export const svg = (W: number, H: number, body: string) =>
75 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${body}</svg>`
76
77type TextOpts = { size?: number; cls?: 't' | 'm'; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string }
78export const text = (x: number, y: number, s: string, o: TextOpts = {}) =>
79 `<text x="${x}" y="${y}" ${o.fill ? `fill="${o.fill}"` : `class="${o.cls ?? 't'}"`} text-anchor="${o.anchor ?? 'start'}" font-family="${FONT}" font-size="${o.size ?? 12.5}" font-weight="${o.weight ?? 400}" font-variant-numeric="tabular-nums">${esc(s)}</text>`
80
81// Rounded track with a fill. `pct` undefined = indeterminate (a bar sliding across while live).
82export const bar = (x: number, y: number, w: number, h: number, pct: number | undefined, color: string, opts: { live?: boolean; ticks?: number[] } = {}) => {
83 const id = `b${++uid}`
84 const r = h / 2
85 const fill =
86 pct === undefined
87 ? `<rect class="slide" x="${x}" y="${y}" width="${w * 0.4}" height="${h}" rx="${r}" fill="${color}"/>`
88 : `<rect${opts.live ? ' class="live"' : ''} x="${x}" y="${y}" width="${(Math.max(0, Math.min(100, pct)) / 100) * w}" height="${h}" rx="${r}" fill="${color}"/>`
89 const ticks = (opts.ticks ?? []).map(p => `<rect x="${x + (p / 100) * w - 0.5}" y="${y - 2}" width="1" height="${h + 4}" class="m" opacity=".6"/>`).join('')
90 return `<defs><clipPath id="${id}"><rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${r}"/></clipPath></defs><rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${r}"/><g clip-path="url(#${id})">${fill}</g>${ticks}`
91}
92
93export const dot = (cx: number, cy: number, color: string, live = false) => `<circle${live ? ' class="live"' : ''} cx="${cx}" cy="${cy}" r="4" fill="${color}"/>`
94
95// A filled pill with white text; returns its width via `w` so rows can chain chips.
96export const chip = (x: number, y: number, s: string, color: string, size = 11) => {
97 const w = Math.ceil(tw(s, size)) + 14
98 return { w, svg: `<rect x="${x}" y="${y}" width="${w}" height="18" rx="9" fill="${color}"/>${text(x + w / 2, y + 12.5, s, { size, weight: 600, anchor: 'middle', fill: '#fff' })}` }
99}
100
101// Ring gauge, `pct` of the circle filled clockwise from the top.
102export const ring = (cx: number, cy: number, r: number, pct: number, color: string, live = false) => {
103 const c = 2 * Math.PI * r
104 const f = (Math.max(0, Math.min(100, pct)) / 100) * c
105 return `<circle class="k" cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke-width="4" style="stroke:currentColor;opacity:.15"/><circle${live ? ' class="live"' : ''} cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="4" stroke-linecap="round" stroke-dasharray="${f} ${c}" transform="rotate(-90 ${cx} ${cy})"/>`
106}
107
108// Svg px width for a band/pane row: about 8px per column, minus room for buttons.
109export const svgWidth = (columns: number | undefined, reserve = 96) => Math.max(180, Math.min(1600, (columns || 100) * 8 - reserve))
110
111// The terminal's element table carries an Svg stub that draws nothing: test the surface too.
112export const isRich = (ui: object, e: { surface: string }) => e.surface !== 'terminal' && 'Svg' in ui
113
114// AbovePrompt is one strip shared by every mod: a hook that returns a tree hides the mods beneath it
115// unless it nests what `next(e)` drew. Call this first, put the result in your column. Claude Code's
116// own placeholder ({ type: 'engine' }) is never nested: the desktop drops a band that nests it.
117export const below = async (next: (e: any) => unknown, e: unknown) => {
118 const drawn = await Promise.resolve(next(e)).catch(() => null)
119 return drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
120}
121
122// Age in a few characters, as the filetree draws it: now, 12m, 3h, 5d.
123export const age = (ms: number) => {
124 const m = Math.floor(ms / 60_000)
125 return m < 1 ? 'now' : m < 60 ? `${m}m` : m < 1440 ? `${Math.floor(m / 60)}h` : `${Math.floor(m / 1440)}d`
126}
127
128// Static shapes need no theme CSS: skip it, hundreds of rows would carry it each.
129export const svgBare = (W: number, H: number, body: string) =>
130 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${body}</svg>`
131