SLOPSHOPPER

helm

KOZMOS Helm: one-press settings profiles in a sidebar. Seven built-in ways of working (Token saver, Fast flow, Max quality, Plan first, Guarded, Autopilot…

panecommand
★ 15v0.1.0MITupdated 2026-10-18ersinkoc/claude-mods/mods/helm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · helm
│ ┃ KOZMOS · Helm ✕ › fix the failing auth test and add an audit log call │ ┃ ⎈ HELM unset · default effort · unset mode │ ┃ [ ⎘ Save my setup ] ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ▾ ◔ Token saver ·········· 0/3 ⏺ Update(src/auth.ts) │ ┃ Sonnet, low effort, short answers, Haik… ⎿ Added 2 lines, removed 1 line │ ┃ ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ ⊘ model: not in this Claude Code versi… │ ⎿ 3 pass, 1 fail │ ┃ │ ☑ Effort default → low │ │ ┃ │ less thinking per request │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ☑ Subagent model unset → haiku │ │ ┃ │ subagents run on the cheapest model │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ⊘ outputStyle: not in this Claude Code… │ │ ┃ │ ⊘ promptSuggestionEnabled: not in this… │ › /helm │ ┃ │ ⊘ recap: not in this Claude Code versi… │ ⎿ helm: Helm open: press a profile to see what it sets. │ ┃ │ ⊘ autoCompact: not in this Claude Code… │ │ ┃ │ ⊘ workflowSizeGuideline: not in this C… │ │ ┃ │ ⊘ workflowKeywordTriggerEnabled: not i… │ │ ┃ │ ⊘ autoContinueAtUsageLimit: not in thi… │ │ ┃ │ ☑ MCP output cap unset → 10000 │ │ ┃ │ caps MCP tool results (default 25000) │ │ ┃ │ │ │ ┃ │ [ Apply Token saver · 3 ] │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ ▸ » Fast flow ·········· 0/1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Helm
⎈ HELM unset · default effort · unset mode [ ⎘ Save my setup ] ▾ ◔ Token saver ·········· 0/3 Sonnet, low effort, short answers, Haiku subagents ╭──────────────────────────────────────────────────────────╮ │ ⊘ model: not in this Claude Code version │ │ ☑ Effort default → low │ │ less thinking per request │ │ ☑ Subagent model unset → haiku │ │ subagents run on the cheapest model │ │ ⊘ outputStyle: not in this Claude Code version │ │ ⊘ promptSuggestionEnabled: not in this Claude Code ver… │ │ ⊘ recap: not in this Claude Code version │ │ ⊘ autoCompact: not in this Claude Code version │ │ ⊘ workflowSizeGuideline: not in this Claude Code versi… │ │ ⊘ workflowKeywordTriggerEnabled: not in this Claude Co… │ │ ⊘ autoContinueAtUsageLimit: not in this Claude Code ve… │ │ ☑ MCP output cap unset → 10000 │ │ caps MCP tool results (default 25000) │ │ │ │ [ Apply Token saver · 3 ] │ ╰──────────────────────────────────────────────────────────╯ ▸ » Fast flow ·········· 0/1 Sonnet, edits auto-accepted, fewer interruptions ▸ ◆ Max quality █████····· 1/2 The most capable model, deepest reasoning, big workflows ▸ ◇ Plan first ·········· 0/1 Opus plans, Sonnet builds; nothing runs before you appr… ▸ ▣ Guarded ·········· 0/2 You approve each change; secrets unreadable; no bypass ▸ ➤ Autopilot ·········· 0/1 Long unattended runs: auto mode, recap, push alerts ▸ ☾ Quiet focus ·········· 0/0 No tips, suggestions, pushes or motion
README

KOZMOS Helm

One-press settings profiles in a sidebar. Each profile sets Claude Code up for one way of working; press it to see exactly what it changes, leave out any step you don't want, then Apply. Every activation can be undone.

ProfileForSets
◔ Token saverspending lessSonnet, effort low, Haiku subagents, Concise output, no prompt suggestions or recap, auto-compact, small workflows, no ultracode keyword, no carrying on at the usage limit, MCP output capped at 10,000 tokens
» Fast flowless waitingSonnet, effort medium, edits auto-accepted, Concise output, prompt suggestions, turn duration and progress bar, medium workflows
◆ Max qualitythe best resultbest model, effort xhigh, subagents keep their own model, default output, large workflows with the ultracode keyword, checkpoints, auto-compact
◇ Plan firstthink, then buildopusplan (Opus plans, Sonnet builds), effort high, plan mode by default and never on its own, questions wait for you, checkpoints
▣ Guardedcontrol and safetyevery edit and command asks, bypassPermissions disabled, Read deny rules for .env, secrets/, ~/.ssh, ~/.aws (they stop the Read tool, not a Bash cat), questions never answer themselves, checkpoints, verbose
➤ Autopilotlong unattended runsauto mode (also while planning), effort high, workflows, a question waits 5 minutes then work goes on, carrying on at the usage limit, recap, push notifications, auto-compact
☾ Quiet focusfewer distractionsno tips, prompt suggestions, pushes, recap or PR footer; reduced motion
★ Your owncoming back to your setup/helm save <name> (or ⎘ Save my setup) records every setting the built-ins touch, as it is now

Each card shows how much of the profile is already in force (4/8, or ✓ in force). In the detail, ● is a change (old value struck through, the new one and why), ✓ is already set and ⊘ is skipped (locked by your organization, missing from this Claude Code version, or a value it does not offer). Press ☑ beside a change to leave it out of this activation. When the 5-hour or weekly limit passes suggestAt (80 %), Token saver is marked suggested.

Commands

CommandWhat it does
/helmtoggles the pane
/helm <profile>opens the pane on that profile (prints its plan where no pane fits)
/helm apply <profile>applies it and lists what changed and what was skipped
/helm save <name> · /helm rm <name>saves your current setup as a profile (up to 6) · removes one
/helm undoreverts the last activation (also ↶ Undo in the pane)
/helm list · /helm historyevery profile with its progress · the last 20 activations

Profile ids: saver, flow, quality, planner, guarded, autopilot, quiet, and my-<name> for yours; a profile's name works too.

Where each change lands

  • /config rows (model, permission mode, output style, auto-compact…) go through $.config.set, exactly as if you changed them in /config.
  • Effort is saved per model, the way /effort saves it: modelSettings["<model id>"].effortLevel in ~/.claude/settings.json. When the profile keeps your model, Helm saves it at once for the model in use; when it switches model, it saves it under the id your next request names, and sends that request at the new effort. A model id with a dot (Bedrock, Vertex) cannot be saved this way; the effort then rides along with requests only.
  • Keys with no /config row (env.CLAUDE_CODE_SUBAGENT_MODEL, env.MAX_MCP_OUTPUT_TOKENS, permissions.deny, permissions.disableBypassPermissionsMode) are written to ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR/settings.json). The first write of each session copies the file to settings.json.helm.bak. A file that is not valid JSON is never overwritten: those steps are skipped and the pane says why.

settings.json is written before any /config row, and Helm waits until Claude Code has reloaded it: the engine writes rows from the settings it holds, so a row set first would quietly put the old file back. If the engine never reloads, the row changes are skipped and you are asked to restart the session.

Undo puts back exactly the values an activation changed, newest first; a deny rule you added yourself since stays. A settings.json that cannot be restored keeps the history entry, so /helm undo can be run again once it is fixed. The env variables are read when Claude Code starts, so count on them from the next session.

Türkçe

Kenar çubuğunda tek dokunuşla ayar profilleri. Her profil Claude Code'u bir çalışma biçimine göre ayarlar; profile basınca tam olarak neyi değiştireceğini görürsünüz, istemediğiniz adımı dışarıda bırakır, sonra Apply'a basarsınız. Her etkinleştirme geri alınabilir.

  • ◔ Token saver: daha az harcama — Sonnet, düşük efor, Haiku alt ajanlar, kısa yanıtlar, kullanım limitinde otomatik devam yok, MCP çıktısı 10.000 tokenla sınırlı.
  • » Fast flow: daha az bekleme — Sonnet, orta efor, düzenlemeler otomatik kabul.
  • ◆ Max quality: en iyi sonuç — best model, xhigh efor, büyük iş akışları.
  • ◇ Plan first: önce plan — opusplan (Opus planlar, Sonnet yazar), plan modu varsayılan, onayınız olmadan çalışmaz.
  • ▣ Guarded: kontrol ve güvenlik — her işlem sorar, bypass kapalı, .env/secrets//~/.ssh/~/.aws okuma yasağı.
  • ➤ Autopilot: uzun gözetimsiz işler — auto mod, soru 5 dakika bekler, özet ve anlık bildirimler.
  • ☾ Quiet focus: dikkat dağıtmayan — ipucu, öneri, bildirim ve animasyon yok.
  • ★ Kendi profiliniz: /helm save <ad> (ya da ⎘ Save my setup) şu anki ayarlarınızı kaydeder; tek dokunuşla geri dönersiniz.

Her kart profilin ne kadarının zaten geçerli olduğunu gösterir (4/8 ya da ✓ in force). Detayda bir değişikliğin yanındaki ☑'e basarak onu bu etkinleştirmenin dışında bırakabilirsiniz. 5 saatlik ya da haftalık limit suggestAt (%80) eşiğini geçince Token saver suggested olarak işaretlenir.

/helm paneli açıp kapatır; /helm <profil> o profili açar, /helm apply <profil> uygular, /helm save <ad> / /helm rm <ad> kendi profillerinizi yönetir, /helm undo (ya da paneldeki ↶ Undo) son etkinleştirmeyi geri alır, /helm list ve /helm history durumu yazar.

/config satırları menünün yazdığı gibi $.config.set ile yazılır. Efor, /effort'un yaptığı gibi model başına modelSettings["<model id>"].effortLevel olarak kaydedilir (model değişmiyorsa hemen). /config'te satırı olmayan anahtarlar ~/.claude/settings.json'a yazılır; her oturumun ilk yazımında dosya settings.json.helm.bak olarak yedeklenir, geçerli JSON olmayan dosyaya asla yazılmaz. settings.json önce yazılır ve Claude Code dosyayı yeniden yükleyene kadar beklenir; ancak sonra /config satırları değiştirilir.

Source 5 files
hooks/register.tsx 630 lines
1/**
2 * KOZMOS Helm: one-press settings profiles.
3 *
4 * A pane of profiles, each a way of working (Token saver, Fast flow, Max
5 * quality, Plan first, Guarded, Autopilot, Quiet focus) or a setup the person
6 * saved with `/helm save <name>`. A profile opens to show every step: what
7 * changes (old → new, and why), what is already set, what is skipped; any
8 * change can be left out before Apply. Every activation is kept in the store
9 * and `/helm undo` (or the pane's Undo) puts back exactly what it changed.
10 *
11 * Where a step lands:
12 *   - `/config` rows through `$.config.set`, as the menu writes them;
13 *   - keys with no row (subagent model, MCP output cap, deny rules, bypass)
14 *     into ~/.claude/settings.json, read-modified-written, after the file is
15 *     copied to settings.json.helm.bak once a session. A file that is not
16 *     valid JSON is never overwritten;
17 *   - effort as `modelSettings[<model id>].effortLevel`, at once for the model
18 *     in use, else under the id the next request names (and sent with it).
19 *
20 * The engine writes `/config` rows from the settings it holds, and may miss a
21 * file write that lands right after one of its own: so settings.json is
22 * written first, Helm waits until `$.settings.read` shows it (writing once
23 * more if it does not), and only then sets rows.
24 */
25import { atom, read, update } from 'claude-code'
26import type { ConfigRow, EngineInterface, Register } from 'claude-code'
27
28import type { HelmBackup, HelmEntry, HelmLive, HelmPending, HelmProfile, HelmSnapshot, HelmUi } from '../types'
29import { KZ, bar, pxOf } from './lib/kz.ts'
30import {
31  BUILT_IN, SKIP_REASON, advice, applySettings, captureProfile, currentEffort, effortPath, findProfile, inSync, isFileStep, isInForce,
32  matchOf, parseSettings, planFor, restorable, revertSettings, sanitizeCustom, show, stepId, suggestAtOf, userSettingsPath,
33} from './profiles.ts'
34import type { Effort, Json, Live, PlanItem, Profile } from './profiles.ts'
35import { cardSvg, clockOf, headerSvg, historySvg, historyText, lineText, listText, markOf, meterText, parseArgs, planText, stepSvg, summary } from './view.ts'
36
37const PANE = 'kz-helm'
38const TITLE = 'KOZMOS · Helm'
39const KEEP = 20
40/** How long a write waits for the engine to reload it, per attempt: 20 × 150 ms. */
41const RELOAD_TRIES = 20
42const RELOAD_STEP_MS = 150
43const NOT_RELOADED = 'Claude Code did not reload settings.json; restart the session, then apply again'
44
45type Host = EngineInterface
46type Tone = 'success' | 'warning' | 'error'
47
48const uiAtom = atom({ plugin: 'helm', key: 'ui' } as const, { selected: null, off: {}, notice: null, isBusy: false, isBackedUp: false } as HelmUi)
49const snapAtom = atom({ plugin: 'helm', key: 'snapshot' } as const, { rows: [], user: {}, path: null, error: null, limits: [] } as HelmSnapshot)
50const historyAtom = atom({ plugin: 'helm', key: 'history' } as const, [] as HelmBackup[])
51const liveAtom = atom({ plugin: 'helm', key: 'live' } as const, {} as HelmLive)
52const pendingAtom = atom({ plugin: 'helm', key: 'pending' } as const, null as HelmPending | null)
53const customAtom = atom({ plugin: 'helm', key: 'custom' } as const, [] as HelmProfile[])
54
55const message = (err: unknown): string => String(err).replace(/^\w*Error: /, '')
56/** An id `$.session.model()` names that settings can key: `claude-…`, no alias, no dot. */
57const isModelId = (m: string | undefined): m is string => typeof m === 'string' && /^claude-[a-z0-9-]+$/i.test(m)
58
59// ---------------------------------------------------------------- reading
60
61async function settingsPath($: Host): Promise<string | null> {
62  const dir = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
63  const home = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined))
64  return userSettingsPath(dir, home)
65}
66
67/** The user settings file, safe to write; or why it is not (and then never written). */
68type UserFile =
69  | { path: string; user: Json; text: string | null; error: null }
70  | { path: string | null; user: Json; text: string | null; error: string }
71
72async function readUser($: Host): Promise<UserFile> {
73  const path = await settingsPath($)
74  if (path === null) return { path, user: {}, text: null, error: 'no home directory to find settings.json in' }
75  let text: string
76  try {
77    text = String(await $.fs.read(path))
78  } catch {
79    // no file yet is fine; a file the engine has loaded but Helm cannot read is not
80    const loaded = await $.settings.read({ source: 'user' }).catch(() => ({}))
81    return Object.keys(loaded).length === 0 ? { path, user: {}, text: null, error: null } : { path, user: { ...loaded }, text: null, error: `cannot read ${path}` }
82  }
83  const parsed = parseSettings(text)
84  return parsed.error === null ? { path, user: parsed.user, text, error: null } : { path, user: {}, text, error: parsed.error }
85}
86
87async function refresh($: Host): Promise<HelmSnapshot> {
88  const rows = await $.config.list().catch(() => [] as ConfigRow[])
89  const { path, user, error } = await readUser($)
90  const usage = await $.session.usage().catch(() => undefined)
91  const limits = (usage?.rateLimits ?? []).map(l => ({ kind: l.kind, percentUsed: l.percentUsed }))
92  const snap: HelmSnapshot = {
93    rows: rows.map(r => ({ key: r.key, label: r.label, kind: r.kind, value: r.value, ...(r.options ? { options: r.options } : {}), isLocked: r.isLocked })),
94    user,
95    path,
96    error,
97    limits,
98  }
99  await update($, snapAtom, () => snap)
100  return snap
101}
102
103async function profiles($: Host): Promise<Profile[]> {
104  return [...BUILT_IN, ...(await read($, customAtom))]
105}
106
107async function liveNow($: Host): Promise<Live> {
108  const want = await read($, pendingAtom)
109  return { ...(await read($, liveAtom)), ...(want ? { pending: want.value } : {}) }
110}
111
112// ---------------------------------------------------------------- keeping
113
114async function saveHistory($: Host, list: HelmBackup[]): Promise<void> {
115  await update($, historyAtom, () => list)
116  await $.store.set('history', list).catch(() => undefined)
117}
118
119async function setPending($: Host, value: HelmPending | null): Promise<void> {
120  await update($, pendingAtom, () => value)
121  await $.store.set('pending', value).catch(() => undefined)
122}
123
124async function saveCustom($: Host, list: HelmProfile[]): Promise<void> {
125  await update($, customAtom, () => list)
126  await $.store.set('custom', list).catch(() => undefined)
127}
128
129async function load($: Host): Promise<void> {
130  const history = await $.store.get('history').catch(() => undefined)
131  if (Array.isArray(history)) await update($, historyAtom, () => (history as HelmBackup[]).filter(b => b && typeof b.id === 'string' && Array.isArray(b.entries)).slice(0, KEEP))
132  const pending = await $.store.get('pending').catch(() => undefined)
133  if (pending && typeof pending === 'object' && typeof (pending as HelmPending).value === 'string') await update($, pendingAtom, () => pending as HelmPending)
134  const custom = await $.store.get('custom').catch(() => undefined)
135  await update($, customAtom, () => sanitizeCustom(custom))
136}
137
138async function notify($: Host, text: string, tone: Tone): Promise<void> {
139  await update($, uiAtom, s => ({ ...s, notice: { text, tone } }))
140}
141
142// ---------------------------------------------------------------- writing
143
144/**
145 * Writes settings.json (copying it aside first, once a session) and resolves
146 * once the engine has loaded it: false if it never did.
147 */
148async function writeUser($: Host, path: string, before: string | null, next: Json, paths: string[]): Promise<boolean> {
149  if (before !== null && !(await read($, uiAtom)).isBackedUp) {
150    await $.fs.write(`${path}.helm.bak`, before)
151    await update($, uiAtom, s => ({ ...s, isBackedUp: true }))
152  }
153  const text = `${JSON.stringify(next, null, 2)}\n`
154  for (const body of [text, text.trimEnd()]) {
155    await $.fs.write(path, body)
156    for (let i = 0; i < RELOAD_TRIES; i++) {
157      const loaded = await $.settings.read({ source: 'user' }).catch(() => undefined)
158      if (loaded && inSync(loaded, next, paths)) return true
159      await $.clock.sleep(RELOAD_STEP_MS)
160    }
161  }
162  return false
163}
164
165/** Saves an effort under a model id and adds the write to its activation's history entry. */
166async function saveEffort($: Host, model: string, value: Effort, backupId: string): Promise<boolean> {
167  const at = effortPath(model)
168  const file = await readUser($)
169  if (at === undefined || file.error !== null) return false
170  const { next, entries } = applySettings(file.user, [{ layer: 'settings', path: at, label: `Effort (${model})`, value, why: '' }])
171  if (entries.length === 0) return true
172  await saveHistory($, (await read($, historyAtom)).map(b => (b.id === backupId ? { ...b, entries: [...b.entries, ...entries] } : b)))
173  return writeUser($, file.path, file.text, next, [at])
174}
175
176async function applyProfile($: Host, profile: Profile, off: readonly string[]): Promise<HelmBackup> {
177  const snap = await refresh($)
178  const plan = planFor(profile, snap.rows, snap.user, await liveNow($))
179  const steps = profile.steps.filter(s => !off.includes(stepId(s)))
180  const entries: HelmEntry[] = []
181  const skipped: { label: string; reason: string }[] = []
182  let isSynced = true
183
184  // settings.json first: the /config writes below then start from it
185  const fileSteps = steps.filter(isFileStep).filter(s => plan.find(p => p.id === stepId(s))!.status === 'change')
186  if (fileSteps.length > 0) {
187    const file = await readUser($)
188    if (file.error !== null) {
189      for (const s of fileSteps) skipped.push({ label: s.label, reason: file.error })
190    } else {
191      const { next, entries: written } = applySettings(file.user, fileSteps)
192      // recorded before the write: a write that fails halfway can still be undone
193      entries.push(...written)
194      try {
195        isSynced = await writeUser($, file.path, file.text, next, written.map(w => w.path))
196        if (!isSynced) skipped.push({ label: 'settings.json', reason: NOT_RELOADED })
197      } catch (err) {
198        isSynced = false
199        for (const w of written) skipped.push({ label: w.label, reason: `may not be written: ${message(err)}` })
200      }
201    }
202  }
203
204  let switchedModel = false
205  for (const step of steps) {
206    if (step.layer !== 'config') continue
207    const item = plan.find(p => p.id === stepId(step))!
208    if (item.status === 'same') continue
209    if (item.status !== 'change') {
210      skipped.push({ label: item.label, reason: SKIP_REASON[item.status] })
211      continue
212    }
213    if (!isSynced) {
214      skipped.push({ label: item.label, reason: NOT_RELOADED })
215      continue
216    }
217    const row = snap.rows.find(r => r.key === step.key)!
218    const r = await $.config.set({ key: step.key, value: step.value }).catch(err => ({ deny: message(err) }))
219    if (r.deny !== undefined) skipped.push({ label: item.label, reason: r.deny })
220    else {
221      entries.push({ layer: 'config', key: step.key, label: row.label, previous: restorable(row) })
222      if (step.key === 'model') switchedModel = true
223    }
224  }
225
226  const effortStep = steps.find(s => s.layer === 'effort')
227  const effort = effortStep?.layer === 'effort' && plan.find(p => p.id === 'effort')?.status === 'change' ? effortStep.value : undefined
228  const backup: HelmBackup = {
229    id: `${await $.clock.now().catch(() => 0)}-${(await read($, historyAtom)).length}`,
230    profile: profile.id,
231    name: profile.name,
232    icon: profile.icon,
233    at: await $.clock.now().catch(() => 0),
234    entries,
235    skipped,
236    ...(effort ? { effort } : {}),
237  }
238  if (entries.length > 0 || effort) await saveHistory($, [backup, ...(await read($, historyAtom))].slice(0, KEEP))
239  if (effort) {
240    // the model in use is known now, unless this activation just switched it
241    const model = switchedModel || !isSynced ? undefined : await $.session.model().catch(() => undefined)
242    const saved = isModelId(model) ? await saveEffort($, model, effort, backup.id).catch(() => false) : false
243    await setPending($, { value: effort, backupId: backup.id, ...(saved ? { savedFor: model } : {}) })
244  }
245  await refresh($)
246  return (await read($, historyAtom)).find(b => b.id === backup.id) ?? backup
247}
248
249async function undoLast($: Host): Promise<{ text: string; tone: Tone }> {
250  const [last, ...rest] = await read($, historyAtom)
251  if (!last) return { text: 'Nothing to undo', tone: 'warning' }
252  if ((await read($, pendingAtom))?.backupId === last.id) await setPending($, null)
253  // the effort the last request carried was this activation's: unknown again until the next one
254  if (last.effort) await update($, liveAtom, l => ({ ...(l.model ? { model: l.model } : {}) }))
255  const failed: string[] = []
256  let isSynced = true
257  const fileEntries = last.entries.filter(e => e.layer !== 'config')
258  if (fileEntries.length > 0) {
259    const file = await readUser($)
260    if (file.error !== null) failed.push(`settings.json (${file.error})`)
261    else {
262      try {
263        isSynced = await writeUser($, file.path, file.text, revertSettings(file.user, fileEntries), fileEntries.map(f => f.path))
264      } catch (err) {
265        failed.push(`settings.json (${message(err)})`)
266      }
267    }
268  }
269  for (const entry of [...last.entries].reverse()) {
270    if (entry.layer !== 'config') continue
271    if (!isSynced) {
272      failed.push(`${entry.label} (${NOT_RELOADED})`)
273      continue
274    }
275    const r = await $.config.set({ key: entry.key, value: entry.previous }).catch(err => ({ deny: message(err) }))
276    if (r.deny !== undefined) failed.push(`${entry.label} (${r.deny})`)
277  }
278  // a settings.json that could not be put back keeps the entry, so undo can be tried again
279  const isFileFailed = failed.some(f => f.startsWith('settings.json'))
280  if (!isFileFailed) await saveHistory($, rest)
281  await refresh($)
282  if (isFileFailed) return { text: `Not undone: ${failed.join(', ')}. Fix it and run /helm undo again`, tone: 'error' }
283  const n = last.entries.length
284  return failed.length === 0
285    ? { text: `Undid ${last.name}: ${n} setting${n === 1 ? '' : 's'} restored`, tone: 'success' }
286    : { text: `Undid ${last.name}, except ${failed.join(', ')}`, tone: 'warning' }
287}
288
289/** Applies a profile with the steps left out in the pane; undefined while another activation runs. */
290async function activate($: Host, profile: Profile): Promise<HelmBackup | undefined> {
291  if ((await read($, uiAtom)).isBusy) return undefined
292  await update($, uiAtom, s => ({ ...s, isBusy: true }))
293  try {
294    const b = await applyProfile($, profile, (await read($, uiAtom)).off[profile.id] ?? [])
295    await update($, uiAtom, s => ({ ...s, off: { ...s.off, [profile.id]: [] } }))
296    await notify($, summary(b), b.skipped.length === 0 ? 'success' : 'warning')
297    return b
298  } finally {
299    await update($, uiAtom, s => ({ ...s, isBusy: false }))
300  }
301}
302
303async function saveCurrent($: Host, name: string): Promise<{ text: string; tone: Tone }> {
304  const snap = await refresh($)
305  const custom = await read($, customAtom)
306  const made = captureProfile(name, snap.rows, snap.user, await liveNow($), custom)
307  if (typeof made === 'string') return { text: made, tone: 'error' }
308  const isNew = !custom.some(p => p.id === made.id)
309  await saveCustom($, isNew ? [...custom, made] : custom.map(p => (p.id === made.id ? made : p)))
310  await update($, uiAtom, s => ({ ...s, selected: made.id }))
311  return { text: `${isNew ? 'Saved' : 'Updated'} ★ ${made.name}: ${made.steps.length} settings · /helm apply ${made.id} brings them back`, tone: 'success' }
312}
313
314async function removeCustom($: Host, id: string): Promise<{ text: string; tone: Tone }> {
315  const custom = await read($, customAtom)
316  const p = findProfile(custom, id) ?? findProfile(custom, `my-${id.trim().toLowerCase()}`)
317  if (!p) return { text: findProfile(BUILT_IN, id) ? 'Built-in profiles cannot be removed' : `No saved profile "${id}"`, tone: 'error' }
318  await saveCustom($, custom.filter(x => x.id !== p.id))
319  await update($, uiAtom, s => ({ ...s, selected: s.selected === p.id ? null : s.selected }))
320  return { text: `Removed ★ ${p.name}`, tone: 'success' }
321}
322
323async function snapshotName($: Host): Promise<string> {
324  const now = await $.clock.now().catch(() => 0)
325  return `Snapshot ${clockOf(now).replace(':', '')}`
326}
327
328async function toggleStep($: Host, profileId: string, id: string): Promise<void> {
329  await update($, uiAtom, s => {
330    const cur = s.off[profileId] ?? []
331    return { ...s, off: { ...s.off, [profileId]: cur.includes(id) ? cur.filter(x => x !== id) : [...cur, id] } }
332  })
333}
334
335async function select($: Host, id: string): Promise<void> {
336  await update($, uiAtom, s => ({ ...s, selected: id, notice: null }))
337}
338
339async function press($: Host, run: () => Promise<{ text: string; tone: Tone }>): Promise<void> {
340  const r = await run()
341  await notify($, r.text, r.tone)
342}
343
344async function isOpen($: Host): Promise<boolean> {
345  return (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
346}
347
348async function openPane($: Host): Promise<boolean> {
349  try {
350    return (await $.ui.open({ id: PANE, title: TITLE, focus: true })).isPlaced
351  } catch {
352    return false
353  }
354}
355
356// ---------------------------------------------------------------- register
357
358export const register: Register = (on, options) => {
359  const suggestAt = suggestAtOf(options.suggestAt)
360
361  on('session.start', async ($, e, next) => {
362    const started = await next(e)
363    await $.command
364      .register({
365        name: 'helm',
366        description: 'KOZMOS: settings profiles (token saver, fast flow, max quality, plan first, guarded, autopilot, quiet, your own)',
367        argumentHint: '[<profile> | apply <profile> | save <name> | rm <name> | undo | list | history | close]',
368        immediate: true,
369      })
370      .catch(() => undefined)
371    await load($)
372    return started
373  })
374
375  // The main loop's requests: learn the model and effort in use; send a just
376  // applied effort, and save it under this model when it was not saved yet.
377  on('turn.step', async function* ($, e, next) {
378    if (e.agentId) return yield* next(e)
379    const want = await read($, pendingAtom)
380    if (!want) {
381      await update($, liveAtom, () => ({ model: e.model, ...(e.effort !== undefined ? { effort: e.effort } : {}) }))
382      return yield* next(e)
383    }
384    await setPending($, null)
385    if (want.savedFor !== e.model) await saveEffort($, e.model, want.value, want.backupId).catch(() => false)
386    await update($, liveAtom, () => ({ model: e.model, effort: want.value }))
387    await refresh($)
388    return yield* next({ ...e, effort: want.value })
389  })
390
391  // keep the cards in step with changes made in /config itself
392  on('config.set', async ($, e, next) => {
393    const r = await next(e)
394    if (r.deny === undefined && (await isOpen($))) await refresh($)
395    return r
396  }).catch(($, e, next) => next(e))
397
398  on('command.run', { command: 'helm' }, async ($, e) => {
399    const action = parseArgs(e.args)
400    switch (action.kind) {
401      case 'close':
402        await $.ui.close({ id: PANE }).catch(() => undefined)
403        return { text: 'Helm closed.' }
404      case 'toggle': {
405        if (await isOpen($)) {
406          await $.ui.close({ id: PANE }).catch(() => undefined)
407          return { text: 'Helm closed.' }
408        }
409        const snap = await refresh($)
410        await update($, uiAtom, s => ({ ...s, notice: null }))
411        if (await openPane($)) return { text: 'Helm open: press a profile to see what it sets.' }
412        const live = await liveNow($)
413        return { text: listText((await profiles($)).map(p => ({ profile: p, plan: planFor(p, snap.rows, snap.user, live) })), null) }
414      }
415      case 'list': {
416        const snap = await refresh($)
417        const live = await liveNow($)
418        const rows = (await profiles($)).map(p => ({ profile: p, plan: planFor(p, snap.rows, snap.user, live) }))
419        const saver = rows.find(r => r.profile.id === 'saver')!
420        return { text: listText(rows, advice(snap.limits, suggestAt, isInForce(saver.plan))) }
421      }
422      case 'history':
423        return { text: historyText(await read($, historyAtom)) }
424      case 'undo': {
425        const r = await undoLast($)
426        await notify($, r.text, r.tone)
427        return { text: r.text }
428      }
429      case 'save': {
430        const r = await saveCurrent($, action.name)
431        await notify($, r.text, r.tone)
432        return { text: r.text }
433      }
434      case 'rm': {
435        const r = await removeCustom($, action.id)
436        await notify($, r.text, r.tone)
437        return { text: r.text }
438      }
439      case 'apply': {
440        const profile = findProfile(await profiles($), action.id)
441        if (!profile) return { text: `Unknown profile "${action.id}". Profiles: ${(await profiles($)).map(p => p.id).join(', ')}` }
442        const b = await activate($, profile)
443        if (!b) return { text: 'Helm is already applying a profile; try again in a moment' }
444        const skipped = b.skipped.map(x => `\n  ⊘ ${x.label}: ${x.reason}`).join('')
445        return { text: `${summary(b)}${skipped}\n/helm undo reverts it` }
446      }
447      case 'open': {
448        const profile = findProfile(await profiles($), action.id)
449        if (!profile) return { text: `Unknown "${action.id}". Profiles: ${(await profiles($)).map(p => p.id).join(', ')}; or /helm help` }
450        const snap = await refresh($)
451        await update($, uiAtom, s => ({ ...s, selected: profile.id, notice: null }))
452        const plan = planFor(profile, snap.rows, snap.user, await liveNow($))
453        if (!(await openPane($))) return { text: planText(profile, plan, (await read($, uiAtom)).off[profile.id]) }
454        const n = plan.filter(p => p.status === 'change').length
455        return { text: `${profile.icon} ${profile.name}: ${n} change${n === 1 ? '' : 's'}, shown in the pane · /helm apply ${profile.id}` }
456      }
457      case 'help':
458        return { text: action.reason }
459    }
460  })
461
462  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
463    const state = await read($, uiAtom)
464    const snap = await read($, snapAtom)
465    const history = await read($, historyAtom)
466    const live = await liveNow($)
467    const all = await profiles($)
468    const plans = new Map(all.map(p => [p.id, planFor(p, snap.rows, snap.user, live)] as const))
469    const planOf = (p: Profile): PlanItem[] => plans.get(p.id)!
470    const open = findProfile(all, state.selected ?? '') ?? all[0]!
471    const inForce = all.find(p => isInForce(planOf(p)))
472    const suggestion = advice(snap.limits, suggestAt, isInForce(planOf(BUILT_IN[0]!)))
473    const effort = currentEffort(snap.user, live)
474    const model = snap.rows.find(r => r.key === 'model')
475    const mode = snap.rows.find(r => r.key === 'permissionMode')
476    const facts = `${show(model?.value).replace(/ \(.*\)$/, '').toLowerCase()} · ${effort ?? 'default'} effort · ${show(mode?.value)} mode`
477    const last = history[0]
478    const off = (p: Profile): string[] => state.off[p.id] ?? []
479    const changes = (p: Profile): number => planOf(p).filter(x => x.status === 'change' && !off(p).includes(x.id)).length
480
481    const ui = $.ui.resolve(e)
482    const { Box, Button, Text } = ui
483    const notice = state.notice ? (
484      <Text key="notice" color={state.notice.tone} wrap="truncate-end">{state.notice.tone === 'success' ? '✓ ' : '! '}{state.notice.text}</Text>
485    ) : null
486    const fileError = snap.error ? <Text key="ferr" color="warning" wrap="truncate-end">! {snap.error}: its steps are left alone</Text> : null
487    const tools = (
488      <Box key="tools" flexDirection="row" gap={1}>
489        {last ? <Button key="undo" label={`↶ Undo ${last.name}`} onPress={() => void press($, () => undoLast($))} /> : null}
490        <Button key="snapshot" label="⎘ Save my setup" dimColor onPress={() => void press($, async () => saveCurrent($, await snapshotName($)))} />
491      </Box>
492    )
493    const applyRow = (p: Profile) => {
494      const n = changes(p)
495      return (
496        <Box key={`actions:${p.id}`} flexDirection="row" gap={1} marginTop={1}>
497          {n > 0 ? (
498            <Button key={`apply:${p.id}`} variant="primary" hotkey="a" autoFocus label={state.isBusy ? 'Applying…' : `Apply ${p.name} · ${n}`} onPress={() => void activate($, p)} />
499          ) : (
500            <Text color="success" bold>{isInForce(planOf(p)) ? '✓ In force: nothing to change' : 'Nothing selected to change'}</Text>
501          )}
502          {p.isCustom ? <Button key={`rm:${p.id}`} label="✕ Remove" dimColor onPress={() => void press($, () => removeCustom($, p.id))} /> : null}
503        </Box>
504      )
505    }
506
507    if ('Svg' in ui && e.surface !== 'terminal') {
508      const { Svg } = ui
509      const W = pxOf(e.props.bodyColumns, 52)
510      const cardW = Math.max(180, W - 76)
511      const stepW = Math.max(180, W - 48)
512      const detail = (p: Profile) => (
513        <Box key={`detail:${p.id}`} flexDirection="column">
514          {planOf(p).map(x => {
515            const isOff = off(p).includes(x.id)
516            return (
517              <Box key={`row:${p.id}:${x.id}`} flexDirection="row" alignItems="center" gap={1}>
518                <Svg source={stepSvg(stepW, x, p.color, isOff)} alt={`${markOf(x, isOff)} ${lineText(x, isOff)}`} width={stepW} height={x.status === 'change' ? 34 : 20} />
519                {x.status === 'change' ? <Button key={`step:${p.id}:${x.id}`} label={isOff ? '☐' : '☑'} plain dimColor={isOff} onPress={() => void toggleStep($, p.id, x.id)} /> : null}
520              </Box>
521            )
522          })}
523          {applyRow(p)}
524        </Box>
525      )
526      return (
527        <Box flexDirection="column" gap={1}>
528          <Svg key="head" source={headerSvg(W, facts, inForce?.name ?? null, suggestion)} alt={`Helm: ${facts}${inForce ? `, ${inForce.name} in force` : ''}${suggestion ? `, Token saver suggested (${suggestion})` : ''}`} width={W} height={suggestion ? 64 : 46} />
529          {notice}
530          {fileError}
531          {tools}
532          {all.map(p => (
533            <Box key={`card:${p.id}`} flexDirection="column">
534              <Box flexDirection="row" alignItems="center" gap={1}>
535                <Svg source={cardSvg(cardW, p, planOf(p), p.id === open.id, suggestion !== null && p.id === 'saver')} alt={`${p.icon} ${p.name}: ${p.tagline}, ${meterText(planOf(p))}`} width={cardW} height={54} />
536                <Button key={`pick:${p.id}`} label={p.id === open.id ? '▾' : '▸'} plain onPress={() => void select($, p.id)} />
537              </Box>
538              {p.id === open.id ? detail(p) : null}
539            </Box>
540          ))}
541          {history.slice(0, 5).map((b, i) => (
542            <Svg key={`h:${i}`} source={historySvg(W, b, all.find(p => p.id === b.profile)?.color ?? KZ.mist)} alt={`${clockOf(b.at)} ${b.name}: ${summary(b)}`} width={W} height={20} />
543          ))}
544        </Box>
545      )
546    }
547
548    const cols = Math.max(40, e.props.bodyColumns || 52)
549    const labelW = Math.min(26, Math.max(14, Math.floor(cols * 0.42)))
550    const meterW = 10
551    const detail = (p: Profile) => (
552      <Box key={`detail:${p.id}`} flexDirection="column" borderStyle="round" borderColor={p.color} paddingX={1}>
553        {planOf(p).map(x => {
554          const isOff = off(p).includes(x.id)
555          if (x.status === 'change')
556            return (
557              <Box key={`row:${p.id}:${x.id}`} flexDirection="column">
558                <Box flexDirection="row">
559                  <Button key={`step:${p.id}:${x.id}`} label={isOff ? '☐' : '☑'} plain dimColor={isOff} onPress={() => void toggleStep($, p.id, x.id)} />
560                  <Text wrap="truncate-end">
561                    <Text dimColor={isOff}> {x.label.padEnd(labelW)}</Text>
562                    <Text dimColor strikethrough>{x.from}</Text>
563                    <Text dimColor> → </Text>
564                    <Text color={isOff ? undefined : p.color} dimColor={isOff} bold={!isOff}>{x.to}</Text>
565                  </Text>
566                </Box>
567                <Text color={KZ.mist} wrap="truncate-end">{'   '}{isOff ? 'left out of this activation' : x.why}</Text>
568              </Box>
569            )
570          if (x.status === 'same')
571            return (
572              <Text key={`row:${p.id}:${x.id}`} wrap="truncate-end">
573                <Text color="success">✓ </Text>
574                <Text dimColor> {x.label.padEnd(labelW)}{x.to}</Text>
575              </Text>
576            )
577          return (
578            <Text key={`row:${p.id}:${x.id}`} color="warning" wrap="truncate-end">
579              ⊘ <Text dimColor> {x.label}: {SKIP_REASON[x.status]}</Text>
580            </Text>
581          )
582        })}
583        {applyRow(p)}
584      </Box>
585    )
586    return (
587      <Box flexDirection="column" gap={1}>
588        <Box key="head" flexDirection="column">
589          <Text wrap="truncate-end">
590            <Text color={KZ.clay} bold>⎈ HELM</Text>
591            <Text dimColor>  {facts}</Text>
592          </Text>
593          {inForce ? <Text color="success" wrap="truncate-end">✓ {inForce.icon} {inForce.name} in force</Text> : null}
594          {suggestion ? <Text color={KZ.amber} wrap="truncate-end">◔ Token saver suggested: {suggestion}</Text> : null}
595          {notice}
596          {fileError}
597          {tools}
598        </Box>
599        {all.map(p => {
600          const plan = planOf(p)
601          const { same, of } = matchOf(plan)
602          const isOpenCard = p.id === open.id
603          return (
604            <Box key={`card:${p.id}`} flexDirection="column">
605              <Box flexDirection="row" justifyContent="space-between">
606                <Button key={`pick:${p.id}`} label={`${isOpenCard ? '▾' : '▸'} ${p.icon} ${p.name}`} plain dimColor={!isOpenCard} onPress={() => void select($, p.id)} />
607                <Text>
608                  {suggestion !== null && p.id === 'saver' ? <Text color={KZ.amber}>suggested </Text> : null}
609                  <Text color={p.color}>{bar(of === 0 ? 0 : same / of, meterW, '·')}</Text>
610                  <Text color={isInForce(plan) ? 'success' : undefined} dimColor={!isInForce(plan)}> {meterText(plan)}</Text>
611                </Text>
612              </Box>
613              <Text dimColor wrap="truncate-end">{'    '}{p.tagline}</Text>
614              {isOpenCard ? detail(p) : null}
615            </Box>
616          )
617        })}
618        {history.length > 0 ? (
619          <Box key="history" flexDirection="column">
620            <Text dimColor bold>HISTORY</Text>
621            {history.slice(0, 5).map((b, i) => (
622              <Text key={`h:${i}`} dimColor wrap="truncate-end">{clockOf(b.at)}  {b.icon} {b.name}: {summary(b).replace(`${b.name} applied: `, '')}</Text>
623            ))}
624          </Box>
625        ) : null}
626      </Box>
627    )
628  })
629}
630
hooks/lib/kz.ts 521 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140// The cuts below count UTF-16 units; they must not land between the two halves of an emoji.
141const isHigh = (u: number): boolean => u >= 0xd800 && u <= 0xdbff
142const isLow = (u: number): boolean => u >= 0xdc00 && u <= 0xdfff
143
144export function clip(s: string, max: number): string {
145  const one = s.replace(/\s+/g, ' ').trim()
146  if (one.length <= max) return one
147  let cut = Math.max(1, max - 1)
148  if (isHigh(one.charCodeAt(cut - 1))) cut--
149  return one.slice(0, cut) + '…'
150}
151
152export function padEnd(s: string, n: number): string {
153  if (s.length < n) return s + ' '.repeat(n - s.length)
154  // A pair cut in half gives way to a space, so the result keeps its width.
155  return n > 0 && isHigh(s.charCodeAt(n - 1)) ? s.slice(0, n - 1) + ' ' : s.slice(0, n)
156}
157
158export function padStart(s: string, n: number): string {
159  if (s.length < n) return ' '.repeat(n - s.length) + s
160  const k = s.length - n
161  return k > 0 && isLow(s.charCodeAt(k)) ? ' ' + s.slice(k + 1) : s.slice(k)
162}
163
164/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
165export function modelName(id: string | undefined): string {
166  if (!id) return '—'
167  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
168  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
169  const fam = m[1]!
170  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
171}
172
173/** Last path segment, either slash. */
174export function baseName(p: string): string {
175  const parts = p.split(/[\\/]/).filter(Boolean)
176  return parts[parts.length - 1] ?? p
177}
178
179/** A stable small hash for seeding. */
180export function hash(s: string): number {
181  let h = 2166136261
182  for (let i = 0; i < s.length; i++) {
183    h ^= s.charCodeAt(i)
184    h = Math.imul(h, 16777619)
185  }
186  return h >>> 0
187}
188
189/** Deterministic 0..1 noise from two ints. */
190export function noise(x: number, y: number): number {
191  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
192  return s - Math.floor(s)
193}
194
195/** A seeded PRNG (mulberry32). */
196export function rng(seed: number): () => number {
197  let a = seed >>> 0
198  return () => {
199    a = (a + 0x6d2b79f5) >>> 0
200    let t = a
201    t = Math.imul(t ^ (t >>> 15), t | 1)
202    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
203    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
204  }
205}
206
207// ---------------------------------------------------------------------------
208// Text gauges for the terminal.
209
210const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
211
212/** A smooth bar with eighth blocks: `█████▍    `. */
213export function bar(ratio: number, width: number, empty = '░'): string {
214  const w = Math.max(1, Math.floor(width))
215  const exact = clamp01(ratio) * w
216  const full = Math.floor(exact)
217  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
218  const used = full + (part ? 1 : 0)
219  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
220}
221
222/** A segmented gauge: `▰▰▰▱▱▱`. */
223export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
224  const w = Math.max(1, Math.floor(width))
225  const n = Math.round(clamp01(ratio) * w)
226  return on.repeat(n) + off.repeat(w - n)
227}
228
229const SPARK = '▁▂▃▄▅▆▇█'
230
231/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
232export function sparkline(values: readonly number[], width: number, max?: number): string {
233  const tail = values.slice(-Math.max(1, width))
234  const top = max ?? Math.max(1e-9, ...tail)
235  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
236  return line.padStart(width, ' ')
237}
238
239/** Braille line graph, 2 samples per cell, `rows` cells tall. */
240export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
241  const samples = values.slice(-(width * 2))
242  const top = max ?? Math.max(1e-9, ...samples)
243  const dotsTall = rows * 4
244  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
245  const offset = width * 2 - samples.length
246  // Dot bits per column, from the bottom row of a cell up.
247  const LEFT = [0x40, 0x04, 0x02, 0x01]
248  const RIGHT = [0x80, 0x20, 0x10, 0x08]
249  samples.forEach((v, i) => {
250    const x = offset + i
251    const col = Math.floor(x / 2)
252    const isRight = x % 2 === 1
253    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
254    for (let d = 0; d <= h; d++) {
255      const row = rows - 1 - Math.floor(d / 4)
256      const bits = isRight ? RIGHT : LEFT
257      const cellRow = grid[row]
258      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
259    }
260  })
261  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
262}
263
264// ---------------------------------------------------------------------------
265// Raster: a grid of colored cells, packed as the engine wants it.
266
267const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
268
269export function toBase64(bytes: Uint8Array): string {
270  let out = ''
271  let i = 0
272  for (; i + 2 < bytes.length; i += 3) {
273    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
274    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
275  }
276  const rest = bytes.length - i
277  if (rest === 1) {
278    const n = bytes[i]! << 16
279    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
280  } else if (rest === 2) {
281    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
282    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
283  }
284  return out
285}
286
287/** The terminal's own default color, for a cell's foreground or background. */
288export const DEFAULT_COLOR = 0x01000000
289
290export class Canvas {
291  readonly cols: number
292  readonly rows: number
293  private readonly words: Uint32Array
294
295  constructor(cols: number, rows: number) {
296    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
297    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
298    this.words = new Uint32Array(this.cols * this.rows * 3)
299    this.clear()
300  }
301
302  clear(bg: number = DEFAULT_COLOR): void {
303    for (let i = 0; i < this.cols * this.rows; i++) {
304      this.words[i * 3] = 0x20
305      this.words[i * 3 + 1] = DEFAULT_COLOR
306      this.words[i * 3 + 2] = bg
307    }
308  }
309
310  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
311  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
312    const cx = Math.floor(x)
313    const cy = Math.floor(y)
314    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
315    const i = (cy * this.cols + cx) * 3
316    const code = ch.codePointAt(0) ?? 0x20
317    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
318    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
319    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
320  }
321
322  /** Writes a string left to right, clipped to the canvas. */
323  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
324    let cx = x
325    for (const ch of s) {
326      this.set(cx, y, ch, fg, bg)
327      cx++
328    }
329  }
330
331  /** Paints a cell's background only, keeping its character. */
332  paint(x: number, y: number, bg: string | number): void {
333    const cx = Math.floor(x)
334    const cy = Math.floor(y)
335    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
336    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
337  }
338
339  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
340  pixel(x: number, py: number, color: string): void {
341    const cx = Math.floor(x)
342    const cy = Math.floor(py / 2)
343    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
344    const i = (cy * this.cols + cx) * 3
345    const c = hexToInt(color)
346    const isTop = Math.floor(py) % 2 === 0
347    if (this.words[i] !== 0x2580) {
348      this.words[i] = 0x2580
349      this.words[i + 1] = DEFAULT_COLOR
350      this.words[i + 2] = DEFAULT_COLOR
351    }
352    if (isTop) this.words[i + 1] = c
353    else this.words[i + 2] = c
354  }
355
356  /** The `cells` prop of a Raster. */
357  encode(): string {
358    return toBase64(new Uint8Array(this.words.buffer))
359  }
360}
361
362// ---------------------------------------------------------------------------
363// SVG for the desktop: one drawing per row (the desktop wraps siblings).
364
365export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
366export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
367
368const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
369
370export function xml(s: string): string {
371  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
372}
373
374/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
375export const SVG_BASE_CSS = `
376.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
377@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
378.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
379.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
380@media (prefers-reduced-motion: reduce){*{animation:none!important}}
381`
382
383export function svg(width: number, height: number, body: string, css = ''): string {
384  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
385}
386
387/** Rough width of UI text in px, for fitting labels. */
388export function textWidth(s: string, size: number): number {
389  let w = 0
390  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
391  return w
392}
393
394export function fitText(s: string, size: number, maxW: number): string {
395  if (textWidth(s, size) <= maxW) return s
396  let out = ''
397  for (const ch of s) {
398    if (textWidth(out + ch + '…', size) > maxW) break
399    out += ch
400  }
401  return out + '…'
402}
403
404/** A rounded progress bar as SVG markup. */
405export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
406  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
407  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
408    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
409}
410
411/** SVG text helper. */
412export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
413  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
414  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
415}
416
417/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
418export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
419  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
420}
421
422// ---------------------------------------------------------------------------
423// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
424// Anthropic first-party rates as of 2026-10. Used only where the engine reports
425// tokens and not money (a subagent's own spend); the session total comes from
426// $.session.usage().cost, which is the engine's.
427
428const PRICES: [RegExp, [number, number, number, number]][] = [
429  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
430  [/opus-5-5/i, [4, 20, 0.2, 5]],
431  [/opus/i, [5, 25, 0.5, 6.25]],
432  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
433  [/sonnet/i, [3, 15, 0.3, 3.75]],
434  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
435  [/haiku/i, [1, 5, 0.1, 1.25]],
436]
437
438export type Usage = {
439  input_tokens?: number
440  output_tokens?: number
441  cache_read_input_tokens?: number
442  cache_creation_input_tokens?: number
443}
444
445export function priceOf(model: string): [number, number, number, number] {
446  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
447}
448
449export function costOf(model: string, u: Usage | null | undefined): number {
450  if (!u) return 0
451  const [i, o, r, w] = priceOf(model)
452  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
453}
454
455export function tokensOf(u: Usage | null | undefined): number {
456  if (!u) return 0
457  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
458}
459
460/** The context a request filled: everything it read plus what it wrote. */
461export function contextOf(u: Usage | null | undefined): number {
462  return tokensOf(u)
463}
464
465export function windowOf(model: string): number {
466  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
467}
468
469// ---------------------------------------------------------------------------
470// Rate limits.
471
472export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
473
474export function limitLabel(kind: string): string {
475  if (kind === 'five_hour') return '5h'
476  if (kind === 'seven_day') return '7d'
477  if (kind === 'spend_limit') return '$'
478  return kind.replace(/_/g, ' ')
479}
480
481/** Milliseconds until a window resets, or undefined. */
482export function untilReset(l: Limit, now: number): number | undefined {
483  if (!l.resetsAt) return undefined
484  const t = Date.parse(l.resetsAt)
485  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
486}
487
488/** The length of a window in ms, for burn-rate math. */
489export function windowMs(kind: string): number | undefined {
490  if (kind === 'five_hour') return 5 * 3600_000
491  if (kind === 'seven_day') return 7 * 24 * 3600_000
492  return undefined
493}
494
495// ---------------------------------------------------------------------------
496// Tool calls, described in a few words.
497
498/** What a tool call is about: the command, the file, the pattern, the agent's task. */
499export function toolDetail(tool: string, input: unknown): string {
500  const e = (input ?? {}) as Record<string, unknown>
501  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
502  const t = String(tool)
503  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
504  if (s('file_path')) return baseName(s('file_path'))
505  if (s('notebook_path')) return baseName(s('notebook_path'))
506  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
507  if (s('pattern')) return clip(s('pattern'), 60)
508  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
509  if (s('query')) return clip(s('query'), 60)
510  if (s('subject')) return clip(s('subject'), 60)
511  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
512  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
513  return ''
514}
515
516/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
517export function toolName(tool: string): string {
518  const t = String(tool)
519  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
520}
521
hooks/profiles.ts 474 lines
1// Helm's pure core: the built-in profiles, the plan a profile makes against
2// the current settings, applying and undoing settings.json steps, and saving
3// the current setup as a profile of its own. No `$` here (it may not cross an
4// import), so every rule is tested directly.
5
6import type { HelmEffort, HelmEntry, HelmProfile, HelmRow, HelmStep, HelmValue } from '../types'
7import { KZ } from './lib/kz.ts'
8
9export type Json = Record<string, unknown>
10export type Step = HelmStep
11export type Profile = HelmProfile
12export type Row = HelmRow
13export type Effort = HelmEffort
14
15export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
16
17const cfg = (key: string, value: HelmValue, why: string): Step => ({ layer: 'config', key, value, why })
18const env = (name: string, label: string, value: string | null, why: string): Step => ({ layer: 'settings', path: `env.${name}`, label, value, why })
19const effort = (value: Effort, why: string): Step => ({ layer: 'effort', value, why })
20
21export const SUBAGENT = 'CLAUDE_CODE_SUBAGENT_MODEL'
22export const MCP_CAP = 'MAX_MCP_OUTPUT_TOKENS'
23export const NO_BYPASS = 'permissions.disableBypassPermissionsMode'
24
25/**
26 * The built-in profiles. Every `config` key is a row Claude Code 2.1.302
27 * lists, with a value that row offers; a row a version lacks is skipped.
28 */
29export const BUILT_IN: readonly Profile[] = [
30  {
31    id: 'saver',
32    name: 'Token saver',
33    icon: '◔',
34    color: KZ.green,
35    tagline: 'Sonnet, low effort, short answers, Haiku subagents',
36    steps: [
37      cfg('model', 'sonnet', 'cheaper per token than Opus'),
38      effort('low', 'less thinking per request'),
39      env(SUBAGENT, 'Subagent model', 'haiku', 'subagents run on the cheapest model'),
40      cfg('outputStyle', 'Concise', 'shorter answers, fewer output tokens'),
41      cfg('promptSuggestionEnabled', false, 'each suggestion is another model call'),
42      cfg('recap', false, 'the recap is another model call'),
43      cfg('autoCompact', true, 'the context never grows unbounded'),
44      cfg('workflowSizeGuideline', 'small', 'smaller multi-agent fan-outs'),
45      cfg('workflowKeywordTriggerEnabled', false, 'no fan-out from a stray "ultracode"'),
46      cfg('autoContinueAtUsageLimit', false, 'stops at the usage limit instead of carrying on'),
47      env(MCP_CAP, 'MCP output cap', '10000', 'caps MCP tool results (default 25000)'),
48    ],
49  },
50  {
51    id: 'flow',
52    name: 'Fast flow',
53    icon: '»',
54    color: KZ.amber,
55    tagline: 'Sonnet, edits auto-accepted, fewer interruptions',
56    steps: [
57      cfg('model', 'sonnet', 'faster answers than Opus'),
58      effort('medium', 'balanced reasoning time'),
59      cfg('permissionMode', 'acceptEdits', 'file edits go through without a prompt'),
60      cfg('outputStyle', 'Concise', 'less to read between steps'),
61      cfg('promptSuggestionEnabled', true, 'Tab takes the next likely prompt'),
62      cfg('turnDuration', true, 'see how long each turn took'),
63      cfg('progressBar', true, 'progress shows in the terminal tab'),
64      cfg('workflowSizeGuideline', 'medium', 'parallel agents when they help'),
65    ],
66  },
67  {
68    id: 'quality',
69    name: 'Max quality',
70    icon: '◆',
71    color: KZ.violet,
72    tagline: 'The most capable model, deepest reasoning, big workflows',
73    steps: [
74      cfg('model', 'best', 'the most capable model available to you'),
75      effort('xhigh', 'the deepest reasoning per request'),
76      env(SUBAGENT, 'Subagent model', null, 'subagents keep the model they ask for'),
77      cfg('outputStyle', 'default', 'full explanations, nothing trimmed'),
78      cfg('workflows', true, 'multi-agent workflows available'),
79      cfg('workflowKeywordTriggerEnabled', true, '"ultracode" starts a workflow'),
80      cfg('workflowSizeGuideline', 'large', 'room for large fan-outs'),
81      cfg('checkpoints', true, 'any change can be rewound'),
82      cfg('autoCompact', true, 'long sessions keep going'),
83    ],
84  },
85  {
86    id: 'planner',
87    name: 'Plan first',
88    icon: '◇',
89    color: KZ.cyan,
90    tagline: 'Opus plans, Sonnet builds; nothing runs before you approve',
91    steps: [
92      cfg('model', 'opusplan', 'Opus while planning, Sonnet while building'),
93      effort('high', 'a careful plan'),
94      cfg('permissionMode', 'plan', 'every session starts by planning'),
95      cfg('useAutoModeDuringPlan', false, 'the plan waits for your approval'),
96      cfg('askUserQuestionTimeout', 'never', 'questions wait for your answer'),
97      cfg('checkpoints', true, 'any change can be rewound'),
98    ],
99  },
100  {
101    id: 'guarded',
102    name: 'Guarded',
103    icon: '▣',
104    color: KZ.blue,
105    tagline: 'You approve each change; secrets unreadable; no bypass',
106    steps: [
107      cfg('permissionMode', 'default', 'every edit and command asks first'),
108      cfg('useAutoModeDuringPlan', false, 'plan mode never acts on its own'),
109      { layer: 'settings', path: NO_BYPASS, label: 'Bypass mode', value: 'disable', why: 'bypassPermissions cannot be turned on' },
110      {
111        layer: 'list',
112        path: 'permissions.deny',
113        label: 'Deny rules',
114        add: ['Read(./.env)', 'Read(./.env.*)', 'Read(./secrets/**)', 'Read(~/.ssh/**)', 'Read(~/.aws/**)'],
115        why: 'the Read tool is refused .env, secrets/, ~/.ssh and ~/.aws',
116      },
117      cfg('askUserQuestionTimeout', 'never', 'Claude never answers its own question'),
118      cfg('checkpoints', true, 'every change can be rewound'),
119      cfg('verbose', true, 'see each tool call in full'),
120      cfg('workflowKeywordTriggerEnabled', false, 'no fan-out from a keyword'),
121    ],
122  },
123  {
124    id: 'autopilot',
125    name: 'Autopilot',
126    icon: '➤',
127    color: KZ.red,
128    tagline: 'Long unattended runs: auto mode, recap, push alerts',
129    steps: [
130      cfg('permissionMode', 'auto', 'the classifier approves safe actions'),
131      cfg('useAutoModeDuringPlan', true, 'plans run without stopping'),
132      effort('high', 'fewer wrong turns on long runs'),
133      cfg('workflows', true, 'parallel agents for big tasks'),
134      cfg('askUserQuestionTimeout', '5m', 'a question waits 5 minutes, then work goes on'),
135      cfg('autoContinueAtUsageLimit', true, 'the run carries on at the usage limit'),
136      cfg('recap', true, 'a summary waits for you when you return'),
137      cfg('agentPushNotifEnabled', true, 'a push when Claude decides something'),
138      cfg('inputNeededNotifEnabled', true, 'a push when it needs you'),
139      cfg('autoCompact', true, 'the run never stops on a full context'),
140    ],
141  },
142  {
143    id: 'quiet',
144    name: 'Quiet focus',
145    icon: '☾',
146    color: KZ.teal,
147    tagline: 'No tips, suggestions, pushes or motion',
148    steps: [
149      cfg('tips', false, 'no tips while you work'),
150      cfg('promptSuggestionEnabled', false, 'an empty prompt box stays empty'),
151      cfg('reduceMotion', true, 'calmer animations'),
152      cfg('agentPushNotifEnabled', false, 'no push when Claude decides'),
153      cfg('inputNeededNotifEnabled', false, 'no push when it needs you'),
154      cfg('recap', false, 'no recap after you step away'),
155      cfg('prStatus', false, 'no PR status footer'),
156    ],
157  },
158]
159
160export const findProfile = (profiles: readonly Profile[], id: string): Profile | undefined => {
161  const want = id.trim().toLowerCase()
162  return want ? profiles.find(p => p.id === want || p.name.toLowerCase() === want) : undefined
163}
164
165// ---------------------------------------------------------------- reading values
166
167/** A row's shown value equals what a profile sets (the menu shows `Default (recommended)` for `default`). */
168export function sameValue(current: HelmValue | undefined, wanted: HelmValue): boolean {
169  if (current === wanted) return true
170  if (typeof current !== 'string' || typeof wanted !== 'string') return false
171  const c = current.toLowerCase()
172  const w = wanted.toLowerCase()
173  return c === w || c.startsWith(`${w} (`)
174}
175
176export const show = (v: unknown): string => {
177  if (v === undefined || v === null || v === '') return 'unset'
178  if (v === true) return 'on'
179  if (v === false) return 'off'
180  if (Array.isArray(v)) return v.length === 0 ? 'none' : `${v.length} rule${v.length === 1 ? '' : 's'}`
181  return String(v)
182}
183
184export function getPath(obj: Json, path: string): unknown {
185  let cur: unknown = obj
186  for (const part of path.split('.')) {
187    if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) return undefined
188    cur = (cur as Json)[part]
189  }
190  return cur
191}
192
193/** A copy of `obj` with `path` set, or removed when `value` is undefined; parents left empty are dropped. */
194export function setPath(obj: Json, path: string, value: unknown): Json {
195  const dot = path.indexOf('.')
196  const out: Json = { ...obj }
197  if (dot < 0) {
198    if (value === undefined) delete out[path]
199    else out[path] = value
200    return out
201  }
202  const head = path.slice(0, dot)
203  const child = out[head]
204  const base = child !== null && typeof child === 'object' && !Array.isArray(child) ? (child as Json) : {}
205  const next = setPath(base, path.slice(dot + 1), value)
206  if (Object.keys(next).length === 0) delete out[head]
207  else out[head] = next
208  return out
209}
210
211const listAt = (obj: Json, path: string): string[] => {
212  const v = getPath(obj, path)
213  return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
214}
215
216/** The main loop's model id and effort as last seen, and an effort waiting for the next request. */
217export type Live = { model?: string; effort?: string | number; pending?: string }
218
219/**
220 * Where a model's effort is saved: `modelSettings.<id>.effortLevel`. Undefined
221 * for an id a dotted path cannot name (a Bedrock or Vertex id with a dot).
222 */
223export const effortPath = (model: string | undefined): string | undefined =>
224  !model || model.includes('.') ? undefined : `modelSettings.${model}.effortLevel`
225
226/** The effort the model in use runs at: an effort waiting to be sent, its saved level, else what the last request carried. */
227export function currentEffort(user: Json, live: Live): string | undefined {
228  if (live.pending !== undefined) return live.pending
229  const path = effortPath(live.model)
230  const saved = path ? getPath(user, path) : undefined
231  if (typeof saved === 'string') return saved
232  return live.effort === undefined ? undefined : String(live.effort)
233}
234
235// ---------------------------------------------------------------- planning
236
237export type Status = 'change' | 'same' | 'locked' | 'missing' | 'invalid'
238
239export type PlanItem = {
240  /** Names the step within its profile: `config:<key>`, `settings:<path>`, `list:<path>`, `effort`. */
241  id: string
242  layer: Step['layer']
243  label: string
244  from: string
245  to: string
246  why: string
247  status: Status
248}
249
250export const SKIP_REASON: Record<Exclude<Status, 'change' | 'same'>, string> = {
251  locked: 'locked by your organization',
252  missing: 'not in this Claude Code version',
253  invalid: 'value not offered here',
254}
255
256export const stepId = (s: Step): string =>
257  s.layer === 'config' ? `config:${s.key}` : s.layer === 'effort' ? 'effort' : `${s.layer}:${s.path}`
258
259/** Whether a `/config` row takes the value a step sets. */
260function fits(row: Row, value: HelmValue): boolean {
261  if (row.kind === 'boolean') return typeof value === 'boolean'
262  if (row.kind === 'number') return typeof value === 'number'
263  if (row.kind === 'choice') return typeof value === 'string' && (!row.options || row.options.some(o => sameValue(o, value)))
264  return typeof value === 'string'
265}
266
267/** What each step of a profile would do now. */
268export function planFor(profile: Profile, rows: readonly Row[], user: Json, live: Live = {}): PlanItem[] {
269  const modelStep = profile.steps.find(s => s.layer === 'config' && s.key === 'model')
270  const modelRow = rows.find(r => r.key === 'model')
271  // a profile that switches model makes the current model's saved effort moot
272  const switchesModel = modelStep?.layer === 'config' && modelRow !== undefined && !sameValue(modelRow.value, modelStep.value)
273  return profile.steps.map((step): PlanItem => {
274    const id = stepId(step)
275    if (step.layer === 'effort') {
276      const current = live.pending ?? (switchesModel ? undefined : currentEffort(user, live))
277      return { id, layer: 'effort', label: 'Effort', from: current ?? 'default', to: step.value, why: step.why, status: current === step.value ? 'same' : 'change' }
278    }
279    if (step.layer === 'config') {
280      const row = rows.find(r => r.key === step.key)
281      const to = show(step.value)
282      if (!row) return { id, layer: 'config', label: step.key, from: '—', to, why: step.why, status: 'missing' }
283      const base = { id, layer: 'config' as const, label: row.label, from: show(row.value), to, why: step.why }
284      if (row.isLocked) return { ...base, status: 'locked' }
285      if (!fits(row, step.value)) return { ...base, status: 'invalid' }
286      return { ...base, status: sameValue(row.value, step.value) ? 'same' : 'change' }
287    }
288    if (step.layer === 'settings') {
289      const current = getPath(user, step.path)
290      return { id, layer: 'settings', label: step.label, from: show(current), to: show(step.value), why: step.why, status: (current ?? null) === step.value ? 'same' : 'change' }
291    }
292    const have = listAt(user, step.path)
293    const missing = step.add.filter(x => !have.includes(x))
294    return {
295      id,
296      layer: 'list',
297      label: step.label,
298      from: show(have),
299      to: missing.length === 0 ? show(have) : `+${missing.length} rule${missing.length === 1 ? '' : 's'}`,
300      why: step.why,
301      status: missing.length === 0 ? 'same' : 'change',
302    }
303  })
304}
305
306/** How much of a profile is in force: steps that match, of those that can apply. */
307export function matchOf(plan: readonly PlanItem[]): { same: number; of: number } {
308  const applicable = plan.filter(p => p.status === 'same' || p.status === 'change')
309  return { same: applicable.filter(p => p.status === 'same').length, of: applicable.length }
310}
311
312export const isInForce = (plan: readonly PlanItem[]): boolean => {
313  const { same, of } = matchOf(plan)
314  return of > 0 && same === of
315}
316
317/** A row's value as `$.config.set` takes it back: a choice's option, not the menu's label for it. */
318export function restorable(row: Row): HelmValue {
319  if (row.kind === 'choice' && row.options) return row.options.find(o => sameValue(row.value, o)) ?? row.value
320  return row.value
321}
322
323// ---------------------------------------------------------------- applying and undoing
324
325type FileStep = Extract<Step, { layer: 'settings' | 'list' }>
326
327export const isFileStep = (s: Step): s is FileStep => s.layer === 'settings' || s.layer === 'list'
328
329/** Applies settings and list steps to a settings object; returns the new object and what undoes it. */
330export type FileEntry = Exclude<HelmEntry, { layer: 'config' }>
331
332export function applySettings(user: Json, steps: readonly FileStep[]): { next: Json; entries: FileEntry[] } {
333  let next = user
334  const entries: FileEntry[] = []
335  for (const step of steps) {
336    if (step.layer === 'settings') {
337      const previous = getPath(next, step.path)
338      if ((previous ?? null) === step.value) continue
339      next = setPath(next, step.path, step.value ?? undefined)
340      entries.push({ layer: 'settings', path: step.path, label: step.label, previous: previous ?? null, had: previous !== undefined })
341    } else {
342      const have = listAt(next, step.path)
343      const added = step.add.filter(x => !have.includes(x))
344      if (added.length === 0) continue
345      next = setPath(next, step.path, [...have, ...added])
346      entries.push({ layer: 'list', path: step.path, label: step.label, added })
347    }
348  }
349  return { next, entries }
350}
351
352/** Puts back what `applySettings` changed, newest first; entries the person added themselves since stay. */
353export function revertSettings(user: Json, entries: readonly HelmEntry[]): Json {
354  let next = user
355  for (const entry of [...entries].reverse()) {
356    if (entry.layer === 'settings') next = setPath(next, entry.path, entry.had ? entry.previous : undefined)
357    else if (entry.layer === 'list') {
358      const kept = listAt(next, entry.path).filter(x => !entry.added.includes(x))
359      next = setPath(next, entry.path, kept.length === 0 ? undefined : kept)
360    }
361  }
362  return next
363}
364
365/** Whether the settings the engine loaded hold `next`'s value at every path. */
366export function inSync(loaded: Json, next: Json, paths: readonly string[]): boolean {
367  return paths.every(p => JSON.stringify(getPath(loaded, p) ?? null) === JSON.stringify(getPath(next, p) ?? null))
368}
369
370/** Parses a settings.json text: the object, or why it must not be overwritten. */
371export function parseSettings(text: string): { user: Json; error: string | null } {
372  if (text.trim() === '') return { user: {}, error: null }
373  try {
374    const parsed: unknown = JSON.parse(text)
375    if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return { user: {}, error: 'settings.json is not a JSON object' }
376    return { user: parsed as Json, error: null }
377  } catch {
378    return { user: {}, error: 'settings.json is not valid JSON' }
379  }
380}
381
382/** The user settings file: `$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`. */
383export function userSettingsPath(configDir: string | undefined, home: string | undefined): string | null {
384  const trim = (s: string) => s.replace(/[\\/]+$/, '')
385  if (configDir) return `${trim(configDir)}/settings.json`
386  return home ? `${trim(home)}/.claude/settings.json` : null
387}
388
389// ---------------------------------------------------------------- your own profiles
390
391export const MAX_CUSTOM = 6
392const CUSTOM_COLORS = [KZ.magenta, KZ.yellow, KZ.lime, KZ.clay, KZ.blue, KZ.violet]
393
394export const slug = (name: string): string =>
395  name.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/ı/g, 'i').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24)
396
397/** Every key and path the built-in profiles touch: what a saved profile records. */
398export function universe(profiles: readonly Profile[] = BUILT_IN): { config: string[]; settings: { path: string; label: string }[] } {
399  const config: string[] = []
400  const settings: { path: string; label: string }[] = []
401  for (const p of profiles)
402    for (const s of p.steps) {
403      if (s.layer === 'config' && !config.includes(s.key)) config.push(s.key)
404      if (s.layer === 'settings' && !settings.some(x => x.path === s.path)) settings.push({ path: s.path, label: s.label })
405    }
406  return { config, settings }
407}
408
409/**
410 * The current setup as a profile: each `/config` row the built-ins touch (not
411 * the locked ones), their settings.json keys, and the effort of the model in
412 * use. Deny rules are left out: undoing someone's own rules is not a profile's
413 * job.
414 */
415export function captureProfile(name: string, rows: readonly Row[], user: Json, live: Live, taken: readonly Profile[]): Profile | string {
416  const clean = name.trim().replace(/\s+/g, ' ').slice(0, 32)
417  const base = slug(clean)
418  if (!base) return 'Give the profile a name: /helm save <name>'
419  const id = `my-${base}`
420  if (BUILT_IN.some(p => p.id === base || p.name.toLowerCase() === clean.toLowerCase())) return `"${clean}" is a built-in profile's name; pick another`
421  const others = taken.filter(p => p.id !== id)
422  if (others.length >= MAX_CUSTOM) return `Up to ${MAX_CUSTOM} saved profiles; remove one first (/helm rm <name>)`
423  const { config, settings } = universe()
424  const steps: Step[] = []
425  for (const key of config) {
426    const row = rows.find(r => r.key === key)
427    if (row && !row.isLocked) steps.push(cfg(key, restorable(row), 'as you had it'))
428  }
429  const e = currentEffort(user, live)
430  if (e && (EFFORTS as readonly string[]).includes(e)) steps.push(effort(e as Effort, 'as you had it'))
431  for (const s of settings) {
432    const v = getPath(user, s.path)
433    if (v === undefined || typeof v === 'string') steps.push({ layer: 'settings', path: s.path, label: s.label, value: v ?? null, why: 'as you had it' })
434  }
435  const color = CUSTOM_COLORS[others.length % CUSTOM_COLORS.length]!
436  return { id, name: clean, icon: '★', color, tagline: `your setup, ${steps.length} settings`, steps, isCustom: true }
437}
438
439/** Saved profiles read back from the store: anything malformed is dropped. */
440export function sanitizeCustom(v: unknown): Profile[] {
441  if (!Array.isArray(v)) return []
442  return v
443    .filter((p): p is Profile => {
444      if (p === null || typeof p !== 'object') return false
445      const o = p as Profile
446      return typeof o.id === 'string' && o.id.startsWith('my-') && typeof o.name === 'string' && typeof o.color === 'string' && Array.isArray(o.steps) && o.steps.every(isStep)
447    })
448    .map(p => ({ ...p, icon: '★', tagline: typeof p.tagline === 'string' ? p.tagline : '', isCustom: true }))
449    .slice(0, MAX_CUSTOM)
450}
451
452function isStep(s: unknown): s is Step {
453  if (s === null || typeof s !== 'object') return false
454  const o = s as Record<string, unknown>
455  if (typeof o.why !== 'string') return false
456  if (o.layer === 'config') return typeof o.key === 'string' && ['string', 'number', 'boolean'].includes(typeof o.value)
457  if (o.layer === 'settings') return typeof o.path === 'string' && typeof o.label === 'string' && (o.value === null || typeof o.value === 'string')
458  if (o.layer === 'effort') return (EFFORTS as readonly unknown[]).includes(o.value)
459  return false
460}
461
462// ---------------------------------------------------------------- advice
463
464/** Token saver is suggested when a limit window passes `at` percent and it is not in force already. */
465export function advice(limits: readonly { kind: string; percentUsed: number }[], at: number, isSaverOn: boolean): string | null {
466  if (isSaverOn || at >= 100) return null
467  const hot = [...limits].filter(l => Number.isFinite(l.percentUsed) && l.percentUsed >= at).sort((a, b) => b.percentUsed - a.percentUsed)[0]
468  if (!hot) return null
469  const name = hot.kind === 'five_hour' ? '5-hour' : hot.kind === 'seven_day' ? 'weekly' : hot.kind.replace(/_/g, ' ')
470  return `${name} limit at ${Math.round(hot.percentUsed)}%`
471}
472
473export const suggestAtOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? Math.min(100, Math.max(50, Math.round(v))) : 80)
474
hooks/view.ts 162 lines
1// Helm's pure view helpers: the command's arguments, the text answers and
2// the desktop's SVG rows. No `$` here.
3
4import type { HelmBackup } from '../types'
5import { KZ, fitText, svg, svgBar, svgText, xml } from './lib/kz.ts'
6import { SKIP_REASON, matchOf } from './profiles.ts'
7import type { PlanItem, Profile } from './profiles.ts'
8
9export type HelmAction =
10  | { kind: 'toggle' }
11  | { kind: 'open'; id: string }
12  | { kind: 'apply'; id: string }
13  | { kind: 'save'; name: string }
14  | { kind: 'rm'; id: string }
15  | { kind: 'undo' }
16  | { kind: 'list' }
17  | { kind: 'history' }
18  | { kind: 'close' }
19  | { kind: 'help'; reason: string }
20
21export const USAGE = '/helm [<profile> | apply <profile> | save <name> | rm <name> | undo | list | history | close]'
22
23/** `/helm <verb> <rest>` into an action; a lone word that is not a verb names a profile. */
24export function parseArgs(args: string): HelmAction {
25  const trimmed = args.trim()
26  if (!trimmed) return { kind: 'toggle' }
27  const gap = trimmed.search(/\s/)
28  const verb = (gap < 0 ? trimmed : trimmed.slice(0, gap)).toLowerCase()
29  const rest = gap < 0 ? '' : trimmed.slice(gap).trim()
30  if (verb === 'apply' || verb === 'use') return rest ? { kind: 'apply', id: rest } : { kind: 'help', reason: `Usage: /helm apply <profile>` }
31  if (verb === 'save') return rest ? { kind: 'save', name: rest } : { kind: 'help', reason: 'Usage: /helm save <name> (saves your current setup as a profile)' }
32  if (verb === 'rm' || verb === 'remove' || verb === 'delete') return rest ? { kind: 'rm', id: rest } : { kind: 'help', reason: 'Usage: /helm rm <name>' }
33  if (verb === 'undo') return { kind: 'undo' }
34  if (verb === 'list' || verb === 'ls') return { kind: 'list' }
35  if (verb === 'history' || verb === 'log') return { kind: 'history' }
36  if (verb === 'close' || verb === 'off') return { kind: 'close' }
37  if (verb === 'help') return { kind: 'help', reason: USAGE }
38  return { kind: 'open', id: trimmed }
39}
40
41export const markOf = (p: PlanItem, isOff: boolean): string =>
42  p.status === 'change' ? (isOff ? '○' : '●') : p.status === 'same' ? '✓' : '⊘'
43
44/** One plan line as text: what changes and why, what is already set, or why it is skipped. */
45export function lineText(p: PlanItem, isOff = false): string {
46  if (p.status === 'change') return `${p.label}: ${p.from} → ${p.to}${isOff ? '  (left out)' : `  (${p.why})`}`
47  if (p.status === 'same') return `${p.label}: ${p.to}`
48  return `${p.label}: ${SKIP_REASON[p.status]}`
49}
50
51export function planText(profile: Profile, plan: readonly PlanItem[], off: readonly string[] = []): string {
52  const lines = [`${profile.icon} ${profile.name}: ${profile.tagline}`]
53  for (const p of plan) lines.push(`  ${markOf(p, off.includes(p.id))} ${lineText(p, off.includes(p.id))}`)
54  const n = plan.filter(p => p.status === 'change' && !off.includes(p.id)).length
55  lines.push(n === 0 ? '  already in force' : `/helm apply ${profile.id} makes ${n} change${n === 1 ? '' : 's'}`)
56  return lines.join('\n')
57}
58
59export const meterText = (plan: readonly PlanItem[]): string => {
60  const { same, of } = matchOf(plan)
61  return of > 0 && same === of ? '✓ in force' : `${same}/${of}`
62}
63
64export function listText(rows: readonly { profile: Profile; plan: readonly PlanItem[] }[], suggested: string | null): string {
65  const lines = ['Helm profiles']
66  for (const { profile: p, plan } of rows) {
67    const tag = suggested !== null && p.id === 'saver' ? `  ← suggested: ${suggested}` : ''
68    lines.push(`  ${p.icon} ${p.id.padEnd(10)} ${meterText(plan).padEnd(10)} ${p.tagline}${tag}`)
69  }
70  lines.push(USAGE)
71  return lines.join('\n')
72}
73
74export const changedCount = (b: HelmBackup): number => b.entries.length + (b.effort && !b.entries.some(e => e.layer === 'settings' && e.path.endsWith('.effortLevel')) ? 1 : 0)
75
76/** One line on what an activation did. */
77export function summary(b: HelmBackup): string {
78  const n = changedCount(b)
79  const head = n === 0 ? `${b.name}: already in place` : `${b.name} applied: ${n} setting${n === 1 ? '' : 's'} changed`
80  const effort = b.effort ? ` (effort ${b.effort})` : ''
81  return b.skipped.length === 0 ? `${head}${effort}` : `${head}${effort}, ${b.skipped.length} skipped`
82}
83
84export const clockOf = (at: number): string => {
85  const d = new Date(at)
86  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
87}
88
89export function historyText(list: readonly HelmBackup[]): string {
90  if (list.length === 0) return 'Helm history: nothing applied yet'
91  return ['Helm history (newest first; /helm undo reverts the top one)', ...list.map(b => `  ${clockOf(b.at)}  ${b.icon} ${b.name}: ${changedCount(b)} changed${b.skipped.length ? `, ${b.skipped.length} skipped` : ''}`)].join('\n')
92}
93
94// ---------------------------------------------------------------- desktop rows
95
96const MARK_COLOR = (p: PlanItem, color: string, isOff: boolean): string =>
97  p.status === 'change' ? (isOff ? KZ.mist : color) : p.status === 'same' ? KZ.green : KZ.amber
98
99export function headerSvg(W: number, facts: string, active: string | null, suggestion: string | null): string {
100  const h = suggestion ? 64 : 46
101  const body =
102    `<rect class="p" x="0" y="0" width="${W}" height="${h}" rx="10"/>` +
103    `<rect x="10" y="10" width="26" height="26" rx="7" fill="${KZ.clay}"/>` +
104    svgText(23, 28, '⎈', { size: 16, weight: 700, anchor: 'middle', fill: KZ.ink }) +
105    svgText(46, 22, 'HELM', { size: 13, weight: 800 }) +
106    svgText(46, 37, fitText(facts, 11, W - 60), { cls: 's', size: 11, mono: true }) +
107    (active ? svgText(W - 12, 22, fitText(`✓ ${active}`, 11, W / 2 - 40), { size: 11, weight: 600, anchor: 'end', fill: KZ.green }) : '') +
108    (suggestion ? svgText(12, 56, fitText(`◔ Token saver suggested: ${suggestion}`, 11, W - 24), { size: 11, weight: 600, fill: KZ.amber }) : '')
109  return svg(W, h, body)
110}
111
112export function cardSvg(W: number, p: Profile, plan: readonly PlanItem[], isOpen: boolean, isSuggested: boolean): string {
113  const { same, of } = matchOf(plan)
114  const isOn = of > 0 && same === of
115  const H = 54
116  const right = isOn ? '✓ in force' : `${same}/${of}`
117  const body =
118    `<rect class="p" x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="10"${isOpen ? ` stroke="${p.color}" stroke-width="1.5"` : ''}/>` +
119    `<rect x="10" y="12" width="30" height="30" rx="8" fill="${p.color}"/>` +
120    svgText(25, 33, p.icon, { size: 16, weight: 700, anchor: 'middle', fill: KZ.ink }) +
121    svgText(50, 24, fitText(p.name, 13, W - 170), { size: 13, weight: 700, fill: p.color }) +
122    (isSuggested ? svgText(W - 12, 24, 'suggested', { size: 10, weight: 700, anchor: 'end', fill: KZ.amber }) : '') +
123    svgText(50, 41, fitText(p.tagline, 11, W - 150), { cls: 's', size: 11 }) +
124    svgBar(W - 92, 34, 50, 5, of === 0 ? 0 : same / of, p.color) +
125    svgText(W - 12, isSuggested ? 40 : 30, right, { size: 10, weight: 600, anchor: 'end', fill: isOn ? KZ.green : undefined, cls: 'm' })
126  return svg(W, H, body)
127}
128
129export function stepSvg(W: number, p: PlanItem, color: string, isOff: boolean): string {
130  const H = 34
131  const mark = markOf(p, isOff)
132  const tone = MARK_COLOR(p, color, isOff)
133  const labelW = Math.min(170, Math.max(90, W * 0.4))
134  let detail: string
135  if (p.status === 'change') {
136    const from = fitText(p.from, 11, (W - labelW - 40) / 2)
137    const fromW = Math.min((W - labelW - 40) / 2, from.length * 6.2)
138    detail =
139      svgText(labelW + 22, 14, from, { cls: 'm', size: 11 }) +
140      `<line x1="${labelW + 22}" y1="10" x2="${labelW + 22 + fromW}" y2="10" stroke="${KZ.mist}" stroke-width="1"/>` +
141      svgText(labelW + 28 + fromW, 14, fitText(`→ ${p.to}`, 11, W - labelW - fromW - 36), { size: 11, weight: 700, fill: isOff ? KZ.mist : color })
142  } else {
143    detail = svgText(labelW + 22, 14, fitText(p.status === 'same' ? p.to : SKIP_REASON[p.status], 11, W - labelW - 30), { cls: 'm', size: 11 })
144  }
145  const why = p.status === 'change' ? (isOff ? 'left out of this activation' : p.why) : ''
146  const body =
147    svgText(8, 14, mark, { size: 12, weight: 700, fill: tone }) +
148    svgText(22, 14, fitText(p.label, 12, labelW - 4), { size: 12, weight: p.status === 'change' ? 600 : 400, cls: p.status === 'change' && !isOff ? 't' : 's' }) +
149    detail +
150    (why ? svgText(22, 29, fitText(why, 10, W - 30), { cls: 'm', size: 10 }) : '')
151  return svg(W, why ? H : 20, body)
152}
153
154export function historySvg(W: number, b: HelmBackup, color: string): string {
155  const body =
156    `<circle cx="9" cy="10" r="4" fill="${xml(color)}"/>` +
157    svgText(20, 14, clockOf(b.at), { cls: 'm', size: 11, mono: true }) +
158    svgText(62, 14, fitText(`${b.icon} ${b.name}`, 12, W - 170), { size: 12, weight: 600 }) +
159    svgText(W - 8, 14, `${changedCount(b)} changed${b.skipped.length ? ` · ${b.skipped.length} skipped` : ''}`, { cls: 's', size: 11, anchor: 'end' })
160  return svg(W, 20, body)
161}
162
types/index.d.ts 100 lines
1// Helm's $.state contract: what its pane draws from.
2
3export type HelmValue = string | number | boolean | readonly string[]
4export type HelmEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
5
6/** One step of a profile, on one of the layers Helm writes. */
7export type HelmStep =
8  /** A `/config` row, written with `$.config.set` as the menu writes it. */
9  | { layer: 'config'; key: string; value: HelmValue; why: string }
10  /** A settings.json key with no `/config` row; `value: null` removes it. */
11  | { layer: 'settings'; path: string; label: string; value: string | null; why: string }
12  /** Entries added to a settings.json list (`permissions.deny`), keeping what is there. */
13  | { layer: 'list'; path: string; label: string; add: string[]; why: string }
14  /** The effort for the model in use, saved as `modelSettings[<id>].effortLevel`. */
15  | { layer: 'effort'; value: HelmEffort; why: string }
16
17export type HelmProfile = {
18  id: string
19  name: string
20  icon: string
21  color: string
22  tagline: string
23  steps: HelmStep[]
24  /** Saved by the person with `/helm save`, not built in. */
25  isCustom?: boolean
26}
27
28/** A `/config` row, the part Helm reads. */
29export type HelmRow = {
30  key: string
31  label: string
32  kind: 'boolean' | 'choice' | 'text' | 'number'
33  value: HelmValue
34  options?: readonly string[]
35  isLocked: boolean
36}
37
38export type HelmLimit = { kind: string; percentUsed: number }
39
40export type HelmSnapshot = {
41  rows: HelmRow[]
42  /** ~/.claude/settings.json as parsed; `{}` when there is none. */
43  user: Record<string, unknown>
44  /** Where that file is; null when no config or home directory is known. */
45  path: string | null
46  /** Why the file cannot be written (not JSON): its steps are then skipped. */
47  error: string | null
48  /** The 5-hour and weekly limits as last read. */
49  limits: HelmLimit[]
50}
51
52/** What one activation changed, so undo can put it back. */
53export type HelmEntry =
54  | { layer: 'config'; key: string; label: string; previous: HelmValue }
55  | { layer: 'settings'; path: string; label: string; previous: unknown; had: boolean }
56  | { layer: 'list'; path: string; label: string; added: string[] }
57
58export type HelmBackup = {
59  id: string
60  profile: string
61  name: string
62  icon: string
63  at: number
64  entries: HelmEntry[]
65  skipped: { label: string; reason: string }[]
66  /** The effort the activation set, when it set one. */
67  effort?: HelmEffort
68}
69
70/** The main loop's model id and effort, as the last request carried them. */
71export type HelmLive = { model?: string; effort?: string | number }
72
73/** An effort still to send with the next request (and to save, if that request names another model). */
74export type HelmPending = { value: HelmEffort; backupId: string; savedFor?: string }
75
76export type HelmUi = {
77  /** The profile whose detail is open; null means the first. */
78  selected: string | null
79  /** Step ids left out of the next activation, per profile. */
80  off: Record<string, string[]>
81  /** A one-line result under the header. */
82  notice: { text: string; tone: 'success' | 'warning' | 'error' } | null
83  isBusy: boolean
84  /** Whether settings.json was copied aside already this session. */
85  isBackedUp: boolean
86}
87
88declare module 'claude-code' {
89  interface PluginState {
90    helm: {
91      ui: HelmUi
92      snapshot: HelmSnapshot
93      history: HelmBackup[]
94      live: HelmLive
95      pending: HelmPending | null
96      custom: HelmProfile[]
97    }
98  }
99}
100