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,12 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
2
  import type { TUI } from "@earendil-works/pi-tui";
2
3
 
4
+ type EditorFactory = Exclude<
5
+ ReturnType<ExtensionContext["ui"]["getEditorComponent"]>,
6
+ undefined
7
+ >;
8
+ import type { CodexUsageSnapshot } from "./codex-usage.js";
9
+
3
10
  export type AnimationMode = "off" | "intro" | "continuous";
4
11
 
5
12
  export const EDITOR_MODES = {
@@ -14,8 +21,11 @@ export const DEFAULT_EDITOR_MODE = EDITOR_MODES.AUTO;
14
21
 
15
22
  export interface GlobalEditorSettings {
16
23
  version: number;
24
+ enabled: boolean;
17
25
  editorMode: EditorMode;
18
26
  workingTreeEnabled: boolean;
27
+ headerVariant: HeaderVariant;
28
+ mascot: MascotChoice;
19
29
  }
20
30
 
21
31
  export function resolveEffectiveEditorMode(
@@ -30,15 +40,30 @@ export function shouldShowFooterMetadata(editorEffective: boolean): boolean {
30
40
  return !editorEffective;
31
41
  }
32
42
 
33
- export type HeaderVariant = "osdy-theme" | "classic";
43
+ export const HEADER_VARIANT_CHOICES = ["osdy-theme", "neon"] as const;
44
+
45
+ export type HeaderVariant = (typeof HEADER_VARIANT_CHOICES)[number];
46
+
47
+ export const MASCOT_CHOICES = ["current", "bts"] as const;
48
+
49
+ export type MascotChoice = (typeof MASCOT_CHOICES)[number];
34
50
 
35
51
  export type WorkingTreePlacement = "aboveEditor" | "belowEditor";
36
52
 
53
+ export type CodexUsageState =
54
+ | { kind: "idle" }
55
+ | { kind: "loading"; snapshot: CodexUsageSnapshot | undefined }
56
+ | { kind: "ready"; snapshot: CodexUsageSnapshot }
57
+ | { kind: "error"; message: string; snapshot: CodexUsageSnapshot | undefined };
58
+
37
59
  export type OsdyState = {
60
+ codexUsage: CodexUsageState;
38
61
  enabled: boolean;
39
62
  editorEffective: boolean;
40
63
  editorMode: EditorMode;
64
+ fallbackEditorFactory: EditorFactory | undefined;
41
65
  headerVariant: HeaderVariant;
66
+ mascot: MascotChoice;
42
67
  smallMode: boolean;
43
68
  tui: TUI | undefined;
44
69
  workingTreeEnabled: boolean;
@@ -4,6 +4,8 @@ import test from "node:test";
4
4
  // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
5
5
  import * as profileLabel from "./profile-label.ts";
6
6
  // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
7
+ import { HEADER_VARIANTS, MASCOTS, mascotForChoice, scaleHeader } from "./constants.ts";
8
+ // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
7
9
  import { shouldShowFooterMetadata } from "./types.ts";
8
10
 
9
11
  void test("footer metadata is shown only when the native editor is effective", () => {
@@ -11,16 +13,135 @@ void test("footer metadata is shown only when the native editor is effective", (
11
13
  assert.equal(shouldShowFooterMetadata(true), false);
12
14
  });
13
15
 
16
+ void test("neon header keeps the parsed 129 by 13 transparent tone structure", () => {
17
+ assert.deepEqual(Object.keys(HEADER_VARIANTS), ["osdy-theme", "neon"]);
18
+ const neon = scaleHeader("neon", 1_000, 100);
19
+ assert.equal(neon.header.length, 13);
20
+ const toneMap = neon.toneMap;
21
+ assert.ok(toneMap);
22
+ assert.equal(toneMap.length, 13);
23
+ assert.equal(Array.from(neon.header[0] ?? "").length, 129);
24
+ assert.equal(
25
+ neon.header.reduce(
26
+ (count, row) =>
27
+ count + Array.from(row).filter((character) => character !== " ").length,
28
+ 0,
29
+ ),
30
+ 1121,
31
+ );
32
+ for (let row = 0; row < neon.header.length; row += 1) {
33
+ const glyphs = Array.from(neon.header[row] ?? "");
34
+ const tones: string[] = Array.from(toneMap[row] ?? "");
35
+ assert.equal(glyphs.length, tones.length);
36
+ for (let column = 0; column < glyphs.length; column += 1)
37
+ if (glyphs[column] === " ") assert.equal(tones[column], " ");
38
+ }
39
+ });
40
+
41
+ void test("neon header keeps white and black fixed while pink follows accent", () => {
42
+ assert.deepEqual(HEADER_VARIANTS.neon.headerTonePalette, {
43
+ b: "#FFFFFF",
44
+ h: { kind: "theme", token: "accent" },
45
+ l: { kind: "theme", token: "accent", dimmed: true },
46
+ m: { kind: "theme", token: "accent", dimmed: true },
47
+ d: "#12011B",
48
+ p: { kind: "theme", token: "accent", dimmed: true },
49
+ c: "#FFFFFF",
50
+ v: "#FFFFFF",
51
+ });
52
+ });
53
+
54
+ void test("selected headers render through foreground-only tone-map animation", () => {
55
+ const ui = readFileSync(new URL("./ui.ts", import.meta.url), "utf8");
56
+ const animation = readFileSync(new URL("./animation.ts", import.meta.url), "utf8");
57
+ assert.match(ui, /const variant = HEADER_VARIANTS\[state\.headerVariant\];/);
58
+ assert.match(ui, /variant\.headerMap,/);
59
+ assert.match(ui, /variant\.headerTonePalette/);
60
+ assert.match(animation, /return `\\u001B\[38;2;/);
61
+ assert.doesNotMatch(animation, /\[48;/);
62
+ });
63
+
64
+ void test("mascot selection wires Bts art through the responsive renderer", () => {
65
+ const ui = readFileSync(new URL("./ui.ts", import.meta.url), "utf8");
66
+
67
+ assert.deepEqual(Object.keys(MASCOTS), ["current", "bts"]);
68
+ assert.ok(mascotForChoice("bts"));
69
+ assert.match(ui, /const mascot = mascotForChoice\(state\.mascot\);/);
70
+ assert.match(ui, /scaleMascot\(\s*mascot\.art,/);
71
+ });
72
+
73
+ void test("Bts retains the 60 by 26 raccoon glyph structure and transparent tone cells", () => {
74
+ const art = MASCOTS.bts.art;
75
+ assert.equal(art.mascot.length, 26);
76
+ assert.equal(Array.from(art.mascot[0] ?? "").length, 60);
77
+ assert.equal(
78
+ art.mascot.reduce(
79
+ (count, row) =>
80
+ count + Array.from(row).filter((character) => character !== " ").length,
81
+ 0,
82
+ ),
83
+ 682,
84
+ );
85
+ for (let row = 0; row < art.mascot.length; row += 1)
86
+ for (
87
+ let column = 0;
88
+ column < Array.from(art.mascot[row] ?? "").length;
89
+ column += 1
90
+ )
91
+ if (Array.from(art.mascot[row] ?? "")[column] === " ")
92
+ assert.equal(Array.from(art.toneMap[row] ?? "")[column], " ");
93
+ });
94
+
95
+ void test("Bts receives the original right-edge glow", () => {
96
+ const art = MASCOTS.bts.art;
97
+ for (let row = 0; row < art.mascot.length; row += 1) {
98
+ const glyphs = Array.from(art.mascot[row] ?? "");
99
+ const tones = Array.from(art.toneMap[row] ?? "");
100
+ const rightmost = glyphs
101
+ .map((glyph, column) => ({ glyph, column }))
102
+ .filter(({ glyph }) => glyph !== " ")
103
+ .slice(-3)
104
+ .reverse();
105
+ assert.deepEqual(
106
+ rightmost.map(({ column }) => tones[column]),
107
+ ["p", "c", "v"].slice(0, rightmost.length),
108
+ );
109
+ }
110
+ });
111
+
112
+ void test("mascot tone rendering never emits terminal background sequences", () => {
113
+ const animation = readFileSync(
114
+ new URL("./animation.ts", import.meta.url),
115
+ "utf8",
116
+ );
117
+
118
+ assert.match(animation, /return `\\u001B\[38;2;/);
119
+ assert.doesNotMatch(animation, /\[48;/);
120
+ });
121
+
14
122
  void test("maximum thinking uses the existing extra-high theme color", () => {
15
123
  const source = readFileSync(new URL("./ui.ts", import.meta.url), "utf8");
16
124
  assert.match(source, /max:\s*"thinkingXhigh"/);
17
125
  });
18
126
 
127
+ void test("renders the extended editor thinking level in bold", () => {
128
+ const source = readFileSync(new URL("./ui.ts", import.meta.url), "utf8");
129
+ assert.match(source, /theme\.bold\(thinkingLevel\)/);
130
+ });
131
+
19
132
  void test("places a managed profile beside the model in simple mode and preserves its fallback", () => {
20
133
  assert.equal(
21
134
  profileLabel.resolveActiveProfileLabel({ OSDY_PI_PROFILE_NAME: "work" }),
22
135
  "work",
23
136
  );
137
+ assert.equal(
138
+ profileLabel.resolveActiveProfileLabel({ OSDY_PI_PROFILE_NAME: "WORK" }),
139
+ "WORK",
140
+ );
141
+ assert.equal(
142
+ profileLabel.resolveActiveProfileLabel({ OSDY_PI_PROFILE_NAME: "DEFAULT" }),
143
+ undefined,
144
+ );
24
145
  assert.equal(profileLabel.resolveActiveProfileLabel({}), undefined);
25
146
  assert.equal(
26
147
  profileLabel.formatModelMetadata("gpt-5", "high", "work"),
@@ -38,6 +159,13 @@ void test("places a managed profile beside the model in simple mode and preserve
38
159
  );
39
160
  });
40
161
 
162
+ void test("wires themed Codex quota bars below both native footer and extended editor", () => {
163
+ const source = readFileSync(new URL("./ui.ts", import.meta.url), "utf8");
164
+ assert.match(source, /renderCompactCodexQuotaBars/);
165
+ assert.equal((source.match(/renderCompactCodexQuotaBars\(/g) ?? []).length, 2);
166
+ assert.doesNotMatch(source, /formatCodexUsageMetadata/);
167
+ });
168
+
41
169
  void test("replaces the extended editor Osdy-Pi title with a managed profile or preserves its fallback", () => {
42
170
  assert.equal(profileLabel.resolveEditorTitleLabel("work"), "work");
43
171
  assert.equal(
@@ -18,6 +18,11 @@ import {
18
18
  asciiAnimationMode,
19
19
  } from "./animation.js";
20
20
  import { fitBorder } from "./border.js";
21
+ import { formatContextUsage } from "./context-usage-ui.js";
22
+ import {
23
+ renderCompactCodexQuotaBars,
24
+ resolveCodexUsageLayout,
25
+ } from "./codex-usage-ui.js";
21
26
  import {
22
27
  ANIMATION_INTERVAL_MS,
23
28
  COMPACT_HEADER_HIDDEN_COLUMNS,
@@ -25,17 +30,19 @@ import {
25
30
  HEADER_VARIANTS,
26
31
  INTRO_ANIMATION_FRAMES,
27
32
  MASCOT_GAP,
33
+ mascotForChoice,
28
34
  mascotWidthForRows,
29
35
  scaleHeader,
30
36
  scaleMascot,
31
37
  STACKED_CONTENT_MAX_ROWS,
32
38
  STACKED_HEADER_MAX_ROWS,
33
39
  STACKED_HEADER_MIN_ROWS,
40
+ type MascotConfig,
34
41
  type ScaledHeaderArt,
35
42
  WORKING_SPINNER_FRAMES,
36
43
  } from "./constants.js";
37
44
  import { formatPath } from "./format.js";
38
- import { modelLabel, usageLabel } from "./metrics.js";
45
+ import { contextUsageData, modelLabel } from "./metrics.js";
39
46
  import {
40
47
  formatModelMetadata,
41
48
  resolveActiveProfileLabel,
@@ -88,16 +95,14 @@ type HeaderConfig = (typeof HEADER_VARIANTS)[HeaderVariant];
88
95
  function calculateHeaderLayout(
89
96
  variantName: HeaderVariant,
90
97
  variant: HeaderConfig,
98
+ mascot: MascotConfig,
91
99
  width: number,
92
100
  terminalColumns: number,
93
101
  terminalRows: number,
94
102
  ): HeaderLayout {
95
103
  const renderWidth = Math.max(1, width);
96
104
  const fullHeaderWidth = headerWidth(variantName);
97
- const mascotArt = {
98
- mascot: variant.mascot ?? [],
99
- toneMap: variant.mascotMap ?? [],
100
- };
105
+ const mascotArt = mascot.art;
101
106
  const hasMascot = mascotArt.mascot.length > 0 && mascotArt.toneMap.length > 0;
102
107
  const heightLimitedMascotWidth = hasMascot
103
108
  ? mascotWidthForRows(mascotArt, terminalRows)
@@ -132,7 +137,7 @@ function calculateHeaderLayout(
132
137
  }
133
138
 
134
139
  function renderMascotLines(
135
- variant: HeaderConfig,
140
+ mascot: MascotConfig,
136
141
  mascotArt: ReturnType<typeof scaleMascot>,
137
142
  frame: number,
138
143
  theme: SimpleTheme,
@@ -140,14 +145,14 @@ function renderMascotLines(
140
145
  ): string[] {
141
146
  return mascotArt.mascot.map((line, index) => {
142
147
  const toneLine = mascotArt.toneMap[index];
143
- if (toneLine && variant.mascotTonePalette) {
148
+ if (toneLine) {
144
149
  return animateAsciiLineWithToneMap(
145
150
  line,
146
151
  toneLine,
147
152
  index,
148
153
  frame,
149
154
  theme,
150
- variant.mascotTonePalette,
155
+ mascot.tonePalette,
151
156
  animationStyle,
152
157
  );
153
158
  }
@@ -156,9 +161,9 @@ function renderMascotLines(
156
161
  index,
157
162
  frame,
158
163
  theme,
159
- variant.mascotPalette.baseColor,
160
- variant.mascotPalette.highlightColor,
161
- variant.mascotPalette.trailColor,
164
+ "accent",
165
+ "mdHeading",
166
+ "mdLink",
162
167
  animationStyle,
163
168
  );
164
169
  });
@@ -211,22 +216,20 @@ function renderResponsiveHeader(
211
216
  animationStyle: "animated" | "static",
212
217
  ): string[] {
213
218
  const variant = HEADER_VARIANTS[state.headerVariant];
219
+ const mascot = mascotForChoice(state.mascot);
214
220
  const layout = calculateHeaderLayout(
215
221
  state.headerVariant,
216
222
  variant,
223
+ mascot,
217
224
  width,
218
225
  terminalColumns,
219
226
  terminalRows,
220
227
  );
221
228
  const mascotArt = layout.hasMascot
222
- ? scaleMascot(
223
- { mascot: variant.mascot ?? [], toneMap: variant.mascotMap ?? [] },
224
- layout.mascotWidthBudget,
225
- layout.mascotRowBudget,
226
- )
229
+ ? scaleMascot(mascot.art, layout.mascotWidthBudget, layout.mascotRowBudget)
227
230
  : { mascot: [], toneMap: [] };
228
231
  const mascotLines = renderMascotLines(
229
- variant,
232
+ mascot,
230
233
  mascotArt,
231
234
  frame,
232
235
  theme,
@@ -318,31 +321,33 @@ class OsdyFooter implements Component {
318
321
  : [locationLine];
319
322
  }
320
323
  const thinkingLevel = this.pi.getThinkingLevel();
321
- const thinkingLine = truncateToWidth(
322
- formatModelMetadata(
323
- modelLabel(this.ctx),
324
- this.theme.fg(
325
- THINKING_THEME_TOKENS[thinkingLevel],
326
- `\u001B[1m${thinkingLevel}\u001B[22m`,
327
- ),
328
- resolveActiveProfileLabel(),
324
+ const baseMetadata = formatModelMetadata(
325
+ modelLabel(this.ctx),
326
+ this.theme.fg(
327
+ THINKING_THEME_TOKENS[thinkingLevel],
328
+ `\u001B[1m${thinkingLevel}\u001B[22m`,
329
329
  ),
330
- width,
331
- ellipsis,
330
+ resolveActiveProfileLabel(),
332
331
  );
332
+ const cachedCodexUsage =
333
+ this.state.codexUsage.kind === "idle"
334
+ ? undefined
335
+ : this.state.codexUsage.snapshot;
336
+ const codexSnapshot =
337
+ this.ctx.model?.provider === "openai-codex" ? cachedCodexUsage : undefined;
338
+ const thinkingLine = truncateToWidth(baseMetadata, width, ellipsis);
339
+ const quotaLines = codexSnapshot
340
+ ? renderCompactCodexQuotaBars(this.theme, codexSnapshot, width)
341
+ : [];
333
342
  const usageLine = truncateToWidth(
334
- usageLabel(this.ctx).trim(),
343
+ formatContextUsage(this.theme, contextUsageData(this.ctx), width),
335
344
  width,
336
345
  ellipsis,
337
346
  );
347
+ const lines = [thinkingLine, ...quotaLines, usageLine, locationLine];
338
348
  return statusLine
339
- ? [
340
- thinkingLine,
341
- usageLine,
342
- locationLine,
343
- truncateToWidth(statusLine, width, ellipsis),
344
- ]
345
- : [thinkingLine, usageLine, locationLine];
349
+ ? [...lines, truncateToWidth(statusLine, width, ellipsis)]
350
+ : lines;
346
351
  }
347
352
 
348
353
  invalidate(): void {}
@@ -433,6 +438,7 @@ export function createWorkingWidgetFactory(workingState: WorkingWidgetState) {
433
438
  export function createEditorComponent(
434
439
  pi: ExtensionAPI,
435
440
  ctx: ExtensionContext,
441
+ state: OsdyState,
436
442
  ): (
437
443
  tui: TUI,
438
444
  theme: EditorTheme,
@@ -476,15 +482,26 @@ export function createEditorComponent(
476
482
  "mdLink",
477
483
  ` ${resolveEditorTitleLabel(resolveActiveProfileLabel())} `,
478
484
  );
479
- const topRight = ctx.ui.theme.fg(
480
- "muted",
481
- ` ${modelLabel(ctx)} · think ${pi.getThinkingLevel()} `,
485
+ const cachedCodexUsage =
486
+ state.codexUsage.kind === "idle" ? undefined : state.codexUsage.snapshot;
487
+ const codexSnapshot =
488
+ ctx.model?.provider === "openai-codex" ? cachedCodexUsage : undefined;
489
+ const thinkingLevel = pi.getThinkingLevel();
490
+ const modelAndThinking = `${modelLabel(ctx)} · think ${ctx.ui.theme.bold(thinkingLevel)}`;
491
+ const layout = resolveCodexUsageLayout(topLeft, ` ${modelAndThinking} `);
492
+ const topRight = ctx.ui.theme.fg("muted", layout.topRight);
493
+ const bottomLeft = formatContextUsage(
494
+ ctx.ui.theme,
495
+ contextUsageData(ctx),
496
+ innerWidth,
482
497
  );
483
- const bottomLeft = ctx.ui.theme.fg("muted", usageLabel(ctx));
484
498
  lines[0] = `${borderColor("╭")}${fitBorder(topLeft, topRight, editorWidth - 2, borderColor)}${borderColor("╮")}`;
485
499
  lines[bottomIndex] =
486
500
  `${borderColor("╰")}${fitBorder(bottomLeft, "", editorWidth - 2, borderColor)}${borderColor("╯")}`;
487
- return lines;
501
+ const quotaLines = codexSnapshot
502
+ ? renderCompactCodexQuotaBars(ctx.ui.theme, codexSnapshot, editorWidth)
503
+ : [];
504
+ return [...lines, ...quotaLines];
488
505
  }
489
506
  }
490
507
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osdy-pi",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Osdy Pi package: themes, header, and custom editor for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,7 +11,8 @@
11
11
  "extension"
12
12
  ],
13
13
  "bin": {
14
- "osdy-pi": "bin/osdy-pi.mjs"
14
+ "osdy-pi": "bin/osdy-pi.mjs",
15
+ "osdy": "bin/osdy.mjs"
15
16
  },
16
17
  "scripts": {
17
18
  "typecheck": "tsc --noEmit",
@@ -24,6 +25,8 @@
24
25
  "LICENSE",
25
26
  "bin",
26
27
  "scripts/osdy-pi-account-profiles.mjs",
28
+ "scripts/osdy-pi-gentle-coexistence.mjs",
29
+ "scripts/osdy-pi-profile-setup.mjs",
27
30
  "themes",
28
31
  "extensions",
29
32
  "mapche1.png"