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,8 +1,50 @@
1
1
  import assert from "node:assert/strict";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { registerHooks } from "node:module";
2
4
  import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
3
6
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- // @ts-expect-error Node's native TypeScript runner resolves test-only TypeScript source imports.
5
- import { PLUGIN_EVENTS, subscribeQuestionPromptAudioNotification } from "./plugin-events.ts";
7
+ import ts from "typescript";
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
+ const source = readFileSync(fileURLToPath(url), "utf8");
26
+ return {
27
+ format: "module",
28
+ shortCircuit: true,
29
+ source: ts.transpileModule(source, {
30
+ compilerOptions: {
31
+ module: ts.ModuleKind.ESNext,
32
+ target: ts.ScriptTarget.ES2022,
33
+ },
34
+ }).outputText,
35
+ };
36
+ }
37
+ return nextLoad(url, context);
38
+ },
39
+ });
40
+
41
+ const { PLUGIN_EVENTS, subscribeQuestionPromptAudioNotification } =
42
+ await import("./plugin-events.js");
43
+ const {
44
+ createActiveSessionRefresh,
45
+ getOsdyCommandCompletions,
46
+ refreshCodexUsage,
47
+ } = await import("./runtime.js");
6
48
 
7
49
  class TestEventBus {
8
50
  event: string | undefined;
@@ -38,16 +80,322 @@ class TestSessionContextProvider {
38
80
  }
39
81
  }
40
82
 
83
+ void test("header command completes only catalog choices and is persisted through session wiring", () => {
84
+ assert.deepEqual(getOsdyCommandCompletions("header"), [
85
+ { value: "header osdy-theme", label: "header osdy-theme" },
86
+ { value: "header neon", label: "header neon" },
87
+ { value: "header status", label: "header status" },
88
+ ]);
89
+ assert.deepEqual(getOsdyCommandCompletions("header n"), [
90
+ { value: "header neon", label: "header neon" },
91
+ ]);
92
+ assert.deepEqual(getOsdyCommandCompletions("classic"), []);
93
+
94
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
95
+ assert.match(
96
+ source,
97
+ /async function handleHeaderCommand[\s\S]*?state\.headerVariant = action;[\s\S]*?applyOsdyPi\([\s\S]*?saveVisualSettings\(state, settingsStore\)/,
98
+ );
99
+ assert.match(source, /headerVariant: state\.headerVariant/);
100
+ assert.match(source, /state\.headerVariant = editorSettings\.headerVariant;/);
101
+ assert.doesNotMatch(source, /registerCommand\(`osdy-pi-\$\{variant\}`/);
102
+ assert.doesNotMatch(source, /\["osdy-theme", "classic"\]/);
103
+ assert.doesNotMatch(source, /if \(isHeaderVariant\(action\)\)/);
104
+ });
105
+
106
+ void test("mascot command completes current, Bts, and status while persisting with immediate refresh", () => {
107
+ assert.deepEqual(getOsdyCommandCompletions("mascot"), [
108
+ { value: "mascot current", label: "mascot current" },
109
+ { value: "mascot bts", label: "mascot bts" },
110
+ { value: "mascot status", label: "mascot status" },
111
+ ]);
112
+ assert.deepEqual(getOsdyCommandCompletions("mascot b"), [
113
+ { value: "mascot bts", label: "mascot bts" },
114
+ ]);
115
+ assert.deepEqual(getOsdyCommandCompletions("mascot d"), []);
116
+
117
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
118
+ assert.match(
119
+ source,
120
+ /state\.mascot = action;[\s\S]*?applyOsdyPi\([\s\S]*?saveVisualSettings\(state, settingsStore\)/,
121
+ );
122
+ assert.match(source, /state\.mascot = editorSettings\.mascot;/);
123
+ assert.match(source, /function mascotLabel\(mascot: MascotChoice\): string/);
124
+ assert.match(source, /osdy-pi mascot: \$\{mascotLabel\(state\.mascot\)\}/);
125
+ });
126
+
127
+ void test("account switching requests an editor render independently of usage refresh", () => {
128
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
129
+
130
+ assert.match(
131
+ source,
132
+ /registerAccountProfilesCommand\(pi, \{[\s\S]*?requestRender: \(\) => \{[\s\S]*?state\.tui\?\.requestRender\(\);[\s\S]*?\},[\s\S]*?refreshUsage: async \(\) => \{[\s\S]*?const activeSessionContext = sessionContext;[\s\S]*?await refreshCurrentCodexUsage\(activeSessionContext\)/,
133
+ );
134
+ });
135
+
136
+ void test("usage refresh resolves the active session instead of a distinct command context", async () => {
137
+ const activeSessionContext = { id: "active-session" };
138
+ const commandContext = { id: "command-context" };
139
+ const refreshedContexts: Array<{ id: string }> = [];
140
+ const refreshActiveSessionUsage = createActiveSessionRefresh(
141
+ () => activeSessionContext,
142
+ (context) => {
143
+ refreshedContexts.push(context);
144
+ return Promise.resolve();
145
+ },
146
+ );
147
+
148
+ assert.notEqual(commandContext, activeSessionContext);
149
+ await refreshActiveSessionUsage();
150
+ assert.deepEqual(refreshedContexts, [activeSessionContext]);
151
+ });
152
+
153
+ void test("agent settlement refreshes enabled active Codex sessions without comparing event context identity", () => {
154
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
155
+ const agentSettledHandler = source.match(
156
+ /pi\.on\("agent_settled", \(\) => \{([\s\S]*?)\n\t\}\);/,
157
+ )?.[1];
158
+ const agentEndHandler = source.match(
159
+ /pi\.on\("agent_end", \(_event, ctx\) => \{([\s\S]*?)\n\t\}\);/,
160
+ )?.[1];
161
+
162
+ assert.ok(agentSettledHandler);
163
+ assert.match(
164
+ agentSettledHandler,
165
+ /const activeSessionContext = sessionContext;[\s\S]*?state\.enabled[\s\S]*?activeSessionContext &&[\s\S]*?activeSessionContext\.model\?\.provider === "openai-codex"[\s\S]*?void refreshCurrentCodexUsage\(activeSessionContext\)/,
166
+ );
167
+ assert.doesNotMatch(agentSettledHandler, /activeSessionContext\s*===\s*ctx/);
168
+ assert.ok(agentEndHandler);
169
+ assert.doesNotMatch(agentEndHandler, /refreshCurrentCodexUsage/);
170
+ });
171
+
172
+ void test("Codex usage state refresh repaints the shared TUI after loading and error", async () => {
173
+ const renderedStates: string[] = [];
174
+ const state = {
175
+ codexUsage: { kind: "idle" } as const,
176
+ tui: {
177
+ requestRender: () => renderedStates.push(state.codexUsage.kind),
178
+ },
179
+ };
180
+
181
+ await refreshCodexUsage(
182
+ {
183
+ modelRegistry: {
184
+ getProviderAuth: () => Promise.resolve(undefined),
185
+ },
186
+ },
187
+ state,
188
+ new AbortController(),
189
+ );
190
+
191
+ assert.deepEqual(renderedStates, ["loading", "error"]);
192
+ assert.equal(state.codexUsage.kind, "error");
193
+ });
194
+
195
+ void test("Codex usage refresh repaints the shared TUI with its ready quota snapshot", async () => {
196
+ const snapshot = {
197
+ planType: "plus",
198
+ ordinaryUsageAllowed: true,
199
+ buckets: [
200
+ {
201
+ id: "codex",
202
+ label: undefined,
203
+ primary: { usedPercent: 37, windowMinutes: 300, resetsAt: undefined },
204
+ secondary: undefined,
205
+ },
206
+ ],
207
+ credits: undefined,
208
+ fetchedAt: 1_900_000_000_000,
209
+ };
210
+ const renderedStates: string[] = [];
211
+ const state = {
212
+ codexUsage: { kind: "idle" } as const,
213
+ tui: {
214
+ requestRender: () => renderedStates.push(state.codexUsage.kind),
215
+ },
216
+ };
217
+ const accessToken = `header.${Buffer.from(
218
+ JSON.stringify({
219
+ "https://api.openai.com/auth": { chatgpt_account_id: "account-id" },
220
+ }),
221
+ ).toString("base64url")}.signature`;
222
+
223
+ await refreshCodexUsage(
224
+ {
225
+ modelRegistry: {
226
+ getProviderAuth: () => Promise.resolve({ auth: { apiKey: accessToken } }),
227
+ },
228
+ },
229
+ state,
230
+ new AbortController(),
231
+ () => Promise.resolve(snapshot),
232
+ );
233
+
234
+ assert.deepEqual(renderedStates, ["loading", "ready"]);
235
+ assert.deepEqual(state.codexUsage, { kind: "ready", snapshot });
236
+ });
237
+
238
+ void test("Codex refresh discards prior usage snapshots and shutdown resets usage state", () => {
239
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
240
+ assert.match(
241
+ source,
242
+ /state\.codexUsage = \{ kind: "loading", snapshot: undefined \}/,
243
+ );
244
+ assert.match(source, /kind: "error",[\s\S]*?snapshot: undefined,/);
245
+ assert.match(
246
+ source,
247
+ /pi\.on\("session_shutdown", \(\) => \{[\s\S]*?codexUsageAbort\?\.abort\(\);[\s\S]*?state\.codexUsage = \{ kind: "idle" \}/,
248
+ );
249
+ });
250
+
251
+ void test("agent and tool completion reclaim the Gentle changed-files widget", () => {
252
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
253
+ const agentEndHandler = source.match(
254
+ /pi\.on\("agent_end", \(_event, ctx\) => \{([\s\S]*?)\n\t\}\);/,
255
+ )?.[1];
256
+ const toolExecutionEndHandler = source.match(
257
+ /pi\.on\("tool_execution_end", \(event, ctx\) => \{([\s\S]*?)\n\t\}\);/,
258
+ )?.[1];
259
+
260
+ assert.ok(agentEndHandler);
261
+ assert.ok(toolExecutionEndHandler);
262
+ assert.match(
263
+ agentEndHandler,
264
+ /if \(state\.enabled\) clearGentleShellChangesWidget\(ctx\)/,
265
+ );
266
+ assert.match(
267
+ toolExecutionEndHandler,
268
+ /if \(state\.enabled\) clearGentleShellChangesWidget\(ctx\)/,
269
+ );
270
+ });
271
+
272
+ void test("session shutdown does not restore the captured fallback editor", () => {
273
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
274
+ const shutdownHandler = source.match(
275
+ /pi\.on\("session_shutdown", \(\) => \{([\s\S]*?)\n\t\}\);/,
276
+ )?.[1];
277
+
278
+ assert.ok(shutdownHandler);
279
+ assert.doesNotMatch(shutdownHandler, /disableOsdyPi|setEditorComponent/);
280
+ });
281
+
282
+ void test("persisted disabled startup leaves Gentle UI unclaimed and simple startup stays native", () => {
283
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
284
+ const sessionStartHandler = source.match(
285
+ /pi\.on\("session_start", async \(_event, ctx\) => \{([\s\S]*?)\n\t\}\);/,
286
+ )?.[1];
287
+
288
+ assert.ok(sessionStartHandler);
289
+ assert.match(
290
+ sessionStartHandler,
291
+ /state\.fallbackEditorFactory = ctx\.ui\.getEditorComponent\(\);[\s\S]*?state\.enabled = editorSettings\.enabled;[\s\S]*?if \(!state\.enabled\) return;/,
292
+ );
293
+ assert.match(
294
+ sessionStartHandler,
295
+ /state\.editorMode = editorSettings\.editorMode;[\s\S]*?claimOsdyVisualLayer\(/,
296
+ );
297
+ assert.match(
298
+ sessionStartHandler,
299
+ /if \(!state\.enabled\) return;[\s\S]*?startResponsive\(\)/,
300
+ );
301
+ assert.match(
302
+ source,
303
+ /const startResponsive = \(\): void => \{[\s\S]*?responsiveCoordinator = createResponsiveCoordinator\(/,
304
+ );
305
+ });
306
+
307
+ void test("enable, disable, and their on/off aliases persist the complete visual settings", () => {
308
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
309
+
310
+ assert.match(source, /\["enable", "on"\]\.includes\(action\)/);
311
+ assert.match(source, /\["disable", "off"\]\.includes\(action\)/);
312
+ assert.match(
313
+ source,
314
+ /settingsStore\.save\(\{[\s\S]*?enabled: state\.enabled,[\s\S]*?editorMode: state\.editorMode,[\s\S]*?workingTreeEnabled: state\.workingTreeEnabled,[\s\S]*?\}\)/,
315
+ );
316
+ });
317
+
318
+ void test("does not import or register the response-card Markdown transformer", () => {
319
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
320
+
321
+ assert.doesNotMatch(source, /response-card/);
322
+ assert.doesNotMatch(source, /pi\.registerMarkdownTransformer\(/);
323
+ });
324
+
325
+ void test("every registered theme defines non-empty assistant message tokens", () => {
326
+ const manifest = JSON.parse(
327
+ readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
328
+ ) as { pi: { themes: string[] } };
329
+
330
+ assert.equal(manifest.pi.themes.length, 14);
331
+ for (const themePath of manifest.pi.themes) {
332
+ const theme = JSON.parse(
333
+ readFileSync(
334
+ new URL(`../../${themePath.replace(/^\.\//, "")}`, import.meta.url),
335
+ "utf8",
336
+ ),
337
+ ) as { colors: Record<string, unknown> };
338
+ for (const token of [
339
+ "assistantMessageBg",
340
+ "assistantMessageAccentBg",
341
+ "assistantMessageText",
342
+ ]) {
343
+ const value = theme.colors[token];
344
+ assert.equal(
345
+ typeof value === "string" && value.trim() !== "",
346
+ true,
347
+ `${themePath} must define a non-empty ${token}`,
348
+ );
349
+ }
350
+ assert.equal(
351
+ theme.colors.assistantMessageAccentBg,
352
+ theme.colors.accent,
353
+ `${themePath} assistant stripe must match its accent`,
354
+ );
355
+ assert.equal(
356
+ theme.colors.userMessageAccentBg,
357
+ "#FFFFFF",
358
+ `${themePath} user stripe must be white`,
359
+ );
360
+ }
361
+ });
362
+
363
+ void test("Tokyo Night uses original palette tokens for message stripes", () => {
364
+ const theme = JSON.parse(
365
+ readFileSync(
366
+ new URL("../../themes/osdy-pi-tokyo-night.json", import.meta.url),
367
+ "utf8",
368
+ ),
369
+ ) as { colors: Record<string, unknown> };
370
+
371
+ assert.deepEqual(
372
+ {
373
+ assistantMessageBg: theme.colors.assistantMessageBg,
374
+ assistantMessageAccentBg: theme.colors.assistantMessageAccentBg,
375
+ assistantMessageText: theme.colors.assistantMessageText,
376
+ userMessageAccentBg: theme.colors.userMessageAccentBg,
377
+ userMessageBg: theme.colors.userMessageBg,
378
+ userMessageText: theme.colors.userMessageText,
379
+ },
380
+ {
381
+ assistantMessageBg: "elevated",
382
+ assistantMessageAccentBg: "blue",
383
+ assistantMessageText: "text",
384
+ userMessageAccentBg: "#FFFFFF",
385
+ userMessageBg: "panel",
386
+ userMessageText: "text",
387
+ },
388
+ );
389
+ });
390
+
41
391
  void test("question prompts subscribe through the plugin event bus", () => {
42
392
  const api = new TestExtensionApi();
43
393
  const sessionContextProvider = new TestSessionContextProvider();
44
394
  const notifiedContexts: ExtensionContext[] = [];
45
395
 
46
- subscribeQuestionPromptAudioNotification(
47
- api,
48
- sessionContextProvider,
49
- { onQuestionRequested: (ctx) => notifiedContexts.push(ctx) },
50
- );
396
+ subscribeQuestionPromptAudioNotification(api, sessionContextProvider, {
397
+ onQuestionRequested: (ctx) => notifiedContexts.push(ctx),
398
+ });
51
399
 
52
400
  assert.equal(api.events.event, PLUGIN_EVENTS.QUESTION_PROMPT);
53
401
  assert.equal(api.lifecycleOnCallCount, 0);