SLOPSHOPPER

swiss-knife

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…

newpanebandrowsguardcommand
★ 3v1.0.2MITupdated 2026-10-25nocodehack/swiss-knife
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · swiss-knife
│ ┃ Swiss Knife ✕ › fix the failing auth test and add an audit log call╭─────────────╮ │ ┃ Swiss KnifeHandoffPinsSettings │ swiss-knife │ │ ┃ ───────────────────────────────────────────… ⏺ Read(src/auth.ts) │ ✅ OK │ │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines ╰─────────────╯ │ ┃ │■I updated src/auth.ts to reject expired c… ⏺ Update(src/auth.ts) │ ┃ │ Your note · 08:53 ⎿ Added 2 lines, removed 1 line │ ┃ ╰─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ Click Pin under any reply to keep it here. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /report │ ⎿ swiss-knife: app, today │ ⎿ swiss-knife: OK │ ⎿ swiss-knife: Time: 0h 00m Cost: $0.00 │ │ ⟨Claude Code's own drawing⟩ ✓ Done · 1s · worth $0.00 OK ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ swiss-knife: app · 0h 00m today · worth $0.00

Draws

Band
⟨Claude Code's own drawing⟩ ✓ Done · 1s · worth $0.00 OK
Pane · Swiss Knife
Swiss KnifeHandoffPinsSettings ───────────────────────────────────────────────────────────… ╭──────────────────────────────────────────────────────────╮ │■I updated src/auth.ts to reject expired claims … [ ▸ ]│ │ Your note · 08:53 │ ╰──────────────────────────────────────────────────────────╯ Click Pin under any reply to keep it here.
Pane · swiss-knife-handoff
This tab now lives inside the Swiss Knife tab. You can close this one. ! Couldn't write the handoff Nothing was lost. Try again in a moment. [ Try again ]
Pane · swiss-knife-pins
This tab now lives inside the Swiss Knife tab. You can close this one. ╭──────────────────────────────────────────────────────────╮ │■I updated src/auth.ts to reject expired claims … [ ▸ ]│ │ Your note · 08:53 │ ╰──────────────────────────────────────────────────────────╯ Click Pin under any reply to keep it here.
Pane · swiss-knife-settings
This tab now lives inside the Swiss Knife tab. You can close this one. ╭────────────────────────────╮╭────────────────────────────╮ │Daily budget ││New chat at │ │Off ││70% full │ │[ − ][ + ] ││[ − ][ + ] │ ╰────────────────────────────╯╰────────────────────────────╯ ╭────────────────────────────╮╭────────────────────────────╮ │Sound ││Weekly share │ │60% ││Off │ │[ − ][ + ][ Mute ] ││[ − ][ + ] │ ╰────────────────────────────╯╰────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │Mascot and strip │ │Help along the way │ │Safety All on │ │Try a feature ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │Turn Swiss Knife off [ Turn off ]│ │Hides everything and stops tracking │ ╰──────────────────────────────────────────────────────────╯
README

🔪 Swiss Knife for Claude Code

A free Claude Code mod that fixes the 3 most annoying things about Claude Code. One-line install, no coding.

License: MIT Claude Code 2.1.287+ Works in the desktop app and the terminal

<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">


The 3 fixes

1. Claude waits for you, and you don't notice

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.

2. You hit your plan limit by surprise

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

3. Long chats eat your tokens

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.


Install (1 minute)

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.


Everything in the box

Each feature can be turned on or off in the Settings tab.

What it does
🔔 Needs youChime, notification and a red strip when Claude asks a question, wants permission or wants a plan approved.
📊 Plan limits5-hour and weekly gauges, reset times, and a "runs out around…" forecast.
🧳 HandoffA handoff note for a new chat when this one gets full. More details writes the long version. Kept in .claude/handoffs/.
⏱ Time & costWorking 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.
💸 BudgetA daily budget per project, with warnings at 80% and 100%.
🛡 GuardCommands 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 taskAfter each task: what got done, step by step, what is still needed, and the files changed.
🧭 Model tipsSays when a cheaper or stronger model fits the work better. You switch in the model menu.
⏳ Quick memory timerWarns before Claude's cache of a big chat (50k+ tokens) runs out, using your promptCacheTtl setting.
📌 PinsPin any reply. Plans you approve are pinned for you.
🙈 Hide secretsKeys, 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.
🦀 MascotA pixel buddy that shows what Claude is doing: thinking, writing, testing, done, or asleep on your break.
📝 /report1–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.


Commands

CommandWhat it does
/handoffWrite a handoff note now (/handoff <next step> adds your own next step)
/report · /report weekPlain-English summary of today or the last 7 days
/pin · /pin <text> · /pin offPin the last reply, pin your own note, or remove all pins
/budget 5 · /budget 20% · /budget offDaily dollars, or a share of your weekly limit, for this project
/knifeStatus. 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.


Safety and privacy

  • Guard is a seatbelt, not a vault. It catches the common destructive commands and asks you first. It never approves anything by itself, and it can't catch every way to delete a file.
  • Your numbers stay on your computer, in Claude Code's own storage for this mod.
  • Four features send text to Claude, using your own Claude Code login. No API key is needed, and each call costs a fraction of a cent:
  • the handoff note (your chat; Haiku, or Sonnet for More details)
  • the last-task recap after longer tasks (your request and Claude's reply)
  • /report (what you asked and Claude's replies)
  • model tips (the last few messages)

The handoff and /report run only when you ask. Model tips can be turned off in Settings.


If something breaks

Claude Code mods are new and Claude Code updates often. If an update breaks something:

  • Turn it off: Settings → Turn Swiss Knife off, or type /knife off.
  • Uninstall: /plugin uninstall swiss-knife in a terminal session (or claude plugin uninstall swiss-knife).
  • Tell me: open an issue with what you saw.

Related

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>

Source 16 files
hooks/register.tsx 1607 lines
1// 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 lines
1// 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}
168
hooks/coach.ts 63 lines
1// 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}
63
hooks/dashboard.ts 252 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
252
hooks/ui.ts 241 lines
1// 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}
241
hooks/sound.ts 23 lines
1// 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')
23
hooks/handoff.ts 135 lines
1// 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}
135
hooks/guard.ts 76 lines
1// 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'])
76
hooks/mascot.ts 102 lines
1// 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}
102
hooks/limits.ts 63 lines
1// 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}
63
hooks/pin.ts 53 lines
1// 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`
53
hooks/secrets.ts 75 lines
1// 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