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…

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.
| Profile | For | Sets |
|---|---|---|
| ◔ Token saver | spending less | Sonnet, 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 flow | less waiting | Sonnet, effort medium, edits auto-accepted, Concise output, prompt suggestions, turn duration and progress bar, medium workflows |
| ◆ Max quality | the best result | best model, effort xhigh, subagents keep their own model, default output, large workflows with the ultracode keyword, checkpoints, auto-compact |
| ◇ Plan first | think, then build | opusplan (Opus plans, Sonnet builds), effort high, plan mode by default and never on its own, questions wait for you, checkpoints |
| ▣ Guarded | control and safety | every 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 |
| ➤ Autopilot | long unattended runs | auto 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 focus | fewer distractions | no tips, prompt suggestions, pushes, recap or PR footer; reduced motion |
| ★ Your own | coming 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.
| Command | What it does |
|---|---|
/helm | toggles 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 undo | reverts the last activation (also ↶ Undo in the pane) |
/helm list · /helm history | every 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.
/config rows (model, permission mode, output style, auto-compact…) go through $.config.set, exactly as if you changed them in /config./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./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.
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.
best model, xhigh efor, büyük iş akışları.opusplan (Opus planlar, Sonnet yazar), plan modu varsayılan, onayınız olmadan çalışmaz..env/secrets//~/.ssh/~/.aws okuma yasağı./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.
hooks/register.tsx 630 lines1/**
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}
630hooks/lib/kz.ts 521 lines1// 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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
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}
521hooks/profiles.ts 474 lines1// 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)
474hooks/view.ts 162 lines1// 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}
162types/index.d.ts 100 lines1// 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