osdy-pi 1.2.0 → 1.3.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 (44) hide show
  1. package/README.md +160 -30
  2. package/bin/osdy-pi.mjs +29 -6
  3. package/bin/osdy.mjs +43 -0
  4. package/extensions/osdy-pi/account-profiles.test.ts +111 -97
  5. package/extensions/osdy-pi/account-profiles.ts +91 -162
  6. package/extensions/osdy-pi/animation.test.ts +84 -0
  7. package/extensions/osdy-pi/animation.ts +17 -8
  8. package/extensions/osdy-pi/codex-usage-ui.test.ts +1145 -0
  9. package/extensions/osdy-pi/codex-usage-ui.ts +728 -0
  10. package/extensions/osdy-pi/codex-usage.test.ts +276 -0
  11. package/extensions/osdy-pi/codex-usage.ts +276 -0
  12. package/extensions/osdy-pi/constants.ts +134 -34
  13. package/extensions/osdy-pi/context-usage-ui.test.ts +168 -0
  14. package/extensions/osdy-pi/context-usage-ui.ts +60 -0
  15. package/extensions/osdy-pi/editor-settings.test.ts +121 -23
  16. package/extensions/osdy-pi/editor-settings.ts +39 -3
  17. package/extensions/osdy-pi/metrics.test.ts +95 -0
  18. package/extensions/osdy-pi/metrics.ts +39 -1
  19. package/extensions/osdy-pi/profile-label.ts +2 -2
  20. package/extensions/osdy-pi/runtime-helpers.test.ts +220 -1
  21. package/extensions/osdy-pi/runtime-helpers.ts +42 -19
  22. package/extensions/osdy-pi/runtime.test.ts +355 -7
  23. package/extensions/osdy-pi/runtime.ts +344 -66
  24. package/extensions/osdy-pi/types.ts +26 -1
  25. package/extensions/osdy-pi/ui.test.ts +128 -0
  26. package/extensions/osdy-pi/ui.ts +57 -40
  27. package/package.json +5 -2
  28. package/scripts/osdy-pi-account-profiles.mjs +521 -76
  29. package/scripts/osdy-pi-gentle-coexistence.mjs +200 -0
  30. package/scripts/osdy-pi-profile-setup.mjs +175 -0
  31. package/themes/osdy-pi-catppuccin-frappe.json +6 -2
  32. package/themes/osdy-pi-catppuccin-latte.json +6 -2
  33. package/themes/osdy-pi-catppuccin-macchiato.json +6 -2
  34. package/themes/osdy-pi-catppuccin-mocha.json +6 -2
  35. package/themes/osdy-pi-dark.json +6 -2
  36. package/themes/osdy-pi-dracula.json +6 -2
  37. package/themes/osdy-pi-kanagawa-dragon.json +6 -2
  38. package/themes/osdy-pi-kanagawa-lotus.json +6 -2
  39. package/themes/osdy-pi-kanagawa-wave.json +6 -2
  40. package/themes/osdy-pi-lucent-orange.json +6 -2
  41. package/themes/osdy-pi-matrix.json +6 -2
  42. package/themes/osdy-pi-new.json +6 -2
  43. package/themes/osdy-pi-sexy.json +6 -2
  44. package/themes/osdy-pi-tokyo-night.json +6 -2
@@ -1,5 +1,5 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
- import type { HeaderVariant } from "./types.js";
2
+ import type { HeaderVariant, MascotChoice } from "./types.js";
3
3
 
4
4
  export const ANIMATION_ENABLED = true;
5
5
  export const ANIMATION_INTERVAL_MS = 30;
@@ -100,18 +100,62 @@ const HTML_MASCOT_MAP = [
100
100
  " dmmmmmm ",
101
101
  ] as const;
102
102
 
103
- const HEADER_CLASSIC = [
104
- "░█████╗░░██████╗██████╗░██╗░░░██╗░░░░░░██████╗░██╗",
105
- "██╔══██╗██╔════╝██╔══██╗╚██╗░██╔╝░░░░░░██╔══██╗ ║",
106
- "██║░░██║╚█████╗░██║░░██║░╚████╔╝░█████╗██████╔╝██║",
107
- "██║░░██║░╚═══██╗██║░░██║░░╚██╔╝░░╚════╝██╔═══╝░██║",
108
- "╚█████╔╝██████╔╝██████╔╝░░░██║░░░░░░░░░██║░░░░░██║",
109
- "░╚════╝░╚═════╝░╚═════╝░░░░╚═╝░░░░░░░░░╚═╝░░░░░╚═╝",
110
- " ╭━╮╱╱╱╱╱╱╭╮╱╱╱╱╱╭╮╱╱╱╱╱╭╮╱╱╭╮╭╮╱╱╱╱╭━╮",
111
- " ┃╭╋━┳━━┳━╋╋╮╭━╮╭╯┣━┳┳╮╭╯┣━╮┣╋╯┣━┳━╮┃━┫",
112
- " ┃╰┫╋┃┃┃┃╋┃┃╰┫╋╰┫╋┃╋┃╭╯┃╋┃┻┫┃┃╋┃┻┫╋╰╋━┃",
113
- " ╰━┻━┻┻┻┫╭┻┻━┻━━┻━┻━┻╯╱╰━┻━╯╰┻━┻━┻━━┻━╯",
114
- " ╱╱╱╱╱╱╱╰╯ </>",
103
+ const BTS_MASCOT = [
104
+ " ▒▒▒▒ ▒▒▒░ ",
105
+ " ▓▓▓▓▓▓▒ ░▒▓▓▓▓▓▒ ",
106
+ " ▓▓▓ ▒▓▓▒░ ▒▓▓▓░░▓▓▓ ",
107
+ " ░▓▓▒ ▒▒▓▒░░ ░▒▒▒▒▒░░ ▒▒▒▓▒ ▒▓ ",
108
+ " ░▓▓▒ ░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ▒▓ ",
109
+ " ▓▓▓ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▓▓▒ ",
110
+ " ░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ",
111
+ " ░▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒ ",
112
+ " ░▒▒▒▒▓▓▓▒ ▓▓▓▓▓▒▒▒▓▓▓▓▓ ░▓▓▓▓▒ ",
113
+ " ░▒▒▓▓▓▓ ░▓▓▓▒▒▒▓▓▓░ ▒▓▓▓░ ░ ▒▒ ",
114
+ " ▒▓▓▓▓░ ▓▓▒▒▒▓▓ ░▓▒░ ▓▓▓▓▓▓░ ",
115
+ " ▓▓▓▓▓▒ ▓▓ ▒▒▒░ ▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓ ",
116
+ " ▒▓▓▓▒░ ░▓▓░░░▓▓░ ░▓▓▓▓▒▒▓▒▒▓▓▓▓",
117
+ " ░▓░ ▓▓▓▓ ▒▓▓▓▓ ▓▓▓▓▓▒▒▓▒▓▓▓▓░",
118
+ " ▒▓▒ ▓▓▓▓▓▓ ▒▓▓▓▓▓░ ░▓ ░▓▓▓▓▓▓▒▓▓▓ ",
119
+ " ▓▓░░░░▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒░▒▓░ ░░▒▓▓▓░ ",
120
+ " ░▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒ ",
121
+ " ▒▒▒▒▒░ ▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒░ ░ ",
122
+ " ░░▒▒▒▒░░░░ ░░▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒░ ░░░ ░ ",
123
+ "▒▒▒▒▒▒ ░ ░▒▒▒░ ▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░ ░ ",
124
+ "░▒▒░░ ░▒▒▒▒ ░▒▒▒▒░ ▓▓▓▓▓▓▓▓▓▓▓▒░ ░▒▒░░ ",
125
+ " ▒░ ░▒▒▒▒▒░ ░▒░ ▓▓▓▓▓▓▓▓▓▓▓▒▒▒ ",
126
+ " ▒▒▒▒░ ▒▒░ ░▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒ ",
127
+ " ▒▒▒▒░░ ▒▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒ ",
128
+ " ░▒░ ▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░ ",
129
+ " ░▒▒▒▒░ ░▒▒▒▒░ ",
130
+ ] as const;
131
+
132
+ const BTS_MASCOT_MAP = [
133
+ " mmmm mmmv ",
134
+ " lhhhhlv dmlhhhhp ",
135
+ " hhl mhhpv mlhhddhhl ",
136
+ " dhhv mphmvd dmmmmmvd mmphp pl ",
137
+ " dhhm dmmmmmmmmmmmmmmmmmmmmd pl ",
138
+ " hhl ddmmmmmmmmmmmmmmmmmmmmmmmvd dhhp ",
139
+ " vmvmmmmmmmmmmmmmmmmmmmmmmmmmmmmv ",
140
+ " dmmmmplhhhhhhmmmmmmmmmphhhhhhpmv ",
141
+ " vmmmphhhp lhhhhmmmlhhhh vhhhlm ",
142
+ " vmmhhhl dlhhmmmlhlv mlhhd d cc ",
143
+ " mhhhhd lhmmmlh dlpd hhbbbld ",
144
+ " lhhhhp hb mmmd lb hhhbhcbbbbbh ",
145
+ " mhhhpd vhhvvvlhv dhbbbccbccbbbl",
146
+ " dhv hhhh phhhl lhhbbcchccbhhv",
147
+ " plp lhhhhp plhhhld dp dlhbbbbcbhh ",
148
+ " pldddvhhhhhhhhhhhhhhhhhhhmdmlv dvmlhhd ",
149
+ " vmhhhhhhhhhhhhhm ",
150
+ " mmmmmd mmmmmmmllllllmmmmmv d ",
151
+ " dvmmmmvvvd dvmmmmmmmhhhhhhhhlmmmmmvv dvd d ",
152
+ "vmmmmm d vmmmd phhhhhhhhhhhmmmmmmmmmv d ",
153
+ "vmmvd vmmmm vmmmmv hhhhhhhhhhhmv vmmvd ",
154
+ " md dmmmmmv vmd hhhhhhhhhhhmmm ",
155
+ " mmmmv mmv dmhhhhhhhhhhhhmmm ",
156
+ " mmmmvd mmmmmmhhhhhhhhhhhhhmmmm ",
157
+ " vmd mmmmmmmhhhhhhhhmmmmmv ",
158
+ " dmmmmv vmmmmd ",
115
159
  ] as const;
116
160
 
117
161
  const HEADER_SIMPLE = [
@@ -128,6 +172,33 @@ const HEADER_SIMPLE = [
128
172
  " ░░░░░░ <ideas_compiler/>",
129
173
  ] as const;
130
174
 
175
+ const NEON_HEADER_ROWS = [
176
+ [" █████████ ████████ ", " ddddddddd dddddddd "],
177
+ [" ██████████████████████ ███████████ █████████████████████ ████████████", " dddddddddddpdddddddddd ddccccccddd ddddddddddddddddddddd ddccccccccpd"],
178
+ [" ██████████████████████████ ███████████ ████████████████████████████████████", " dddccchhhhhhhhhhhhhhhcpddd ddcchhhhmdd dddccchhhhhhhhhhhhhhhcdddphhhhhllddd"],
179
+ [" ███████████████████████████████████████████████████████████████████████████████ ████████████████████████████████████ ", " pddpchhhhhhhhhhhhhhhhhhhhmdddddddddddddddddddddddddddchhhhhmdddddddddpddddddddd ddpllhhhhhhllllllhhhhhhmdddddddddddd "],
180
+ [" ████████████████████████████████████████████████████████████████████████████████ ███████████████████████████████████ ", " ddpchhhhhhldddddddchhhhhlmddppchhhhhhhhhhhcdppcchhhhhhhhhhhldchhhhhpdddpchhhhcdd dddddcchhhhdddddpchhhhlddpcchhhcpdd "],
181
+ [" ██████████████ ████████████████████████████████████████████ ████████████████████████████████████████ ████████████████████ ", " dddcchhhhhlddd ddchhhhhldpcchhhhlmmmmmmmddcchhhhlmmlhhhhhhd chhhhlddddcchhhhddddddddddddddpchhhhhmdd dchhhhhmddpchhhhlddd "],
182
+ [" ██████████████ ███████████████████████████████████████████████████████████████████████████████████████████████████████████ ", " dddcchhhhhmddd pdcchhhhhlddpplhhhhlddddddddcchhhhldddchhhhlddchhhhhmddpchhhhhldcchhhhhhhlpdpcchhhhhhhhhhhhhmmdddpchhhhlddd "],
183
+ [" ████████████████████████████ █████████████████████████████████████████████████████████████████████████████████████████████ ", " ddcchhhhhldddddddpchhhhhhmdd dddmhhhhhldddddchhhhlmddpchhhhmddchhhhhhcccchhhhhmddlllllllllddpchhhhhlllllllldddddpcchhhhldd "],
184
+ ["█████████████████████████████████████████████████████████████████████████████████████████████████████████████ ████████████████ ", "ddchhhhhhhlcccccccchhhhhhddpccccccchhhhhhddpchhhhhhcccchhhhlddddmhhhhhhhhhhhhhmdddddddddddddpchhhhldddddddddd ddchhhhhmddddddd "],
185
+ [" ███████████████████████████████████████████████████████████████████████████████ ██████████ ██████████████████ ", " ddmhhhhhhhhhhhhhhhhhhhddpchhhhhhhhhhhhmddddhhhhhhhhhhhhhhmdddddddddddpchhhhhmdd pchhhhhldd dddchhhhhhhhmddddd "],
186
+ [" █████████████████████████████████████ ████████████████████████████████████ ███████████ ███████████ █ ", " ddddddddddddddddddddddddddddddddddddd ddddddddddddddddddcchhhhhhhhhhhhmddd dpccldddddd dpchldddddd d "],
187
+ [" ██ █████████████████ ██████ ██████ ", " dd chhhhhhhhhhlddddd dddddd dddddd "],
188
+ [" ████████████████ ", " dddddddddddddddd "],
189
+ ] as const;
190
+
191
+ const NEON_HEADER_TONES: MascotTonePalette = {
192
+ b: "#FFFFFF",
193
+ h: { kind: "theme", token: "accent" },
194
+ l: { kind: "theme", token: "accent", dimmed: true },
195
+ m: { kind: "theme", token: "accent", dimmed: true },
196
+ d: "#12011B",
197
+ p: { kind: "theme", token: "accent", dimmed: true },
198
+ c: "#FFFFFF",
199
+ v: "#FFFFFF",
200
+ };
201
+
131
202
  type HeaderPalette = {
132
203
  baseColor: string;
133
204
  highlightColor: string;
@@ -148,7 +219,13 @@ export const MASCOT_TONE_KEYS = {
148
219
  export type MascotToneKey =
149
220
  (typeof MASCOT_TONE_KEYS)[keyof typeof MASCOT_TONE_KEYS];
150
221
 
151
- export type MascotTonePalette = Record<MascotToneKey, string>;
222
+ export type ThemeForegroundColor = {
223
+ kind: "theme";
224
+ token: string;
225
+ dimmed?: true;
226
+ };
227
+
228
+ export type MascotTonePalette = Record<MascotToneKey, string | ThemeForegroundColor>;
152
229
 
153
230
  type HeaderVariantConfig = {
154
231
  label: string;
@@ -162,18 +239,6 @@ type HeaderVariantConfig = {
162
239
  mascotTonePalette?: MascotTonePalette;
163
240
  };
164
241
 
165
- const CLASSIC_PALETTE: HeaderPalette = {
166
- baseColor: "accent",
167
- highlightColor: "mdHeading",
168
- trailColor: "mdLink",
169
- };
170
-
171
- const CLASSIC_LINK_PALETTE: HeaderPalette = {
172
- baseColor: "mdLink",
173
- highlightColor: "mdHeading",
174
- trailColor: "accent",
175
- };
176
-
177
242
  const OSDY_THEME_CYAN_PALETTE: HeaderPalette = {
178
243
  baseColor: "accent",
179
244
  highlightColor: "mdHeading",
@@ -209,6 +274,17 @@ const HTML_MASCOT_TONES: MascotTonePalette = {
209
274
  v: "mdLink",
210
275
  };
211
276
 
277
+ const BTS_MASCOT_TONES: MascotTonePalette = {
278
+ b: "#FCFCFC",
279
+ h: "#F9F3E7",
280
+ l: "#DDD5CC",
281
+ m: "#969291",
282
+ d: "#53445F",
283
+ p: "#C9BBA8",
284
+ c: "#CDA5F1",
285
+ v: "#746D6C",
286
+ };
287
+
212
288
  const RAW_BODY_TONE_KEYS = [
213
289
  MASCOT_TONE_KEYS.background,
214
290
  MASCOT_TONE_KEYS.bright,
@@ -220,7 +296,8 @@ const RAW_HEX_COLOR = /^#[0-9A-F]{6}$/;
220
296
 
221
297
  function assertRawHexPalette(palette: MascotTonePalette): void {
222
298
  for (const tone of RAW_BODY_TONE_KEYS) {
223
- if (!RAW_HEX_COLOR.test(palette[tone])) {
299
+ const color = palette[tone];
300
+ if (typeof color !== "string" || !RAW_HEX_COLOR.test(color)) {
224
301
  throw new Error(`Mascot tone ${tone} must be a #RRGGBB color.`);
225
302
  }
226
303
  }
@@ -378,9 +455,31 @@ const ROSE_MASCOT = addRightEdgeGlow({
378
455
  mascot: HTML_MASCOT,
379
456
  toneMap: HTML_MASCOT_MAP,
380
457
  });
458
+ const BTS_MASCOT_ART = addRightEdgeGlow({
459
+ mascot: BTS_MASCOT,
460
+ toneMap: BTS_MASCOT_MAP,
461
+ });
462
+ for (const [art, palette] of [
463
+ [ROSE_MASCOT, HTML_MASCOT_TONES],
464
+ [BTS_MASCOT_ART, BTS_MASCOT_TONES],
465
+ ] as const) {
466
+ assertRawHexPalette(palette);
467
+ assertMascotStructure(art, palette);
468
+ }
381
469
 
382
- assertRawHexPalette(HTML_MASCOT_TONES);
383
- assertMascotStructure(ROSE_MASCOT, HTML_MASCOT_TONES);
470
+ export type MascotConfig = {
471
+ art: MascotArt;
472
+ tonePalette: MascotTonePalette;
473
+ };
474
+
475
+ export const MASCOTS: Record<MascotChoice, MascotConfig> = {
476
+ current: { art: ROSE_MASCOT, tonePalette: HTML_MASCOT_TONES },
477
+ bts: { art: BTS_MASCOT_ART, tonePalette: BTS_MASCOT_TONES },
478
+ };
479
+
480
+ export function mascotForChoice(choice: MascotChoice): MascotConfig {
481
+ return MASCOTS[choice];
482
+ }
384
483
 
385
484
  type PreparedMascotArt = {
386
485
  art: MascotArt;
@@ -453,13 +552,14 @@ export const HEADER_VARIANTS: Record<HeaderVariant, HeaderVariantConfig> = {
453
552
  mascotPalette: OSDY_THEME_MASCOT_PALETTE,
454
553
  mascotTonePalette: HTML_MASCOT_TONES,
455
554
  },
456
- classic: {
457
- label: "Classic",
458
- header: HEADER_CLASSIC,
555
+ neon: {
556
+ label: "Neon",
557
+ header: NEON_HEADER_ROWS.map(([header]) => header),
558
+ headerMap: NEON_HEADER_ROWS.map(([, toneMap]) => toneMap),
559
+ headerTonePalette: NEON_HEADER_TONES,
459
560
  mascot: ROSE_MASCOT.mascot,
460
561
  mascotMap: ROSE_MASCOT.toneMap,
461
- linePalette: (lineIndex) =>
462
- lineIndex >= 6 ? CLASSIC_LINK_PALETTE : CLASSIC_PALETTE,
562
+ linePalette: osdyThemePalette,
463
563
  mascotPalette: OSDY_THEME_MASCOT_PALETTE,
464
564
  mascotTonePalette: HTML_MASCOT_TONES,
465
565
  },
@@ -0,0 +1,168 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { registerHooks } from "node:module";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import ts from "typescript";
7
+ import { visibleWidth } from "@earendil-works/pi-tui";
8
+
9
+ registerHooks({
10
+ resolve(specifier, context, nextResolve) {
11
+ if (
12
+ specifier.startsWith(".") &&
13
+ specifier.endsWith(".js") &&
14
+ context.parentURL?.endsWith(".ts")
15
+ ) {
16
+ const sourceUrl = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
17
+ if (existsSync(fileURLToPath(sourceUrl))) {
18
+ return { shortCircuit: true, url: sourceUrl.href };
19
+ }
20
+ }
21
+ return nextResolve(specifier, context);
22
+ },
23
+ load(url, context, nextLoad) {
24
+ if (url.endsWith(".ts")) {
25
+ return {
26
+ format: "module",
27
+ shortCircuit: true,
28
+ source: ts.transpileModule(readFileSync(fileURLToPath(url), "utf8"), {
29
+ compilerOptions: {
30
+ module: ts.ModuleKind.ESNext,
31
+ target: ts.ScriptTarget.ES2022,
32
+ },
33
+ }).outputText,
34
+ };
35
+ }
36
+ return nextLoad(url, context);
37
+ },
38
+ });
39
+
40
+ const { formatContextUsage } = await import("./context-usage-ui.js");
41
+
42
+ const theme = {
43
+ fg(token: string, text: string): string {
44
+ return `<${token}>${text}</${token}>`;
45
+ },
46
+ };
47
+
48
+ void test("formats the selected single-line usage shape", () => {
49
+ assert.equal(
50
+ formatContextUsage(theme, {
51
+ input: 0,
52
+ output: 0,
53
+ cacheRead: 0,
54
+ cacheWrite: 0,
55
+ cost: 0,
56
+ percent: 60,
57
+ contextWindow: 272_000,
58
+ }),
59
+ "tok ↑0 ↓0 · $0.0000 · ctx <warning>[██████░░░░]</warning> 60%/272.0k",
60
+ );
61
+ });
62
+
63
+ void test("selects the full or compact usage line at the ANSI-aware width boundary", () => {
64
+ const ansiTheme = {
65
+ fg(_token: string, text: string): string {
66
+ return `\u001B[33m${text}\u001B[39m`;
67
+ },
68
+ };
69
+ const usage = {
70
+ input: 0,
71
+ output: 0,
72
+ cacheRead: 0,
73
+ cacheWrite: 0,
74
+ cost: 0,
75
+ percent: 60,
76
+ contextWindow: 272_000,
77
+ };
78
+ const full =
79
+ "tok ↑0 ↓0 · $0.0000 · ctx \u001B[33m[██████░░░░]\u001B[39m 60%/272.0k";
80
+
81
+ assert.equal(visibleWidth(full), 49);
82
+ assert.equal(formatContextUsage(ansiTheme, usage, 49), full);
83
+ assert.equal(
84
+ formatContextUsage(ansiTheme, usage, 48),
85
+ "$0.0000 · ctx \u001B[33m[██████░░░░]\u001B[39m 60%",
86
+ );
87
+ });
88
+
89
+ void test("formats a ten-cell green context bar at the inclusive 30% threshold", () => {
90
+ assert.equal(
91
+ formatContextUsage(theme, {
92
+ input: 0,
93
+ output: 0,
94
+ cacheRead: 0,
95
+ cacheWrite: 0,
96
+ cost: 0,
97
+ percent: 30,
98
+ contextWindow: 272_000,
99
+ }),
100
+ "tok ↑0 ↓0 · $0.0000 · ctx <success>[███░░░░░░░]</success> 30%/272.0k",
101
+ );
102
+ });
103
+
104
+ void test("uses warning and error colors for higher rounded usage", () => {
105
+ assert.equal(
106
+ formatContextUsage(theme, {
107
+ input: 1_500,
108
+ output: 2_500_000,
109
+ cacheRead: 12,
110
+ cacheWrite: 34,
111
+ cost: 1.2,
112
+ percent: 30.5,
113
+ contextWindow: 200_000,
114
+ }),
115
+ "tok ↑1.5k ↓2.5m R12 W34 · $1.2000 · ctx <warning>[███░░░░░░░]</warning> 31%/200.0k",
116
+ );
117
+ assert.equal(
118
+ formatContextUsage(theme, {
119
+ input: 0,
120
+ output: 0,
121
+ cacheRead: 0,
122
+ cacheWrite: 0,
123
+ cost: 1.2,
124
+ percent: 80.5,
125
+ contextWindow: 200_000,
126
+ }),
127
+ "tok ↑0 ↓0 · $1.2000 · ctx <error>[████████░░]</error> 81%/200.0k",
128
+ );
129
+ });
130
+
131
+ void test("clamps usage and safely represents missing context usage", () => {
132
+ assert.equal(
133
+ formatContextUsage(theme, {
134
+ input: 0,
135
+ output: 0,
136
+ cacheRead: 0,
137
+ cacheWrite: 0,
138
+ cost: 0,
139
+ percent: -10,
140
+ contextWindow: 200_000,
141
+ }),
142
+ "tok ↑0 ↓0 · $0.0000 · ctx <success>[░░░░░░░░░░]</success> 0%/200.0k",
143
+ );
144
+ assert.equal(
145
+ formatContextUsage(theme, {
146
+ input: 0,
147
+ output: 0,
148
+ cacheRead: 0,
149
+ cacheWrite: 0,
150
+ cost: 0,
151
+ percent: undefined,
152
+ contextWindow: 272_000,
153
+ }),
154
+ "tok ↑0 ↓0 · $0.0000 · ctx <success>[░░░░░░░░░░]</success> ?%/272.0k",
155
+ );
156
+ assert.equal(
157
+ formatContextUsage(theme, {
158
+ input: 0,
159
+ output: 0,
160
+ cacheRead: 0,
161
+ cacheWrite: 0,
162
+ cost: 0,
163
+ percent: undefined,
164
+ contextWindow: undefined,
165
+ }),
166
+ "tok ↑0 ↓0 · $0.0000 · ctx <success>[░░░░░░░░░░]</success> ?%/?",
167
+ );
168
+ });
@@ -0,0 +1,60 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { shortNumber } from "./format.js";
3
+
4
+ export type ContextUsageDisplayData = {
5
+ input: number;
6
+ output: number;
7
+ cacheRead: number;
8
+ cacheWrite: number;
9
+ cost: number;
10
+ percent: number | undefined;
11
+ contextWindow: number | undefined;
12
+ };
13
+
14
+ type UsageTheme = {
15
+ fg(token: "success" | "warning" | "error", text: string): string;
16
+ };
17
+
18
+ function roundedClampedPercent(
19
+ percent: number | undefined,
20
+ ): number | undefined {
21
+ if (percent === undefined || !Number.isFinite(percent)) return undefined;
22
+ return Math.min(100, Math.max(0, Math.round(percent)));
23
+ }
24
+
25
+ function usageTone(percent: number): "success" | "warning" | "error" {
26
+ if (percent <= 30) return "success";
27
+ if (percent <= 80) return "warning";
28
+ return "error";
29
+ }
30
+
31
+ function usageBar(percent: number): string {
32
+ const filledCells = Math.round(percent / 10);
33
+ return "█".repeat(filledCells) + "░".repeat(10 - filledCells);
34
+ }
35
+
36
+ export function formatContextUsage(
37
+ theme: UsageTheme,
38
+ usage: ContextUsageDisplayData,
39
+ availableWidth?: number,
40
+ ): string {
41
+ const percent = roundedClampedPercent(usage.percent);
42
+ const displayPercent = percent ?? 0;
43
+ const percentageLabel = percent === undefined ? "?" : String(percent);
44
+ const contextWindowLabel =
45
+ usage.contextWindow === undefined || !Number.isFinite(usage.contextWindow)
46
+ ? "?"
47
+ : shortNumber(usage.contextWindow);
48
+ const cacheLabel =
49
+ usage.cacheRead || usage.cacheWrite
50
+ ? ` R${shortNumber(usage.cacheRead)} W${shortNumber(usage.cacheWrite)}`
51
+ : "";
52
+ const bar = theme.fg(
53
+ usageTone(displayPercent),
54
+ `[${usageBar(displayPercent)}]`,
55
+ );
56
+ const full = `tok ↑${shortNumber(usage.input)} ↓${shortNumber(usage.output)}${cacheLabel} · $${usage.cost.toFixed(4)} · ctx ${bar} ${percentageLabel}%/${contextWindowLabel}`;
57
+ if (availableWidth === undefined || visibleWidth(full) <= availableWidth)
58
+ return full;
59
+ return `$${usage.cost.toFixed(4)} · ctx ${bar} ${percentageLabel}%`;
60
+ }
@@ -4,10 +4,14 @@ import {
4
4
  createEditorSettingsStore,
5
5
  normalizeEditorSettings,
6
6
  type EditorSettingsFileSystem,
7
- // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
7
+ // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
8
8
  } from "./editor-settings.ts";
9
- // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
10
- import { EDITOR_MODES } from "./types.ts";
9
+ import {
10
+ EDITOR_MODES,
11
+ HEADER_VARIANT_CHOICES,
12
+ MASCOT_CHOICES,
13
+ // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
14
+ } from "./types.ts";
11
15
 
12
16
  class MemoryEditorSettingsFileSystem implements EditorSettingsFileSystem {
13
17
  content: string | undefined;
@@ -41,35 +45,120 @@ class MemoryEditorSettingsFileSystem implements EditorSettingsFileSystem {
41
45
  private pendingContent = "";
42
46
  }
43
47
 
48
+ void test("editor settings preserve version-one header choices and fall back safely", async () => {
49
+ assert.deepEqual(HEADER_VARIANT_CHOICES, ["osdy-theme", "neon"]);
50
+ assert.equal(
51
+ normalizeEditorSettings({ version: 1 }).headerVariant,
52
+ "osdy-theme",
53
+ );
54
+ assert.equal(
55
+ normalizeEditorSettings({ version: 1, headerVariant: "classic" })
56
+ .headerVariant,
57
+ "osdy-theme",
58
+ );
59
+ assert.equal(
60
+ normalizeEditorSettings({ version: 1, headerVariant: "invalid" })
61
+ .headerVariant,
62
+ "osdy-theme",
63
+ );
64
+
65
+ const fileSystem = new MemoryEditorSettingsFileSystem();
66
+ const store = createEditorSettingsStore(
67
+ "/agent/extensions/osdy-pi/settings.json",
68
+ fileSystem,
69
+ );
70
+ await store.save({
71
+ version: 1,
72
+ enabled: true,
73
+ editorMode: EDITOR_MODES.AUTO,
74
+ workingTreeEnabled: true,
75
+ headerVariant: "neon",
76
+ mascot: "current",
77
+ });
78
+ assert.equal((await store.load()).headerVariant, "neon");
79
+ });
80
+
44
81
  void test("editor settings normalize only supported versioned modes", () => {
45
- assert.deepEqual(
46
- normalizeEditorSettings({ version: 1, editorMode: "simple" }),
47
- {
82
+ for (const [input, editorMode] of [
83
+ [{ version: 1, editorMode: "simple" }, EDITOR_MODES.SIMPLE],
84
+ [{ version: 1, editorMode: "invalid" }, EDITOR_MODES.AUTO],
85
+ [{ version: 2, editorMode: "extended" }, EDITOR_MODES.AUTO],
86
+ [null, EDITOR_MODES.AUTO],
87
+ ] as const) {
88
+ assert.deepEqual(normalizeEditorSettings(input), {
48
89
  version: 1,
49
- editorMode: EDITOR_MODES.SIMPLE,
90
+ enabled: true,
91
+ editorMode,
50
92
  workingTreeEnabled: true,
51
- },
93
+ headerVariant: "osdy-theme",
94
+ mascot: "current",
95
+ });
96
+ }
97
+ });
98
+
99
+ void test("mascot choices preserve the version-one current default, migrate raccoon to Bts, and persist Bts", async () => {
100
+ assert.deepEqual(MASCOT_CHOICES, ["current", "bts"]);
101
+ assert.equal(
102
+ normalizeEditorSettings({ version: 1, mascot: "invalid" }).mascot,
103
+ "current",
52
104
  );
53
- assert.deepEqual(
54
- normalizeEditorSettings({ version: 1, editorMode: "invalid" }),
55
- {
56
- version: 1,
57
- editorMode: EDITOR_MODES.AUTO,
58
- workingTreeEnabled: true,
59
- },
105
+ assert.equal(
106
+ normalizeEditorSettings({ version: 1, mascot: "raccoon" }).mascot,
107
+ "bts",
60
108
  );
61
- assert.deepEqual(
62
- normalizeEditorSettings({ version: 2, editorMode: "extended" }),
63
- {
64
- version: 1,
65
- editorMode: EDITOR_MODES.AUTO,
66
- workingTreeEnabled: true,
67
- },
109
+ for (const mascot of ["detective", "explorer"] as const) {
110
+ assert.equal(
111
+ normalizeEditorSettings({ version: 1, mascot }).mascot,
112
+ "current",
113
+ );
114
+ }
115
+
116
+ const fileSystem = new MemoryEditorSettingsFileSystem();
117
+ const store = createEditorSettingsStore(
118
+ "/agent/extensions/osdy-pi/settings.json",
119
+ fileSystem,
68
120
  );
69
- assert.deepEqual(normalizeEditorSettings(null), {
121
+ await store.save({
70
122
  version: 1,
123
+ enabled: true,
71
124
  editorMode: EDITOR_MODES.AUTO,
72
125
  workingTreeEnabled: true,
126
+ headerVariant: "neon",
127
+ mascot: "bts",
128
+ });
129
+ assert.equal((await store.load()).mascot, "bts");
130
+ });
131
+
132
+ void test("enabled defaults safely and persists globally", async () => {
133
+ assert.equal(
134
+ normalizeEditorSettings({ version: 1, editorMode: "simple" }).enabled,
135
+ true,
136
+ );
137
+ assert.equal(
138
+ normalizeEditorSettings({ version: 1, enabled: "no" }).enabled,
139
+ true,
140
+ );
141
+
142
+ const fileSystem = new MemoryEditorSettingsFileSystem();
143
+ const store = createEditorSettingsStore(
144
+ "/agent/extensions/osdy-pi/settings.json",
145
+ fileSystem,
146
+ );
147
+ await store.save({
148
+ version: 1,
149
+ enabled: false,
150
+ editorMode: EDITOR_MODES.SIMPLE,
151
+ workingTreeEnabled: false,
152
+ headerVariant: "osdy-theme",
153
+ mascot: "current",
154
+ });
155
+ assert.deepEqual(await store.load(), {
156
+ version: 1,
157
+ enabled: false,
158
+ editorMode: EDITOR_MODES.SIMPLE,
159
+ workingTreeEnabled: false,
160
+ headerVariant: "osdy-theme",
161
+ mascot: "current",
73
162
  });
74
163
  });
75
164
 
@@ -98,8 +187,11 @@ void test("working-tree visibility defaults safely and persists globally", async
98
187
  );
99
188
  await firstStore.save({
100
189
  version: 1,
190
+ enabled: true,
101
191
  editorMode: EDITOR_MODES.AUTO,
102
192
  workingTreeEnabled: false,
193
+ headerVariant: "osdy-theme",
194
+ mascot: "current",
103
195
  });
104
196
  const reloadedStore = createEditorSettingsStore(
105
197
  "/agent/extensions/osdy-pi/settings.json",
@@ -125,8 +217,11 @@ void test("editor settings load safely and save atomically", async () => {
125
217
 
126
218
  await store.save({
127
219
  version: 1,
220
+ enabled: true,
128
221
  editorMode: EDITOR_MODES.SIMPLE,
129
222
  workingTreeEnabled: true,
223
+ headerVariant: "osdy-theme",
224
+ mascot: "current",
130
225
  });
131
226
  assert.deepEqual(fileSystem.mkdirPaths, ["/agent/extensions/osdy-pi"]);
132
227
  assert.deepEqual(fileSystem.writePaths, [
@@ -152,8 +247,11 @@ void test("new editor settings stores load their own persisted mode", async () =
152
247
 
153
248
  await firstStore.save({
154
249
  version: 1,
250
+ enabled: true,
155
251
  editorMode: EDITOR_MODES.EXTENDED,
156
252
  workingTreeEnabled: true,
253
+ headerVariant: "osdy-theme",
254
+ mascot: "current",
157
255
  });
158
256
  const reloadedStore = createEditorSettingsStore(
159
257
  "/agent/extensions/osdy-pi/settings.json",