savemytokens 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +164 -0
  3. package/dist/adapters/claude-code/index.js +130 -0
  4. package/dist/adapters/claude-code/merge.js +90 -0
  5. package/dist/adapters/claude-code/parse.js +642 -0
  6. package/dist/adapters/claude-code/provider.js +105 -0
  7. package/dist/adapters/codex/index.js +74 -0
  8. package/dist/adapters/codex/parse.js +389 -0
  9. package/dist/adapters/codex/provider.js +171 -0
  10. package/dist/adapters/index.js +10 -0
  11. package/dist/adapters/pending.js +29 -0
  12. package/dist/adapters/types.js +1 -0
  13. package/dist/analyze/aggregate.js +180 -0
  14. package/dist/analyze/combine.js +11 -0
  15. package/dist/analyze/detectors.js +244 -0
  16. package/dist/analyze/index.js +29 -0
  17. package/dist/analyze/score.js +20 -0
  18. package/dist/cli-options.js +149 -0
  19. package/dist/cli.js +141 -0
  20. package/dist/collect.js +62 -0
  21. package/dist/commands/audit.js +74 -0
  22. package/dist/commands/control.js +654 -0
  23. package/dist/commands/hud.js +71 -0
  24. package/dist/commands/install.js +369 -0
  25. package/dist/commands/policy.js +93 -0
  26. package/dist/commands/privacy.js +28 -0
  27. package/dist/commands/set.js +83 -0
  28. package/dist/commands/theme.js +136 -0
  29. package/dist/commands/watch.js +135 -0
  30. package/dist/core/cost.js +24 -0
  31. package/dist/core/hash.js +0 -0
  32. package/dist/core/pricing.js +63 -0
  33. package/dist/core/resource.js +1 -0
  34. package/dist/core/tokens.js +32 -0
  35. package/dist/core/types.js +1 -0
  36. package/dist/hooks/nudge.js +111 -0
  37. package/dist/hooks/rules.js +14 -0
  38. package/dist/privacy/payload.js +22 -0
  39. package/dist/report/graph.js +162 -0
  40. package/dist/report/graphs.js +61 -0
  41. package/dist/report/render.js +183 -0
  42. package/dist/report/schedule.js +143 -0
  43. package/dist/report/settings.js +237 -0
  44. package/dist/report/views.js +418 -0
  45. package/dist/runtime/hook.mjs +234 -0
  46. package/dist/runtime/kernel.mjs +1472 -0
  47. package/dist/runtime/statusline.mjs +243 -0
  48. package/dist/scheduler/keys.js +112 -0
  49. package/dist/scheduler/plan.js +287 -0
  50. package/dist/storage/cache.js +38 -0
  51. package/dist/storage/paths.js +29 -0
  52. package/dist/storage/store.js +48 -0
  53. package/dist/util/ansi.js +35 -0
  54. package/dist/util/fmt.js +76 -0
  55. package/package.json +51 -0
@@ -0,0 +1,654 @@
1
+ import { detailRows, helpOverlay, labelsFor, planRows } from "../report/views.js";
2
+ import { PRESERVE_KINDS, renderSettings, selectableRows, settingsRows } from "../report/settings.js";
3
+ import { keyActions, splitKeys } from "../scheduler/keys.js";
4
+ import { buildPlan, cyclePriority, demote, promote, selectionIndex, equalize, cyclePolicy, cyclePreset, cycleTheme, moveSegment, nextShare, saveCustomAdvice, toggleColumn, togglePreserve, toggleSegment, setPriority, setShare, setPinned, resetPreferences, setState, visibleRows, } from "../scheduler/plan.js";
5
+ import { HUD_PRESETS, builtinThemes, loadConfig, loadTheme, paint, saveConfig, userThemes, windowBounds, } from "../runtime/kernel.mjs";
6
+ import { hookInstalled, installPlan, runInstall } from "./install.js";
7
+ import { colorEnabled, padEndVisible, visibleWidth } from "../util/ansi.js";
8
+ import { ago } from "../util/fmt.js";
9
+ const REFRESH_MS = 2500;
10
+ const STEP = 0.05;
11
+ const MAX_CUSTOM = 200;
12
+ const ALT_ON = "\u001b[?1049h";
13
+ const ALT_OFF = "\u001b[?1049l";
14
+ const HIDE = "\u001b[?25l";
15
+ const SHOW = "\u001b[?25h";
16
+ const CLEAR = "\u001b[2J\u001b[H";
17
+ const MIN_COLUMNS = 60;
18
+ function size() {
19
+ return { columns: Math.max(MIN_COLUMNS, process.stdout.columns ?? 100), rows: Math.max(14, process.stdout.rows ?? 30) };
20
+ }
21
+ function contextFor(control, selected, interactive, expanded = false) {
22
+ const { columns, rows } = size();
23
+ return {
24
+ expanded,
25
+ theme: loadTheme(control.config.theme.tui),
26
+ color: colorEnabled,
27
+ columns,
28
+ rows,
29
+ selected,
30
+ interactive,
31
+ labels: labelsFor(visibleRows(control.schedule)),
32
+ };
33
+ }
34
+ function header(control, viewName, theme, color, columns) {
35
+ const windowLabel = control.schedule.key === "seven_day" ? "7d" : "5h";
36
+ const read = control.schedule.quota ? `read ${ago(control.schedule.quota.at, control.schedule.now)}` : "no reading";
37
+ const lefts = [
38
+ ` ${paint(theme, "accent", "SaveMyTokens", color)} ${paint(theme, "dim", `· ${control.provider.label} · ${windowLabel}`, color)}`,
39
+ ` ${paint(theme, "accent", "SaveMyTokens", color)} ${paint(theme, "dim", `· ${windowLabel}`, color)}`,
40
+ ` ${paint(theme, "accent", "smt", color)}`,
41
+ ];
42
+ const rights = [
43
+ `${paint(theme, "dim", read, color)} ${paint(theme, "accent", viewName, color)} `,
44
+ `${paint(theme, "accent", viewName, color)} `,
45
+ ];
46
+ for (const right of rights) {
47
+ for (const left of lefts) {
48
+ const gap = columns - visibleWidth(left) - visibleWidth(right);
49
+ if (gap >= 2)
50
+ return left + " ".repeat(gap) + right;
51
+ }
52
+ }
53
+ return padEndVisible(lefts[lefts.length - 1] ?? "", columns);
54
+ }
55
+ export function boxed(lines, theme, color, columns) {
56
+ const widest = Math.max(24, ...lines.map((line) => visibleWidth(line)));
57
+ const inner = Math.min(Math.max(0, columns - 4), widest + 4);
58
+ const h = theme.border.h ?? "─";
59
+ const v = theme.border.v ?? "│";
60
+ const left = Math.max(0, Math.floor((columns - inner - 2) / 2));
61
+ const pad = " ".repeat(left);
62
+ const edge = (start, end) => `${pad}${paint(theme, "accent", `${start}${h.repeat(inner)}${end}`, color)}`;
63
+ const out = [edge(theme.border.tl ?? "┌", theme.border.tr ?? "┐")];
64
+ for (const line of lines) {
65
+ const body = visibleWidth(line) > inner - 2 ? line : padEndVisible(line, inner - 2);
66
+ out.push(`${pad}${paint(theme, "accent", v, color)} ${body} ${paint(theme, "accent", v, color)}`);
67
+ }
68
+ out.push(edge(theme.border.bl ?? "└", theme.border.br ?? "┘"));
69
+ return out;
70
+ }
71
+ function windowAround(lines, room) {
72
+ if (lines.length <= room)
73
+ return lines;
74
+ const cursorLine = lines.findIndex((line) => line.includes("❯"));
75
+ if (cursorLine === -1)
76
+ return lines.slice(0, room);
77
+ const half = Math.floor(room / 2);
78
+ const start = Math.max(0, Math.min(lines.length - room, cursorLine - half));
79
+ return lines.slice(start, start + room);
80
+ }
81
+ function fullScreen(control, body, footer, viewName, context, center = false) {
82
+ const { theme, color, columns, rows } = context;
83
+ const rule = paint(theme, "dim", (theme.border.h ?? "─").repeat(columns), color);
84
+ const top = [header(control, viewName, theme, color, columns), rule];
85
+ const bottom = [rule, ...footer];
86
+ const room = Math.max(1, rows - top.length - bottom.length - 1);
87
+ const framed = center ? boxed(body, theme, color, columns) : body;
88
+ const shown = center ? framed.slice(0, room) : windowAround(framed, room);
89
+ const spare = Math.max(0, room - shown.length);
90
+ const above = new Array(center ? Math.floor(spare / 2) : 0).fill("");
91
+ const below = new Array(spare - above.length).fill("");
92
+ return [...top, ...above, ...shown, ...below, ...bottom].join("\n");
93
+ }
94
+ function footerFor(context, showHelp) {
95
+ const { theme, color } = context;
96
+ if (showHelp)
97
+ return [paint(theme, "dim", " ? close help q quit", color)];
98
+ return [paint(theme, "dim", keyHints(HINTS, context.columns), color)];
99
+ }
100
+ const HINTS = [
101
+ "↑↓ select",
102
+ "←→ target",
103
+ "space up",
104
+ "x down",
105
+ "p priority",
106
+ "e even",
107
+ "⏎ open",
108
+ "m all",
109
+ "f pin",
110
+ "s settings",
111
+ "? help",
112
+ "q quit",
113
+ ];
114
+ export function footerKeys(columns) {
115
+ return keyHints(HINTS, columns);
116
+ }
117
+ function keyHints(hints, columns) {
118
+ for (let keep = hints.length; keep > 1; keep--) {
119
+ const shown = [...hints.slice(0, keep - 1), hints[hints.length - 1] ?? ""];
120
+ const line = ` ${shown.join(" ")}`;
121
+ if (line.length <= columns)
122
+ return line;
123
+ }
124
+ return ` ${hints[hints.length - 1] ?? ""}`;
125
+ }
126
+ function toJson(control) {
127
+ return JSON.stringify({
128
+ generatedAt: control.schedule.now,
129
+ window: { key: control.schedule.key, ...control.schedule.bounds },
130
+ resources: control.resources,
131
+ enforcement: control.enforcement,
132
+ unusedPool: control.schedule.unusedPool,
133
+ unattributedPercent: control.unattributed,
134
+ deferred: control.deferred,
135
+ projects: control.schedule.projects.map((view) => ({
136
+ project: view.project,
137
+ label: view.label,
138
+ bucket: view.bucket,
139
+ liveSessions: view.liveSessions,
140
+ target: view.allocation.target,
141
+ pinnedTarget: view.allocation.pinned,
142
+ priority: view.settings.priority,
143
+ pinned: view.settings.pinned,
144
+ parked: view.settings.parked,
145
+ observed: view.observed,
146
+ attributedPercent: view.attributedPercent,
147
+ pressure: view.pressure,
148
+ tokens: view.usage.tokens,
149
+ prompt: view.prompt,
150
+ sessions: view.sessions.map((session) => session.claimant.id),
151
+ })),
152
+ sessions: control.schedule.claimants.map((view) => ({
153
+ id: view.claimant.id,
154
+ label: view.claimant.label,
155
+ project: view.claimant.project,
156
+ state: view.state,
157
+ bucket: view.bucket,
158
+ target: view.allocation.target,
159
+ observed: view.observed,
160
+ attributedPercent: view.attributedPercent,
161
+ pressure: view.pressure,
162
+ tokens: view.usage.tokens,
163
+ weighted: view.usage.weighted,
164
+ prompt: view.claimant.prompt,
165
+ stale: view.stale,
166
+ heartbeat: view.claimant.heartbeat ?? 0,
167
+ })),
168
+ }, null, 2);
169
+ }
170
+ function centre(line, width) {
171
+ const pad = Math.max(0, Math.floor((width - visibleWidth(line)) / 2));
172
+ return " ".repeat(pad) + line;
173
+ }
174
+ function wrapPlain(text, width) {
175
+ const out = [];
176
+ let line = "";
177
+ for (const word of text.split(" ")) {
178
+ if (line.length === 0)
179
+ line = word;
180
+ else if (line.length + 1 + word.length <= width)
181
+ line += ` ${word}`;
182
+ else {
183
+ out.push(line);
184
+ line = word;
185
+ }
186
+ }
187
+ if (line)
188
+ out.push(line);
189
+ return out;
190
+ }
191
+ const SETUP_CHOICES = [
192
+ ["Install", "hooks and a status line"],
193
+ ["Install, no line", "hooks and a silent status line: the numbers still arrive, nothing is drawn"],
194
+ ["Not now", "nothing is written"],
195
+ ];
196
+ export function setupScreen(choice, theme, color, columns, details = false) {
197
+ const inner = Math.max(20, Math.min(columns - 6, details ? 60 : 50));
198
+ const out = [];
199
+ const middle = (line) => {
200
+ out.push(centre(line, inner));
201
+ };
202
+ middle(paint(theme, "accent", "Install SaveMyTokens?", color));
203
+ out.push("");
204
+ for (const line of wrapPlain("It adds four hooks and a status line to Claude Code. Without them, nothing here is live.", inner)) {
205
+ out.push(paint(theme, "fg", line, color));
206
+ }
207
+ out.push("");
208
+ const labels = SETUP_CHOICES.map(([label]) => label);
209
+ const button = (at) => at === choice
210
+ ? paint(theme, at === 2 ? "warn" : "ok", `[ ${labels[at] ?? ""} ]`, color)
211
+ : paint(theme, "dim", ` ${labels[at] ?? ""} `, color);
212
+ const oneLine = labels.reduce((total, label) => total + (label ?? "").length + 6, 0);
213
+ if (oneLine <= inner) {
214
+ middle(labels.map((_, at) => button(at)).join(" "));
215
+ }
216
+ else {
217
+ for (let at = 0; at < labels.length; at++)
218
+ middle(button(at));
219
+ }
220
+ out.push("");
221
+ const noteRows = Math.max(...SETUP_CHOICES.map(([, note]) => wrapPlain(note ?? "", inner).length));
222
+ const note = wrapPlain(SETUP_CHOICES[choice]?.[1] ?? "", inner);
223
+ for (const line of note)
224
+ middle(paint(theme, "dim", line, color));
225
+ for (let spare = note.length; spare < noteRows; spare++)
226
+ out.push("");
227
+ out.push("");
228
+ if (details) {
229
+ for (const change of installPlan()) {
230
+ out.push(paint(theme, "fg", clipLine(change.file, inner), color));
231
+ for (const line of change.lines)
232
+ out.push(` ${paint(theme, "dim", clipLine(line, inner - 2), color)}`);
233
+ out.push("");
234
+ }
235
+ for (const line of wrapPlain("Nothing else is touched, and no network call is made.", inner)) {
236
+ out.push(paint(theme, "dim", line, color));
237
+ }
238
+ out.push("");
239
+ }
240
+ const keys = `← → choose · enter confirm · d ${details ? "hide" : "what changes"}`;
241
+ middle(paint(theme, "dim", clipLine(keys, inner), color));
242
+ out.push("");
243
+ const long = "Undo any time: npx savemytokens uninstall";
244
+ middle(paint(theme, "dim", long.length <= inner ? long : "npx savemytokens uninstall", color));
245
+ return out.map((line) => padEndVisible(line, inner));
246
+ }
247
+ function clipLine(text, width) {
248
+ return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
249
+ }
250
+ function spanLabel(key) {
251
+ return key === "seven_day" ? "weekly" : key === "spend_limit" ? "monthly spend" : "5-hour";
252
+ }
253
+ export function primerScreen(theme, color, columns, agent = "your agent", span = "rolling") {
254
+ const inner = Math.max(24, Math.min(columns - 8, 54));
255
+ const out = [];
256
+ const middle = (line) => {
257
+ out.push(centre(line, inner));
258
+ };
259
+ const flush = (text, role) => {
260
+ for (const line of wrapPlain(text, inner))
261
+ out.push(paint(theme, role, line, color));
262
+ };
263
+ const key = (name, what) => {
264
+ const gutter = 7;
265
+ const [first, ...rest] = wrapPlain(what, inner - gutter);
266
+ out.push(`${paint(theme, "accent", padEndVisible(name, gutter - 1), color)} ${paint(theme, "fg", first ?? "", color)}`);
267
+ for (const line of rest)
268
+ out.push(`${" ".repeat(gutter)}${paint(theme, "fg", line, color)}`);
269
+ };
270
+ middle(paint(theme, "accent", "One window, split by you", color));
271
+ out.push("");
272
+ flush(`${agent} gives you one ${span} allowance across every window you have open. This divides it between your projects.`, "dim");
273
+ out.push("");
274
+ key("← →", "move a project's share. The others move to fit, so the window always adds up.");
275
+ out.push("");
276
+ key("e", "back to an even split.");
277
+ out.push("");
278
+ key("p", "priority: who receives what a finished project hands back.");
279
+ out.push("");
280
+ key("space", "move a project between ACTIVE and RECENT.");
281
+ out.push("");
282
+ flush("Each session is told the share it is working within, and to wind down as it runs out.", "dim");
283
+ out.push("");
284
+ middle(paint(theme, "ok", "[ Got it ]", color));
285
+ return out;
286
+ }
287
+ function tightPreview(control) {
288
+ const now = control.schedule.now;
289
+ const project = control.schedule.projects.find((view) => view.bucket === "active") ?? control.schedule.projects[0];
290
+ const history = control.schedule.quota?.history ?? [];
291
+ const recent = history.filter((point) => typeof point.five_hour === "number" && point.at >= now - 45 * 60 * 1000);
292
+ const first = recent[0];
293
+ const last = recent[recent.length - 1];
294
+ const globalRate = first && last && last.at > first.at
295
+ ? (last.five_hour - first.five_hour) / ((last.at - first.at) / 3_600_000)
296
+ : null;
297
+ const config = control.config;
298
+ return {
299
+ label: project?.label ?? "this project",
300
+ target: project?.allocation.target ?? 0,
301
+ usedPoints: project?.attributedPercent ?? 0,
302
+ pressure: project?.pressure.value ?? 0,
303
+ ratePerHour: globalRate === null ? null : globalRate * (project?.observed ?? 0),
304
+ now,
305
+ preserve: config.preserveFor[process.cwd()] ?? config.preserveFor.default ?? [],
306
+ custom: config.customAdvice[process.cwd()] ?? config.customAdvice.default ?? "",
307
+ };
308
+ }
309
+ function previewView(control) {
310
+ const now = control.schedule.now;
311
+ const bounds = windowBounds(control.schedule.quota, "five_hour", now);
312
+ const view = control.schedule.claimants.find((row) => row.bucket === "active") ?? control.schedule.claimants[0];
313
+ const quota = {};
314
+ for (const key of ["five_hour", "seven_day", "spend_limit"]) {
315
+ const window = control.schedule.quota?.windows?.[key];
316
+ if (window && (typeof window.resetsAt !== "number" || window.resetsAt * 1000 > now))
317
+ quota[key] = window;
318
+ }
319
+ return {
320
+ label: view?.claimant.label || "session",
321
+ target: view?.allocation.target ?? 1,
322
+ observed: view?.observed ?? 0,
323
+ used: view?.attributedPercent ?? null,
324
+ pressure: view?.pressure.value ?? 0,
325
+ priority: view?.claimant.priority ?? "normal",
326
+ quota,
327
+ history: (control.schedule.quota?.history ?? [])
328
+ .filter((point) => typeof point.five_hour === "number")
329
+ .map((point) => point.five_hour),
330
+ rate: null,
331
+ from: bounds.from,
332
+ to: bounds.to,
333
+ now,
334
+ };
335
+ }
336
+ export async function runControl(options) {
337
+ let control = buildPlan(Date.now(), true, options.window, options.adapter);
338
+ if (options.json) {
339
+ process.stdout.write(`${toJson(control)}\n`);
340
+ return;
341
+ }
342
+ if (!process.stdout.isTTY || !process.stdin.isTTY || options.command === "status") {
343
+ const context = contextFor(control, -1, false, true);
344
+ process.stdout.write(`\n${planRows(control, context).join("\n")}\n\n`);
345
+ return;
346
+ }
347
+ const stdin = process.stdin;
348
+ try {
349
+ stdin.setRawMode(true);
350
+ }
351
+ catch {
352
+ const context = contextFor(control, -1, false, true);
353
+ process.stdout.write(`\n${planRows(control, context).join("\n")}\n\n`);
354
+ return;
355
+ }
356
+ const config = loadConfig();
357
+ const offerInstall = !hookInstalled() && !config.offeredInstallAt;
358
+ const offerPrimer = !config.primerSeenAt;
359
+ let mode = offerInstall ? "setup" : offerPrimer ? "primer" : "plan";
360
+ let setupDetails = false;
361
+ let settingsCursor = 0;
362
+ let setupChoice = 0;
363
+ let expanded = false;
364
+ let showHelp = false;
365
+ let editing = false;
366
+ let custom = "";
367
+ let selected = 0;
368
+ let selectedId = null;
369
+ stdin.resume();
370
+ stdin.setEncoding("utf8");
371
+ process.stdout.write(ALT_ON + HIDE);
372
+ const rows = () => visibleRows(control.schedule, expanded);
373
+ const settle = () => {
374
+ const list = rows();
375
+ selected = selectionIndex(list.map((view) => view.project), selectedId, selected);
376
+ selectedId = list[selected]?.project ?? null;
377
+ };
378
+ const draw = () => {
379
+ const context = contextFor(control, selected, true, expanded);
380
+ const actual = process.stdout.columns ?? 0;
381
+ if (actual > 0 && actual < MIN_COLUMNS) {
382
+ process.stdout.write(`${CLEAR}\n ${paint(context.theme, "warn", `${actual} columns is too narrow`, context.color)}\n ${paint(context.theme, "dim", `widen the terminal to ${MIN_COLUMNS} · q quits`, context.color)}\n`);
383
+ return;
384
+ }
385
+ const body = showHelp
386
+ ? helpOverlay(control, context)
387
+ : mode === "setup"
388
+ ? setupScreen(setupChoice, context.theme, context.color, context.columns, setupDetails)
389
+ : mode === "primer"
390
+ ? primerScreen(context.theme, context.color, context.columns, control.provider.label, spanLabel(control.schedule.key))
391
+ : mode === "settings"
392
+ ? renderSettings(control.config, settingsRows(control.config), settingsCursor, editing, custom, previewView(control), context.theme, context.color, tightPreview(control), context.columns)
393
+ : mode === "detail"
394
+ ? detailRows(control, context)
395
+ : planRows(control, context);
396
+ const footer = mode === "setup" && !showHelp
397
+ ? [paint(context.theme, "dim", keyHints(["← → choose", "enter confirm", "d what changes", "q quit"], context.columns), context.color)]
398
+ : mode === "primer"
399
+ ? [paint(context.theme, "dim", keyHints(["any key to start"], context.columns), context.color)]
400
+ : mode === "detail" && !showHelp
401
+ ? [
402
+ paint(context.theme, "dim", keyHints(["↑↓ project", "←→ target", "p priority", "f pin", "d done", "esc back", "q quit"], context.columns), context.color),
403
+ ]
404
+ : mode === "settings" && !showHelp
405
+ ? [
406
+ paint(context.theme, "dim", editing
407
+ ? keyHints(["type it", "enter keep", "esc cancel"], context.columns)
408
+ : keyHints(["↑↓ move", "space or enter toggles", "←→ change or reorder", "esc back"], context.columns), context.color),
409
+ ]
410
+ : footerFor(context, showHelp);
411
+ const title = mode === "primer" ? "how it works" : mode === "setup" ? "setup" : mode === "settings" ? "settings" : mode === "detail" ? "session" : "plan";
412
+ process.stdout.write(CLEAR + fullScreen(control, body, footer, title, context, (mode === "setup" || mode === "primer") && !showHelp));
413
+ };
414
+ const refresh = () => {
415
+ control = buildPlan(Date.now(), true, options.window, options.adapter);
416
+ settle();
417
+ draw();
418
+ };
419
+ const stop = () => {
420
+ clearInterval(timer);
421
+ process.stdout.off("resize", draw);
422
+ stdin.setRawMode(false);
423
+ stdin.pause();
424
+ process.stdout.write(SHOW + ALT_OFF);
425
+ };
426
+ settle();
427
+ const timer = setInterval(refresh, REFRESH_MS);
428
+ process.stdout.on("resize", draw);
429
+ draw();
430
+ await new Promise((resolve) => {
431
+ const finish = () => {
432
+ stop();
433
+ resolve();
434
+ };
435
+ const apply = (action) => {
436
+ if (action.kind === "quit") {
437
+ finish();
438
+ return false;
439
+ }
440
+ if (mode === "setup") {
441
+ if (action.kind === "share")
442
+ setupChoice = (setupChoice + (action.delta > 0 ? 1 : 2)) % 3;
443
+ else if (action.kind === "up" || action.kind === "down")
444
+ setupChoice = (setupChoice + 1) % 3;
445
+ else if (action.kind === "state" && action.state === "done")
446
+ setupDetails = !setupDetails;
447
+ else if (action.kind === "resume" || action.kind === "save") {
448
+ const stored = loadConfig();
449
+ stored.offeredInstallAt = Date.now();
450
+ saveConfig(stored);
451
+ if (setupChoice < 2) {
452
+ try {
453
+ runInstall({ dryRun: false, force: false, rules: false, quiet: true });
454
+ if (setupChoice === 1) {
455
+ const after = loadConfig();
456
+ after.hud = { ...after.hud, segments: [] };
457
+ saveConfig(after);
458
+ }
459
+ }
460
+ catch { }
461
+ }
462
+ mode = offerPrimer ? "primer" : "plan";
463
+ refresh();
464
+ }
465
+ else if (action.kind === "skip") {
466
+ const stored = loadConfig();
467
+ stored.offeredInstallAt = Date.now();
468
+ saveConfig(stored);
469
+ mode = offerPrimer ? "primer" : "plan";
470
+ refresh();
471
+ }
472
+ return true;
473
+ }
474
+ if (mode === "primer") {
475
+ const stored = loadConfig();
476
+ stored.primerSeenAt = Date.now();
477
+ saveConfig(stored);
478
+ mode = "plan";
479
+ refresh();
480
+ return true;
481
+ }
482
+ if (mode === "settings") {
483
+ const rows = settingsRows(control.config);
484
+ const selectable = selectableRows(rows);
485
+ if (!selectable.includes(settingsCursor))
486
+ settingsCursor = selectable[0] ?? 0;
487
+ const at = selectable.indexOf(settingsCursor);
488
+ const current = rows[settingsCursor];
489
+ if (action.kind === "up")
490
+ settingsCursor = selectable[Math.max(0, at - 1)] ?? settingsCursor;
491
+ else if (action.kind === "down")
492
+ settingsCursor = selectable[Math.min(selectable.length - 1, at + 1)] ?? settingsCursor;
493
+ else if (action.kind === "back" || action.kind === "skip") {
494
+ mode = "plan";
495
+ refresh();
496
+ }
497
+ else if (current) {
498
+ const names = [...new Set([...builtinThemes(), ...userThemes()])];
499
+ const activate = action.kind === "toggleCurrent" || action.kind === "save" || action.kind === "resume";
500
+ if (activate && current.kind === "advice") {
501
+ custom = control.config.customAdvice.default ?? "";
502
+ editing = true;
503
+ }
504
+ else if (activate) {
505
+ if (current.kind === "reset")
506
+ resetPreferences();
507
+ else if (current.kind === "column")
508
+ toggleColumn(current.id);
509
+ else if (current.kind === "segment")
510
+ toggleSegment(current.id);
511
+ else if (current.kind === "preserve")
512
+ togglePreserve(PRESERVE_KINDS[current.index] ?? "");
513
+ else if (current.kind === "theme")
514
+ cycleTheme(current.surface, 1, [...new Set([...builtinThemes(), ...userThemes()])]);
515
+ else if (current.kind === "policy")
516
+ cyclePolicy(1);
517
+ else if (current.kind === "preset")
518
+ cyclePreset(1, Object.keys(HUD_PRESETS));
519
+ }
520
+ else if (action.kind === "share") {
521
+ const delta = action.delta > 0 ? 1 : -1;
522
+ if (current.kind === "theme")
523
+ cycleTheme(current.surface, delta, names);
524
+ else if (current.kind === "policy")
525
+ cyclePolicy(delta);
526
+ else if (current.kind === "preset")
527
+ cyclePreset(delta, Object.keys(HUD_PRESETS));
528
+ else if (current.kind === "segment")
529
+ moveSegment(current.id, delta);
530
+ }
531
+ control = buildPlan(Date.now(), false, options.window, options.adapter);
532
+ }
533
+ return true;
534
+ }
535
+ const view = rows()[selected];
536
+ switch (action.kind) {
537
+ case "help":
538
+ showHelp = !showHelp;
539
+ break;
540
+ case "expand":
541
+ expanded = !expanded;
542
+ settle();
543
+ break;
544
+ case "resume":
545
+ mode = mode === "detail" ? "plan" : "detail";
546
+ break;
547
+ case "back":
548
+ if (mode === "detail")
549
+ mode = "plan";
550
+ else if (showHelp)
551
+ showHelp = false;
552
+ break;
553
+ case "preferences":
554
+ settingsCursor = 0;
555
+ editing = false;
556
+ showHelp = false;
557
+ mode = "settings";
558
+ break;
559
+ case "up":
560
+ selected = Math.max(0, selected - 1);
561
+ selectedId = rows()[selected]?.project ?? null;
562
+ break;
563
+ case "down":
564
+ selected = Math.min(Math.max(0, rows().length - 1), selected + 1);
565
+ selectedId = rows()[selected]?.project ?? null;
566
+ break;
567
+ case "share":
568
+ if (view) {
569
+ setShare(view.project, nextShare(view, action.delta), control.provider.id);
570
+ refresh();
571
+ }
572
+ break;
573
+ case "unpin":
574
+ if (view) {
575
+ setShare(view.project, null, control.provider.id);
576
+ refresh();
577
+ }
578
+ break;
579
+ case "priority":
580
+ if (view) {
581
+ setPriority(view.project, cyclePriority(view.settings.priority), control.provider.id);
582
+ refresh();
583
+ }
584
+ break;
585
+ case "equalize":
586
+ equalize(control.provider.id);
587
+ refresh();
588
+ break;
589
+ case "state":
590
+ if (view) {
591
+ setState(view.project, action.state, control.provider.id);
592
+ refresh();
593
+ }
594
+ break;
595
+ case "pin":
596
+ if (view) {
597
+ setPinned(view.project, !view.settings.pinned, control.provider.id);
598
+ refresh();
599
+ }
600
+ break;
601
+ case "park":
602
+ if (view) {
603
+ demote(view, control.provider.id);
604
+ refresh();
605
+ }
606
+ break;
607
+ case "add":
608
+ case "toggleMember":
609
+ if (view) {
610
+ promote(view, control.provider.id);
611
+ selectedId = view.project;
612
+ refresh();
613
+ }
614
+ break;
615
+ case "refresh":
616
+ refresh();
617
+ break;
618
+ default:
619
+ break;
620
+ }
621
+ return true;
622
+ };
623
+ stdin.on("data", (chunk) => {
624
+ if (editing) {
625
+ for (const key of splitKeys(String(chunk))) {
626
+ if (key === "\r" || key === "\n" || key === "\u001b") {
627
+ if (key !== "\u001b") {
628
+ saveCustomAdvice("default", custom);
629
+ control = buildPlan(Date.now(), false, options.window, options.adapter);
630
+ }
631
+ editing = false;
632
+ }
633
+ else if (key === "\u007f" || key === "\b") {
634
+ custom = custom.slice(0, -1);
635
+ }
636
+ else if (key === "\u0003") {
637
+ finish();
638
+ return;
639
+ }
640
+ else if (key.length === 1 && key >= " ") {
641
+ custom = (custom + key).slice(0, MAX_CUSTOM);
642
+ }
643
+ }
644
+ draw();
645
+ return;
646
+ }
647
+ for (const action of keyActions(String(chunk), mode === "settings" ? "prefs" : "plan", STEP)) {
648
+ if (!apply(action))
649
+ return;
650
+ }
651
+ draw();
652
+ });
653
+ });
654
+ }