The 3 most annoying things about Claude Code, fixed: a ping when Claude needs you, plan-limit gauges with a forecast, and handoff to a fresh chat. Plus time…

A free Claude Code mod that fixes the 3 most annoying things about Claude Code. One-line install, no coding.
<img src="docs/needs-you.png" alt="The strip above the prompt: Claude needs you, waiting 0:32" width="640"><br> <img src="docs/panel.png" alt="The Swiss Knife panel: Claude needs you, last task, today's time and cost, plan limits" width="360">
You leave Claude working, come back an hour later, and find it has been waiting 40 minutes for you to click Allow. Swiss Knife plays a chime, sends a desktop notification and turns the strip red the moment Claude needs you. Still waiting after 5 minutes? It pings you again.
Your 5-hour and weekly limits appear as gauges with reset times and a forecast: "At this pace your 5-hour limit runs out around 15:10." You get a heads-up at 80% and again at 95%.
With every message, Claude reads the whole chat again from the start, so a long chat costs more with every reply. When the chat is about 70% full, Swiss Knife suggests a fresh start. Click Continue in a new chat: it writes a short handoff note (the goal, where things stand, files, decisions, the next step). Open a new chat in the same project and Claude reads it with your first message. Nothing is lost, and you never have to type /clear.
You need Claude Code 2.1.287 or newer (run claude update).
Mods install through Claude Code's plugin system, so the command says
plugin. A mod is a plugin that changes Claude Code itself: its screen, sounds and prompts.
In a terminal Claude Code session, paste:
/plugin install swiss-knife --marketplace nocodehack/swiss-knife
Answer y to add the marketplace, then press Enter to install it for your user. That's it: the strip and the panel show up right away.
From a shell (PowerShell, Terminal):
claude plugin install swiss-knife --marketplace nocodehack/swiss-knife
In the Claude desktop app: click + next to the prompt box → Plugins → Add marketplace → type nocodehack/swiss-knife → Add plugin.
A mod installed from a terminal also loads in the desktop app's local sessions.
Each feature can be turned on or off in the Settings tab.
| What it does | ||
|---|---|---|
| 🔔 Needs you | Chime, notification and a red strip when Claude asks a question, wants permission or wants a plan approved. | |
| 📊 Plan limits | 5-hour and weekly gauges, reset times, and a "runs out around…" forecast. | |
| 🧳 Handoff | A handoff note for a new chat when this one gets full. More details writes the long version. Kept in .claude/handoffs/. | |
| ⏱ Time & cost | Working time and cost per project (breaks don't count). On a subscription it shows what the work is worth at API prices, not a bill. | |
| 💸 Budget | A daily budget per project, with warnings at 80% and 100%. | |
| 🛡 Guard | Commands that destroy work (rm -rf, git reset --hard, force-push, `curl … \ | bash`) stop at Claude Code's own permission prompt with a plain-English question. |
| ✅ Last task | After each task: what got done, step by step, what is still needed, and the files changed. | |
| 🧭 Model tips | Says when a cheaper or stronger model fits the work better. You switch in the model menu. | |
| ⏳ Quick memory timer | Warns before Claude's cache of a big chat (50k+ tokens) runs out, using your promptCacheTtl setting. | |
| 📌 Pins | Pin any reply. Plans you approve are pinned for you. | |
| 🙈 Hide secrets | Keys, passwords and emails show as a grey bar on screen, handy when you record or share your screen. Claude still gets the real text. Covers the prompt box and the chat, not the app's own permission dialogs: switch to Accept edits while you record. | |
| 🦀 Mascot | A pixel buddy that shows what Claude is doing: thinking, writing, testing, done, or asleep on your break. | |
| 📝 /report | 1–3 plain-English lines of what you did today or this week, with time and cost. Ready to send to a client. |
See any feature in seconds: Settings → Try a feature, or type /knife demo cache, handoff, limits, needs-you, budget or recap.
| Command | What it does |
|---|---|
/handoff | Write a handoff note now (/handoff <next step> adds your own next step) |
/report · /report week | Plain-English summary of today or the last 7 days |
/pin · /pin <text> · /pin off | Pin the last reply, pin your own note, or remove all pins |
/budget 5 · /budget 20% · /budget off | Daily dollars, or a share of your weekly limit, for this project |
/knife | Status. Also on, off, show, sound on/off, volume 0-100, handoff <percent>, demo <name> |
You never need a command: everything is a button in the panel.
/report (what you asked and Claude's replies)The handoff and /report run only when you ask. Model tips can be turned off in Settings.
Claude Code mods are new and Claude Code updates often. If an update breaks something:
/knife off./plugin uninstall swiss-knife in a terminal session (or claude plugin uninstall swiss-knife).Want to go deeper on the cache? cache-tax by Karan Bansal keeps the cache warm during breaks and shows the exact price of a cold resend.
<sub>Unofficial. Not made by or affiliated with Anthropic. Claude and Claude Code are trademarks of Anthropic.<br> Made by @nocodehack · MIT License</sub>
hooks/register.tsx 1607 lines1// Swiss Knife: one mod, many blades. Meter, /report, model coach, handoff, guard, budget, mascot,
2// "needs you" alerts, plan limits, /pin and the changed-files list.
3// The rules live in the other files in this folder; this file wires them to Claude Code.
4import type { Register } from 'claude-code'
5import {
6 type Day, type Project, dayKey, emptyDay, projectName, projectRoot, costDelta, isNearFolder, workedMs, statusText, sumDays, fmtTime, fmtSpend,
7 budgetWarning, budgetLevel, parseBudget, money, usageDelta, fmtPct, pctWarning, reportPrompt, REPORT_SYSTEM, oneLine, ago, fmtDuration, firstSentence, IDLE_MS,
8} from './meter'
9import { sizeOf, DIRECTION_SYSTEM, coachLine, label } from './coach'
10import { bar, moodWords } from './dashboard'
11import {
12 type Alert, type Level, type AlertKind, ACCENT, RED, AMBER, GREEN, reportLine, docSections, fileItem, sectionSummary, cleanPin, clock, when, actionText, spendText, waitingText, strip, barSvg, barText, ruleSvg, volumeBarsSvg,
13 segmentsSvg, segmentsText, docLine,
14} from './ui'
15import { type Sound, DEFAULT_SOUND, MIN_TASK_MS, step, parseVolume, windowsPlay, soundLabel } from './sound'
16import {
17 type Note, type HandoffLevel, HANDOFF_AT, STALE_MS, shouldWarn, warnText, handoffSystem, handoffInput, parseDoc, noteContext, notePaste,
18 noteFile, noteMarkdown, loadingBar, parseThreshold, conversationText,
19} from './handoff'
20import { explain, SHELL_TOOLS } from './guard'
21import { type Mood, moodForTool, frameAt, svgFrameAt, playLength, cellsFor, COLUMNS, ROWS } from './mascot'
22import { type Window, type Sample, FIVE_HOUR, WEEKLY, windowOf, current, fmtReset, addSample, runsOutAt, fmtClock, limitWarning } from './limits'
23import { type Pin, makePin, relPath, EDIT_TOOLS, addChanged, titleOf, togglePin } from './pin'
24import { mask, scrub, parseRecap, secretRanges, MASKED } from './secrets'
25import { BLADES, DEMOS, type BladeKey, type Group } from './settings'
26import { cacheTtlMs, cacheState, cacheText, parseTtl } from './cache'
27
28const PANE = 'swiss-knife'
29const PINS_PANE = 'swiss-knife-pins'
30const SETTINGS_PANE = 'swiss-knife-settings'
31const HANDOFF_PANE = 'swiss-knife-handoff'
32const HARD_TASK_MS = 90_000 // a task this long, or with this many steps, gets an automatic recap
33const HARD_TASK_TOOLS = 8
34const REPING_MS = 5 * 60_000 // still waiting after this long: a second ping
35const RECAP_SYSTEM =
36 'Line 1 starts with "RECAP:" and says, in 1 or 2 short sentences of plain English, ' +
37 'what was just achieved, for someone who is not a developer: start with a verb, no code, no file names, no jargon, ' +
38 'only claim what the reply confirms. Line 2 starts with "NEXT:" and gives the most useful next request the person ' +
39 'could type, under 12 words, in their voice (like "make the form send emails"), or "none". ' +
40 'Then a line "REPORT:" followed by 2 to 5 lines starting with "- ": what was done or found, step by step, ' +
41 'and anything left to check, in the same plain words, only what the reply confirms. ' +
42 'Write the text in the language the person asked in; keep the labels RECAP:, NEXT:, REPORT: and "Left to check:" in English.'
43
44// What one answer used: dollars (what it would cost on the API) and points of the plan's limits.
45type ReplyUsage = { usd?: number; five?: number; week?: number }
46const replyKey = (text: string) => oneLine(text, 160)
47const usageKey = () => `usage:${k.sessionId}`
48const taskKey = () => `task:${k.sessionId}`
49
50// The last-task report and this chat's changed files outlive a restart of the app.
51async function saveTask($: any) {
52 await $.store.set(taskKey(), { lastTask: k.lastTask, changed: k.changed }).catch(() => undefined)
53}
54
55// "2% of 5-hour", "<1% of week", "worth $0.20": what one answer used.
56function usageParts(r: ReplyUsage): string[] {
57 const pct = (n: number) => (n > 0 ? fmtPct(n) : '<1%')
58 const out: string[] = []
59 if (r.five !== undefined) out.push(`${pct(r.five)} of 5-hour`)
60 if (r.week !== undefined) out.push(`${pct(r.week)} of week`)
61 if (r.usd !== undefined) out.push(`${k.limits.length ? 'worth ' : ''}${r.usd < 0.01 ? 'under 1¢' : `$${r.usd.toFixed(2)}`}`)
62 return out
63}
64
65type Tab = 'knife' | 'handoff' | 'pins' | 'settings'
66
67type Blades = { all: boolean; mascot: boolean; coach: boolean; guard: boolean; ping: boolean; cache: boolean; next: boolean; secrets: boolean; strip: boolean }
68
69// This session's running values. They start over when the mod reloads; totals live in $.store.
70const k = {
71 cwd: '',
72 lastActivity: undefined as number | undefined,
73 lastUsd: undefined as number | undefined, // the session's reported cost at the last answer
74 hasCost: false,
75 isFirstPrompt: true,
76 isWorking: false,
77 hasWarnedHandoff: false,
78 handoffAt: HANDOFF_AT,
79 contextPercent: 0,
80 sound: { ...DEFAULT_SOUND } as Sound,
81 turnStartAt: 0,
82 turnTools: 0,
83 appDrawnAt: 0, // when the desktop app last drew the mascot: it needs a redraw per frame
84 appFrame: '',
85 model: '',
86 coachSize: null as 'small' | 'medium' | 'big' | null, // where the chat is going, as the coach last judged it
87 blades: { all: true, mascot: true, coach: true, guard: true, ping: true, cache: true, next: true, secrets: true, strip: true } as Blades,
88 lastReplyAt: 0, // when Claude last answered: the cheap cache runs from here
89 contextTokens: 0,
90 cacheLine: '', // the cache timer's text as last drawn
91 cacheTtl: null as number | null, // the person's own promptCacheTtl setting, when they set one
92
93 status: '', // the meter line, also drawn in the band
94 message: null as Alert | null, // one line for the person: welcome back, a demo, a warning
95 tab: 'knife' as Tab, // the panel's tab: Swiss Knife, Handoff, Pins or Settings
96 action: '', // what Claude is doing now, for the strip: "Writing index.html"
97 second: 0, // the clock second last drawn, so running timers tick
98 isHandedOff: false, // a handoff was just written: the strip says so until the next prompt
99 noteSent: null as Note | null, // the handoff a new chat just picked up, shown until the first reply
100 note: null as Note | null, // a handoff note waiting for the first prompt
101 noteAt: '', // the store key it waits under: this folder's, or a folder inside or around it
102 isNoteStale: false, // that note is old: it waits for "Continue it" instead of going along by itself
103 // The Handoff tab: writing (the mascot writes), ready (the document), failed, or idle.
104 handoff: { status: 'idle', level: 'standard', startedAt: 0, note: null, file: '' } as {
105 status: 'idle' | 'writing' | 'ready' | 'failed'; level: HandoffLevel; startedAt: number; note: Note | null; file: string; messages?: number
106 },
107 handoffFrame: '',
108 nextStep: '', // the suggested next request, a button in the strip until the next prompt
109 waiting: null as { since: number; text: string } | null, // Claude is waiting for the person
110 limits: [] as Window[],
111 samples: [] as Sample[], // the 5-hour window over the last hour, for the pace
112 limitShown: {} as Record<string, number>,
113 changed: [] as string[], // files Claude changed this session
114 sessionId: '', // pins made in this session can scroll back to their message
115 expanded: '', // the pin whose full text the Pins tab shows
116 pins: null as Pin[] | null, // kept in memory: every reply's pin button reads it on each redraw
117 turnFiles: [] as string[], // files changed by the current prompt
118 isTaskOpen: false, // the last task's full list of changed files
119 isSoundOpen: false, // the volume row under the panel's header
120 hoOpen: '', // the handoff section opened in the Handoff tab
121 settingsOpen: '', // the Settings group opened
122 isFilesOpen: false, // the "files changed in this chat" list, opened in full
123 reportAt: 0, // when the last-task report last changed: it fades in from there
124 isBandFolded: false, // the strip above the prompt, folded to one line
125 lastWeekPct: undefined as number | undefined, // the weekly limit's reading at the last answer
126 lastFivePct: undefined as number | undefined, // and the 5-hour limit's
127 replyUsage: {} as Record<string, ReplyUsage>, // what each answer of this chat used, by its text
128 lastTask: null as null | { at: number; ms: number; usd?: number; asked: string; summary: string; report?: string[]; files: string[] },
129 mood: 'greeting' as Mood,
130 moodAt: 0,
131 band: null as { requestId: string; drawn: string } | null, // where the terminal mascot is mounted
132 timer: null as { cancel: () => void } | null,
133}
134
135// A chip's label with room on both sides: the button itself fills the chip, so all of it is clickable.
136const pad = (label: string) => ` ${label} `
137
138const projectKey = () => `project:${k.cwd}`
139const noteKey = () => `handoff:${k.cwd}`
140const pinsKey = () => `pins:${k.cwd}`
141
142async function load($: any): Promise<Project> {
143 return ((await $.store.get(projectKey())) as Project | undefined) ?? { name: projectName(k.cwd), days: {} }
144}
145
146// Adds working time (and any change) to today, saves, and refreshes the meter line.
147async function track($: any, change: (d: Day, p: Project) => void = () => {}) {
148 if (!k.blades.all) return
149 const cwd = (await $.session.cwd()) || k.cwd // the desktop app starts in your home folder, then moves to the project
150 const root = projectRoot(k.cwd, cwd)
151 if (root !== k.cwd) k.pins = null // another project: its own pins
152 k.cwd = root
153 if (!k.cwd) return
154 const now = await $.clock.now()
155 const p = await load($)
156 const day = dayKey(now)
157 const today = (p.days[day] ??= emptyDay())
158 today.done ??= []
159 today.ms += workedMs(k.lastActivity, now)
160 k.lastActivity = now
161 change(today, p)
162 const week = sumDays(p, dayKey(now - 6 * 86400000))
163 const hasPlan = k.limits.length > 0
164 const warning = budgetWarning(p, day, today.usd) ?? pctWarning(p, dayKey(now - (new Date(now).getDay() * 86400000)), week.weekPct ?? 0)
165 await $.store.set(projectKey(), p)
166 k.status = statusText(p.name, today, k.hasCost, p.budgetUsd, hasPlan)
167 $.ui.status(k.status)
168 if (warning) { $.ui.toast(warning); await setMood($, 'gag-tokens') } // the alert itself comes from the numbers
169}
170
171function say($: any, text: string, level: Level = 'info', kind: AlertKind = 'note') {
172 k.message = text ? { kind, level, text } : null
173 $.ui.invalidate('ui.render')
174}
175
176async function setMood($: any, mood: Mood) {
177 if (k.mood === mood) return
178 k.mood = mood
179 k.moodAt = await $.clock.now()
180 $.ui.invalidate('ui.render') // the desktop app redraws its picture; the terminal repaints on the next tick
181}
182
183// Pins: a list per project, newest first.
184async function loadPins($: any): Promise<Pin[]> {
185 if (k.pins) return k.pins
186 const list = (await $.store.get(pinsKey())) as Pin[] | undefined
187 const old = (await $.store.get(`pin:${k.cwd}`)) as Omit<Pin, 'id'> | undefined // the single pin of the first version
188 k.pins = list ?? (old ? [{ ...old, id: `pin-${old.at}` }] : [])
189 return k.pins
190}
191
192async function savePins($: any, pins: Pin[]) {
193 k.pins = pins
194 await $.store.set(pinsKey(), pins)
195 $.ui.invalidate('ui.render')
196}
197
198// The 📌 under a reply: pins it, or unpins it when it is pinned already.
199async function pinMessage($: any, requestId: string, text: string) {
200 const pins = await loadPins($)
201 const updated = togglePin(pins, makePin(text, titleOf(text), await $.clock.now(), { requestId, sessionId: k.sessionId }))
202 await savePins($, updated)
203 $.ui.toast(updated.length > pins.length ? 'Pinned. Find it in the Pins tab.' : 'Unpinned.')
204}
205
206async function unpin($: any, id: string) {
207 await savePins($, (await loadPins($)).filter(x => x.id !== id))
208}
209
210async function foldBand($: any) {
211 k.isBandFolded = !k.isBandFolded
212 await $.store.set('bandFolded', k.isBandFolded)
213 $.ui.invalidate('ui.render')
214}
215
216function toggleText($: any, id: string) {
217 k.expanded = k.expanded === id ? '' : id
218 $.ui.invalidate('ui.render')
219}
220
221// "Go to message": scroll the conversation, up or down, to the pinned reply.
222async function jumpTo($: any, pin: Pin) {
223 if (!pin.requestId) return
224 const byKey = await $.ui.scroll({ to: { key: `reply-${pin.requestId}` }, block: 'center' }).catch((x: unknown) => ({ deny: String(x) }))
225 if (byKey.deny === undefined) return
226 const byId = await $.ui.scroll({ to: { requestId: pin.requestId }, block: 'center' }).catch((x: unknown) => ({ deny: String(x) }))
227 if (byId.deny === undefined) return
228 $.ui.toast(`The app didn't scroll to that message (${oneLine(String(byId.deny || byKey.deny), 90)}).`)
229}
230
231// Clicking a pin: scroll the conversation to its message when it is in this session, else show its full text.
232async function openPin($: any, pin: Pin) {
233 if (pin.requestId && pin.sessionId === k.sessionId) {
234 // First the 📌 row this mod drew under the reply, then the reply itself.
235 const byKey = await $.ui.scroll({ to: { key: `reply-${pin.requestId}` }, block: 'start' }).catch((x: unknown) => ({ deny: String(x) }))
236 if (byKey.deny === undefined) return
237 const byId = await $.ui.scroll({ to: { requestId: pin.requestId }, block: 'start' }).catch((x: unknown) => ({ deny: String(x) }))
238 if (byId.deny === undefined) return
239 $.ui.toast(`Couldn't jump to that message (${oneLine(String(byId.deny || byKey.deny), 80)}). Showing its text here instead.`)
240 }
241 k.expanded = k.expanded === pin.id ? '' : pin.id
242 $.ui.invalidate('ui.render')
243}
244
245async function copyPin($: any, pin: Pin, surface: string) {
246 const r = await $.ui.copy({ text: pin.text, surface }).catch((x: unknown) => ({ isCopied: false, reason: String(x) }))
247 $.ui.toast(r.isCopied ? 'Copied.' : `Couldn't copy (${oneLine(String(r.reason), 80)}).`)
248}
249
250function toggleFiles($: any) {
251 k.isFilesOpen = !k.isFilesOpen
252 $.ui.invalidate('ui.render')
253}
254
255function toggleTask($: any) {
256 k.isTaskOpen = !k.isTaskOpen
257 $.ui.invalidate('ui.render')
258}
259
260async function showSettings($: any) {
261 await showPane($, 'settings')
262}
263
264async function toggleBlade($: any, key: BladeKey) {
265 k.blades[key] = !k.blades[key]
266 await $.store.set('blades', k.blades)
267 $.ui.invalidate('ui.render')
268}
269
270async function setAll($: any) {
271 k.blades.all = !k.blades.all
272 await $.store.set('blades', k.blades)
273 if (!k.blades.all) $.ui.status(undefined)
274 else { k.lastActivity = undefined; await track($) }
275 $.ui.invalidate('ui.render')
276}
277
278// Budget from the Settings tab: a share of the weekly limit in steps of 5%, or dollars in steps of $1; 0 turns it off.
279async function stepBudget($: any, kind: 'pct' | 'usd', by: number | 'off') {
280 const p = await load($)
281 if (kind === 'pct') {
282 const next = by === 'off' ? 0 : Math.max(0, Math.min(100, (p.budgetPct ?? 0) + by))
283 if (next > 0) { p.budgetPct = next; delete p.pctWarned } else delete p.budgetPct
284 } else {
285 const next = by === 'off' ? 0 : Math.max(0, Math.round(((p.budgetUsd ?? 0) + by) * 100) / 100)
286 if (next > 0) { p.budgetUsd = next; delete p.budgetWarned } else delete p.budgetUsd
287 }
288 await $.store.set(projectKey(), p)
289 await track($)
290 $.ui.invalidate('ui.render')
291}
292
293async function setHandoffAt($: any, at: number) {
294 k.handoffAt = Math.max(20, Math.min(95, at))
295 k.hasWarnedHandoff = false
296 await $.store.set('handoffAt', k.handoffAt)
297 $.ui.invalidate('ui.render')
298}
299
300// /knife demo: stages a feature with sample numbers, so the ones that need a long chat can be seen in seconds.
301// The next real answer from Claude puts the real numbers back.
302async function demo($: any, name: string): Promise<string> {
303 const now = await $.clock.now()
304 const ttl = cacheTtlMs(k.limits.length > 0, k.cacheTtl)
305 if (name === 'needs-you') { k.waiting = null; await needsYou($, 'Claude needs your permission to run npm install'); return 'Demo: Claude is waiting for you. Send any message to clear it.' }
306 if (name === 'handoff') { k.contextPercent = 74; k.hasWarnedHandoff = true; $.ui.toast(warnText(74)); await setMood($, 'context-nearly-full') }
307 else if (name === 'cache') { k.contextTokens = 82_000; k.lastReplyAt = now - ttl + 4 * 60_000 }
308 else if (name === 'cache-gone') { k.contextTokens = 82_000; k.lastReplyAt = now - ttl - 60_000 }
309 else if (name === 'limits') {
310 const reset = new Date(now + 2 * 3600_000).toISOString()
311 k.limits = [{ kind: FIVE_HOUR, percentUsed: 86, resetsAt: reset }, { kind: WEEKLY, percentUsed: 52, resetsAt: new Date(now + 4 * 86400_000).toISOString() }]
312 k.samples = [{ at: now - 30 * 60_000, percent: 70 }, { at: now, percent: 86 }]
313 $.ui.toast(`Demo: you've used 86% of your 5-hour limit.`)
314 } else if (name === 'budget') { say($, 'Over today\'s budget: $5.40 of $5 (demo).', 'urgent', 'budget'); $.ui.toast('Demo: over today\'s $5 budget.'); await setMood($, 'gag-tokens') }
315 else if (name === 'recap') {
316 k.lastTask = { at: now, ms: 134_000, usd: 0.12, asked: 'add a contact form with name, email and message',
317 summary: 'Added a contact form under the address with name, email and message fields, a check that the email looks right, and a thank-you note after sending. It also fits phone screens.',
318 report: ['Added a form with name, email and message under the shop address.', 'The email box now checks that the address looks right before sending.', 'A thank-you note shows after sending.', 'Left to check: the form does not send anything yet; it needs an email service.'],
319 files: ['index.html', 'css/form.css', 'js/form.js', 'images/send.svg', 'README.md'] }
320 k.isTaskOpen = false
321 k.reportAt = now
322 offerNext($, 'make the form send me an email')
323 void chime($, 'done')
324 } else return `Demos: ${Object.keys(DEMOS).join(', ')}. Example: /knife demo cache`
325 await showPane($)
326 $.ui.invalidate('ui.render')
327 return `Demo: ${DEMOS[name]}. The next real answer from Claude puts the real numbers back.`
328}
329
330// At startup only the Swiss Knife tab opens: the app shows the last tab opened, so Pins and Settings wait for a click.
331async function openTabs($: any) {
332 await showPane($)
333}
334
335async function showPins($: any) {
336 await showPane($, 'pins')
337}
338
339// One app tab holds everything; its own tabs switch between Swiss Knife, Handoff, Pins and Settings.
340async function showPane($: any, tab?: Tab) {
341 if (tab && tab !== k.tab) { k.tab = tab; $.ui.invalidate('ui.render') }
342 await $.ui.open({ id: PANE, title: 'Swiss Knife' }).catch(() => undefined)
343}
344
345// After a task: a plain-English recap for the "Last task" report, saved for /report too.
346// The toast is only for long tasks, the ones you may have walked away from.
347async function recap($: any, asked: string, did: string, isLong: boolean) {
348 try {
349 const r = await $.model.complete({ model: 'haiku', maxTokens: 600, system: RECAP_SYSTEM, prompt: `Asked: ${asked}\nClaude's reply: ${did}` })
350 if (!r.isAnswered || !r.text.trim()) return
351 const { recap: text, next, report } = parseRecap(r.text)
352 const line = oneLine(text, 600)
353 // Next step: shown dim in the empty prompt box, Tab takes it.
354 if (next && k.blades.next) offerNext($, oneLine(next, 120))
355 if (k.lastTask) { k.lastTask.summary = line; k.lastTask.report = report.map(x => oneLine(x, 300)); k.reportAt = await $.clock.now(); await saveTask($) }
356 $.ui.invalidate('ui.render')
357 if (isLong) $.ui.toast(`✅ ${line}`)
358 await track($, d => { if (d.done.length) d.done[d.done.length - 1] = line })
359 } catch { /* the recap is a bonus: the reply's first sentence stays */ }
360}
361
362// The next step: the prompt box's dim suggestion where it draws one (the terminal), and a button in the
363// strip everywhere, which types it into the prompt box.
364function offerNext($: any, text: string) {
365 k.nextStep = text
366 void $.prompt.suggest({ text }).catch(() => undefined)
367 $.ui.invalidate('ui.render')
368}
369
370async function takeNext($: any, surface: string) {
371 const text = k.nextStep
372 if (!text) return
373 const r = await $.prompt.fill({ text, mode: 'replace' }).catch(() => ({ isFilled: false }))
374 if (r.isFilled) { k.nextStep = ''; $.ui.invalidate('ui.render'); return }
375 const c = await $.ui.copy({ text, surface }).catch(() => ({ isCopied: false }))
376 $.ui.toast(c.isCopied ? 'Copied: paste it in the prompt box.' : text)
377}
378
379// Down to zero is off; "+" turns it back on.
380function quieter($: any) {
381 const volume = step(k.sound.volume, -0.1)
382 return setSound($, volume === 0 ? { volume: 0, isOn: false } : { volume })
383}
384
385async function setSound($: any, change: Partial<Sound>) {
386 const before = k.sound
387 k.sound = { ...k.sound, ...change }
388 // The mascot reacts: dances when it gets louder, puts on its nightcap when quieter or muted, waves when sound comes back.
389 const reaction: Mood | null =
390 !k.sound.isOn ? 'asleep-nightcap'
391 : !before.isOn ? 'greeting'
392 : k.sound.volume > before.volume ? 'gag-dance'
393 : k.sound.volume < before.volume ? 'asleep-nightcap' : null
394 if (reaction && k.blades.mascot) { k.mood = 'idle'; await setMood($, reaction) }
395 await $.store.set('sound', k.sound)
396 $.ui.invalidate('ui.render')
397 if (change.volume !== undefined && k.sound.isOn) void chime($, 'done')
398}
399
400// A sound: Windows' own player at the chosen volume; elsewhere Claude Code's player.
401async function chime($: any, sound: 'done' | 'attention') {
402 if (!k.sound.isOn || k.sound.volume <= 0) return
403 try {
404 if ((await $.env.get('OS')) === 'Windows_NT')
405 await $.process.run(windowsPlay(`${$.plugin.root}\\sounds\\${sound}.wav`, k.sound.volume), { timeoutMs: 6000 })
406 else await $.audio.play({ asset: `sounds/${sound}.wav` }, { gain: k.sound.volume })
407 } catch { /* no sound is better than a broken session */ }
408}
409
410// Needs you: Claude asked a question, wants permission or wants a plan approved.
411async function needsYou($: any, text: string) {
412 if (!k.blades.all || !k.blades.ping || k.waiting) return
413 const since = await $.clock.now()
414 k.waiting = { since, text: waitingText(text) }
415 await setMood($, 'waiting-for-you')
416 void chime($, 'attention')
417 const p = await load($)
418 void $.ui.notify(text, { title: `Claude needs you in ${p.name}` }).catch(() => undefined)
419 $.clock.after(REPING_MS, () => { void pingAgain($, since).catch(() => undefined) })
420}
421
422async function pingAgain($: any, since: number) {
423 if (k.waiting?.since !== since) return // answered already
424 void chime($, 'attention')
425 const p = await load($)
426 void $.ui.notify(`Still waiting, 5 min now. ${k.waiting.text}`, { title: `Claude needs you in ${p.name}` }).catch(() => undefined)
427}
428
429async function answered($: any) {
430 if (!k.waiting) return
431 k.waiting = null
432 await setMood($, k.isWorking ? 'thinking' : 'idle')
433}
434
435// Plan limits: keep the windows, the pace of the 5-hour one, and warn at 80% and 95%.
436function takeLimits($: any, windows: readonly Window[] | undefined, now: number) {
437 if (!windows || windows.length === 0) return
438 k.limits = windows.filter(w => Number.isFinite(w.percentUsed)).map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt }))
439 void $.store.set('limits', k.limits) // a new session knows the plan before its first answer
440 const five = windowOf(k.limits, FIVE_HOUR)
441 if (five) k.samples = addSample(k.samples, { at: now, percent: five.percentUsed })
442 for (const w of [five, windowOf(k.limits, WEEKLY)]) {
443 const warning = limitWarning(w, k.limitShown)
444 if (warning) $.ui.toast(warning)
445 }
446 $.ui.invalidate('ui.render')
447}
448
449const restMood = (): Mood => (k.contextPercent >= k.handoffAt ? 'context-nearly-full' : 'idle')
450
451// Every 100 ms: settle finished animations, fall asleep on a break, repaint the mascot.
452async function tick($: any) {
453 const now = await $.clock.now()
454 const elapsed = now - k.moodAt
455 if ((k.mood === 'done' || k.mood === 'greeting') && elapsed > playLength(k.mood) + 1500) await setMood($, restMood())
456 else if ((k.mood === 'error' || k.mood === 'gag-tokens') && elapsed > 4000) await setMood($, k.isWorking ? 'thinking' : restMood())
457 else if ((k.mood === 'gag-dance' || k.mood === 'asleep-nightcap') && elapsed > (k.sound.isOn ? 3000 : 4500)) await setMood($, k.isWorking ? 'thinking' : restMood())
458 else if (!k.isWorking && !k.waiting && k.mood !== 'asleep' && now - (k.lastActivity ?? k.moodAt) > IDLE_MS) await setMood($, 'asleep')
459 if (now - k.reportAt < 1600) $.ui.invalidate('ui.render') // the report fading in
460 const second = Math.floor(now / 1000)
461 if ((k.isWorking || k.waiting) && second !== k.second) { k.second = second; $.ui.invalidate('ui.render') } // "0:42" ticking
462 if (k.handoff.status === 'writing') { // the Handoff tab: the mascot writing, the bar sliding
463 const id = `${svgFrameAt('writing', now - k.handoff.startedAt).index}:${Math.floor((now - k.handoff.startedAt) / 120)}`
464 if (id !== k.handoffFrame) { k.handoffFrame = id; $.ui.invalidate('ui.render') }
465 }
466 const cacheLine = currentCacheLine(now)
467 if (cacheLine !== k.cacheLine) { k.cacheLine = cacheLine; $.ui.invalidate('ui.render') }
468 if (k.blades.mascot && now - k.appDrawnAt < 60_000) {
469 const id = `${k.mood}:${svgFrameAt(k.mood, now - k.moodAt).index}`
470 if (id !== k.appFrame) { k.appFrame = id; $.ui.invalidate('ui.render') }
471 }
472 const band = k.band
473 if (!band || !k.blades.mascot) return
474 const { index, frame } = frameAt(k.mood, now - k.moodAt)
475 const id = `${k.mood}:${index}`
476 if (id === band.drawn) return
477 const r = await $.ui.blit({ requestId: band.requestId, key: 'mascot', cells: cellsFor(k.mood, index, frame) })
478 if (r.deny !== undefined) { k.band = null; $.ui.invalidate('ui.render') } // moved or resized: the next render remounts it
479 else band.drawn = id
480}
481
482// A reload drops timers, so this runs again at every session and turn start.
483function startTicker($: any) {
484 if (k.timer) return
485 k.timer = $.clock.every(100, () => { void tick($).catch(() => undefined) })
486}
487
488// Model coach: after each reply, a quick look at where the chat is going. It only advises; the person
489// switches in the app's model menu, so the menu and the mascot always name the model that runs.
490async function coachAfter($: any) {
491 try {
492 const messages = await $.session.messages()
493 const r = await $.model.complete({ model: 'haiku', system: DIRECTION_SYSTEM, prompt: conversationText(messages, 8, 600), effort: 'low', maxTokens: 10 })
494 if (!r.isAnswered) return
495 k.coachSize = sizeOf(r.text)
496 $.ui.invalidate('ui.render')
497 } catch { /* advice is optional */ }
498}
499
500// An earlier version could change the default model; this only clears what it left behind.
501async function restoreModel($: any) {
502 await $.store.delete('coach:restore').catch(() => undefined)
503}
504
505// Guard: a destructive command waits for a yes in Claude Code's own permission prompt,
506// explained in plain English. That prompt is drawn natively on every surface, desktop included.
507const guardReason = (command: string) => explain(command)
508
509// What needs saying right now, most urgent first: needs you, then anything over a limit, then warnings, then tips.
510async function alerts($: any, p: Project, today: Day): Promise<Alert[]> {
511 const out: Alert[] = []
512 const now = await $.clock.now()
513 if (k.waiting) out.push({ kind: 'waiting', level: 'urgent', text: `${k.waiting.text} We'll ping you again in 5 min.` })
514 const spent = budgetLevel(today.usd, p.budgetUsd)
515 if (spent > 0 && k.hasCost) out.push({ kind: 'budget', level: spent === 100 ? 'urgent' : 'warn',
516 text: spent === 100 ? `Over today's budget: $${today.usd.toFixed(2)} of ${money(p.budgetUsd!)}.` : `80% of today's ${money(p.budgetUsd!)} budget used.` })
517 if (p.budgetPct) {
518 const share = sumDays(p, dayKey(now - 6 * 86400000)).weekPct ?? 0
519 if (share >= p.budgetPct * 0.8) out.push({ kind: 'budget', level: share >= p.budgetPct ? 'urgent' : 'warn', text: `This project used ${fmtPct(share)} of your weekly limit, of its ${p.budgetPct}% share.` })
520 }
521 for (const w of [current(windowOf(k.limits, FIVE_HOUR), now), current(windowOf(k.limits, WEEKLY), now)]) {
522 if (!w || w.percentUsed < 80) continue
523 const name = w.kind === FIVE_HOUR ? '5-hour' : 'weekly'
524 out.push({ kind: 'limit', level: w.percentUsed >= 95 ? 'urgent' : 'warn', text: `${Math.round(w.percentUsed)}% of your ${name} limit used. Resets ${fmtReset(w, now)}.` })
525 }
526 if (k.message && k.message.level !== 'info') out.push(k.message)
527 if (k.contextPercent >= k.handoffAt) out.push({ kind: 'chat-full', level: 'warn', text: warnText(k.contextPercent) })
528 const cache = currentCacheLine(now)
529 if (cache) out.push({ kind: 'cache', level: cache.startsWith('Quick memory ran out') ? 'warn' : 'info', text: cache })
530 const coachText = k.blades.coach && k.coachSize && !k.isWorking ? coachLine(k.coachSize, k.model, k.contextTokens) : ''
531 if (coachText) out.push({ kind: 'tip', level: 'info', text: coachText })
532 if (k.message && k.message.level === 'info') out.push(k.message)
533 const rank = (a: Alert) => (a.kind === 'waiting' ? 0 : a.level === 'urgent' ? 1 : a.level === 'warn' ? 2 : a.kind === 'cache' ? 3 : a.kind === 'tip' ? 4 : 5)
534 const sorted = out.map((a, i) => ({ a, i })).sort((x, y) => rank(x.a) - rank(y.a) || x.i - y.i).map(x => x.a)
535 return k.blades.secrets ? sorted.map(a => ({ ...a, text: mask(a.text) })) : sorted
536}
537
538// The cache timer: only near the end of the cheap window, or once it ran out, while you are away.
539function currentCacheLine(now: number): string {
540 if (!k.blades.cache || k.isWorking || k.waiting) return ''
541 const st = cacheState(k.lastReplyAt, now, cacheTtlMs(k.limits.length > 0, k.cacheTtl), k.contextTokens)
542 return st ? cacheText(st, k.contextTokens, k.limits.length > 0) : ''
543}
544
545// The handoff document: Haiku for the quick one, Sonnet for the detailed one. Kept for the next
546// session in this project, and as a file in .claude/handoffs.
547async function writeHandoff($: any, level: HandoffLevel, next = ''): Promise<{ note: Note; file: string } | null> {
548 try {
549 const messages = await $.session.messages()
550 if (messages.length < 2) return null
551 const p = await load($)
552 const r = await $.model.complete({
553 model: level === 'full' ? 'sonnet' : 'haiku', system: handoffSystem(level),
554 prompt: handoffInput(messages, k.changed, p.name, level),
555 maxTokens: level === 'full' ? 6000 : 2000, timeoutMs: level === 'full' ? 240_000 : 90_000,
556 })
557 const note = r.isAnswered ? parseDoc(r.text, await $.clock.now(), level) : null
558 if (!note) return null
559 if (next.trim()) note.next = oneLine(next, 120) // the person's own words win
560 await $.store.set(noteKey(), note)
561 const file = noteFile(note)
562 const isFiled = await $.fs.write(`${k.cwd}/${file}`, noteMarkdown(note, p.name)).then(() => true, () => false)
563 return { note, file: isFiled ? file : '' }
564 } catch { return null }
565}
566
567// The 🧳 button and /handoff: the Handoff tab opens, the mascot writes, then the document shows there.
568// A mod can't open a session, so the person opens one; its first message carries the document.
569async function startHandoff($: any, level: HandoffLevel = 'standard', next = '') {
570 if (k.handoff.status === 'writing') { await showHandoff($); return }
571 k.handoff = { ...k.handoff, status: 'writing', level, startedAt: await $.clock.now(), messages: (await $.session.messages()).length }
572 $.ui.invalidate('ui.render')
573 await showHandoff($)
574 const done = await writeHandoff($, level, next)
575 k.handoff = done ? { ...k.handoff, status: 'ready', note: done.note, file: done.file } : { ...k.handoff, status: 'failed' }
576 if (done) { k.isHandedOff = true; $.ui.toast('Handoff ready. Open a new chat in this project to continue.'); void chime($, 'done') }
577 $.ui.invalidate('ui.render')
578}
579
580async function showHandoff($: any) {
581 await showPane($, 'handoff')
582}
583
584async function copyHandoff($: any, surface: string) {
585 const note = k.handoff.note
586 if (!note) return
587 const r = await $.ui.copy({ text: notePaste(note), surface }).catch((x: unknown) => ({ isCopied: false, reason: String(x) }))
588 $.ui.toast(r.isCopied ? 'Copied. Paste it as the first message of any chat.' : `Couldn't copy (${oneLine(String(r.reason), 80)}).`)
589}
590
591// Throw the handoff away: the next session starts with nothing.
592async function discardHandoff($: any) {
593 k.handoff = { ...k.handoff, status: 'idle', note: null, file: '' }
594 k.note = null
595 k.isHandedOff = false
596 await $.store.delete(noteKey())
597 $.ui.invalidate('ui.render')
598}
599
600// A waiting note, at a session's start: a recent one goes along with the first message by itself,
601// with its next step ready in the prompt box; an old one waits for "Continue it".
602async function takeNote($: any, now: number): Promise<boolean> {
603 // This folder's note first; else the newest one written in a folder inside or around this one
604 // (a chat opened one level up from where the last one worked still finds it).
605 let key = noteKey(), note = (await $.store.get(key)) as Note | undefined
606 if (!note) {
607 for (const other of ((await $.store.keys().catch(() => [])) as string[]).filter(x => x.startsWith('handoff:') && x !== key)) {
608 const n = (await $.store.get(other)) as Note | undefined
609 if (n && isNearFolder(other.slice('handoff:'.length), k.cwd) && (!note || n.at > note.at)) { note = n; key = other }
610 }
611 }
612 if (!note) return false
613 k.note = note
614 k.noteAt = key
615 k.isNoteStale = now - note.at > STALE_MS
616 if (!k.isNoteStale && note.next) void $.prompt.suggest({ text: oneLine(note.next, 120) }).catch(() => undefined)
617 $.ui.invalidate('ui.render') // the strip says "Continuing", or asks about an old one
618 return true
619}
620
621async function continueNote($: any) {
622 k.isNoteStale = false
623 if (k.note?.next) void $.prompt.suggest({ text: oneLine(k.note.next, 120) }).catch(() => undefined)
624 $.ui.invalidate('ui.render')
625}
626
627// A new chat in the same process (/clear): nothing of the last chat carries over but the handoff note.
628async function startOver($: any) {
629 k.isFirstPrompt = true
630 k.lastReplyAt = 0; k.contextPercent = 0; k.contextTokens = 0; k.hasWarnedHandoff = false
631 k.coachSize = null
632 await takeNote($, await $.clock.now())
633}
634
635// When a fresh chat would help: the quick memory is running out or gone, or the chat is getting full.
636const isFreshTime = (now: number) => currentCacheLine(now) !== '' || k.contextPercent >= k.handoffAt
637
638function forecast(now: number): string {
639 const five = windowOf(k.limits, FIVE_HOUR)
640 const at = runsOutAt(k.samples, five)
641 return at && at > now ? `At this pace your 5-hour limit runs out around ${fmtClock(at)}.` : ''
642}
643
644// The drawing kit for one surface: the toolkit's elements, and the bar and rule that suit it.
645function kit($: any, e: any) {
646 const { Box, Text, Button } = $.ui.resolve(e)
647 const Svg = e.surface === 'terminal' ? null : ($.ui.resolve(e) as any).Svg
648 const isTerm = e.surface === 'terminal'
649 const Bar = ({ f, label }: { f: number; label: string }) => (Svg
650 ? <Svg source={barSvg(f)} alt={label} height={8} />
651 : <Text color={f >= 0.95 ? RED : f >= 0.8 ? AMBER : ACCENT} wrap="truncate-end">{barText(f, 28)}</Text>)
652 const Rule = () => (Svg ? <Svg source={ruleSvg()} alt="" height={1} /> : <Text dimColor wrap="truncate-end">{'─'.repeat(120)}</Text>)
653 return { Box, Text, Button, Svg, isTerm, Bar, Rule }
654}
655
656const TABS: { id: Tab; label: string }[] = [
657 { id: 'knife', label: 'Swiss Knife' }, { id: 'handoff', label: 'Handoff' }, { id: 'pins', label: 'Pins' }, { id: 'settings', label: 'Settings' },
658]
659
660function setTab($: any, tab: Tab) {
661 k.tab = tab
662 $.ui.invalidate('ui.render')
663}
664
665const spendOf = (usd: number | undefined, budget = 0) => (usd === undefined ? '' : spendText(usd, k.hasCost, k.limits.length > 0, budget))
666
667// What the strip shows now: the inputs of strip(), from this session's values.
668async function stripNow($: any, now: number) {
669 const p = await load($)
670 const today = p.days[dayKey(now)] ?? emptyDay()
671 const list = await alerts($, p, today)
672 const budget = p.budgetUsd && k.hasCost ? p.budgetUsd : 0
673 const meta = statusText(p.name, today, k.hasCost, budget || undefined, k.limits.length > 0)
674 const t = k.lastTask
675 const show = k.blades.secrets ? mask : (x: string) => x
676 const noteOf = (n: Note, isSent: boolean) => ({ goal: show(oneLine(n.goal, 70)), at: n.at, isStale: !isSent && k.isNoteStale, isSent })
677 return strip({
678 now, meta, alerts: list,
679 waiting: k.waiting,
680 working: k.isWorking ? { action: k.action || 'Thinking', since: k.turnStartAt || now } : null,
681 isAsleep: k.mood === 'asleep',
682 done: t && t.summary ? { at: t.at, took: [fmtDuration(t.ms), spendOf(t.usd)].filter(Boolean).join(' · '), summary: show(firstSentence(t.summary, 140)) } : null,
683 next: k.blades.next && k.nextStep ? oneLine(k.nextStep, 48) : '',
684 note: k.note ? noteOf(k.note, false) : k.noteSent ? noteOf(k.noteSent, true) : null,
685 isHandedOff: k.isHandedOff && k.handoff.status === 'ready',
686 })
687}
688
689function stripAction($: any, key: string, surface: string) {
690 if (key === 'fresh-chat') return void startHandoff($)
691 if (key === 'next') return void takeNext($, surface)
692 if (key === 'note-continue') return void continueNote($)
693 if (key === 'note-drop') return void discardHandoff($)
694}
695
696// The strip above the prompt: the mascot, one status line, the one alert that wins, one action.
697async function stripView($: any, e: any, rest: unknown) {
698 const { Box, Text, Button, Svg, isTerm } = kit($, e)
699 const now = await $.clock.now()
700 const s = await stripNow($, now)
701 const head = (
702 <Text wrap="truncate-end">
703 {s.glyph ? <Text color={s.glyphColor}>{`${s.glyph} `}</Text> : ''}
704 <Text bold color={s.titleColor}>{s.title}</Text>
705 {s.meta ? <Text dimColor>{` · ${s.meta}`}</Text> : ''}
706 </Text>
707 )
708 const side = isTerm ? null : (
709 <Box flexDirection="row" gap="sm" alignItems="center">
710 <Button key="show-panel" label="Panel" variant="secondary" onPress={() => showPane($, 'knife')} />
711 <Button key={k.isBandFolded ? 'unfold-band' : 'fold-band'} label={k.isBandFolded ? '▴' : '▾'} variant="secondary" onPress={() => foldBand($)} />
712 </Box>
713 )
714 if (k.isBandFolded) {
715 k.band = null
716 return (
717 <Box flexDirection="column">
718 {rest}
719 <Box flexDirection="row" gap="sm" alignItems="center" paddingX="sm">
720 {Svg && k.blades.mascot ? <Svg key="strip-mascot" source={svgFrameAt(k.mood, now - k.moodAt).svg} alt={`Mascot: ${moodWords(k.mood)}`} width={26} height={24} /> : null}
721 <Box flexGrow={1} flexShrink={1}>{head}</Box>
722 {side}
723 </Box>
724 </Box>
725 )
726 }
727 let picture: unknown = null
728 if (k.blades.mascot && isTerm && e.props.maxRows >= ROWS + 2 && e.props.bodyColumns >= COLUMNS + 30) {
729 const { Raster } = $.ui.resolve(e)
730 const { index, frame } = frameAt(k.mood, now - k.moodAt)
731 k.band = { requestId: e.requestId, drawn: `${k.mood}:${index}` }
732 picture = <Raster key="mascot" columns={COLUMNS} rows={ROWS} cells={cellsFor(k.mood, index, frame)} />
733 } else if (k.blades.mascot && Svg) {
734 k.band = null
735 k.appDrawnAt = now
736 picture = <Svg key="strip-mascot" source={svgFrameAt(k.mood, now - k.moodAt).svg} alt={`Mascot: ${moodWords(k.mood)}`} width={50} height={46} />
737 } else k.band = null
738 const isTip = s.detailGlyph === 'Tip'
739 return (
740 <Box flexDirection="column">
741 {rest}
742 <Box flexDirection="row" gap="md" alignItems="center" paddingX="sm" paddingY="xs"
743 borderStyle={isTerm ? undefined : 'round'} borderColor={isTerm ? undefined : s.isUrgent ? RED : 'promptBorder'}>
744 {picture}
745 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
746 {head}
747 {s.detail ? (
748 <Text wrap="wrap">
749 {s.detailGlyph ? <Text bold color={s.detailColor}>{`${s.detailGlyph} `}</Text> : ''}
750 <Text color={isTip ? undefined : s.detailColor}>{isTip ? s.detail.replace(/ Switch in the model menu\..*$/, '') : s.detail}</Text>
751 </Text>
752 ) : null}
753 {s.actions.length > 0 ? (
754 <Box flexDirection="row" gap="sm" marginTop="xs">
755 {s.actions.map(a => <Button key={`strip-${a.key}`} label={a.label} variant={a.isPrimary ? 'primary' : 'secondary'} onPress={(press: any) => stripAction($, a.key, press.surface)} />)}
756 </Box>
757 ) : null}
758 </Box>
759 {side}
760 </Box>
761 </Box>
762 )
763}
764
765// The tab row at the top of the panel.
766function tabRow($: any, e: any) {
767 const { Box, Text, Button, Rule } = kit($, e)
768 return (
769 <Box flexDirection="column" gap="xs">
770 <Box flexDirection="row" gap="md" paddingX="sm">
771 {TABS.map(t => (
772 <Button key={`tab-${t.id}`} label={t.label} plain onPress={() => setTab($, t.id)}>
773 <Text bold={t.id === k.tab} color={t.id === k.tab ? ACCENT : undefined} dimColor={t.id !== k.tab}>{t.label}</Text>
774 </Button>
775 ))}
776 </Box>
777 <Rule />
778 </Box>
779 )
780}
781
782// A row with the name on the left and the value on the right.
783const pairRow = (Box: any, Text: any, key: string, name: string, value: string, dim = false) => (
784 <Box key={key} flexDirection="row" justifyContent="space-between" gap="sm">
785 <Text dimColor={dim}>{name}</Text>
786 <Text dimColor={dim} tabular>{value}</Text>
787 </Box>
788)
789
790// The Swiss Knife tab: who is working on what, what needs you, the last task, today, the limits, the files.
791async function knifeView($: any, e: any) {
792 const { Box, Text, Button, Svg, Bar } = kit($, e)
793 const now = await $.clock.now()
794 const p = await load($)
795 const today = p.days[dayKey(now)] ?? emptyDay()
796 const week = sumDays(p, dayKey(now - 6 * 86400000))
797 const hasPlan = k.limits.length > 0
798 const budget = p.budgetUsd && k.hasCost ? p.budgetUsd : 0
799 const list = await alerts($, p, today)
800 const s = await stripNow($, now)
801 const model = label(k.model || 'model')
802 const show = k.blades.secrets ? mask : (x: string) => x
803 const status = k.waiting
804 ? { glyph: '●', color: RED, title: 'Claude needs you', sub: `Waiting ${clock(now - k.waiting.since)} · ${model}` }
805 : k.isWorking ? { glyph: '●', color: ACCENT, title: k.action || 'Thinking', sub: `${model} · ${clock(now - (k.turnStartAt || now))}` }
806 : { glyph: s.title === 'Done' ? '✓' : '', color: GREEN, title: s.title === 'Done' ? 'Done' : k.mood === 'asleep' ? 'On a break' : 'Ready', sub: model }
807 // Each part of the panel is a card of its own, so the eye finds it before reading; alerts take their colour on the border.
808 const section = (key: string, body: unknown, border = 'promptBorder') => (
809 <Box key={key} flexDirection="column" gap="xs" borderStyle="round" borderColor={border} paddingX="md" paddingY="sm">{body}</Box>
810 )
811 const mute = () => setSound($, { isOn: !k.sound.isOn, ...(k.sound.volume === 0 ? { volume: 0.5 } : {}) })
812 const louder = () => setSound($, { volume: step(k.sound.volume, 0.1), isOn: true })
813 const level = k.sound.isOn ? `${Math.round(k.sound.volume * 100)}%` : 'off'
814 const toggleSound = () => { k.isSoundOpen = !k.isSoundOpen; $.ui.invalidate('ui.render') }
815 // The sound stays out of the way: one "Sound 60%" button opens the volume row.
816 const volumeRow = (
817 <Box key="volume" flexDirection="row" gap="sm" alignItems="center" borderStyle="round" borderColor="promptBorder" paddingX="md" paddingY="xs">
818 <Box flexGrow={1}><Text dimColor>Volume</Text></Box>
819 <Button key="quieter" label="−" variant="secondary" onPress={() => quieter($)} />
820 {Svg ? <Svg key="volume-bars" source={volumeBarsSvg(k.sound.volume, k.sound.isOn)} alt={`Volume ${level}`} width={97} height={14} /> : <Text dimColor tabular>{level}</Text>}
821 <Button key="louder" label="+" variant="secondary" onPress={louder} />
822 <Button key="mute" label={k.sound.isOn ? 'Mute' : 'Unmute'} variant="secondary" onPress={mute} />
823 </Box>
824 )
825 const out: unknown[] = []
826 // Header: the mascot, the project and what is happening; the model and the sound on the right.
827 out.push(
828 <Box key="head" flexDirection="column" gap="sm">
829 <Box flexDirection="row" gap="md" alignItems="center">
830 {Svg && k.blades.mascot ? <Svg key="panel-mascot" source={svgFrameAt(k.mood, now - k.moodAt).svg} alt={`Mascot: ${moodWords(k.mood)}`} width={88} height={80} /> : null}
831 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
832 <Text bold>{p.name}</Text>
833 <Text wrap="truncate-end">{status.glyph ? <Text color={status.color}>{`${status.glyph} `}</Text> : <Text color={GREEN}>● </Text>}<Text bold={!!k.waiting} color={k.waiting ? RED : undefined}>{status.title}</Text></Text>
834 {status.sub !== model ? <Text dimColor>{status.sub.replace(` · ${model}`, '').replace(`${model} · `, '')}</Text> : null}
835 </Box>
836 <Box flexDirection="column" alignItems="flex-end" gap="xs">
837 <Box borderStyle="round" borderColor="promptBorder" paddingX="xs"><Text size="footnote" dimColor>{model}</Text></Box>
838 <Button key="sound" label={`Sound ${level}`} variant={k.isSoundOpen ? 'primary' : 'secondary'} onPress={toggleSound} />
839 </Box>
840 </Box>
841 {k.isSoundOpen ? volumeRow : null}
842 </Box>,
843 )
844 list.slice(0, 4).forEach((a, i) => {
845 const glyph = a.kind === 'waiting' ? '● Claude needs you' : a.kind === 'tip' ? 'Tip' : a.kind === 'note' ? '→' : a.level === 'urgent' ? '! Over the limit' : '! Heads up'
846 const color = a.level === 'urgent' ? RED : a.kind === 'tip' || a.kind === 'note' ? ACCENT : AMBER
847 out.push(section(`alert-${i}`, [
848 <Text key={`alert-${i}-head`} bold color={color}>{glyph}</Text>,
849 <Text key={`alert-${i}-text`} wrap="wrap">{a.text}</Text>,
850 ], color))
851 })
852 if (k.lastTask) {
853 const t = k.lastTask
854 const age = now - k.reportAt
855 const steps = (t.report ?? []).slice(0, 5).map(x => reportLine(show(x)))
856 const rows: unknown[] = [
857 <Box key="task-head" flexDirection="row" justifyContent="space-between" gap="sm">
858 <Text><Text bold color={GREEN}>✓ </Text><Text bold>Last task</Text></Text>
859 <Text dimColor tabular>{[fmtDuration(t.ms), spendOf(t.usd)].filter(Boolean).join(' · ')}</Text>
860 </Box>,
861 <Text key="task-sum" wrap="wrap">{show(t.summary)}</Text>,
862 ]
863 steps.forEach((r, i) => {
864 if (age <= (i + 1) * 250) return // the steps fade in one by one
865 rows.push(r.isTodo
866 ? <Box key={`task-${i}`} borderStyle="round" borderColor={AMBER} paddingX="sm"><Text wrap="wrap"><Text bold color={AMBER}>{`! ${r.lead} `}</Text>{r.text}</Text></Box>
867 : <Text key={`task-${i}`} wrap="wrap"><Text color={GREEN}>✓ </Text>{r.text}</Text>)
868 })
869 if (t.files.length === 0) rows.push(<Text key="task-files" dimColor>No files changed</Text>)
870 else {
871 const many = t.files.length > 1
872 rows.push(many
873 ? <Button key="task-files" label="Changed files" plain onPress={() => toggleTask($)}>
874 <Text dimColor>{k.isTaskOpen ? `▾ Changed ${t.files.length} files` : `▸ Changed ${t.files[0]} and ${t.files.length - 1} more`}</Text>
875 </Button>
876 : <Text key="task-files" dimColor wrap="truncate-middle">{`Changed ${t.files[0]}`}</Text>)
877 if (many && k.isTaskOpen) t.files.forEach(f => rows.push(<Text key={`task-file-${f}`} dimColor wrap="truncate-middle">{` ${f}`}</Text>))
878 }
879 out.push(section('task', rows))
880 }
881 // Today: the two numbers big, the budget as a pixel bar.
882 const big = (key: string, value: string, sub: string) => (
883 <Box key={key} flexDirection="column" width="50%">
884 <Text size="title" bold tabular>{value}</Text>
885 <Text dimColor wrap="wrap">{sub}</Text>
886 </Box>
887 )
888 const todayRows: unknown[] = [
889 <Text key="today-head" bold>Today</Text>,
890 <Box key="today-big" flexDirection="row" gap="md">
891 {big('today-time', fmtTime(today.ms), 'time worked')}
892 {k.hasCost ? big('today-cost', `$${today.usd.toFixed(2)}`, budget ? `of ${money(budget)} budget` : hasPlan ? 'worth, at API prices' : 'spent') : null}
893 </Box>,
894 ]
895 if (budget) todayRows.push(<Bar key="today-bar" f={today.usd / budget} label={`Budget: ${Math.round((today.usd / budget) * 100)}% used`} />)
896 if (p.budgetPct) {
897 todayRows.push(pairRow(Box, Text, 'today-share', 'Share of weekly limit', `${fmtPct(week.weekPct ?? 0)} of ${p.budgetPct}%`))
898 todayRows.push(<Bar key="share-bar" f={(week.weekPct ?? 0) / p.budgetPct} label="Weekly share" />)
899 }
900 todayRows.push(<Text key="today-week" dimColor>{[`This week ${fmtTime(week.ms)}`, k.hasCost ? spendOf(week.usd) : ''].filter(Boolean).join(' · ')}</Text>)
901 out.push(section('today', todayRows))
902 const five = current(windowOf(k.limits, FIVE_HOUR), now), weekly = current(windowOf(k.limits, WEEKLY), now)
903 if (five || weekly) {
904 const fc = forecast(now)
905 const limitRows = (key: string, name: string, w: Window, note: string) => [
906 <Box key={`${key}-row`} flexDirection="row" justifyContent="space-between" alignItems="flex-end" gap="sm">
907 <Text>{name}</Text>
908 <Text size="heading" bold tabular>{`${Math.round(w.percentUsed)}%`}</Text>
909 </Box>,
910 <Bar key={`${key}-bar`} f={w.percentUsed / 100} label={`${name}: ${Math.round(w.percentUsed)}% used`} />,
911 <Text key={`${key}-note`} dimColor wrap="wrap">{note}</Text>,
912 ]
913 const resets = (w: Window) => (fmtReset(w, now) ? `Resets ${fmtReset(w, now)}.` : '')
914 out.push(section('limits', [
915 <Text key="limits-head" bold>Plan limits</Text>,
916 ...(five ? limitRows('five', '5-hour limit', five, [fc || (five.percentUsed < 50 ? 'Plenty left.' : ''), resets(five)].filter(Boolean).join(' ')) : []),
917 ...(weekly ? limitRows('week', 'Weekly limit', weekly, [weekly.percentUsed < 50 ? 'Plenty left.' : '', resets(weekly)].filter(Boolean).join(' ')) : []),
918 ]))
919 }
920 if (k.contextPercent >= 50) out.push(section('chat', [
921 pairRow(Box, Text, 'chat-row', 'This chat', `${k.contextPercent}% full`),
922 <Bar key="chat-bar" f={k.contextPercent / 100} label={`This chat is ${k.contextPercent}% full`} />,
923 ]))
924 if (k.changed.length) out.push(section('files', [
925 <Box key="files-head" flexDirection="row" justifyContent="space-between" alignItems="center" gap="sm">
926 <Button key="files-more" label="Files changed in this chat" plain onPress={() => toggleFiles($)}>
927 <Text>{`${k.isFilesOpen ? '▾' : '▸'} Files changed in this chat`}</Text>
928 </Button>
929 <Text dimColor tabular>{String(k.changed.length)}</Text>
930 </Box>,
931 ...(k.isFilesOpen ? k.changed.slice(0, 60).map(f => <Text key={`file-${f}`} wrap="truncate-middle">{` ${f}`}</Text>) : []),
932 ]))
933 const isReady = k.handoff.status === 'ready'
934 out.push(
935 <Box key="fresh" flexDirection="column">
936 <Button key="open-fresh" label={isReady ? 'See your handoff' : 'Continue in a new chat'} variant={isFreshTime(now) && !isReady ? 'primary' : 'secondary'}
937 onPress={() => (isReady || k.handoff.status === 'writing' ? showHandoff($) : void startHandoff($))} />
938 </Box>,
939 )
940 return out
941}
942
943// The Handoff tab: what a handoff is, the mascot writing it, then the document for a new chat.
944async function handoffView($: any, e: any) {
945 const { Box, Text, Button, Svg } = kit($, e)
946 const now = await $.clock.now()
947 const ho = k.handoff
948 const show = k.blades.secrets ? mask : (x: string) => x
949 const mascot = (mood: Mood, elapsed: number) => (Svg ? <Svg key="handoff-mascot" source={svgFrameAt(mood, elapsed).svg} alt="The mascot" width={88} height={80} /> : null)
950 if (ho.status === 'writing') {
951 const elapsed = now - ho.startedAt
952 return [
953 mascot('writing', elapsed),
954 <Text key="ho-title"><Text color={ACCENT}>● </Text><Text bold>{ho.level === 'full' ? 'Writing the detailed handoff' : 'Writing your handoff'}</Text></Text>,
955 <Text key="ho-sub" dimColor tabular>{`Reading ${ho.messages ?? 0} messages · ${Math.floor(elapsed / 1000)} s`}</Text>,
956 Svg ? <Svg key="ho-bar" source={segmentsSvg(elapsed)} alt="Writing" height={8} /> : <Text key="ho-bar" color={ACCENT}>{segmentsText(elapsed)}</Text>,
957 <Text key="ho-note" dimColor wrap="wrap">{ho.level === 'full' ? 'This reads every decision, file and dead end, so it takes a minute or two. You can keep working.' : "You can keep working. We'll tell you when it's ready."}</Text>,
958 ]
959 }
960 if (ho.status === 'ready' && ho.note) return handoffReady($, e, now)
961 if (ho.status === 'failed') return [
962 mascot('error', now - k.moodAt),
963 <Text key="ho-fail"><Text bold color={AMBER}>! </Text><Text bold>Couldn't write the handoff</Text></Text>,
964 <Text key="ho-fail-note" dimColor wrap="wrap">Nothing was lost. Try again in a moment.</Text>,
965 <Box key="ho-retry-row" flexDirection="row"><Button key="ho-retry" label="Try again" variant="primary" onPress={() => void startHandoff($, ho.level)} /></Box>,
966 ]
967 return [
968 mascot('idle', now - k.moodAt),
969 <Text key="ho-idle" bold>Continue in a new chat</Text>,
970 <Text key="ho-idle-why" wrap="wrap">Long chats get slow and forgetful. A handoff is a short note that brings a new chat up to speed: the goal, where it stands and what's next.</Text>,
971 k.contextPercent > 0 ? <Text key="ho-idle-full" dimColor>{`This chat is ${k.contextPercent}% full.`}</Text> : null,
972 <Box key="ho-start-row" flexDirection="column"><Button key="ho-start" label="Write a handoff" variant="primary" onPress={() => void startHandoff($)} /></Box>,
973 <Text key="ho-cost" dimColor wrap="wrap">About 20 seconds. Costs a fraction of a cent.</Text>,
974 ]
975}
976
977// A row that folds open: a name, a short summary on the right, and an arrow.
978function foldRow($: any, e: any, key: string, name: string, summary: string, isOpen: boolean, press: () => void, summaryColor?: string) {
979 const { Box, Text, Button } = kit($, e)
980 return (
981 <Box key={`fold-${key}`} flexDirection="row" justifyContent="space-between" alignItems="center" gap="sm">
982 <Box flexShrink={0}><Text bold={isOpen}>{name}</Text></Box>
983 <Box flexDirection="row" gap="sm" alignItems="center" flexShrink={1} justifyContent="flex-end">
984 <Box flexShrink={1}><Text dimColor={!summaryColor} color={summaryColor} wrap="truncate-end">{summary}</Text></Box>
985 <Button key={`fold-${key}-btn`} label={isOpen ? '▾' : '▸'} variant="secondary" onPress={press} />
986 </Box>
987 </Box>
988 )
989}
990
991const card = (Box: any, key: string, body: unknown, border = 'promptBorder') => (
992 <Box key={key} flexDirection="column" gap="sm" borderStyle="round" borderColor={border} paddingX="md" paddingY="sm">{body}</Box>
993)
994
995function openOnly($: any, field: 'hoOpen' | 'settingsOpen' | 'expanded', id: string) {
996 k[field] = k[field] === id ? '' : id
997 $.ui.invalidate('ui.render')
998}
999
1000// The Handoff tab, ready: what to do first, then the goal and the next step, the rest folded with counts.
1001function handoffReady($: any, e: any, now: number) {
1002 const { Box, Text, Button, Svg } = kit($, e)
1003 const ho = k.handoff
1004 const note = ho.note!
1005 const show = k.blades.secrets ? mask : (x: string) => x
1006 const sections = docSections(show(note.body ?? notePaste(note)))
1007 const goal = sections.find(x => /^goal$/i.test(x.name))
1008 const steps = sections.find(x => /^next steps?$/i.test(x.name))
1009 const rest = sections.filter(x => x !== goal && x !== steps && x.items.length > 0)
1010 const num = (n: string) => <Text bold color={GREEN}>{`${n}.`}</Text>
1011 const out: unknown[] = [
1012 card(Box, 'ho-ready', [
1013 <Box key="ho-ready-head" flexDirection="row" gap="md" alignItems="center">
1014 {Svg ? <Svg key="handoff-mascot" source={svgFrameAt('done', now - ho.startedAt).svg} alt="The mascot, done" width={56} height={51} /> : null}
1015 <Box flexDirection="column">
1016 <Text><Text bold color={GREEN}>✓ </Text><Text bold>Ready for a new chat</Text></Text>
1017 <Text dimColor>{`Written ${when(note.at, now)}${ho.file ? ' · saved in your project' : ''}`}</Text>
1018 </Box>
1019 </Box>,
1020 <Box key="ho-step-1" flexDirection="row" gap="sm" alignItems="center">{num('1')}<Text wrap="wrap">Open a new chat in this project.</Text></Box>,
1021 <Box key="ho-step-2" flexDirection="row" gap="sm" alignItems="center">{num('2')}<Text wrap="wrap">Type anything. Claude reads the handoff first.</Text></Box>,
1022 <Box key="ho-buttons" flexDirection="row" gap="sm">
1023 {e.surface !== 'terminal' ? <Button key="ho-copy" label="Copy" variant="primary" onPress={(press: any) => copyHandoff($, press.surface)} /> : null}
1024 {ho.level !== 'full' ? <Button key="ho-more" label="More details" variant="secondary" onPress={() => void startHandoff($, 'full')} /> : null}
1025 <Button key="ho-discard" label="Discard" variant="secondary" onPress={() => discardHandoff($)} />
1026 </Box>,
1027 ], GREEN),
1028 ]
1029 const doc: unknown[] = []
1030 if (goal?.items.length) doc.push(
1031 <Box key="ho-goal" flexDirection="column">
1032 <Text color={ACCENT}>Goal</Text>
1033 <Text bold wrap="wrap">{goal.items.map(i => i.text).join(' ')}</Text>
1034 </Box>)
1035 if (steps?.items.length) doc.push(
1036 <Box key="ho-next" flexDirection="column" borderStyle="round" borderColor={AMBER} paddingX="sm">
1037 <Text color={AMBER}>Next step</Text>
1038 <Text wrap="wrap">{steps.items[0]!.text}</Text>
1039 {steps.items.length > 1 ? <Text dimColor wrap="wrap">{`Then: ${steps.items.slice(1, 4).map(i => i.text).join(' · ')}`}</Text> : null}
1040 </Box>)
1041 for (const sec of rest) {
1042 const isOpen = k.hoOpen === sec.name
1043 const isFiles = /^files/i.test(sec.name)
1044 doc.push(foldRow($, e, `ho-${sec.name}`, sec.name.replace(/^Files changed$/i, 'Files'), sectionSummary(sec.name, sec.items), isOpen, () => openOnly($, 'hoOpen', sec.name)))
1045 if (!isOpen) continue
1046 const items = [...sec.items].sort((a, b) => (a.mark === 'open' ? 0 : 1) - (b.mark === 'open' ? 0 : 1))
1047 items.forEach((it, i) => doc.push(isFiles && fileItem(it.text).isFile
1048 ? <Box key={`ho-${sec.name}-${i}`} flexDirection="row" justifyContent="space-between" gap="sm">
1049 <Text bold wrap="truncate-middle">{fileItem(it.text).name}</Text>
1050 <Text dimColor wrap="wrap">{fileItem(it.text).what}</Text>
1051 </Box>
1052 : <Text key={`ho-${sec.name}-${i}`} wrap="wrap" dimColor={it.mark === 'done'}>
1053 {it.mark ? <Text color={it.mark === 'done' ? GREEN : AMBER}>{it.mark === 'done' ? '✓ ' : '○ '}</Text> : '– '}{it.text}
1054 </Text>))
1055 }
1056 out.push(card(Box, 'ho-doc', doc))
1057 return out
1058}
1059
1060// The Pins tab: one line per pin, one open at a time.
1061async function pinsList($: any, e: any) {
1062 const { Box, Text, Button, Svg } = kit($, e)
1063 const pins = await loadPins($)
1064 const now = await $.clock.now()
1065 const show = k.blades.secrets ? mask : (x: string) => x
1066 if (pins.length === 0) return [
1067 <Box key="pins-none" flexDirection="column" alignItems="center" gap="sm" borderStyle="dashed" borderColor="promptBorder" paddingX="md" paddingY="md">
1068 {Svg ? <Svg key="pins-mascot" source={svgFrameAt('idle', now - k.moodAt).svg} alt="The mascot" width={66} height={60} /> : null}
1069 <Text bold>Nothing pinned yet</Text>
1070 <Text dimColor wrap="wrap">Click Pin under any reply to keep it here. Plans you approve are pinned for you.</Text>
1071 </Box>,
1072 ]
1073 const rows: unknown[] = []
1074 pins.forEach((pin, n) => {
1075 const isPlan = pin.title.startsWith('Plan:')
1076 const kind = isPlan ? 'Plan · pinned for you' : pin.requestId ? 'Reply' : 'Your note'
1077 const isOpen = k.expanded === pin.id
1078 const canJump = e.surface === 'terminal' && !!pin.requestId && pin.sessionId === k.sessionId
1079 const text = cleanPin(show(pin.text))
1080 const title = isPlan ? cleanPin(show(pin.title.replace(/^Plan:\s*/, ''))) : titleOf(text) // cleaned first, then cut
1081 if (n > 0) rows.push(<Box key={`pin-gap-${pin.id}`} height={1} />)
1082 rows.push(
1083 <Box key={`pin-${pin.id}`} flexDirection="row" gap="sm" alignItems="center">
1084 <Text color={isPlan ? ACCENT : undefined} dimColor={!isPlan}>■</Text>
1085 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
1086 <Text bold={isOpen} wrap="truncate-end">{title}</Text>
1087 <Text dimColor>{`${kind} · ${when(pin.at, now)}`}</Text>
1088 </Box>
1089 <Button key={`pin-open-${pin.id}`} label={isOpen ? '▾' : '▸'} variant="secondary" onPress={() => openOnly($, 'expanded', pin.id)} />
1090 </Box>,
1091 )
1092 if (!isOpen) return
1093 rows.push(
1094 <Box key={`pin-body-${pin.id}`} flexDirection="column" gap="xs" paddingLeft="md">
1095 {text.split('\n').filter(l => l.trim()).slice(0, 40).map((l, i) => <Text key={`pin-${pin.id}-l${i}`} wrap="wrap">{l}</Text>)}
1096 <Box flexDirection="row" gap="sm">
1097 {canJump ? <Button key={`go-${pin.id}`} label="Go to message" variant="secondary" onPress={() => jumpTo($, pin)} /> : null}
1098 {e.surface !== 'terminal' ? <Button key={`copy-${pin.id}`} label="Copy" variant="secondary" onPress={(press: any) => copyPin($, pin, press.surface)} /> : null}
1099 <Button key={`unpin-${pin.id}`} label="Remove" variant="secondary" onPress={() => unpin($, pin.id)} />
1100 </Box>
1101 </Box>,
1102 )
1103 })
1104 return [card(Box, 'pins-list', rows), <Text key="pins-hint" dimColor wrap="wrap">Click Pin under any reply to keep it here.</Text>]
1105}
1106
1107// The Settings tab, short: the four things people change as tiles, the rest folded by group.
1108async function settingsShort($: any, e: any) {
1109 const { Box, Text, Button } = kit($, e)
1110 const p = await load($)
1111 const tile = (key: string, label: string, value: string, controls: unknown, isOff = false) => (
1112 <Box key={`tile-${key}`} flexDirection="column" gap="xs" width="50%" borderStyle="round" borderColor="promptBorder" paddingX="md" paddingY="sm">
1113 <Text dimColor>{label}</Text>
1114 <Text size="title" bold tabular dimColor={isOff}>{value}</Text>
1115 <Box flexDirection="row" gap="sm">{controls}</Box>
1116 </Box>
1117 )
1118 const steps = (key: string, down: () => void, up: () => void) => [
1119 <Button key={`${key}-down`} label="−" variant="secondary" onPress={down} />,
1120 <Button key={`${key}-up`} label="+" variant="secondary" onPress={up} />,
1121 ]
1122 const tiles = [
1123 k.hasCost ? tile('usd', 'Daily budget', p.budgetUsd ? money(p.budgetUsd) : 'Off', steps('usd', () => stepBudget($, 'usd', -1), () => stepBudget($, 'usd', 1)), !p.budgetUsd) : null,
1124 tile('handoff', 'New chat at', `${k.handoffAt}% full`, steps('handoff', () => setHandoffAt($, k.handoffAt - 5), () => setHandoffAt($, k.handoffAt + 5))),
1125 tile('sound', 'Sound', k.sound.isOn ? `${Math.round(k.sound.volume * 100)}%` : 'Off', [
1126 ...steps('vol', () => quieter($), () => setSound($, { volume: step(k.sound.volume, 0.1), isOn: true })),
1127 <Button key="mute" label={k.sound.isOn ? 'Mute' : 'Unmute'} variant="secondary" onPress={() => setSound($, { isOn: !k.sound.isOn, ...(k.sound.volume === 0 ? { volume: 0.5 } : {}) })} />,
1128 ], !k.sound.isOn),
1129 k.limits.length ? tile('pct', 'Weekly share', p.budgetPct ? `${p.budgetPct}%` : 'Off', steps('pct', () => stepBudget($, 'pct', -5), () => stepBudget($, 'pct', 5)), !p.budgetPct) : null,
1130 ].filter(Boolean)
1131 const grid: unknown[] = []
1132 for (let i = 0; i < tiles.length; i += 2) grid.push(<Box key={`tiles-${i}`} flexDirection="row" gap="sm">{tiles.slice(i, i + 2)}</Box>)
1133 // A blade: a lamp that shows on or off at a glance, its name and line, and an On/Off button.
1134 const blade = (b: typeof BLADES[number]) => {
1135 const isOn = k.blades[b.key]
1136 return (
1137 <Box key={`row-${b.key}`} flexDirection="row" gap="sm" alignItems="center">
1138 <Text color={isOn ? GREEN : undefined} dimColor={!isOn}>{isOn ? '■' : '□'}</Text>
1139 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
1140 <Text dimColor={!isOn}>{b.name}</Text>
1141 <Text dimColor wrap="wrap">{b.about}</Text>
1142 </Box>
1143 <Button key={`toggle-${b.key}`} label={isOn ? 'On' : 'Off'} variant="secondary" dimColor={!isOn} onPress={() => toggleBlade($, b.key)} />
1144 </Box>
1145 )
1146 }
1147 const groups: { id: string; name: string; group: Group }[] = [
1148 { id: 'look', name: 'Mascot and strip', group: 'Mascot and sound' },
1149 { id: 'help', name: 'Help along the way', group: 'Help along the way' },
1150 { id: 'safety', name: 'Safety', group: 'Safety' },
1151 ]
1152 const list: unknown[] = []
1153 groups.forEach((g, i) => {
1154 const members = BLADES.filter(b => b.group === g.group)
1155 const on = members.filter(b => k.blades[b.key]).length
1156 const isOpen = k.settingsOpen === g.id
1157 if (i > 0) list.push(<Box key={`set-gap-${g.id}`} height={1} />)
1158 list.push(foldRow($, e, `set-${g.id}`, g.name, on === members.length ? 'All on' : `${on} of ${members.length} on`, isOpen, () => openOnly($, 'settingsOpen', g.id), on === members.length ? GREEN : undefined))
1159 if (isOpen) members.forEach(b => list.push(blade(b)))
1160 })
1161 const demos: [string, string][] = [['cache', 'Quick memory'], ['handoff', 'Handoff'], ['limits', 'Limits'], ['needs-you', 'Needs you'], ['budget', 'Budget'], ['recap', 'Recap']]
1162 const isDemoOpen = k.settingsOpen === 'demo'
1163 list.push(<Box key="set-gap-demo" height={1} />)
1164 list.push(foldRow($, e, 'set-demo', 'Try a feature', `${demos.length} demos`, isDemoOpen, () => openOnly($, 'settingsOpen', 'demo')))
1165 if (isDemoOpen) list.push(
1166 <Box key="demo-buttons" flexDirection="row" gap="sm" flexWrap="wrap">
1167 {demos.map(([id, name]) => <Button key={`demo-${id}`} label={name} variant="secondary" onPress={async () => $.ui.toast(await demo($, id))} />)}
1168 </Box>)
1169 return [
1170 ...grid,
1171 card(Box, 'set-groups', list),
1172 card(Box, 'set-off', [
1173 <Box key="set-off-row" flexDirection="row" justifyContent="space-between" alignItems="center" gap="sm">
1174 <Box flexDirection="column" flexShrink={1}>
1175 <Text>{k.blades.all ? 'Turn Swiss Knife off' : 'Swiss Knife is off'}</Text>
1176 <Text dimColor wrap="wrap">{k.blades.all ? 'Hides everything and stops tracking' : 'Nothing is shown or tracked'}</Text>
1177 </Box>
1178 <Button key="toggle-all" label={k.blades.all ? 'Turn off' : 'Turn on'} variant="secondary" onPress={() => setAll($)} />
1179 </Box>,
1180 ], RED),
1181 ]
1182}
1183
1184// The panel: the tab row, then the open tab.
1185async function paneView($: any, e: any, only?: Tab) {
1186 const { Box, Text } = kit($, e)
1187 if (!k.blades.all && (only ?? k.tab) !== 'settings') return <Box paddingX="md" paddingY="sm"><Text dimColor>Swiss Knife is off. Turn it on in Settings, or type /knife on</Text></Box>
1188 const tab = only ?? k.tab
1189 const body = tab === 'handoff' ? await handoffView($, e) : tab === 'pins' ? await pinsList($, e) : tab === 'settings' ? await settingsShort($, e) : await knifeView($, e)
1190 return (
1191 <Box flexDirection="column" gap="sm">
1192 {only ? <Text dimColor wrap="wrap">This tab now lives inside the Swiss Knife tab. You can close this one.</Text> : tabRow($, e)}
1193 <Box flexDirection="column" gap="md" paddingX="md" paddingBottom="md">{body}</Box>
1194 </Box>
1195 )
1196}
1197
1198// Under each reply: what it used, and Pin.
1199async function replyChip($: any, e: any, drawn: unknown) {
1200 const { Box, Button, Text } = kit($, e)hooks/meter.ts 168 lines1// Meter + report + budget: pure logic, no engine calls, so every rule is easy to test.
2import { metaText, spendText } from './ui'
3
4export const IDLE_MS = 10 * 60 * 1000 // a gap longer than this is a break, not work
5
6// usd: what the work would cost on the pay-per-use API. weekPct: points of the plan's weekly limit it used.
7export type Day = { ms: number; usd: number; tokens: number; prompts: string[]; done: string[]; weekPct?: number }
8export type Last = { at: number; asked: string; did: string }
9export type Project = {
10 name: string
11 days: Record<string, Day>
12 budgetUsd?: number // per day, for this project
13 budgetWarned?: { day: string; level: number } // the highest warning shown that day (80 or 100)
14 budgetPct?: number // share of the plan's weekly limit this project may use in a week
15 pctWarned?: { week: string; level: number }
16 last?: Last // what happened last time, for the welcome-back line
17}
18
19// A budget amount as people write it: $5, $1.50.
20export const money = (n: number) => (Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`)
21
22export const dayKey = (t: number) => new Date(t).toISOString().slice(0, 10)
23export const emptyDay = (): Day => ({ ms: 0, usd: 0, tokens: 0, prompts: [], done: [] })
24export const projectName = (cwd: string) => cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || cwd
25
26// The project stays the folder the chat works in: Claude's shell stepping into a subfolder (cd tools) doesn't make
27// a new project, or the day's time and cost get split across folders. The home folder the desktop app starts in
28// is never kept: the chat moves on to the real project from there.
29const normPath = (p: string) => p.replace(/[\\/]+$/, '').replace(/\\/g, '/').toLowerCase()
30const isHome = (p: string) => /^([a-z]:)?\/(users|home)\/[^/]+$/.test(normPath(p))
31export function projectRoot(current: string, next: string): string {
32 if (!current || !next || isHome(current)) return next || current
33 const c = normPath(current), n = normPath(next)
34 return n === c || n.startsWith(c + '/') ? current : next
35}
36
37// A folder inside the other, or the other inside it: a handoff written there still belongs to this chat.
38export function isNearFolder(a: string, b: string): boolean {
39 const x = normPath(a), y = normPath(b)
40 return !!x && !!y && (x === y || x.startsWith(y + '/') || y.startsWith(x + '/'))
41}
42
43// What the session's running cost added since the last reading. A counter that went down was reset
44// (a new chat in the same window), so all of it is new.
45export const costDelta = (usd: number, last: number | undefined) => (last === undefined ? 0 : usd >= last ? usd - last : usd)
46export const oneLine = (text: string, max: number) => {
47 const t = text.replace(/\s+/g, ' ').trim()
48 return t.length > max ? `${t.slice(0, max - 1)}…` : t
49}
50
51// Working time: count the gap since the last activity, unless it was a break.
52export function workedMs(lastActivity: number | undefined, now: number): number {
53 if (lastActivity === undefined) return 0
54 const gap = now - lastActivity
55 return gap > 0 && gap <= IDLE_MS ? gap : 0
56}
57
58export function fmtTime(ms: number): string {
59 const m = Math.floor(ms / 60000)
60 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
61}
62
63export function fmtTokens(n: number): string {
64 return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M tokens` : n >= 1000 ? `${Math.round(n / 1000)}k tokens` : `${n} tokens`
65}
66
67// Dollars when Claude Code reports them, tokens otherwise. Never an invented price.
68export const fmtSpend = (d: Pick<Day, 'usd' | 'tokens'>, hasCost: boolean) =>
69 hasCost ? `$${d.usd.toFixed(2)}` : fmtTokens(d.tokens)
70
71// The meter line: "bakery2 · 1h 12m today · $3.61 of $10". On a plan the dollars are what the work is worth.
72export function statusText(name: string, d: Day, hasCost: boolean, budgetUsd?: number, hasPlan = false) {
73 return metaText(name, fmtTime(d.ms), spendText(d.usd, hasCost, hasPlan, budgetUsd))
74}
75
76export function sumDays(p: Project, from: string): Day {
77 const out = emptyDay()
78 for (const [k, d] of Object.entries(p.days)) {
79 if (k < from) continue
80 out.ms += d.ms; out.usd += d.usd; out.tokens += d.tokens; out.weekPct = (out.weekPct ?? 0) + (d.weekPct ?? 0)
81 out.prompts.push(...d.prompts); out.done.push(...(d.done ?? []))
82 }
83 return out
84}
85
86// Budget: 80 at four fifths of today's limit, 100 at the limit, 0 below. Each level warns once a day.
87export function budgetLevel(usd: number, budgetUsd?: number): 0 | 80 | 100 {
88 if (!budgetUsd) return 0
89 return usd >= budgetUsd ? 100 : usd >= budgetUsd * 0.8 ? 80 : 0
90}
91export function budgetWarning(p: Project, day: string, usd: number): string | null {
92 const level = budgetLevel(usd, p.budgetUsd)
93 const shown = p.budgetWarned?.day === day ? p.budgetWarned.level : 0
94 if (level <= shown) return null
95 p.budgetWarned = { day, level }
96 return level === 100
97 ? `Over today's budget: $${usd.toFixed(2)} of ${money(p.budgetUsd!)}.`
98 : `80% of today's ${money(p.budgetUsd!)} budget used.`
99}
100// "/budget 5" is dollars a day; "/budget 20%" is a share of the plan's weekly limit, per week.
101export type Budget = { kind: 'usd' | 'pct'; value: number }
102export function parseBudget(args: string): Budget | 'off' | null {
103 const t = args.trim().toLowerCase()
104 if (t === 'off' || t === '0' || t === '0%') return 'off'
105 const isPct = t.endsWith('%')
106 const n = Number(t.replace(/^\$/, '').replace(/%$/, ''))
107 if (!Number.isFinite(n) || n <= 0 || (isPct && n > 100)) return null
108 return { kind: isPct ? 'pct' : 'usd', value: Math.round(n * 100) / 100 }
109}
110
111// Points of a limit used between two readings; a drop means the window reset, so the new reading is all new use.
112export function usageDelta(before: number | undefined, after: number | undefined): number {
113 if (before === undefined || after === undefined) return 0
114 return after >= before ? after - before : after
115}
116
117export const fmtPct = (n: number) => (n > 0 && n < 1 ? '<1%' : `${Math.round(n)}%`)
118
119// The share-of-week budget: 80 at four fifths, 100 at the limit; each level warns once a week.
120export function pctWarning(p: Project, week: string, used: number): string | null {
121 if (!p.budgetPct) return null
122 const level = used >= p.budgetPct ? 100 : used >= p.budgetPct * 0.8 ? 80 : 0
123 const shown = p.pctWarned?.week === week ? p.pctWarned.level : 0
124 if (level <= shown) return null
125 p.pctWarned = { week, level }
126 return level === 100
127 ? `This project used its ${p.budgetPct}% share of your weekly limit (${fmtPct(used)}).`
128 : `This project used 80% of its ${p.budgetPct}% share of your weekly limit.`
129}
130
131// Slash commands (and paths a shell mangled from one) are not work.
132export const isWork = (text: string) => !/^\s*(\/|[A-Za-z]:[\/]Program Files)/.test(text)
133
134// The report reads what was asked AND what Claude said it did, so it reports results, not guesses.
135export function reportPrompt(name: string, period: string, d: Day): string {
136 const asks = d.prompts.filter(isWork).slice(-60).map(p => `- ${p}`).join('\n') || '- (none recorded)'
137 const did = d.done.slice(-60).map(p => `- ${p}`).join('\n') || '- (none recorded)'
138 return `Project: ${name}\nPeriod: ${period}\nWhat the person asked for, in order:\n${asks}\nWhat Claude replied after each piece of work:\n${did}`
139}
140export const REPORT_SYSTEM =
141 'You write short work updates for a non-technical reader (a client or a manager). ' +
142 'Write 1 to 3 bullet lines starting with "• ", each under 15 words, saying what was achieved, in plain English. ' +
143 'Only claim what Claude\'s replies confirm. If a reply says nothing needed changing, say it was checked. ' +
144 'Write fewer lines when there was less work. Never pad. No code, no file names, no jargon, no mention of AI or prompts. ' +
145 'Write in the language the person wrote their requests in.'
146
147// How long a task took: "45s", "2m 14s", "1h 05m".
148export function fmtDuration(ms: number): string {
149 const s = Math.max(0, Math.round(ms / 1000))
150 if (s < 60) return `${s}s`
151 const m = Math.floor(s / 60)
152 return m < 60 ? `${m}m ${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
153}
154
155// The first sentence of a reply, for a quick answer's report (no model call).
156export function firstSentence(text: string, max = 160): string {
157 const t = text.replace(/[#*`_>]/g, '').replace(/\s+/g, ' ').trim()
158 const m = t.match(/^.*?[.!?](\s|$)/)
159 return oneLine(m ? m[0] : t, max)
160}
161
162export function ago(ms: number): string {
163 const m = Math.round(ms / 60000)
164 if (m < 60) return `${m} min ago`
165 const h = Math.round(m / 60)
166 return h < 24 ? `${h} h ago` : `${Math.round(h / 24)} days ago`
167}
168hooks/coach.ts 63 lines1// Model coach: after each reply, the model that fits where the chat is going. It only advises: the person
2// switches in the app's own model menu, so what the app shows is always what runs. Pure logic.
3
4export type Size = 'small' | 'medium' | 'big' | 'unsure'
5export type Effort = 'low' | 'medium' | 'high'
6
7const SMALL = /\b(rename|typo|spelling|fix (a|the|this) (typo|comma|link)|change (the )?(colou?r|text|title|font|wording)|what is|what does|explain this line|format|translate|list the files|add a comment)\b/i
8const BIG = /\b(build|implement|design|refactor|architecture|migrate|from scratch|whole|entire|full|app|website|system|integrat|investigate)\w*/i
9
10export function sizeByRules(prompt: string): Size {
11 const t = prompt.trim()
12 if (t.length > 500 || BIG.test(t)) return 'big'
13 if (SMALL.test(t) || t.length < 50) return 'small'
14 return 'unsure'
15}
16
17export const SIZE_SYSTEM =
18 'Classify the request someone gave a coding assistant. Reply with one word: ' +
19 'SMALL (a quick edit, a question, a tiny fix), MEDIUM (a feature or fix in a few files) ' +
20 'or BIG (needs planning, many files, or hard debugging).'
21
22export const sizeOf = (reply: string): 'small' | 'medium' | 'big' =>
23 /small/i.test(reply) ? 'small' : /medium/i.test(reply) ? 'medium' : 'big'
24
25export const tier = (model: string): 'fast' | 'mid' | 'top' =>
26 /haiku/i.test(model) ? 'fast' : /sonnet/i.test(model) ? 'mid' : 'top'
27export const label = (model: string) =>
28 /haiku/i.test(model) ? 'Haiku' : /sonnet/i.test(model) ? 'Sonnet' : /fable/i.test(model) ? 'Fable' : /opus/i.test(model) ? 'Opus' : model
29
30export type Advice = { family: 'haiku' | 'sonnet' | 'opus'; effort: Effort; why: string }
31
32// The model and effort that fit a task of this size.
33export function advise(size: 'small' | 'medium' | 'big'): Advice {
34 if (size === 'small') return { family: 'haiku', effort: 'low', why: 'This looks like a small task: Haiku on low effort does it for a fraction of the price.' }
35 if (size === 'medium') return { family: 'sonnet', effort: 'medium', why: 'This looks like a medium task: Sonnet on medium effort is fast and good enough.' }
36 return { family: 'opus', effort: 'high', why: 'This looks like a big task: Opus on high effort plans it better and gets it right first time.' }
37}
38
39// The same version of another family: claude-opus-5-5 → claude-haiku-5-5; an alias stays an alias.
40export const sameVersion = (current: string, family: string) =>
41 /opus|sonnet|haiku|fable/i.test(current) && /^claude-/.test(current) ? current.replace(/opus|sonnet|haiku|fable/i, family) : family
42
43
44// Prompts the coach should never hold: commands, and text that is clearly not a task.
45export const isCoachable = (text: string) => text.trim().length > 0 && !/^\s*[/!#]/.test(text)
46
47export const DIRECTION_SYSTEM =
48 'Below is the end of a conversation between a person and a coding assistant. Judge the work that comes next ' +
49 '(their latest request and what will follow from it). Reply with one word: SMALL (quick edits, questions, tiny fixes), ' +
50 'MEDIUM (a feature or fix across a few files) or BIG (planning, many files, hard debugging).'
51
52// The coach's line, or '' when the model in use already fits. A big chat pays once to switch.
53export function coachLine(size: 'small' | 'medium' | 'big', current: string, contextTokens = 0): string {
54 const now = tier(current)
55 const cost = contextTokens >= 50_000 ? ' Switching re-reads this chat once.' : ''
56 const how = ` Switch in the model menu.${cost}`
57 if (size === 'small' && now !== 'fast') return `Small edits now. Haiku would do this for less.${how}`
58 if (size === 'medium' && now === 'top') return `Everyday work now. Sonnet is fast and good enough.${how}`
59 if (size === 'medium' && now === 'fast') return `This needs more than Haiku. Sonnet fits better.${how}`
60 if (size === 'big' && now !== 'top') return `This is getting complex. Opus plans it better.${how}`
61 return ''
62}
63hooks/dashboard.ts 252 lines1// The panel: text bars for the terminal, one drawn card for the desktop app. Pure logic.
2// Design: the mascot is the one bold thing; the data is quiet. Hairlines instead of boxes, sentence case,
3// one type family with aligned numbers, and colour only where it carries meaning (sage, amber, red).
4import type { Mood } from './mascot'
5
6// A text bar for the terminal: ████████░░░░
7export function bar(fraction: number, width = 16): string {
8 const f = clamp01(fraction)
9 const full = Math.round(f * width)
10 return '█'.repeat(full) + '░'.repeat(width - full)
11}
12const clamp01 = (x: number) => Math.max(0, Math.min(1, Number.isFinite(x) ? x : 0))
13
14const WORDS: Record<Mood, string> = {
15 'greeting': 'saying hello', 'idle': 'ready', 'thinking': 'thinking', 'reading': 'reading files',
16 'writing': 'writing', 'running-commands': 'running commands', 'testing': 'testing',
17 'searching-web': 'searching the web', 'error': 'hit a problem', 'done': 'done',
18 'asleep': 'on a break', 'context-nearly-full': 'conversation almost full', 'waiting-for-you': 'waiting for you',
19 'compacting': 'tidying up', 'gag-tokens': 'over budget', 'gag-dance': 'dancing to the sound', 'asleep-nightcap': 'keeping it quiet',
20}
21export const moodWords = (m: Mood) => WORDS[m] ?? m
22const sentence = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
23
24const C = {
25 ink: '#ECE7DF', soft: '#C9C3B8', muted: '#8F8A80', hair: '#2F2D29', track: '#34322E', tick: '#4A4740',
26 sage: '#8DBF8B', amber: '#E3B04B', red: '#E46A4E', glow: '#D97757',
27}
28// Sage, then amber from 70%, red from 90%.
29export const levelColor = (f: number) => (f >= 0.9 ? C.red : f >= 0.7 ? C.amber : C.sage)
30const TONE = { urgent: C.red, warn: C.amber, info: C.sage } as const
31export type Tone = keyof typeof TONE
32
33export const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
34
35// Word-wraps at about `width` characters, at most `lines` lines, an ellipsis when cut.
36export function wrap(text: string, width: number, lines: number): string[] {
37 const out: string[] = []
38 for (const para of text.split('\n')) {
39 let line = ''
40 for (const word of para.split(/\s+/).filter(Boolean)) {
41 if (line && (line + ' ' + word).length > width) { out.push(line); line = word } else line = line ? `${line} ${word}` : word
42 }
43 if (line) out.push(line)
44 }
45 if (out.length > lines) {
46 const kept = out.slice(0, lines)
47 const last = kept[lines - 1]!
48 kept[lines - 1] = (last.length < width ? last : last.replace(/\s+\S+$/, '')) + '…' // whole words only
49 return kept
50 }
51 return out
52}
53
54// The mascot's own SVG, placed inside the card.
55export const placeMascot = (svg: string, x: number, y: number, w: number, h: number) =>
56 svg.replace(/^<svg\b([^>]*?)\swidth="[^"]*"\sheight="[^"]*"/, `<svg$1 x="${x}" y="${y}" width="${w}" height="${h}"`)
57
58// A half-ring gauge with ticks every 10%; `f` from 0 to 1.
59export function gauge(cx: number, cy: number, f: number, value: string, label: string, sub: string): string {
60 const r = 30
61 const at = (t: number) => [cx + r * Math.cos(Math.PI * (1 - t)), cy - r * Math.sin(Math.PI * (1 - t))] as const
62 const [x0, y0] = at(0), [x1, y1] = at(1), [xf, yf] = at(clamp01(f))
63 const p: string[] = []
64 for (let i = 0; i <= 10; i++) {
65 const a = Math.PI * (1 - i / 10), r1 = r + 6, r2 = r + (i % 5 === 0 ? 11 : 9)
66 p.push(`<line x1="${(cx + r1 * Math.cos(a)).toFixed(1)}" y1="${(cy - r1 * Math.sin(a)).toFixed(1)}" x2="${(cx + r2 * Math.cos(a)).toFixed(1)}" y2="${(cy - r2 * Math.sin(a)).toFixed(1)}" stroke="${C.tick}" stroke-width="1.2"/>`)
67 }
68 p.push(`<path d="M${x0.toFixed(1)} ${y0.toFixed(1)} A${r} ${r} 0 0 1 ${x1.toFixed(1)} ${y1.toFixed(1)}" fill="none" stroke="${C.track}" stroke-width="6" stroke-linecap="round"/>`)
69 if (f > 0.005)
70 p.push(`<path d="M${x0.toFixed(1)} ${y0.toFixed(1)} A${r} ${r} 0 0 1 ${xf.toFixed(1)} ${yf.toFixed(1)}" fill="none" stroke="${levelColor(f)}" stroke-width="6" stroke-linecap="round"/>`)
71 p.push(`<text x="${cx}" y="${cy - 3}" text-anchor="middle" font-size="16" font-weight="650" fill="${C.ink}">${esc(value)}</text>`)
72 p.push(`<text x="${cx}" y="${cy + 20}" text-anchor="middle" font-size="13" font-weight="600" fill="${C.ink}">${esc(label)}</text>`)
73 if (sub) p.push(`<text x="${cx}" y="${cy + 36}" text-anchor="middle" font-size="11.5" fill="${C.muted}">${esc(sub)}</text>`)
74 return p.join('')
75}
76
77export type Card = {
78 mascot: string; mood: Mood; name: string; model: string
79 todayTime: string; todaySpend: string; weekTime: string; weekSpend: string
80 contextPercent: number; handoffAt: number
81 limits?: { five?: { percent: number; reset: string }; week?: { percent: number; reset: string } }
82 forecast?: string
83 alerts?: { tone: Tone; text: string }[]
84 lastTask?: { took: string; asked: string; summary: string; report?: string[]; files: string[]; isOpen?: boolean; age?: number }
85 changed?: string[]
86 budget?: { used: number; limit: number; isPct?: boolean }
87 sound?: { isOn: boolean; volume: number }
88}
89
90const W = 320, X = 20, R = 300
91const FONT = `font-family="Segoe UI Variable Text, Segoe UI, system-ui, sans-serif" style="font-variant-numeric: tabular-nums"`
92const hair = (y: number) => `<line x1="${X}" y1="${y}" x2="${R}" y2="${y}" stroke="${C.hair}" stroke-width="1"/>`
93const label = (x: number, y: number, t: string, anchor = 'start') =>
94 `<text x="${x}" y="${y}" text-anchor="${anchor}" font-size="12.5" font-weight="600" fill="${C.muted}">${esc(t)}</text>`
95const line = (x: number, y: number, t: string, fill = C.ink, size = 13) =>
96 `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}">${esc(t)}</text>`
97
98// A thin rule that runs up to the arrow button beside it.
99export const divider = () =>
100 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} 8" width="100%"><line x1="${X}" y1="4" x2="${W - 6}" y2="4" stroke="${C.hair}" stroke-width="1"/></svg>`
101
102// Every file Claude touched in this chat, in the card's type: a few on one line at rest, one per line when open.
103export function filesSvg(files: string[], isOpen: boolean): string {
104 const p: string[] = []
105 let y = 16
106 p.push(label(X, y, `${files.length} file${files.length === 1 ? '' : 's'} changed in this chat`)); y += 19
107 if (isOpen) for (const f of files.slice(0, 60)) { p.push(`<circle cx="${X + 3}" cy="${y - 4}" r="2" fill="${C.sage}"/>` + line(X + 12, y, wrap(f, 42, 1)[0], C.soft, 12.5)); y += 17 }
108 else for (const l of wrap(files.slice(0, 3).join(', ') + (files.length > 3 ? ` and ${files.length - 3} more` : ''), 46, 2)) { p.push(line(X, y, l, C.soft, 12.5)); y += 17 }
109 y += 4
110 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${y}" width="100%" ${FONT}>${p.join('')}</svg>`
111}
112
113const HERO_H = 164
114
115// The volume beside the mascot: ten bars that fill from the bottom, and the level under them.
116export function volumeSvg(sound: { isOn: boolean; volume: number }): string {
117 const on = sound.isOn && sound.volume > 0
118 const steps = on ? Math.round(sound.volume * 10) : 0
119 const p: string[] = []
120 for (let i = 0; i < 10; i++) {
121 const y = 4 + (9 - i) * 8
122 p.push(`<rect x="${10 - (4 + i * 0.9) / 2}" y="${y}" width="${(4 + i * 0.9).toFixed(1)}" height="5" rx="2.5" fill="${i < steps ? (i >= 8 ? C.glow : C.soft) : C.track}"/>`)
123 }
124 p.push(`<text x="10" y="98" text-anchor="middle" font-size="11" font-weight="600" fill="${on ? C.soft : C.muted}">${on ? `${Math.round(sound.volume * 100)}%` : 'off'}</text>`)
125 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-6 0 32 102" width="32" height="102" ${FONT}>${p.join('')}</svg>`
126}
127
128export function cardSvg(c: Card): string {
129 return cardParts(c).whole
130}
131
132// The card in two pieces, split just above the sound row, so the sound buttons sit between them.
133export function cardParts(c: Card): { whole: string; hero: string; top: string; middle: string; bottom: string; isTaskCut: boolean } {
134 const p: string[] = []
135 let isTaskCut = false
136 // Mascot on its glow, then who and what
137 p.push(`<ellipse cx="160" cy="96" rx="112" ry="66" fill="url(#glow)"/>`)
138 if (c.mascot) p.push(placeMascot(c.mascot, 72, 0, 176, 161))
139 p.push(`<text x="160" y="182" text-anchor="middle" font-size="20" font-weight="650" fill="${C.ink}">${esc(c.name)}</text>`)
140 p.push(`<text x="160" y="202" text-anchor="middle" font-size="13" fill="${C.muted}">${esc(`${sentence(moodWords(c.mood))}, on ${c.model}`)}</text>`)
141 let y = 228
142 // Today | This week
143 for (const [x, name, time, spend] of [[X, 'Today', c.todayTime, c.todaySpend], [176, 'This week', c.weekTime, c.weekSpend]] as const) {
144 p.push(label(x, y + 12, name))
145 p.push(`<text x="${x}" y="${y + 40}" font-size="24" font-weight="650" fill="${C.ink}">${esc(time)}</text>`)
146 p.push(`<text x="${x}" y="${y + 60}" font-size="14" font-weight="500" fill="${C.soft}">${esc(spend)}</text>`)
147 }
148 p.push(`<line x1="160" y1="${y}" x2="160" y2="${y + 62}" stroke="${C.hair}" stroke-width="1"/>`)
149 y += 80
150 p.push(hair(y))
151 // Gauges: the conversation, and the plan's limits when Claude Code reports them
152 const cf = c.contextPercent / 100
153 const chat = gauge(0, 0, cf, `${Math.round(c.contextPercent)}%`, 'Chat', cf * 100 >= c.handoffAt ? 'hand off now' : `hand off at ${c.handoffAt}%`)
154 const gy = y + 54
155 if (c.limits && (c.limits.five || c.limits.week)) {
156 const cols = [chat]
157 if (c.limits.five) cols.push(gauge(0, 0, c.limits.five.percent / 100, `${Math.round(c.limits.five.percent)}%`, '5-hour', c.limits.five.reset && `resets ${c.limits.five.reset}`))
158 if (c.limits.week) cols.push(gauge(0, 0, c.limits.week.percent / 100, `${Math.round(c.limits.week.percent)}%`, 'Weekly', c.limits.week.reset && `resets ${c.limits.week.reset}`))
159 const step = (R - X) / cols.length
160 cols.forEach((g, i) => p.push(`<g transform="translate(${(X + step * (i + 0.5)).toFixed(1)} ${gy})">${g}</g>`))
161 } else p.push(`<g transform="translate(160 ${gy})">${chat}</g>`)
162 y = gy + 62
163 if (c.forecast) {
164 for (const l of wrap(c.forecast, 46, 2)) { p.push(line(X, y, l, C.soft, 12.5)); y += 17 }
165 y += 4
166 }
167 // Alerts and notes, only when there is something to say
168 for (const a of c.alerts ?? []) {
169 const ls = wrap(a.text, 42, 6)
170 p.push(`<rect x="${X}" y="${y}" width="3" height="${ls.length * 18 + 6}" rx="1.5" fill="${TONE[a.tone]}"/>`)
171 ls.forEach((l, i) => p.push(line(X + 14, y + 16 + i * 18, l)))
172 y += ls.length * 18 + 18
173 }
174 // What Claude did in the last prompt, in full; a new report fades in line by line
175 const t = c.lastTask
176 const open = true
177 if (t && (t.summary || t.asked)) {
178 p.push(hair(y)); y += 22
179 p.push(label(X, y, 'Last task report'))
180 p.push(`<text x="${R}" y="${y}" text-anchor="end" font-size="12.5" fill="${C.soft}">${esc(t.took)}</text>`)
181 y += 20
182 const from = p.length
183 if (t.asked) for (const l of wrap(`You asked: ${t.asked}`, 46, 3)) { p.push(line(X, y, l, C.muted, 12.5)); y += 18 }
184 for (const l of wrap(t.summary, 44, 8)) { p.push(line(X, y, l, C.ink, 13)); y += 18 }
185 const report = t.report ?? []
186 if (report.length) {
187 y += 6
188 for (const point of report.slice(0, 6)) {
189 wrap(point, 42, 4).forEach((l, i) => { p.push((i === 0 ? `<circle cx="${X + 3}" cy="${y - 4}" r="2" fill="${C.soft}"/>` : '') + line(X + 12, y, l, C.ink, 12.5)); y += 17 })
190 y += 3
191 }
192 }
193 y += 6
194 if (t.files.length) {
195 p.push(line(X, y, `Files changed (${t.files.length})`, C.muted, 12.5)); y += 18
196 for (const f of t.files.slice(0, 6)) { p.push(`<circle cx="${X + 3}" cy="${y - 4}" r="2" fill="${C.sage}"/>` + line(X + 12, y, wrap(f, 42, 1)[0], C.soft, 12.5)); y += 17 }
197 if (t.files.length > 6) { p.push(line(X + 12, y, `and ${t.files.length - 6} more`, C.muted, 12.5)); y += 17 }
198 } else { p.push(line(X, y, 'No files changed', C.muted, 12.5)); y += 17 }
199 if (t.age !== undefined) for (let n = from; n < p.length; n++) {
200 const o = Math.max(0, Math.min(1, (t.age - (n - from) * 70) / 320))
201 if (o < 1) p[n] = `<g opacity="${o.toFixed(2)}" transform="translate(0 ${((1 - o) * 6).toFixed(1)})">${p[n]}</g>`
202 }
203 y += 6
204 }
205 // Every file Claude touched in this chat
206 if (c.changed && c.changed.length) {
207 p.push(hair(y)); y += 22
208 p.push(label(X, y, `${c.changed.length} file${c.changed.length === 1 ? '' : 's'} changed in this chat`)); y += 19
209 for (const l of wrap(c.changed.slice(0, 5).join(', ') + (c.changed.length > 5 ? ` and ${c.changed.length - 5} more` : ''), 46, 3)) { p.push(line(X, y, l, C.soft, 12.5)); y += 17 }
210 y += 8
211 }
212 // Split: the "See more" button goes here
213 const taskY = y
214 // Budget
215 p.push(hair(y)); y += 22
216 p.push(label(X, y, c.budget?.isPct ? 'Budget this week' : 'Budget today'))
217 if (c.budget) {
218 const b = clamp01(c.budget.used / c.budget.limit)
219 const amount = c.budget.isPct
220 ? `${c.budget.used > 0 && c.budget.used < 1 ? '<1' : Math.round(c.budget.used)}% of ${c.budget.limit}% of your limit`
221 : `$${c.budget.used.toFixed(2)} of ${Number.isInteger(c.budget.limit) ? `$${c.budget.limit}` : `$${c.budget.limit.toFixed(2)}`}`
222 p.push(`<text x="${R}" y="${y}" text-anchor="end" font-size="12.5" fill="${C.soft}">${amount}</text>`)
223 p.push(`<rect x="${X}" y="${y + 10}" width="${R - X}" height="6" rx="3" fill="${C.track}"/>`)
224 if (b > 0) p.push(`<rect x="${X}" y="${y + 10}" width="${Math.max(6, (R - X) * b).toFixed(1)}" height="6" rx="3" fill="${levelColor(b)}"/>`)
225 y += 30
226 } else {
227 p.push(`<text x="${R}" y="${y}" text-anchor="end" font-size="12.5" fill="${C.muted}">none set, try /budget 20%</text>`)
228 y += 14
229 }
230 // Split: the sound buttons go here
231 const splitY = y + 4
232 y += 18
233 if (c.sound) {
234 const on = c.sound.isOn && c.sound.volume > 0
235 const steps = Math.round(c.sound.volume * 10)
236 p.push(label(X, y, 'Sound when a task is done'))
237 p.push(`<text x="${R}" y="${y}" text-anchor="end" font-size="12.5" fill="${on ? C.soft : C.muted}">${on ? `${Math.round(c.sound.volume * 100)}%` : 'off'}</text>`)
238 for (let i = 0; i < 10; i++)
239 p.push(`<rect x="${(X + i * 28.4).toFixed(1)}" y="${y + 10}" width="24" height="6" rx="3" fill="${on && i < steps ? C.soft : C.track}"/>`)
240 y += 34
241 }
242 const cmds = ['/report', '/handoff', '/pin', '/budget', '/knife']
243 cmds.forEach((t, i) => p.push(`<text x="${(X + (R - X) * (i + 0.5) / cmds.length).toFixed(1)}" y="${y}" text-anchor="middle" font-size="12" fill="${C.muted}">${t}</text>`))
244 const H = y + 14
245 const svg = (y0: number, y1: number, x0 = 0, w = W) =>
246 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${x0} ${y0} ${w} ${y1 - y0}" width="100%" ${FONT}>` +
247 `<defs><radialGradient id="glow"><stop offset="0" stop-color="${C.glow}" stop-opacity=".30"/><stop offset="1" stop-color="${C.glow}" stop-opacity="0"/></radialGradient></defs>` +
248 p.join('') + `</svg>`
249 // hero: the mascot on its glow alone, narrower, so the volume control can stand beside it
250 return { whole: svg(0, H), hero: svg(0, HERO_H, 40, 240), top: svg(HERO_H, taskY), middle: svg(taskY, splitY), bottom: svg(splitY, H), isTaskCut }
251}
252hooks/ui.ts 241 lines1// The look: one accent, one text size, a few glyphs and plain words. What the strip says at any moment,
2// the bars and the small formats every surface shares. Pure logic.
3
4// Colors follow the person's theme where a theme key exists; SVG bars take fixed tones that read on both.
5export const ACCENT = 'claude'
6export const RED = 'error'
7export const AMBER = 'warning'
8export const GREEN = 'success'
9const HEX = { accent: '#DD775B', amber: '#D9A03A', red: '#E0524A', track: 'rgba(128,128,128,0.28)', line: 'rgba(128,128,128,0.35)' }
10
11export type Level = 'urgent' | 'warn' | 'info'
12export type AlertKind = 'waiting' | 'budget' | 'limit' | 'chat-full' | 'cache' | 'tip' | 'note'
13export type Alert = { kind: AlertKind; level: Level; text: string }
14
15// "0:42", "12:05", "1:02:03": how long something has run.
16export function clock(ms: number): string {
17 const s = Math.max(0, Math.floor(ms / 1000))
18 const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = String(s % 60).padStart(2, '0')
19 return h ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
20}
21
22// "14:02" today, "Mon 14:02" another day.
23export function when(at: number, now: number): string {
24 const d = new Date(at)
25 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
26 return new Date(now).toDateString() === d.toDateString() ? hm : `${d.toLocaleDateString('en-GB', { weekday: 'short' })} ${hm}`
27}
28
29const base = (path: string) => path.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? path
30
31// What Claude is doing right now, as the strip's first words: "Writing index.html".
32export function actionText(tool: string, input: { file_path?: unknown; notebook_path?: unknown; command?: unknown; path?: unknown }): string {
33 const file = String(input.file_path ?? input.notebook_path ?? '')
34 if (/^(Write|Edit|MultiEdit|NotebookEdit)$/.test(tool)) return file ? `Writing ${base(file)}` : 'Writing'
35 if (/^(Read|NotebookRead)$/.test(tool)) return file ? `Reading ${base(file)}` : 'Reading'
36 if (/^(Grep|Glob|LS)$/.test(tool)) return 'Looking through the project'
37 if (/^(WebSearch|WebFetch)$/.test(tool)) return 'Searching the web'
38 if (/^(Bash|PowerShell)$/.test(tool))
39 return /\b(test|tests|pytest|jest|vitest|mocha|spec|lint|check)\b/i.test(String(input.command ?? '')) ? 'Testing' : 'Running a command'
40 if (/^(Task|Agent)$/.test(tool)) return 'Working with a helper'
41 return 'Thinking'
42}
43
44// Money as a person on a plan reads it: they pay no bill per message, so it is what the work is worth.
45export function spendText(usd: number, hasCost: boolean, hasPlan: boolean, budget = 0): string {
46 if (!hasCost) return ''
47 const v = usd > 0 && usd < 0.01 ? 'under 1¢' : `$${usd.toFixed(2)}`
48 if (budget) return `${v} of $${Number.isInteger(budget) ? budget : budget.toFixed(2)}`
49 return hasPlan ? `worth ${v}` : v
50}
51
52// The meta line: "bakery2 · 1h 12m today · $3.61 of $10".
53export const metaText = (name: string, time: string, spend: string) => [name, `${time} today`, spend].filter(Boolean).join(' · ')
54
55// Claude Code's own words for a wait, made short: what it wants and where to answer.
56export function waitingText(raw: string): string {
57 if (/plan/i.test(raw)) return 'It has a plan for you to approve. Answer in the chat.'
58 if (/question/i.test(raw)) return 'It has a question. Answer in the chat.'
59 if (/permission|allow|approve|ok\b/i.test(raw)) return 'It wants your OK to go on. Answer in the chat.'
60 return 'It is waiting for your answer in the chat.'
61}
62
63// The strip, from what is true now: line 1 the status, line 2 the detail or the one alert that wins, line 3 one action.
64export type StripIn = {
65 now: number
66 meta: string
67 waiting?: { since: number; text: string } | null
68 working?: { action: string; since: number } | null
69 isAsleep?: boolean
70 done?: { at: number; took: string; summary: string } | null
71 next?: string
72 alerts: Alert[] // most urgent first
73 note?: { goal: string; at: number; isStale: boolean; isSent: boolean } | null
74 isHandedOff?: boolean
75}
76export type Action = 'fresh-chat' | 'next' | 'note-continue' | 'note-drop'
77export type StripOut = {
78 glyph: string; glyphColor?: string
79 title: string; titleColor?: string
80 meta: string // dim, after the title
81 detail: string; detailGlyph: string; detailColor?: string
82 actions: { key: Action; label: string; isPrimary: boolean }[]
83 isUrgent: boolean
84 folded: string // the one line of the folded strip
85}
86
87export const DONE_SHOWN_MS = 10 * 60_000
88
89export function strip(s: StripIn): StripOut {
90 const out: StripOut = { glyph: '', title: 'Ready', meta: s.meta, detail: '', detailGlyph: '', actions: [], isUrgent: false, folded: '' }
91 const top = s.alerts.find(a => a.kind !== 'waiting')
92 const loud = top && (top.level !== 'info' || top.kind === 'cache') ? top : undefined
93 const tip = top && top.kind === 'tip' ? top : undefined
94 const alertLine = (a: Alert) => {
95 out.detail = a.text
96 out.detailGlyph = a.kind === 'tip' ? 'Tip' : '!'
97 out.detailColor = a.kind === 'tip' ? ACCENT : a.level === 'urgent' ? RED : AMBER
98 if ((a.kind === 'chat-full' || (a.kind === 'cache' && a.level === 'warn')) && !s.note) out.actions = [{ key: 'fresh-chat', label: 'Continue in a new chat', isPrimary: true }]
99 }
100 if (s.waiting) {
101 out.glyph = '●'; out.glyphColor = RED; out.title = 'Claude needs you'; out.titleColor = RED
102 out.meta = `waiting ${clock(s.now - s.waiting.since)}`
103 out.detail = s.waiting.text
104 out.isUrgent = true
105 } else if (s.note && (s.note.isStale || !s.working)) {
106 out.glyph = '→'; out.glyphColor = ACCENT
107 out.title = s.note.isStale ? 'A handoff is waiting:' : 'Continuing:'
108 out.meta = s.note.goal
109 out.detail = s.note.isStale ? `From ${when(s.note.at, s.now)}. Continue it, or start fresh?`
110 : s.note.isSent ? `From your handoff at ${when(s.note.at, s.now)}. Claude has the goal, files and next steps.`
111 : 'Claude reads your handoff with your first message.'
112 out.actions = s.note.isStale
113 ? [{ key: 'note-continue', label: 'Continue it', isPrimary: true }, { key: 'note-drop', label: 'Start fresh', isPrimary: false }]
114 : s.note.isSent ? [] : [{ key: 'note-drop', label: 'Start fresh instead', isPrimary: false }]
115 } else if (s.working) {
116 out.glyph = '●'; out.glyphColor = ACCENT; out.title = s.working.action
117 out.meta = clock(s.now - s.working.since)
118 out.detail = s.meta
119 if (loud) alertLine(loud)
120 } else if (s.isHandedOff) {
121 out.glyph = '✓'; out.glyphColor = GREEN; out.title = 'Handoff ready'
122 out.detail = 'Open a new chat in this project. Claude reads it first.'
123 } else if (s.done && s.now - s.done.at < DONE_SHOWN_MS && !loud) {
124 out.glyph = '✓'; out.glyphColor = GREEN; out.title = 'Done'; out.meta = s.done.took
125 out.detail = s.done.summary
126 } else {
127 out.title = s.isAsleep ? 'On a break' : 'Ready'
128 out.meta = s.isAsleep ? 'timer paused' : s.meta
129 if (s.isAsleep) out.detail = s.meta
130 if (loud ?? tip) alertLine((loud ?? tip)!)
131 }
132 out.folded = [out.title, out.meta].filter(Boolean).join(' · ')
133 return out
134}
135
136// A pixel bar as wide as its slot: square segments like the mascot's, accent, amber from 80%, red from 95%.
137export function barSvg(fraction: number, segments = 24): string {
138 const f = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0))
139 const fill = f >= 0.95 ? HEX.red : f >= 0.8 ? HEX.amber : HEX.accent
140 const lit = Math.round(f * segments)
141 const cell = 1000 / segments, gap = cell * 0.22
142 const cells = Array.from({ length: segments }, (_, i) =>
143 `<rect x="${(i * cell).toFixed(1)}" y="0" width="${(cell - gap).toFixed(1)}" height="8" fill="${i < lit ? fill : HEX.track}"/>`).join('')
144 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 8" preserveAspectRatio="none" width="1000" height="8" shape-rendering="crispEdges">${cells}</svg>`
145}
146
147// The volume row: ten pixel bars that fill in Claude orange, none lit when muted.
148export function volumeBarsSvg(volume: number, isOn: boolean): string {
149 const lit = isOn ? Math.round(Math.max(0, Math.min(1, volume)) * 10) : 0
150 const w = 7, h = 14, gap = 3
151 const cells = Array.from({ length: 10 }, (_, i) =>
152 `<rect x="${i * (w + gap)}" y="0" width="${w}" height="${h}" fill="${i < lit ? HEX.accent : HEX.track}"/>`).join('')
153 const width = 10 * (w + gap) - gap
154 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${h}" width="${width}" height="${h}" shape-rendering="crispEdges">${cells}</svg>`
155}
156
157// A line of the last-task report: done, or the one thing still to do ("Left to check: ...").
158export function reportLine(line: string): { isTodo: boolean; lead: string; text: string } {
159 const m = /^\s*(left to check|still needed|still to do|to do|next)\s*:\s*(.*)$/i.exec(line)
160 return m ? { isTodo: true, lead: 'Still needed:', text: m[2]! } : { isTodo: false, lead: '', text: line.trim() }
161}
162
163// The terminal's bar: █ for done, ░ for the rest.
164export function barText(fraction: number, width = 24): string {
165 const f = Math.max(0, Math.min(1, Number.isFinite(fraction) ? fraction : 0))
166 const full = Math.round(f * width)
167 return '█'.repeat(full) + '░'.repeat(width - full)
168}
169
170// A 1 px hairline between sections.
171export const ruleSvg = () =>
172 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1" preserveAspectRatio="none" width="1000" height="1"><rect width="1000" height="1" fill="${HEX.line}"/></svg>`
173
174// The handoff loader: 20 square segments filling up and starting over while it writes.
175export function segmentsSvg(elapsedMs: number, count = 20): string {
176 const lit = Math.floor(elapsedMs / 350) % (count + 1)
177 const w = 14, gap = 4
178 const cells = Array.from({ length: count }, (_, i) =>
179 `<rect x="${i * (w + gap)}" y="0" width="${w}" height="8" fill="${i < lit ? HEX.accent : HEX.track}"/>`).join('')
180 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${count * (w + gap) - gap} 8" width="${count * (w + gap) - gap}" height="8">${cells}</svg>`
181}
182
183export const segmentsText = (elapsedMs: number, count = 20) => {
184 const lit = Math.floor(elapsedMs / 350) % (count + 1)
185 return '■'.repeat(lit) + '□'.repeat(count - lit)
186}
187
188// A doc line as the Handoff tab shows it: Markdown marks gone, bullets as dashes.
189export function docLine(line: string): { kind: 'heading' | 'text' | 'blank'; text: string } {
190 if (/^#{1,3}\s/.test(line)) return { kind: 'heading', text: line.replace(/^#+\s*/, '') }
191 const t = line.replace(/\*\*|`/g, '').replace(/^(\s*)[-*•]\s+/, '$1– ')
192 return t.trim() ? { kind: 'text', text: t } : { kind: 'blank', text: '' }
193}
194
195// The handoff document as sections, each with its items: "- [done] text" lines, numbered steps, plain lines.
196export type DocItem = { mark: 'open' | 'done' | null; text: string }
197export function docSections(body: string): { name: string; items: DocItem[] }[] {
198 const out: { name: string; items: DocItem[] }[] = []
199 for (const raw of body.split('\n')) {
200 const line = raw.replace(/\*\*|`/g, '').trimEnd()
201 if (/^#{1,3}\s/.test(line)) { out.push({ name: line.replace(/^#+\s*/, '').trim(), items: [] }); continue }
202 const t = line.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, '').trim()
203 if (!out.length || !t || /^none\.?$/i.test(t)) continue
204 const m = /^\[(open|done|x| )\]\s*(.*)$/i.exec(t)
205 const mark = m ? (/done|x/i.test(m[1]!) ? 'done' : 'open') : null
206 out[out.length - 1]!.items.push({ mark, text: m ? m[2]! : t })
207 }
208 return out
209}
210
211// "index.html: footer, lines 27-31" as a name and what changed in it, the path cut to the project.
212export function fileItem(text: string): { name: string; what: string; isFile: boolean } {
213 const m = /^([^\s:]+\.[\w]+|[^\s:]+\/)\s*[:–-]\s*(.*)$/.exec(text)
214 const name = (m ? m[1]! : text).replace(/\\/g, '/').replace(/^.*\/(?=[^/]+\/[^/]+$)/, '')
215 return { name, what: m ? m[2]! : '', isFile: !!m }
216}
217
218// What a folded section says on its row: "1 done · 1 waiting", the file names, or a count.
219export function sectionSummary(name: string, items: DocItem[]): string {
220 if (items.some(i => i.mark)) {
221 const done = items.filter(i => i.mark === 'done').length, open = items.filter(i => i.mark === 'open').length
222 return [done ? `${done} done` : '', open ? `${open} waiting` : ''].filter(Boolean).join(' · ')
223 }
224 if (/^files/i.test(name)) {
225 const files = items.map(i => fileItem(i.text)).filter(x => x.isFile)
226 if (files.length === 0) return items.length === 1 ? 'none kept' : String(items.length)
227 return files.map(x => x.name).slice(0, 2).join(', ') + (files.length > 2 ? ` +${files.length - 2}` : '')
228 }
229 return String(items.length)
230}
231
232// A pin's text without Markdown: links as their words, headings and emphasis gone, bullets as dashes.
233export function cleanPin(text: string): string {
234 return text
235 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
236 .replace(/\*\*|__|`/g, '')
237 .replace(/^#{1,6}\s*/gm, '')
238 .replace(/^\s*[-*+•]\s+/gm, '– ')
239 .trim()
240}
241hooks/sound.ts 23 lines1// Sound: a chime when Claude finishes a task. Pure logic.
2
3export type Sound = { isOn: boolean; volume: number } // volume 0 to 1
4export const DEFAULT_SOUND: Sound = { isOn: true, volume: 0.6 }
5export const MIN_TASK_MS = 10_000 // quick answers stay quiet; a chime is for work you waited on
6
7export const step = (volume: number, by: number) => Math.round(Math.max(0, Math.min(1, volume + by)) * 10) / 10
8
9export function parseVolume(text: string): number | null {
10 const n = Number(text.trim().replace(/%$/, ''))
11 return Number.isFinite(n) && n >= 0 && n <= 100 ? Math.round(n) / 100 : null
12}
13
14// Windows has no player for $.audio, so the mod asks Windows' own media player, at the chosen volume.
15export const windowsPlay = (file: string, volume: number) => [
16 'powershell', '-NoProfile', '-NonInteractive', '-Command',
17 `Add-Type -AssemblyName PresentationCore; $p = New-Object System.Windows.Media.MediaPlayer; ` +
18 `$p.Open([uri]'${file.replace(/'/g, "''")}'); $p.Volume = ${volume.toFixed(2)}; Start-Sleep -Milliseconds 250; ` +
19 `$p.Play(); Start-Sleep -Milliseconds 1400; $p.Close()`,
20]
21
22export const soundLabel = (s: Sound) => (s.isOn ? `🔔 ${Math.round(s.volume * 100)}%` : '🔕 off')
23hooks/handoff.ts 135 lines1// Handoff: before the conversation gets too long, write a handoff document and carry on in a new session.
2// Pure logic.
3
4export const HANDOFF_AT = 70 // default percent of the context window; /knife handoff <percent> changes it
5
6// A note older than this is not attached by itself: the new session asks first.
7export const STALE_MS = 3 * 86_400_000
8
9// `standard` is quick (Haiku); `full` is the detailed rewrite (Sonnet) for those who want everything.
10export type HandoffLevel = 'standard' | 'full'
11
12// goal, done and next are the short version for the panel and the prompt box; body is the whole document.
13export type Note = { at: number; goal: string; done: string; next: string; body?: string; level?: HandoffLevel }
14
15export const shouldWarn = (percent: number | undefined, hasWarned: boolean, at = HANDOFF_AT) =>
16 !hasWarned && typeof percent === 'number' && percent >= at
17
18export const warnText = (percent: number) =>
19 `This chat is ${percent}% full. A new chat keeps answers sharp.`
20
21const HINTS: Record<string, string> = {
22 'Goal': 'one sentence: what you are building, in plain words, no quotes from the chat',
23 'Where it stands': 'at most 4 bullets, each starting "[open]" or "[done]"; open ones first',
24 'Files changed': 'one bullet per file: the path inside the project (index.html, css/form.css, never a full drive path), a colon, then what changed in it',
25 'Decisions': 'one bullet per choice made: the choice, then why, so the next chat does not undo it',
26 'Tried and did not work': 'dead ends, so they are not repeated',
27 'Your preferences': 'what you asked for, liked or disliked along the way',
28 'Running things': 'servers, ports, watchers, and the commands to start them',
29 'Open questions': 'what still needs you to decide',
30 'Next steps': 'numbered, in order; the first one is one sentence, concrete and ready to do',
31}
32
33export const SECTIONS: Record<HandoffLevel, string[]> = {
34 standard: ['Goal', 'Where it stands', 'Files changed', 'Decisions', 'Next steps'],
35 full: ['Goal', 'Where it stands', 'Files changed', 'Decisions', 'Tried and did not work', 'Your preferences', 'Running things', 'Open questions', 'Next steps'],
36}
37
38export const handoffSystem = (level: HandoffLevel) =>
39 'Someone is moving their work to a brand-new Claude Code chat that will not see this conversation. ' +
40 'Write the handoff document that chat will rely on, in Markdown, with exactly these sections in this order:\n' +
41 SECTIONS[level].map(s => `## ${s}\n(${HINTS[s]})`).join('\n') +
42 '\nRules: say "you" for the person and "Claude" for the assistant, never "the person", "the user" or "the assistant". ' +
43 'Be specific: real names, values and file names inside the project. Leave out shell commands such as rm -rf unless the next chat needs them. ' +
44 'Keep every line under 120 characters. Write "None." under a section with nothing in it. ' +
45 'No title, no preamble, no closing remarks. ' +
46 'Write the content in the language the person writes in, but keep the section headings and the [open]/[done] marks exactly as given, in English. ' +
47 (level === 'full' ? 'Be thorough: this document replaces the whole conversation; Where it stands may have more than 4 bullets.' : 'Keep it tight: about 250 words.')
48
49// What the writer reads: the project, the files the mod saw change, and the conversation.
50export function handoffInput(messages: { role: string; text: string }[], files: string[], project: string, level: HandoffLevel): string {
51 const chat = level === 'full' ? conversationText(messages, 200, 3000) : conversationText(messages, 60, 1200)
52 const cap = level === 'full' ? 300_000 : 80_000
53 return `Project: ${project}\nFiles changed in this chat: ${files.length ? files.join(', ') : 'none tracked'}\n\nConversation:\n${chat.slice(-cap)}`
54}
55
56// The text under one "## Section" heading.
57export function sectionOf(md: string, name: string): string {
58 const lines = md.split('\n')
59 const start = lines.findIndex(l => l.replace(/^#+\s*/, '').trim().toLowerCase() === name.toLowerCase() && /^#{1,3}\s/.test(l))
60 if (start < 0) return ''
61 const end = lines.findIndex((l, i) => i > start && /^#{1,3}\s/.test(l))
62 return lines.slice(start + 1, end < 0 ? undefined : end).join('\n').trim()
63}
64
65const firstLine = (s: string) =>
66 s.split('\n').map(l => l.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').replace(/\*\*/g, '').trim()).find(l => l && l !== 'None.') ?? ''
67
68// The writer's Markdown as a note, or null when it is not a handoff document.
69export function parseDoc(text: string, at: number, level: HandoffLevel): Note | null {
70 const body = text.trim().replace(/^```\w*\n/, '').replace(/\n```$/, '').trim()
71 const goal = firstLine(sectionOf(body, 'Goal'))
72 if (!goal) return null
73 return { at, goal, done: firstLine(sectionOf(body, 'Where it stands')), next: firstLine(sectionOf(body, 'Next steps')), body, level }
74}
75
76// The older short note, still read from what earlier versions saved.
77export function parseNote(text: string, at: number): Note | null {
78 const m = text.match(/\{[\s\S]*\}/)
79 if (!m) return null
80 try {
81 const j = JSON.parse(m[0]) as Partial<Note>
82 if (typeof j.goal !== 'string' || !j.goal.trim()) return null
83 return { at, goal: j.goal.trim(), done: String(j.done ?? '').trim(), next: String(j.next ?? '').trim() }
84 } catch {
85 return null
86 }
87}
88
89const DONT_EDIT = '(Files in .claude/handoffs are snapshots Swiss Knife writes only when the person asks for a handoff. Do not edit or update them.)'
90
91// What Claude reads with the first prompt of the new session (the person never sees it twice).
92export const noteContext = (n: Note) =>
93 n.body
94 ? `Handoff document from the previous session in this project. Continue from it:\n\n${n.body}\n\n${DONT_EDIT}`
95 : `Handoff note from the previous session in this project:\nGoal: ${n.goal}\nDone: ${n.done}\nNext: ${n.next}\n${DONT_EDIT}`
96
97// What the Copy button puts on the clipboard, to paste into any session.
98export const notePaste = (n: Note) => `Continue where my last session stopped. Here is the handoff:\n\n${n.body ?? noteContext(n)}`
99
100export const conversationText = (messages: { role: string; text: string }[], count = 16, chars = 700) =>
101 messages
102 .filter(m => m.text.trim() !== '')
103 .slice(-count)
104 .map(m => `[${m.role === 'user' ? 'person' : 'assistant'}] ${m.text.slice(0, chars)}`)
105 .join('\n')
106
107// Where the document is kept in the project: out of the way, one file per handoff.
108export function noteFile(n: Note): string {
109 const d = new Date(n.at)
110 const p = (x: number) => String(x).padStart(2, '0')
111 const slug = n.goal.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'handoff'
112 return `.claude/handoffs/${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}-${slug}.md`
113}
114
115// The note as a file, so it survives a new laptop, another tool, or a lost session.
116export const noteMarkdown = (n: Note, project: string) =>
117 `# Handoff: ${project}
118
119_Saved ${new Date(n.at).toLocaleString('en-GB')} by Swiss Knife${n.level === 'full' ? ' (detailed)' : ''}. A snapshot: do not edit it._
120
121${n.body ?? `**Goal:** ${n.goal}\n\n**Done:** ${n.done}\n\n**Next:** ${n.next}`}
122`
123
124// The loading bar under the writing mascot: a block sliding along.
125export function loadingBar(elapsedMs: number, width = 14): string {
126 const pos = Math.floor(elapsedMs / 120) % (width * 2 - 2)
127 const at = pos < width ? pos : width * 2 - 2 - pos
128 return Array.from({ length: width }, (_, i) => (Math.abs(i - at) <= 1 ? '▰' : '▱')).join('')
129}
130
131export function parseThreshold(text: string): number | null {
132 const n = Number(text.trim().replace(/%$/, ''))
133 return Number.isInteger(n) && n >= 20 && n <= 95 ? n : null
134}
135hooks/guard.ts 76 lines1// Guard: spots commands that destroy work, and says why in plain English. Pure logic.
2
3// The command as the shell runs it: quoted text and heredoc bodies blanked out,
4// so a command that only mentions "rm -rf" (in a message or a script body) is not caught.
5export function shellOnly(command: string): string {
6 return command
7 .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(\n|$)/g, ' ')
8 .replace(/'[^']*'/g, "''")
9 .replace(/"(?:[^"\\]|\\.)*"/g, '""')
10}
11
12// Folders that are safe to wipe: they are rebuilt automatically.
13const REBUILT = /^(\.\/)?(node_modules|dist|build|out|\.next|\.nuxt|coverage|\.cache|__pycache__|\.pytest_cache|tmp)\/?$/
14
15type Rule = { test: (cmd: string) => boolean; why: string }
16
17const RULES: Rule[] = [
18 {
19 // rm with both -r and -f, unless every target is a folder that gets rebuilt anyway
20 test: cmd => [...cmd.matchAll(/\brm\s+((?:-\w+\s+|--[\w-]+\s+)+)([^;&|\n]*)/g)].some(m => {
21 const flags = m[1]!
22 const isRecursive = /-\w*r|--recursive/i.test(flags)
23 const isForced = /-\w*f|--force/.test(flags)
24 const targets = m[2]!.trim().split(/\s+/).filter(Boolean)
25 return isRecursive && isForced && !(targets.length > 0 && targets.every(t => REBUILT.test(t)))
26 }),
27 why: 'deletes a folder and everything in it, with no recycle bin',
28 },
29 {
30 test: cmd => /\bRemove-Item\b[^;|\n]*-Recurse/i.test(cmd) || /\b(rd|rmdir)\s+\/s\b/i.test(cmd) || /\bdel\s+[^\n]*\/s\b/i.test(cmd),
31 why: 'deletes a folder and everything in it, with no recycle bin',
32 },
33 { test: cmd => /\bgit\s+reset\s+--hard\b/.test(cmd), why: 'throws away every change you have not saved in git' },
34 { test: cmd => /\bgit\s+clean\s+-\w*f/.test(cmd), why: 'deletes every new file git is not tracking yet' },
35 { test: cmd => /\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/.test(cmd), why: 'throws away every change you have not saved in git' },
36 { test: cmd => /\bgit\s+push\b[^;&|\n]*(--force\b|\s-f\b)/.test(cmd), why: 'overwrites the online copy of your project, which can erase other work' },
37 { test: cmd => /\b(mkfs|format\s+[a-z]:|diskpart)\b/i.test(cmd) || /\bdd\b[^\n]*\bof=\/dev\//.test(cmd), why: 'wipes a whole drive' },
38 { test: cmd => /\b(drop\s+(table|database|schema)|truncate\s+table)\b/i.test(cmd), why: 'deletes data from a database for good' },
39 { test: cmd => /\b(curl|wget|iwr|Invoke-WebRequest)\b[^\n|]*\|\s*(sudo\s+)?(sh|bash|zsh|iex|Invoke-Expression)\b/i.test(cmd), why: 'runs a script from the internet without showing it to you first' },
40]
41
42// What gets deleted, as a person says it: "the junk folder", "these 3 folders", "everything in this folder".
43function targetOf(cmd: string): string {
44 const rm = /\brm\s+(?:(?:-\w+|--[\w-]+)\s+)+([^;&|\n]*)/.exec(cmd)
45 const ps = /\bRemove-Item\b(?:\s+-(?:Path|LiteralPath))?\s+([^\s;|]+)/i.exec(cmd) ?? /\b(?:rd|rmdir)\s+\/s\s+(?:\/q\s+)?([^\s;&|]+)/i.exec(cmd)
46 const targets = (rm?.[1] ?? ps?.[1] ?? '').trim().split(/\s+/).filter(t => t && !t.startsWith('-'))
47 if (targets.length === 0) return 'this folder'
48 if (targets.some(t => t === '*' || t === '.' || t === './*' || t === '/')) return 'everything in this folder'
49 if (targets.length > 1) return `these ${targets.length} folders`
50 const name = targets[0]!.replace(/["']/g, '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || targets[0]!
51 return /^[~$%]/.test(name) ? `the folder ${name}` : `the ${name} folder`
52}
53
54// The question the person sees in Claude Code's permission prompt, in plain English.
55export function explain(command: string): string | null {
56 const cmd = shellOnly(command)
57 const why = danger(command)
58 if (!why) return null
59 const sure = "Allow it only if you're sure."
60 if (why.startsWith('deletes a folder')) return `Delete ${targetOf(cmd)} for good? This skips the Recycle Bin, so you can't get it back. ${sure}`
61 if (why.includes('not saved in git')) return `Throw away your unsaved changes? Every change not saved in git is lost for good. ${sure}`
62 if (why.includes('git is not tracking')) return `Delete the new files git isn't tracking yet? They can't be brought back. ${sure}`
63 if (why.includes('online copy')) return `Overwrite the online copy of your project? This can erase work saved there. ${sure}`
64 if (why.includes('drive')) return `Wipe a whole drive? Everything on it is lost. ${sure}`
65 if (why.includes('database')) return `Delete data from the database for good? Only a backup brings it back. ${sure}`
66 return `Run a script from the internet without seeing it first? Allow it only if you trust where it comes from.`
67}
68
69// Why this command is dangerous, or null when it is not.
70export function danger(command: string): string | null {
71 const cmd = shellOnly(command)
72 return RULES.find(r => r.test(cmd))?.why ?? null
73}
74
75export const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
76hooks/mascot.ts 102 lines1// Mascot: which animation fits what Claude is doing, and how a frame becomes terminal cells. Pure logic.
2import { FRAMES, PALETTE, WIDTH, HEIGHT, type Frame } from './frames'
3import { ANIM } from './svgs'
4
5export type Mood =
6 | 'greeting' | 'idle' | 'thinking' | 'reading' | 'writing' | 'running-commands' | 'testing'
7 | 'searching-web' | 'error' | 'done' | 'asleep' | 'context-nearly-full' | 'waiting-for-you'
8 | 'compacting' | 'gag-tokens' | 'gag-dance' | 'asleep-nightcap'
9
10export const COLUMNS = WIDTH
11export const ROWS = HEIGHT / 2
12
13// Animations that play once and hold their last frame; the rest loop.
14const ONCE = new Set<Mood>(['greeting', 'done'])
15
16// The animation for a tool Claude is about to use.
17export function moodForTool(tool: string, command = ''): Mood {
18 if (/^(Read|Grep|Glob|LS|NotebookRead)$/.test(tool)) return 'reading'
19 if (/^(Write|Edit|MultiEdit|NotebookEdit)$/.test(tool)) return 'writing'
20 if (/^(WebSearch|WebFetch)$/.test(tool)) return 'searching-web'
21 if (/^(Bash|PowerShell)$/.test(tool))
22 return /\b(test|tests|pytest|jest|vitest|mocha|spec|plugin test|validate|lint|check)\b/i.test(command) ? 'testing' : 'running-commands'
23 return 'thinking'
24}
25
26// The frame to show `elapsed` ms after the mood started.
27export function frameAt(mood: Mood, elapsed: number): { index: number; frame: Frame } {
28 const frames = FRAMES[mood] ?? FRAMES['idle']!
29 const total = frames.reduce((n, f) => n + f.ms, 0)
30 let t = ONCE.has(mood) ? Math.min(elapsed, total - 1) : elapsed % total
31 for (let i = 0; i < frames.length; i++) {
32 if (t < frames[i]!.ms) return { index: i, frame: frames[i]! }
33 t -= frames[i]!.ms
34 }
35 return { index: frames.length - 1, frame: frames[frames.length - 1]! }
36}
37
38// The desktop picture `elapsed` ms after the mood started: the app shows SVG as a still image,
39// so the mod flips single-frame pictures itself, with the artist's timings.
40export function svgFrameAt(mood: Mood, elapsed: number): { index: number; svg: string } {
41 const a = ANIM[mood] ?? ANIM['idle']!
42 const total = a.seq.reduce((n, [, ms]) => n + ms, 0)
43 let t = ONCE.has(mood) || !a.loop ? Math.min(elapsed, total - 1) : elapsed % total
44 for (let i = 0; i < a.seq.length; i++) {
45 const [frame, ms] = a.seq[i]!
46 if (t < ms) return { index: i, svg: a.frames[frame]! }
47 t -= ms
48 }
49 const [frame] = a.seq[a.seq.length - 1]!
50 return { index: a.seq.length - 1, svg: a.frames[frame]! }
51}
52
53// How long a play-once animation runs before the mascot settles.
54export const playLength = (mood: Mood) => (FRAMES[mood] ?? []).reduce((n, f) => n + f.ms, 0)
55
56const DEFAULT = 0x01000000 // the terminal's own color
57const UPPER = 0x2580 // ▀ top half in the foreground color
58const LOWER = 0x2584 // ▄ bottom half in the foreground color
59const KEYS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
60const colorOf = (c: string) => (c === '.' ? DEFAULT : PALETTE[KEYS.indexOf(c)] ?? DEFAULT)
61
62// Two pixel rows per terminal row: a half block, top pixel as foreground, bottom as background.
63export function toCells(px: string[]): Uint32Array {
64 const out = new Uint32Array(COLUMNS * ROWS * 3)
65 let o = 0
66 for (let r = 0; r < ROWS; r++) {
67 const top = px[r * 2]!, bottom = px[r * 2 + 1]!
68 for (let c = 0; c < COLUMNS; c++) {
69 const t = colorOf(top[c]!), b = colorOf(bottom[c]!)
70 if (t === DEFAULT && b === DEFAULT) out.set([0x20, DEFAULT, DEFAULT], o)
71 else if (t === DEFAULT) out.set([LOWER, b, DEFAULT], o)
72 else out.set([UPPER, t, b], o)
73 o += 3
74 }
75 }
76 return out
77}
78
79const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
80
81// Standard padded base64 of the cells' little-endian bytes (no btoa in the mod's sandbox).
82export function toBase64(words: Uint32Array): string {
83 const bytes = new Uint8Array(words.length * 4)
84 words.forEach((w, i) => { bytes[i * 4] = w & 255; bytes[i * 4 + 1] = (w >>> 8) & 255; bytes[i * 4 + 2] = (w >>> 16) & 255; bytes[i * 4 + 3] = (w >>> 24) & 255 })
85 let s = ''
86 for (let i = 0; i < bytes.length; i += 3) {
87 const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
88 s += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
89 s += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
90 s += i + 2 < bytes.length ? B64[n & 63]! : '='
91 }
92 return s
93}
94
95const cache = new Map<string, string>()
96export function cellsFor(mood: Mood, index: number, frame: Frame): string {
97 const key = `${mood}:${index}`
98 let cells = cache.get(key)
99 if (!cells) cache.set(key, (cells = toBase64(toCells(frame.px))))
100 return cells
101}
102hooks/limits.ts 63 lines1// Plan limits: the 5-hour and weekly windows, when they reset, and when you would run out at this pace.
2// Pure logic.
3
4export type Window = { kind: string; percentUsed: number; resetsAt?: string }
5export type Sample = { at: number; percent: number }
6
7export const FIVE_HOUR = 'five_hour'
8export const WEEKLY = 'seven_day'
9export const WARN_AT = [80, 95] // a toast once per window at each
10
11export const windowOf = (all: readonly Window[], kind: string) => all.find(w => w.kind === kind && Number.isFinite(w.percentUsed))
12
13const resetMs = (w?: Window) => (w?.resetsAt ? Date.parse(w.resetsAt) : NaN)
14
15// A reading from before its window reset is stale: the window is empty again until Claude Code reports anew.
16export const current = (w: Window | undefined, now: number): Window | undefined =>
17 w && resetMs(w) <= now ? { ...w, percentUsed: 0, resetsAt: undefined } : w
18
19// "16:40" today, "Fri 09:00" later this week.
20export function fmtReset(w: Window | undefined, now: number): string {
21 const t = resetMs(w)
22 if (!Number.isFinite(t)) return ''
23 const d = new Date(t)
24 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
25 const sameDay = new Date(now).toDateString() === d.toDateString()
26 return sameDay ? hm : `${d.toLocaleDateString('en-GB', { weekday: 'short' })} ${hm}`
27}
28
29// Keeps the last hour of readings for the pace; a drop means the window reset, so it starts over.
30export function addSample(samples: Sample[], s: Sample): Sample[] {
31 const last = samples[samples.length - 1]
32 const kept = last && s.percent < last.percent ? [] : samples.filter(x => s.at - x.at <= 60 * 60_000)
33 return last && last.percent === s.percent && s.at - last.at < 60_000 ? kept : [...kept, s]
34}
35
36// When the 5-hour window runs out at the pace of the last hour, or null when it lasts until it resets.
37export function runsOutAt(samples: Sample[], w: Window | undefined): number | null {
38 if (!w || samples.length < 2) return null
39 const a = samples[0]!, b = samples[samples.length - 1]!
40 const minutes = (b.at - a.at) / 60_000
41 if (minutes < 5 || b.percent <= a.percent) return null
42 const perMs = (b.percent - a.percent) / (b.at - a.at)
43 const at = b.at + (100 - b.percent) / perMs
44 const reset = resetMs(w)
45 return Number.isFinite(reset) && at >= reset ? null : at
46}
47
48export const fmtClock = (t: number) => {
49 const d = new Date(t)
50 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
51}
52
53// The warning to show for this window now, or null; `shown` remembers what was shown, per reset.
54export function limitWarning(w: Window | undefined, shown: Record<string, number>): string | null {
55 if (!w) return null
56 const key = `${w.kind}:${w.resetsAt ?? ''}`
57 const level = [...WARN_AT].reverse().find(l => w.percentUsed >= l)
58 if (!level || (shown[key] ?? 0) >= level) return null
59 shown[key] = level
60 const name = w.kind === FIVE_HOUR ? '5-hour' : 'weekly'
61 return `You've used ${Math.round(w.percentUsed)}% of your ${name} limit.`
62}
63hooks/pin.ts 53 lines1// Pin and changed files: keep the plan in sight, and show what Claude touched. Pure logic.
2
3// A pin: where it came from (a message drawn in this session can be scrolled back to), a title and the text.
4export type Pin = { id: string; at: number; title: string; text: string; requestId?: string; sessionId?: string }
5
6export const PINS_MAX = 30
7
8export const PIN_MAX = 1500
9
10// Markdown to plain lines for the panel: headings, bullets and emphasis marks dropped.
11export function plain(text: string): string {
12 return text
13 .replace(/```[\s\S]*?```/g, ' ')
14 .replace(/^#{1,6}\s*/gm, '')
15 .replace(/^\s*[-*+]\s+/gm, '• ')
16 .replace(/\*\*|__|`/g, '')
17 .replace(/\n{2,}/g, '\n')
18 .trim()
19}
20
21export function makePin(text: string, title: string, at: number, from?: { requestId: string; sessionId: string }): Pin {
22 const t = plain(text)
23 return { id: from?.requestId ?? `pin-${at}`, at, title, text: t.length > PIN_MAX ? `${t.slice(0, PIN_MAX - 1)}…` : t, ...from }
24}
25
26// A title for a pinned message: its first heading or first words.
27export function titleOf(text: string): string {
28 const first = plain(text).split('\n').find(l => l.trim()) ?? 'Pinned message'
29 const t = first.replace(/^•\s*/, '').trim()
30 return t.length > 48 ? `${t.slice(0, 47)}…` : t
31}
32
33// Adds a pin on top, or takes it off when it is already there (the pin button toggles).
34export function togglePin(pins: Pin[], pin: Pin): Pin[] {
35 return pins.some(p => p.id === pin.id) ? pins.filter(p => p.id !== pin.id) : [pin, ...pins].slice(0, PINS_MAX)
36}
37
38// A changed file as a person reads it: the path inside the project, forward slashes.
39export function relPath(cwd: string, file: string): string {
40 const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
41 const base = norm(cwd), f = norm(file)
42 return f.toLowerCase().startsWith(base.toLowerCase() + '/') ? f.slice(base.length + 1) : f.split('/').slice(-2).join('/')
43}
44
45export const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
46
47export function addChanged(list: string[], file: string): string[] {
48 return [file, ...list.filter(f => f !== file)].slice(0, 50)
49}
50
51export const changedLine = (list: string[]) =>
52 list.length === 0 ? '' : `${list.length} file${list.length === 1 ? '' : 's'} changed`
53hooks/secrets.ts 75 lines1// Hide secrets while recording: API keys, tokens, passwords and email addresses are masked in what
2// Claude Code draws. Only the screen changes; Claude still reads the real text. Pure logic.
3
4const SECRETS: RegExp[] = [
5 /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}/g, // Anthropic / OpenAI keys
6 /\bapify_api_[A-Za-z0-9]{20,}/g, // Apify
7 /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/g, // GitHub
8 /\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
9 /\bAIza[0-9A-Za-z_-]{35}\b/g, // Google API key
10 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, // Slack
11 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, // JWT
12 /\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/g, // Stripe
13 /\bwhsec_[A-Za-z0-9]{20,}/g, // Stripe webhook secret
14 /\b(?:hf_[A-Za-z0-9]{30,}|npm_[A-Za-z0-9]{30,}|glpat-[A-Za-z0-9_-]{20,}|sbp_[a-f0-9]{30,}|xai-[A-Za-z0-9]{30,})/g, // Hugging Face, npm, GitLab, Supabase, xAI
15 /\b(?:sk|key|api)_[A-Za-z0-9]{32,}/g, // ElevenLabs (sk_ + 48 hex) and other prefixed keys, even lower case only
16 /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, // email addresses
17 // Any other long random token: 32+ characters mixing lower case, upper case and digits
18 // (git hashes and ids are lower case only, so they stay visible).
19 /\b(?=[A-Za-z0-9_-]*[a-z])(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{32,}/g,
20]
21// NAME_KEY=value, "password": "value", and the like: the value is masked, the name stays.
22const ASSIGNMENT = /\b([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD)[A-Za-z0-9_]*)(["']?\s*[=:]\s*["']?)([^\s"'`,]{6,})/gi
23
24const dots = (s: string) => `${s.slice(0, 4)}${'•'.repeat(Math.min(12, Math.max(4, s.length - 4)))}`
25
26export function mask(text: string): string {
27 let out = text.replace(ASSIGNMENT, (_m, name: string, sep: string, value: string) => `${name}${sep}${dots(value)}`)
28 for (const re of SECRETS) out = out.replace(re, m => dots(m))
29 return out
30}
31
32export const hasSecret = (text: string) => mask(text) !== text
33
34// The transcript pieces whose text the mask rewrites.
35export const MASKED = ['UserMessage', 'AssistantMessage', 'ToolUse', 'ToolResult', 'CommandOutput'] as const
36
37// Masks every string inside a component's props, keeping their shape (ids are left alone).
38export function scrub(value: unknown): unknown {
39 if (typeof value === 'string') return mask(value)
40 if (Array.isArray(value)) return value.map(scrub)
41 if (value && typeof value === 'object') {
42 const out: Record<string, unknown> = {}
43 for (const [key, v] of Object.entries(value)) out[key] = /^(tool_use_id|id|requestId)$/.test(key) ? v : scrub(v)
44 return out
45 }
46 return value
47}
48
49// Pulls "RECAP: ..." and "NEXT: ..." out of the recap model's answer.
50export function parseRecap(text: string): { recap: string; next: string; report: string[] } {
51 const recap = text.match(/RECAP:\s*([\s\S]*?)(?:\n\s*(?:NEXT|REPORT):|$)/i)?.[1]?.trim() ?? text.trim()
52 const next = text.match(/NEXT:\s*(.+)/i)?.[1]?.trim() ?? ''
53 const body = text.match(/REPORT:\s*([\s\S]*)$/i)?.[1] ?? ''
54 const report = body.split('\n').map(l => l.replace(/^\s*[-*•]\s*/, '').trim()).filter(l => l && !/^(RECAP|NEXT):/i.test(l))
55 return { recap, next: /^(none|nothing|n\/a)\.?$/i.test(next) ? '' : next, report }
56}
57
58// Where the secrets are in a text, as [start, end) ranges: what the prompt box paints over.
59export function secretRanges(text: string): [number, number][] {
60 const out: [number, number][] = []
61 for (const m of text.matchAll(new RegExp(ASSIGNMENT.source, 'gi'))) {
62 const valueStart = m.index! + m[1]!.length + m[2]!.length
63 out.push([valueStart, valueStart + m[3]!.length])
64 }
65 for (const re of SECRETS) for (const m of text.matchAll(new RegExp(re.source, 'g'))) out.push([m.index!, m.index! + m[0].length])
66 out.sort((a, b) => a[0] - b[0])
67 const merged: [number, number][] = []
68 for (const r of out) {
69 const last = merged[merged.length - 1]
70 if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1])
71 else merged.push([...r])
72 }
73 return merged
74}
75