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,237 @@
1
+ import { COLUMNS, HUD_PRESETS, HUD_PRESET_ABOUT, POLICIES as ALL_POLICIES, presetMatching, DEFAULT_COLUMNS, DEFAULT_HUD_SEGMENTS, HUD_SEGMENTS, builtinThemes, loadTheme, paint, paintHead, policyNames, renderSegments, stageText, userThemes, } from "../runtime/kernel.mjs";
2
+ import { clip, padEndVisible, padStartVisible } from "../util/ansi.js";
3
+ export const PRESERVE_KINDS = ["implementation", "tests", "end-to-end checks", "documentation", "exploration"];
4
+ const COLUMN_ABOUT = {
5
+ allocation: "the share of the window you want this project to get",
6
+ used: "how much of that allocation it has spent",
7
+ share: "its part of the tokens measured on disk",
8
+ tokens: "tokens it burned in this window",
9
+ priority: "who gets spare capacity first",
10
+ "last prompt": "the last thing you typed there",
11
+ };
12
+ const SEGMENT_ABOUT = {
13
+ tag: "SMT, to mark our half of a wrapped status line",
14
+ project: "the project this session belongs to",
15
+ target: "its allocation, as a percentage of the window",
16
+ used: "how much of the window this session has spent",
17
+ share: "its part of the tokens measured on disk",
18
+ pair: "used and allocation together, as 21%/50%",
19
+ bar: "how far through its allocation it is",
20
+ priority: "HIGH, NORMAL or LOW",
21
+ "5h": "the 5-hour window Anthropic publishes",
22
+ "7d": "the weekly window",
23
+ spend: "a gateway spend limit, when you have one",
24
+ reset: "how long until the 5-hour window resets",
25
+ meter5h: "the 5-hour window drawn as a bar",
26
+ spark: "the shape of the window so far",
27
+ pace: "how far ahead or behind the clock you are",
28
+ empty: "when you run dry at this rate",
29
+ };
30
+ export function settingsRows(config) {
31
+ const rows = [];
32
+ rows.push({ kind: "header", label: "COLUMNS", hint: "space toggles" });
33
+ for (const id of COLUMNS)
34
+ rows.push({ kind: "column", id });
35
+ rows.push({ kind: "blank" });
36
+ rows.push({ kind: "header", label: "THEME", hint: "← → changes it" });
37
+ rows.push({ kind: "theme", surface: "tui" });
38
+ rows.push({ kind: "theme", surface: "hud" });
39
+ rows.push({ kind: "blank" });
40
+ rows.push({ kind: "header", label: "STATUS LINE", hint: "← → picks a shape · space adds or removes a piece" });
41
+ rows.push({ kind: "preset" });
42
+ rows.push({ kind: "preview" });
43
+ const chosen = config.hud?.segments ?? DEFAULT_HUD_SEGMENTS;
44
+ const rest = HUD_SEGMENTS.filter((id) => !chosen.includes(id));
45
+ for (const id of [...chosen, ...rest])
46
+ rows.push({ kind: "segment", id });
47
+ rows.push({ kind: "blank" });
48
+ rows.push({ kind: "header", label: "WHEN IT GETS TIGHT", hint: "← → changes it" });
49
+ rows.push({ kind: "policy" });
50
+ rows.push({ kind: "pressure" });
51
+ const policy = ALL_POLICIES[config.policy] ?? ALL_POLICIES.finish;
52
+ for (const stage of policy?.stages ?? [])
53
+ rows.push({ kind: "stage", at: stage.at });
54
+ rows.push({ kind: "blank" });
55
+ for (let index = 0; index < PRESERVE_KINDS.length; index++)
56
+ rows.push({ kind: "preserve", index });
57
+ rows.push({ kind: "advice" });
58
+ rows.push({ kind: "blank" });
59
+ rows.push({ kind: "reset" });
60
+ return rows;
61
+ }
62
+ export function withToggled(list, id) {
63
+ return list.includes(id) ? list.filter((value) => value !== id) : [...list, id];
64
+ }
65
+ export function withMoved(list, id, delta) {
66
+ const at = list.indexOf(id);
67
+ if (at === -1)
68
+ return list;
69
+ const to = Math.max(0, Math.min(list.length - 1, at + delta));
70
+ if (to === at)
71
+ return list;
72
+ const next = [...list];
73
+ const [item] = next.splice(at, 1);
74
+ if (item)
75
+ next.splice(to, 0, item);
76
+ return next;
77
+ }
78
+ export function selectableRows(rows) {
79
+ const out = [];
80
+ for (const [index, row] of rows.entries()) {
81
+ if (!["header", "blank", "preview", "pressure"].includes(row.kind))
82
+ out.push(index);
83
+ }
84
+ return out;
85
+ }
86
+ function wrap(text, width) {
87
+ const out = [];
88
+ let line = "";
89
+ for (const word of text.split(" ")) {
90
+ if (line.length === 0)
91
+ line = word;
92
+ else if (line.length + 1 + word.length <= width)
93
+ line += ` ${word}`;
94
+ else {
95
+ out.push(line);
96
+ line = word;
97
+ }
98
+ }
99
+ if (line)
100
+ out.push(line);
101
+ return out;
102
+ }
103
+ function themeNames() {
104
+ return [...new Set([...builtinThemes(), ...userThemes()])];
105
+ }
106
+ const INDENT = 4;
107
+ function clockAt(ms) {
108
+ const at = new Date(ms);
109
+ return `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
110
+ }
111
+ function whenStage(at, tight) {
112
+ if (!(tight.target > 0))
113
+ return "no allocation";
114
+ if (tight.pressure >= at / 100)
115
+ return "now";
116
+ if (!tight.ratePerHour || tight.ratePerHour <= 0.05)
117
+ return "not burning";
118
+ const needed = (at / 100) * tight.target * 100 - tight.usedPoints;
119
+ const hours = needed / tight.ratePerHour;
120
+ if (!Number.isFinite(hours) || hours < 0)
121
+ return "now";
122
+ if (hours > 12)
123
+ return "not today";
124
+ return `~${clockAt(tight.now + hours * 3_600_000)}`;
125
+ }
126
+ export function renderSettings(config, rows, cursor, editing, draft, preview, theme, color, tight, width = 100) {
127
+ const columns = config.columns ?? DEFAULT_COLUMNS;
128
+ const segments = config.hud?.segments ?? DEFAULT_HUD_SEGMENTS;
129
+ const preserve = config.preserveFor.default ?? [];
130
+ const out = [];
131
+ for (const [index, row] of rows.entries()) {
132
+ const here = index === cursor;
133
+ const mark = here ? paint(theme, "accent", theme.tui?.cursor ?? "❯", color) : " ";
134
+ if (row.kind === "blank") {
135
+ out.push("");
136
+ continue;
137
+ }
138
+ if (row.kind === "header") {
139
+ out.push(` ${paintHead(theme, row.label, color)} ${paint(theme, "dim", clip(row.hint ?? "", Math.max(0, width - row.label.length - 6)), color)}`);
140
+ continue;
141
+ }
142
+ if (row.kind === "preset") {
143
+ const names = Object.keys(HUD_PRESETS);
144
+ const current = presetMatching(segments);
145
+ const at = current ? names.indexOf(current) : -1;
146
+ const label = current ?? "custom";
147
+ const about = clip(current ? HUD_PRESET_ABOUT[current] ?? "" : "your own arrangement", Math.max(0, width - 42));
148
+ out.push(` ${mark} ${padEndVisible("shape", 15)} ${paint(theme, "dim", "‹", color)} ${paint(theme, "accent", padEndVisible(label, 11), color)} ${paint(theme, "dim", "›", color)} ${paint(theme, "dim", at >= 0 ? `${at + 1}/${names.length}` : " ", color)} ${paint(theme, "dim", about, color)}`);
149
+ continue;
150
+ }
151
+ if (row.kind === "preview") {
152
+ out.push(`${" ".repeat(INDENT)}${renderSegments(segments, preview, loadTheme(config.theme.hud), color)}`);
153
+ out.push("");
154
+ continue;
155
+ }
156
+ if (row.kind === "column") {
157
+ const on = columns.includes(row.id);
158
+ const about = clip(COLUMN_ABOUT[row.id] ?? "", Math.max(0, width - 24));
159
+ out.push(` ${mark} ${on ? paint(theme, "ok", "◉", color) : paint(theme, "dim", "○", color)} ${padEndVisible(row.id, 12)} ${paint(theme, "dim", about, color)}`);
160
+ continue;
161
+ }
162
+ if (row.kind === "segment") {
163
+ const at = segments.indexOf(row.id);
164
+ const on = at !== -1;
165
+ const order = on ? paint(theme, "dim", String(at + 1).padStart(2), color) : " ";
166
+ const name = padEndVisible(row.id, 9);
167
+ const about = clip(SEGMENT_ABOUT[row.id] ?? "", Math.max(0, width - 24));
168
+ out.push(` ${mark} ${on ? paint(theme, "ok", "◉", color) : paint(theme, "dim", "○", color)} ${order} ${on ? name : paint(theme, "dim", name, color)} ${paint(theme, "dim", about, color)}`);
169
+ continue;
170
+ }
171
+ if (row.kind === "theme") {
172
+ const names = themeNames();
173
+ const current = config.theme[row.surface];
174
+ const at = Math.max(0, names.indexOf(current));
175
+ const shown = loadTheme(current);
176
+ const name = `${paint(theme, "dim", "‹", color)} ${paint(theme, "accent", padEndVisible(current, 11), color)} ${paint(theme, "dim", "›", color)}`;
177
+ const position = paint(theme, "dim", `${at + 1}/${names.length}`, color);
178
+ const sample = row.surface === "tui"
179
+ ? `${paint(shown, "accent", shown.tui.cursor ?? "", color)} ${paint(shown, "ok", shown.tui.active ?? "", color)} ${paint(shown, "dim", shown.tui.done ?? "", color)} ${paint(shown, "ok", (shown.tui.fill ?? "|").repeat(4), color)}${paint(shown, "track", (shown.tui.empty ?? ".").repeat(4), color)}`
180
+ : renderSegments(["project", "pair", "5h"], preview, shown, color);
181
+ out.push(` ${mark} ${padEndVisible(row.surface === "tui" ? "control centre" : "status line", 15)} ${name} ${position} ${sample}`);
182
+ continue;
183
+ }
184
+ if (row.kind === "pressure") {
185
+ if (!tight)
186
+ continue;
187
+ const rate = tight.ratePerHour && tight.ratePerHour > 0.05 ? `, burning ${tight.ratePerHour.toFixed(1)}% of it an hour` : ", nothing burning";
188
+ const note = `${tight.label} is at ${Math.round(tight.pressure * 100)}% of its allocation${rate}`;
189
+ out.push(`${" ".repeat(INDENT)}${paint(theme, "dim", clip(note, Math.max(0, width - INDENT)), color)}`);
190
+ out.push("");
191
+ continue;
192
+ }
193
+ if (row.kind === "stage") {
194
+ const policy = ALL_POLICIES[config.policy] ?? ALL_POLICIES.finish;
195
+ const stage = (policy?.stages ?? []).find((entry) => entry.at === row.at);
196
+ const passed = tight ? tight.pressure >= row.at / 100 : false;
197
+ const dot = passed ? paint(theme, "warn", "●", color) : paint(theme, "dim", "○", color);
198
+ const when = tight ? whenStage(row.at, tight) : "";
199
+ const head = `${dot} ${padStartVisible(`${row.at}%`, 4)} ${paint(theme, passed ? "warn" : "dim", padEndVisible(when, 10), color)} ${paint(theme, here ? "accent" : "fg", (stage?.actions ?? []).join(" + "), color)}`;
200
+ out.push(` ${mark} ${head}`);
201
+ if (here && tight) {
202
+ const text = stageText(row.at, {
203
+ target: tight.target,
204
+ observed: 0,
205
+ pressure: tight.pressure,
206
+ basis: "budget",
207
+ preserve: tight.preserve,
208
+ policy,
209
+ custom: tight.custom,
210
+ });
211
+ for (const line of wrap(text, Math.max(30, width - INDENT - 8)))
212
+ out.push(`${" ".repeat(INDENT + 6)}${paint(theme, "dim", line, color)}`);
213
+ }
214
+ continue;
215
+ }
216
+ if (row.kind === "reset") {
217
+ out.push(` ${mark} ${paint(theme, "warn", "reset everything to defaults", color)} ${paint(theme, "dim", "columns, themes, status line, policy. Your allocations stay.", color)}`);
218
+ continue;
219
+ }
220
+ if (row.kind === "policy") {
221
+ const rendered = policyNames()
222
+ .map((name) => (name === config.policy ? paint(theme, "accent", `[${name}]`, color) : paint(theme, "dim", name, color)))
223
+ .join(" ");
224
+ out.push(` ${mark} ${padEndVisible("policy", 15)} ${rendered}`);
225
+ continue;
226
+ }
227
+ if (row.kind === "preserve") {
228
+ const kind = PRESERVE_KINDS[row.index] ?? "";
229
+ const on = preserve.includes(kind);
230
+ out.push(` ${mark} ${on ? paint(theme, "ok", "◉", color) : paint(theme, "dim", "○", color)} ${paint(theme, on ? "fg" : "dim", `preserve ${kind}`, color)}`);
231
+ continue;
232
+ }
233
+ const text = editing && here ? `${draft}${paint(theme, "accent", "▏", color)}` : config.customAdvice.default || paint(theme, "dim", "nothing yet, enter writes one", color);
234
+ out.push(` ${mark} ${padEndVisible("your own line", 15)} ${text}`);
235
+ }
236
+ return out;
237
+ }
@@ -0,0 +1,418 @@
1
+ import { visibleRows, workingSet } from "../scheduler/plan.js";
2
+ import { formatCountdown, formatReset, loadMeter, meterBar, paint, paintHead, pressureRole, } from "../runtime/kernel.mjs";
3
+ import { clip, padEndVisible, padStartVisible, visibleWidth } from "../util/ansi.js";
4
+ import { ago, compactNumber } from "../util/fmt.js";
5
+ import { emptyBar, heatStrip, miniSpark, percentLabel, smallBar } from "./graphs.js";
6
+ function barCellsFor(columns) {
7
+ if (columns >= 130)
8
+ return 18;
9
+ if (columns >= 110)
10
+ return 15;
11
+ if (columns >= 95)
12
+ return 12;
13
+ if (columns >= 80)
14
+ return 9;
15
+ return 6;
16
+ }
17
+ const UNATTRIBUTED_FLOOR = 5;
18
+ export function labelsFor(views) {
19
+ const labels = new Map();
20
+ for (const view of views)
21
+ labels.set(view.project, view.label);
22
+ return labels;
23
+ }
24
+ function capacityRow(control, context) {
25
+ const { theme, color } = context;
26
+ const now = control.schedule.now;
27
+ const published = control.resources.filter((resource) => resource.usedPercent !== null || resource.rolledOver);
28
+ if (published.length === 0) {
29
+ const installed = control.installed;
30
+ const head = installed ? "waiting for the first reading" : "not installed";
31
+ const tails = installed
32
+ ? ["· it arrives the next time a Claude session draws its status line", "· wait for a Claude session to draw it", ""]
33
+ : ["· nothing is live until you run: npx savemytokens install", "· run: npx savemytokens install", ""];
34
+ for (const tail of tails) {
35
+ const line = ` ${paint(theme, "warn", head, color)}${tail ? ` ${paint(theme, "dim", tail, color)}` : ""}`;
36
+ if (visibleWidth(line) <= context.columns)
37
+ return [line];
38
+ }
39
+ return [` ${paint(theme, "warn", clip(head, context.columns - 2), color)}`];
40
+ }
41
+ const levels = [
42
+ { bar: 12, reset: "clock", gap: 4 },
43
+ { bar: 12, reset: "long", gap: 4 },
44
+ { bar: 10, reset: "short", gap: 3 },
45
+ { bar: 6, reset: "short", gap: 2 },
46
+ { bar: 0, reset: "none", gap: 2 },
47
+ ];
48
+ const build = (level) => {
49
+ const parts = published.map((resource) => {
50
+ const used = resource.usedPercent ?? 0;
51
+ const key = resource.id.split(":")[1] ?? "";
52
+ const name = key === "five_hour" ? "5h" : key === "seven_day" ? "7d" : "spend";
53
+ const fresh = resource.usedPercent === null;
54
+ const countdown = !fresh && resource.window.resetsAt ? formatCountdown(resource.window.resetsAt, now) : "";
55
+ const reset = !countdown || level.reset === "none"
56
+ ? ""
57
+ : level.reset === "clock"
58
+ ? ` ${paint(theme, "dim", `resets in ${countdown} (${formatReset(resource.window.resetsAt ?? 0, now)})`, color)}`
59
+ : level.reset === "long"
60
+ ? ` ${paint(theme, "dim", `resets in ${countdown}`, color)}`
61
+ : ` ${paint(theme, "dim", countdown, color)}`;
62
+ const bar = level.bar > 0 ? ` ${meterBar(theme, used / 100, level.bar, pressureRole(used / 100), color)}` : "";
63
+ const figure = fresh
64
+ ? paint(theme, "dim", percentLabel(0), color)
65
+ : paint(theme, pressureRole(used / 100), percentLabel(used), color);
66
+ const note = fresh ? ` ${paint(theme, "dim", "window just reset", color)}` : reset;
67
+ return `${paint(theme, "dim", name, color)}${bar} ${figure}${note}`;
68
+ });
69
+ return ` ${parts.join(" ".repeat(level.gap))}`;
70
+ };
71
+ for (const level of levels) {
72
+ const line = build(level);
73
+ if (visibleWidth(line) <= context.columns)
74
+ return [line];
75
+ }
76
+ return [clip(build(levels[levels.length - 1]), context.columns)];
77
+ }
78
+ const COLUMN_WIDTH = {
79
+ allocation: 10,
80
+ used: 15,
81
+ share: 6,
82
+ tokens: 7,
83
+ priority: 8,
84
+ };
85
+ const DROP_ORDER = ["tokens", "share", "priority", "allocation"];
86
+ const MIN_LABEL = 8;
87
+ function columnWidths(context, wanted) {
88
+ const bar = barCellsFor(context.columns);
89
+ const usedWidth = bar + 8;
90
+ const spanOf = (list) => {
91
+ let total = 3;
92
+ for (const name of list) {
93
+ const width = name === "used" ? usedWidth : COLUMN_WIDTH[name];
94
+ if (width)
95
+ total += width + 1;
96
+ }
97
+ return total;
98
+ };
99
+ let columns = [...wanted];
100
+ for (const drop of DROP_ORDER) {
101
+ if (spanOf(columns) + MIN_LABEL <= context.columns)
102
+ break;
103
+ columns = columns.filter((name) => name !== drop);
104
+ }
105
+ const fixed = spanOf(columns);
106
+ const ideal = Math.min(26, Math.max(14, ...[...context.labels.values()].map((value) => value.length + 2), 14));
107
+ const room = Math.max(MIN_LABEL, context.columns - fixed);
108
+ const wantsPrompt = columns.includes("last prompt");
109
+ const label = Math.max(MIN_LABEL, Math.min(ideal, wantsPrompt ? Math.max(MIN_LABEL, room - 15) : room));
110
+ const spare = room - label - 1;
111
+ const prompt = wantsPrompt && spare >= 14 ? spare : 0;
112
+ return { label, prompt, bar, used: usedWidth, columns };
113
+ }
114
+ function headerRow(context, widths, columns) {
115
+ const { theme, color } = context;
116
+ const cells = [` ${padEndVisible(clip("PROJECT", widths.label - 1), widths.label - 1)}`];
117
+ if (columns.includes("allocation"))
118
+ cells.push(padStartVisible("ALLOCATION", 10));
119
+ if (columns.includes("used"))
120
+ cells.push(padEndVisible("USED OF IT", widths.used));
121
+ if (columns.includes("share"))
122
+ cells.push(padStartVisible("SHARE", 6));
123
+ if (columns.includes("tokens"))
124
+ cells.push(padStartVisible("TOKENS", 7));
125
+ if (columns.includes("priority"))
126
+ cells.push(padEndVisible("PRIORITY", 8));
127
+ if (columns.includes("last prompt") && widths.prompt > 0)
128
+ cells.push(clip("LAST PROMPT", widths.prompt));
129
+ return paintHead(theme, cells.join(" "), color);
130
+ }
131
+ function row(view, index, context, widths, columns) {
132
+ const { theme, color } = context;
133
+ const role = pressureRole(view.pressure.value);
134
+ const cursor = context.interactive && context.selected === index ? paint(theme, "accent", theme.tui?.cursor ?? "❯", color) : " ";
135
+ const pin = view.settings.pinned ? paint(theme, "accent", theme.tui?.pin ?? "★", color) : " ";
136
+ const open = view.bucket === "active";
137
+ const tone = (want) => (open ? want : "dim");
138
+ const sessions = view.liveSessions > 1 ? paint(theme, "dim", `${view.liveSessions}`, color) : " ";
139
+ const label = padEndVisible(paint(theme, tone("fg"), clip(view.label, widths.label - 1), color), widths.label - 1);
140
+ const held = view.allocation.target > 0 ? view.allocation.target : (view.settings.share ?? 0);
141
+ const asked = view.settings.share;
142
+ const squeezed = asked != null && asked - held > 0.005;
143
+ const allocationCell = padStartVisible(held > 0
144
+ ? paint(theme, squeezed ? tone("warn") : tone("fg"), percentLabel(held * 100, 5), color)
145
+ : paint(theme, "dim", view.settings.share === 0 ? "none" : "-", color), 10);
146
+ const starved = view.allocation.target <= 0;
147
+ const used = padEndVisible(starved
148
+ ? `${emptyBar(widths.bar, theme, color)} ${padStartVisible(paint(theme, "dim", open ? "-" : "idle", color), 4)}`
149
+ : `${smallBar(view.pressure.value, widths.bar, theme, color, tone(role))} ${padStartVisible(paint(theme, tone(role), percentLabel(view.pressure.value * 100, 4), color), 4)}`, widths.used);
150
+ const share = padStartVisible(paint(theme, "dim", percentLabel(view.observed * 100, 5), color), 6);
151
+ const tokens = padStartVisible(paint(theme, "dim", compactNumber(view.usage.tokens), color), 7);
152
+ const priority = padEndVisible(paint(theme, view.settings.priority === "high" ? tone("accent") : "dim", view.settings.priority.toUpperCase(), color), 8);
153
+ const cells = [`${cursor}${pin}${sessions} ${label}`];
154
+ if (columns.includes("allocation"))
155
+ cells.push(allocationCell);
156
+ if (columns.includes("used"))
157
+ cells.push(used);
158
+ if (columns.includes("share"))
159
+ cells.push(share);
160
+ if (columns.includes("tokens"))
161
+ cells.push(tokens);
162
+ if (columns.includes("priority"))
163
+ cells.push(priority);
164
+ if (columns.includes("last prompt") && widths.prompt > 0) {
165
+ cells.push(paint(theme, "dim", clip(view.prompt || "-", widths.prompt), color));
166
+ }
167
+ return cells.join(" ");
168
+ }
169
+ function promptColumn(widths, columns) {
170
+ let at = 4 + (widths.label - 1);
171
+ for (const name of columns) {
172
+ if (name === "last prompt")
173
+ continue;
174
+ const width = name === "used" ? widths.used : COLUMN_WIDTH[name];
175
+ if (width)
176
+ at += width + 1;
177
+ }
178
+ return at + 1;
179
+ }
180
+ function idleHeaderRow(context, widths, columns) {
181
+ const { theme, color } = context;
182
+ const head = ` ${padEndVisible("PROJECT", widths.label - 1)} ${padStartVisible("LAST TURN", 10)}`;
183
+ const gap = Math.min(8, Math.max(2, promptColumn(widths, columns) - visibleWidth(head)));
184
+ return paintHead(theme, `${head}${" ".repeat(gap)}LAST PROMPT`, color);
185
+ }
186
+ function idleRow(view, index, context, widths, now, columns) {
187
+ const { theme, color } = context;
188
+ const cursor = context.interactive && context.selected === index ? paint(theme, "accent", theme.tui?.cursor ?? "❯", color) : " ";
189
+ const pin = view.settings.pinned ? paint(theme, "accent", theme.tui?.pin ?? "★", color) : " ";
190
+ const label = padEndVisible(clip(view.label, widths.label - 1), widths.label - 1);
191
+ const when = padStartVisible(ago(view.lastSeen, now), 10);
192
+ const reserved = view.settings.share != null && view.settings.share > 0 ? `${percentLabel(view.settings.share * 100, 4)} held` : "";
193
+ const tag = view.settings.parked ? "parked" : reserved;
194
+ const head = `${label} ${when}`;
195
+ const gap = Math.min(8, Math.max(2, promptColumn(widths, columns) - 4 - visibleWidth(head)));
196
+ const room = Math.max(10, context.columns - 4 - visibleWidth(head) - gap - (tag ? tag.length + 2 : 0));
197
+ const prompt = padEndVisible(clip(view.prompt || "-", room), tag ? room : 0);
198
+ return `${cursor}${pin} ${paint(theme, "dim", `${head}${" ".repeat(gap)}${prompt}${tag ? ` ${tag}` : ""}`, color)}`;
199
+ }
200
+ function footerNote(control, context) {
201
+ const { theme, color } = context;
202
+ const set = workingSet(control.schedule, context.expanded);
203
+ const open = set.members.filter((view) => view.bucket === "active");
204
+ const sessions = open.reduce((sum, view) => sum + view.liveSessions, 0);
205
+ const spare = Math.round(control.schedule.unusedPool * 100);
206
+ const room = Math.max(10, context.columns - 2);
207
+ const waiting = set.members.length - open.length;
208
+ const long = `${open.length} of ${set.members.length} open, across ${sessions} ${sessions === 1 ? "session" : "sessions"}${waiting > 0 ? `, ${waiting} waiting` : ""}${spare > 0 ? `, ${spare}% unclaimed` : ""}.`;
209
+ const short = `${open.length}/${set.members.length} open · ${sessions}s${spare > 0 ? ` · ${spare}% spare` : ""}`;
210
+ const out = [` ${paint(theme, "dim", long.length <= room ? long : short, color)}`];
211
+ const drift = control.unattributed ?? 0;
212
+ if (drift >= UNATTRIBUTED_FLOOR) {
213
+ const head = `${Math.round(drift)}% of the window was spent outside these projects`;
214
+ const tail = "from claude.ai, another machine, or usage SaveMyTokens was not running for";
215
+ const fits = head.length + 1 + tail.length <= room;
216
+ out.push(` ${paint(theme, "warn", clip(head, room), color)}${fits ? ` ${paint(theme, "dim", tail, color)}` : ""}`);
217
+ }
218
+ return out;
219
+ }
220
+ function sectionTitle(name, count, context) {
221
+ const { theme, color } = context;
222
+ return ` ${paintHead(theme, name, color)} ${paint(theme, "dim", String(count), color)}`;
223
+ }
224
+ export function planRows(control, context) {
225
+ const set = workingSet(control.schedule, context.expanded);
226
+ const columns = control.config.columns ?? [];
227
+ const widths = columnWidths(context, columns);
228
+ const shown = widths.columns;
229
+ const now = control.schedule.now;
230
+ const out = [...capacityRow(control, context)];
231
+ let index = 0;
232
+ let printed = 0;
233
+ const budget = context.expanded ? Number.MAX_SAFE_INTEGER : Math.max(6, context.rows - 15);
234
+ out.push("");
235
+ if (set.members.length === 0) {
236
+ out.push(` ${paint(context.theme, "dim", "Nothing sharing the window yet. Open Claude Code in a project, or press a on one below.", context.color)}`);
237
+ }
238
+ else {
239
+ out.push(sectionTitle("ACTIVE", set.members.length, context));
240
+ out.push(headerRow(context, widths, shown));
241
+ for (const view of set.members) {
242
+ if (printed < budget)
243
+ out.push(row(view, index, context, widths, shown));
244
+ index += 1;
245
+ printed += 1;
246
+ }
247
+ }
248
+ if (set.candidates.length > 0) {
249
+ out.push("");
250
+ out.push(sectionTitle("RECENT", set.candidates.length, context));
251
+ out.push(idleHeaderRow(context, widths, shown));
252
+ for (const view of set.candidates) {
253
+ if (printed < budget)
254
+ out.push(idleRow(view, index, context, widths, now, shown));
255
+ index += 1;
256
+ printed += 1;
257
+ }
258
+ }
259
+ const total = set.members.length + set.candidates.length + set.hidden;
260
+ if (total > printed) {
261
+ out.push(` ${paint(context.theme, "dim", `+${total - printed} more`, context.color)}`);
262
+ }
263
+ out.push("");
264
+ out.push(...footerNote(control, context));
265
+ return out;
266
+ }
267
+ export function detailRows(control, context) {
268
+ const { theme, color } = context;
269
+ const rows = visibleRows(control.schedule, context.expanded);
270
+ const view = rows[Math.max(0, Math.min(context.selected, rows.length - 1))];
271
+ if (!view)
272
+ return [` ${paint(theme, "dim", "nothing to show", color)}`];
273
+ const width = Math.min(46, Math.max(20, context.columns - 26));
274
+ const strip = Math.min(66, Math.max(20, context.columns - 10));
275
+ const from = control.schedule.bounds.from;
276
+ const to = control.schedule.bounds.to;
277
+ const live = view.bucket === "active";
278
+ const buckets = view.sessions.flatMap((session) => loadMeter(control.provider.id, session.claimant.id).buckets);
279
+ const at = Math.max(0, Math.min(context.selected, rows.length - 1));
280
+ const walk = rows.length > 1 ? ` ${at + 1}/${rows.length} · ↑↓ moves to the next project` : "";
281
+ const out = [
282
+ ` ${paint(theme, "accent", view.label, color)} ${paint(theme, "dim", `· ${view.bucket}${view.settings.pinned ? " · pinned" : ""}${view.settings.parked ? " · parked" : ""} · ${view.settings.priority}${walk}`, color)}`,
283
+ ` ${paint(theme, "dim", clip(view.project, context.columns - 4), color)}`,
284
+ "",
285
+ ];
286
+ if (live) {
287
+ out.push(` ${paint(theme, "dim", "allocation", color)} ${meterBar(theme, view.allocation.target, width, "accent", color)} ${percentLabel(view.allocation.target * 100)}`);
288
+ out.push(` ${paint(theme, "dim", "used of it", color)} ${smallBar(view.pressure.value, width, theme, color, pressureRole(view.pressure.value))} ${percentLabel(view.pressure.value * 100)}`);
289
+ }
290
+ else {
291
+ out.push(` ${paint(theme, "dim", `nothing running. Last turn ${ago(view.lastSeen, control.schedule.now)}, holding no allocation`, color)}`);
292
+ }
293
+ out.push("");
294
+ out.push(` ${paint(theme, "dim", "of the window", color)} ${percentLabel(view.attributedPercent ?? 0)} ${paint(theme, "dim", `· ${percentLabel(view.observed * 100)} of measured tokens · ${compactNumber(view.usage.tokens)} tokens · ${view.usage.requests} requests`, color)}`);
295
+ out.push("");
296
+ out.push(` ${paint(theme, "dim", "when it burned", color)}`);
297
+ out.push(` ${heatStrip(buckets, from, to, strip, theme, color)}`);
298
+ out.push(` ${paint(theme, "accent", miniSpark(buckets, from, to, strip), color)}`);
299
+ out.push(` ${paint(theme, "dim", `${new Date(from).toTimeString().slice(0, 5)}${" ".repeat(Math.max(1, strip - 11))}${new Date(to).toTimeString().slice(0, 5)}`, color)}`);
300
+ out.push("");
301
+ out.push(` ${paint(theme, "accent", `SESSIONS ${view.sessions.length}`, color)}`);
302
+ for (const session of view.sessions.slice(0, 8)) {
303
+ const alive = session.bucket === "active";
304
+ const mark = paint(theme, alive ? "ok" : "dim", alive ? "•" : "·", color);
305
+ const when = alive
306
+ ? paint(theme, "dim", `${percentLabel(session.allocation.target * 100)} of the window`, color)
307
+ : paint(theme, "dim", ago(session.claimant.lastSeen, control.schedule.now), color);
308
+ out.push(` ${mark} ${padEndVisible(session.claimant.id.slice(0, 8), 9)} ${padEndVisible(when, 18)} ${paint(theme, "dim", clip(session.claimant.prompt || "-", context.columns - 36), color)}`);
309
+ if (!alive) {
310
+ out.push(` ${paint(theme, "dim", clip(`claude --resume ${session.claimant.id}`, context.columns - 8), color)}`);
311
+ }
312
+ }
313
+ const deferred = control.deferred.find((group) => group.project === view.project);
314
+ if (deferred && deferred.items.length > 0) {
315
+ out.push("");
316
+ out.push(` ${paint(theme, "warn", "deferred here", color)}`);
317
+ for (const item of deferred.items.slice(-4))
318
+ out.push(` ${paint(theme, "dim", clip(item.text, context.columns - 8), color)}`);
319
+ }
320
+ return out;
321
+ }
322
+ const HELP_KEYS = [
323
+ ["↑ ↓", "select a project"],
324
+ ["⏎", "open it and see its sessions, esc comes back"],
325
+ ["← →", "move its allocation by 5 points"],
326
+ ["u", "unset this target: back to an even split with the rest"],
327
+ ["e", "unset every target at once"],
328
+ ["p", "priority: high → normal → low, who gets spare capacity first"],
329
+ ["space", "move it up: hidden to RECENT, RECENT to ACTIVE"],
330
+ ["x", "move it down: ACTIVE to RECENT, RECENT to hidden"],
331
+ ["f", "pin the row to the top of the list"],
332
+ ["d", "mark it done: hands its unspent share to the others now"],
333
+ ["b n", "mark it blocked, or needs-more"],
334
+ ["m", "show every project, not just the first screenful"],
335
+ ["s", "settings: columns, theme, status line, what to protect"],
336
+ ["r", "read everything again now"],
337
+ ["? q", "this help · quit"],
338
+ ];
339
+ function helpSection(title, context) {
340
+ return ["", ` ${paintHead(context.theme, title.toUpperCase(), context.color)}`, ""];
341
+ }
342
+ function helpProse(text, context) {
343
+ const { theme, color } = context;
344
+ const room = Math.max(24, context.columns - 6);
345
+ const out = [];
346
+ let line = "";
347
+ for (const word of text.split(" ")) {
348
+ if (line.length === 0)
349
+ line = word;
350
+ else if (line.length + 1 + word.length <= room)
351
+ line += ` ${word}`;
352
+ else {
353
+ out.push(` ${paint(theme, "dim", line, color)}`);
354
+ line = word;
355
+ }
356
+ }
357
+ if (line)
358
+ out.push(` ${paint(theme, "dim", line, color)}`);
359
+ return out;
360
+ }
361
+ export function helpOverlay(control, context) {
362
+ const { theme, color } = context;
363
+ const policy = control.config.policy;
364
+ const stages = policy === "strict" ? "35/60/80" : policy === "relaxed" ? "80/95" : policy === "off" ? "" : "50/80/90";
365
+ const keyWidth = Math.max(...HELP_KEYS.map(([key]) => key.length));
366
+ const room = Math.max(16, context.columns - keyWidth - 8);
367
+ const out = [` ${paintHead(theme, "KEYS", color)}`, ""];
368
+ for (const [key, what] of HELP_KEYS) {
369
+ const wrapped = [];
370
+ let line = "";
371
+ for (const word of what.split(" ")) {
372
+ if (line.length === 0)
373
+ line = word;
374
+ else if (line.length + 1 + word.length <= room)
375
+ line += ` ${word}`;
376
+ else {
377
+ wrapped.push(line);
378
+ line = word;
379
+ }
380
+ }
381
+ if (line)
382
+ wrapped.push(line);
383
+ for (const [at, text] of wrapped.entries()) {
384
+ const gutter = at === 0 ? paint(theme, "accent", padEndVisible(key, keyWidth), color) : " ".repeat(keyWidth);
385
+ out.push(` ${gutter} ${paint(theme, "dim", text, color)}`);
386
+ }
387
+ }
388
+ out.push(...helpSection("the two tables", context));
389
+ out.push(...helpProse("ACTIVE is what shares your window. A project joins it on its own the moment you open Claude Code there, and stays after you close it, holding whatever target you gave it.", context));
390
+ out.push("");
391
+ out.push(...helpProse("RECENT is everything else SaveMyTokens has seen, holding nothing. Space moves the selected project up a level and x moves it down, so x on a RECENT row hides it for good. Press m to see what is hidden and space to bring one back. A target you set is remembered wherever the project sits.", context));
392
+ out.push("");
393
+ out.push(...helpProse("A filled dot means a session is open there right now. Only those spend the window, so one sitting in ACTIVE with nothing running lends its share to the rest and takes it back when you return.", context));
394
+ out.push(...helpSection("allocation and priority", context));
395
+ out.push(...helpProse("Allocation is what you asked for: the share of the window this project should get. Move it with the arrows.", context));
396
+ out.push("");
397
+ out.push(...helpProse("Priority decides who gets the leftovers. Capacity is released whenever a project finishes or sits idle under its target, and that spare goes to every HIGH project first, then NORMAL, then LOW. It is an order, not a weighting: a LOW project gets nothing while a HIGH one still has room.", context));
398
+ out.push("");
399
+ out.push(...helpProse("So allocation is your intent and priority is the tie-break. If pinned targets already add up to the whole window there is no spare, and priority does nothing.", context));
400
+ out.push(...helpSection("the status line", context));
401
+ out.push(...helpProse(control.installed
402
+ ? "Installed. It is the only place Anthropic publishes your 5h and 7d usage, and it is what proves a session is still open, so without it nothing here is live. Change its shape with P."
403
+ : "Not installed, so nothing here is live. It is the only place Anthropic publishes your 5h and 7d usage, and it is what proves a session is still open. Run: npx savemytokens install", context));
404
+ out.push(...helpSection("the numbers", context));
405
+ out.push(...helpProse("5h and 7d are Anthropic's own. Share is measured from the tokens in your transcripts. Used of it is their number split by that share, so the split between rows is ours, not theirs.", context));
406
+ const drift = control.unattributed ?? 0;
407
+ if (drift >= UNATTRIBUTED_FLOOR) {
408
+ out.push("");
409
+ out.push(...helpProse("Window spent outside these projects is claude.ai, another machine, or work done before SaveMyTokens was watching.", context));
410
+ }
411
+ out.push(...helpSection("when it gets tight", context));
412
+ out.push(...helpProse(stages
413
+ ? `Claude is told to wind down at ${stages}% of a project's target. Change it with P.`
414
+ : "Nothing is ever said to Claude, because the policy is off. Change it with P.", context));
415
+ out.push(...helpSection("anything else", context));
416
+ out.push(...helpProse("Questions, bugs and ideas: hello@offbeatport.com", context));
417
+ return out;
418
+ }