SLOPSHOPPER

guardrails

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

newpanebandrowsguardcommand
v0.1.6MITupdated 2026-10-15bharath-gp/ai-harness/plugins/guardrails
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · guardrails
│ ┃ Critique ✕ › fix the failing auth test and add an audit log call │ ┃ Critique Critique failed (unparseable reply) │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Rerun ] [ Auto off ] [ Model: session ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by guardrails: guardrails: user cancelled (delete) │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /reflect-queue │ ⎿ guardrails: Nothing queued. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Critique
Critique Critique failed (unparseable reply) [ Rerun ] [ Auto off ] [ Model: session ]
Pane · assumptions
Assumptions 0/0 reviewed · 0 wrong Nothing to review.
README

ai-harness

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.

Install

/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.

BundleModWhat it does
guardrailsblast-radiusAsks before destructive shell commands run
secret-sentryBlocks API keys and private keys from being written into files
reflectSpots corrections in your prompts and saves them as rules in CLAUDE.md
assumption-trackerLists 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
cockpitagents-progressLive subagent panel and a running-agents band above the prompt
cache-keeperCache countdown band (shown in the last 5 minutes or when cold), /keepwarm pings and a cold-send guard with cost estimate
context-healthContext fill band with warnings, /ctx-handoff and /autohandoff
usage-limitsAlways-on band: session (5h) and weekly (7d) limit usage, reset countdown, pace
filetreeFile tree pane with git status and highlights for files Claude reads and writes
next-stepsSuggests three next prompts after each turn

Other coding agents

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.

License

MIT, see LICENSE. Security reports: SECURITY.md.

Develop

./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.

Source 4 files
hooks/register.tsx 559 lines
1import 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}
559
hooks/band.tsx 176 lines
1// 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}
176
hooks/kit.tsx 93 lines
1// 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)
93
hooks/ui.ts 131 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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