osdy-pi 1.4.0 → 1.5.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 +5 -0
  2. package/README.md +48 -11
  3. package/extensions/osdy-pi/codex-usage-ui.ts +2 -32
  4. package/extensions/osdy-pi/locales/de.json +16 -0
  5. package/extensions/osdy-pi/locales/en.json +15 -0
  6. package/extensions/osdy-pi/locales/es.json +16 -0
  7. package/extensions/osdy-pi/locales/fr.json +16 -0
  8. package/extensions/osdy-pi/locales/pt-BR.json +16 -0
  9. package/extensions/osdy-pi/locales/pt.json +16 -0
  10. package/extensions/osdy-pi/locales/ru.json +16 -0
  11. package/extensions/osdy-pi/locales/uk.json +16 -0
  12. package/extensions/osdy-pi/locales/zh.json +16 -0
  13. package/extensions/osdy-pi/message-role-markers.test.ts +161 -0
  14. package/extensions/osdy-pi/message-role-markers.ts +52 -0
  15. package/extensions/osdy-pi/modal-frame.test.ts +78 -0
  16. package/extensions/osdy-pi/modal-frame.ts +35 -0
  17. package/extensions/osdy-pi/runtime.test.ts +25 -26
  18. package/extensions/osdy-pi/runtime.ts +10 -0
  19. package/extensions/osdy-pi/todo-command.test.ts +132 -0
  20. package/extensions/osdy-pi/todo-command.ts +60 -0
  21. package/extensions/osdy-pi/todo-config.test.ts +67 -0
  22. package/extensions/osdy-pi/todo-config.ts +78 -0
  23. package/extensions/osdy-pi/todo-domain.test.ts +106 -0
  24. package/extensions/osdy-pi/todo-domain.ts +183 -0
  25. package/extensions/osdy-pi/todo-i18n.test.ts +52 -0
  26. package/extensions/osdy-pi/todo-i18n.ts +43 -0
  27. package/extensions/osdy-pi/todo-panel.test.ts +163 -0
  28. package/extensions/osdy-pi/todo-panel.ts +88 -0
  29. package/extensions/osdy-pi/todo-session.test.ts +81 -0
  30. package/extensions/osdy-pi/todo-session.ts +75 -0
  31. package/extensions/osdy-pi/todo-tool-render.test.ts +63 -0
  32. package/extensions/osdy-pi/todo-tool-render.ts +60 -0
  33. package/extensions/osdy-pi/todo-tool.test.ts +139 -0
  34. package/extensions/osdy-pi/todo-tool.ts +104 -0
  35. package/extensions/osdy-pi/todo-widget.test.ts +235 -0
  36. package/extensions/osdy-pi/todo-widget.ts +111 -0
  37. package/extensions/osdy-pi/types.ts +1 -0
  38. package/extensions/osdy-pi/ui.ts +2 -8
  39. package/extensions/osdy-pi/working-animation.test.ts +166 -0
  40. package/extensions/osdy-pi/working-animation.ts +33 -0
  41. package/package.json +11 -2
  42. package/themes/osdy-pi-catppuccin-frappe.json +1 -1
  43. package/themes/osdy-pi-catppuccin-latte.json +1 -1
  44. package/themes/osdy-pi-catppuccin-macchiato.json +1 -1
  45. package/themes/osdy-pi-catppuccin-mocha.json +1 -1
  46. package/themes/osdy-pi-dark.json +1 -1
  47. package/themes/osdy-pi-dracula.json +1 -1
  48. package/themes/osdy-pi-kanagawa-dragon.json +1 -1
  49. package/themes/osdy-pi-kanagawa-lotus.json +1 -1
  50. package/themes/osdy-pi-kanagawa-wave.json +1 -1
  51. package/themes/osdy-pi-lucent-orange.json +1 -1
  52. package/themes/osdy-pi-matrix.json +1 -1
  53. package/themes/osdy-pi-new.json +1 -1
  54. package/themes/osdy-pi-sexy.json +1 -1
  55. package/themes/osdy-pi-tokyo-night.json +1 -1
@@ -0,0 +1,78 @@
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 { visibleWidth } from "@earendil-works/pi-tui";
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 { doubleBorderBox, MODAL_OVERLAY_OPTIONS } = await import("./modal-frame.js");
42
+
43
+ void test("shares the centered usage overlay dimensions", () => {
44
+ assert.deepEqual(MODAL_OVERLAY_OPTIONS, {
45
+ anchor: "center",
46
+ width: 96,
47
+ minWidth: 48,
48
+ maxHeight: "92%",
49
+ margin: 1,
50
+ });
51
+ });
52
+
53
+ void test("preserves the themed usage heading and pads ANSI content by visible width", () => {
54
+ const theme = {
55
+ fg: (name: string, text: string): string =>
56
+ `\u001B[${name === "border" ? 34 : 36}m${text}\u001B[0m`,
57
+ };
58
+ const lines = doubleBorderBox(theme, 12, "Hi", ["\u001B[31m界\u001B[0m!"]);
59
+ const strip = (line: string): string =>
60
+ line.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), "");
61
+ assert.deepEqual(lines.map(strip), [
62
+ "╔ Hi ╗",
63
+ "║界! ║",
64
+ "╚══════════╝",
65
+ ]);
66
+ assert.ok(lines[0]?.includes("\u001B[36m Hi \u001B[0m"));
67
+ assert.ok(lines[1]?.startsWith("\u001B[34m║\u001B[0m"));
68
+ assert.ok(lines.every((line) => visibleWidth(line) === 12));
69
+ });
70
+
71
+ void test("truncates long titles and content in narrow frames", () => {
72
+ const theme = { fg: (_name: string, text: string): string => text };
73
+ const lines = doubleBorderBox(theme, 6, "Long title", ["long content"]);
74
+ const strip = (line: string): string =>
75
+ line.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), "");
76
+ assert.deepEqual(lines.map(strip), ["╔ ...╗", "║l...║", "╚════╝"]);
77
+ assert.ok(lines.every((line) => visibleWidth(line) === 6));
78
+ });
@@ -0,0 +1,35 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { SimpleTheme } from "./types.js";
3
+
4
+ export const MODAL_OVERLAY_OPTIONS = {
5
+ anchor: "center",
6
+ width: 96,
7
+ minWidth: 48,
8
+ maxHeight: "92%",
9
+ margin: 1,
10
+ } as const;
11
+
12
+ export function doubleBorderBox(
13
+ theme: SimpleTheme,
14
+ width: number,
15
+ title: string,
16
+ lines: string[],
17
+ ): string[] {
18
+ const innerWidth = Math.max(1, width - 2);
19
+ const heading = truncateToWidth(` ${title} `, innerWidth, "...", true);
20
+ const leftWidth = Math.floor(
21
+ Math.max(0, innerWidth - visibleWidth(heading)) / 2,
22
+ );
23
+ const rightWidth = Math.max(0, innerWidth - visibleWidth(heading) - leftWidth);
24
+ const pad = (line: string): string => {
25
+ const text = truncateToWidth(line, innerWidth, "...", true);
26
+ return `${text}${" ".repeat(Math.max(0, innerWidth - visibleWidth(text)))}`;
27
+ };
28
+ return [
29
+ `${theme.fg("border", `╔${"═".repeat(leftWidth)}`)}${theme.fg("accent", heading)}${theme.fg("border", `${"═".repeat(rightWidth)}╗`)}`,
30
+ ...lines.map(
31
+ (line) => `${theme.fg("border", "║")}${pad(line)}${theme.fg("border", "║")}`,
32
+ ),
33
+ theme.fg("border", `╚${"═".repeat(innerWidth)}╝`),
34
+ ];
35
+ }
@@ -81,6 +81,20 @@ class TestSessionContextProvider {
81
81
  }
82
82
  }
83
83
 
84
+ void test("runtime wires standalone role markers to the existing enabled state", () => {
85
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
86
+ assert.match(source, /registerMessageRoleMarkers\(pi, \(\) => state\.enabled\)/);
87
+ assert.equal(source.match(/registerMessageRoleMarkers\(pi, /g)?.length, 1);
88
+ });
89
+
90
+ void test("runtime passes one session store to exactly one todo tool and command", () => {
91
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
92
+ assert.equal(source.match(/createTodoSessionStore\(\)/g)?.length, 1);
93
+ assert.equal(source.match(/registerTodoTool\(pi, todoStore\)/g)?.length, 1);
94
+ assert.equal(source.match(/registerTodosCommand\(pi, todoStore\)/g)?.length, 1);
95
+ assert.equal(source.match(/registerTodoWidget\(pi, todoStore\)/g)?.length, 1);
96
+ });
97
+
84
98
  void test("agents setup requires interactive confirmation and reloads only after success", async () => {
85
99
  const notices: string[] = [];
86
100
  let installs = 0;
@@ -349,6 +363,13 @@ void test("enable, disable, and their on/off aliases persist the complete visual
349
363
  );
350
364
  });
351
365
 
366
+ void test("registers session-backed todo surfaces without the old ODD tool", () => {
367
+ const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
368
+ assert.match(source, /import \{ registerTodoTool \} from "\.\/todo-tool\.js"/);
369
+ assert.match(source, /import \{ registerTodosCommand \} from "\.\/todo-command\.js"/);
370
+ assert.doesNotMatch(source, /registerOddTodo|odd-todo-ui/);
371
+ });
372
+
352
373
  void test("does not import or register the response-card Markdown transformer", () => {
353
374
  const source = readFileSync(new URL("./runtime.ts", import.meta.url), "utf8");
354
375
 
@@ -356,7 +377,7 @@ void test("does not import or register the response-card Markdown transformer",
356
377
  assert.doesNotMatch(source, /pi\.registerMarkdownTransformer\(/);
357
378
  });
358
379
 
359
- void test("every registered theme defines non-empty assistant message tokens", () => {
380
+ void test("every registered theme defines native user message colors", () => {
360
381
  const manifest = JSON.parse(
361
382
  readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
362
383
  ) as { pi: { themes: string[] } };
@@ -369,11 +390,7 @@ void test("every registered theme defines non-empty assistant message tokens", (
369
390
  "utf8",
370
391
  ),
371
392
  ) as { colors: Record<string, unknown> };
372
- for (const token of [
373
- "assistantMessageBg",
374
- "assistantMessageAccentBg",
375
- "assistantMessageText",
376
- ]) {
393
+ for (const token of ["userMessageBg", "userMessageText"]) {
377
394
  const value = theme.colors[token];
378
395
  assert.equal(
379
396
  typeof value === "string" && value.trim() !== "",
@@ -381,20 +398,10 @@ void test("every registered theme defines non-empty assistant message tokens", (
381
398
  `${themePath} must define a non-empty ${token}`,
382
399
  );
383
400
  }
384
- assert.equal(
385
- theme.colors.assistantMessageAccentBg,
386
- theme.colors.accent,
387
- `${themePath} assistant stripe must match its accent`,
388
- );
389
- assert.equal(
390
- theme.colors.userMessageAccentBg,
391
- "#FFFFFF",
392
- `${themePath} user stripe must be white`,
393
- );
394
401
  }
395
402
  });
396
403
 
397
- void test("Tokyo Night uses original palette tokens for message stripes", () => {
404
+ void test("Tokyo Night retains its approved native user card colors", () => {
398
405
  const theme = JSON.parse(
399
406
  readFileSync(
400
407
  new URL("../../themes/osdy-pi-tokyo-night.json", import.meta.url),
@@ -404,19 +411,11 @@ void test("Tokyo Night uses original palette tokens for message stripes", () =>
404
411
 
405
412
  assert.deepEqual(
406
413
  {
407
- assistantMessageBg: theme.colors.assistantMessageBg,
408
- assistantMessageAccentBg: theme.colors.assistantMessageAccentBg,
409
- assistantMessageText: theme.colors.assistantMessageText,
410
- userMessageAccentBg: theme.colors.userMessageAccentBg,
411
414
  userMessageBg: theme.colors.userMessageBg,
412
415
  userMessageText: theme.colors.userMessageText,
413
416
  },
414
417
  {
415
- assistantMessageBg: "elevated",
416
- assistantMessageAccentBg: "blue",
417
- assistantMessageText: "text",
418
- userMessageAccentBg: "#FFFFFF",
419
- userMessageBg: "panel",
418
+ userMessageBg: "#283b59",
420
419
  userMessageText: "text",
421
420
  },
422
421
  );
@@ -56,6 +56,11 @@ import {
56
56
  createWorkingController,
57
57
  type WorkingController,
58
58
  } from "./working-controller.js";
59
+ import { registerTodoTool } from "./todo-tool.js";
60
+ import { registerTodosCommand } from "./todo-command.js";
61
+ import { createTodoSessionStore } from "./todo-session.js";
62
+ import { registerMessageRoleMarkers } from "./message-role-markers.js";
63
+ import { registerTodoWidget } from "./todo-widget.js";
59
64
 
60
65
  function scheduleOsdyRefresh(
61
66
  delayMs: number,
@@ -899,6 +904,11 @@ export function registerOsdyPi(pi: ExtensionAPI): void {
899
904
  const settingsStore = createAudioSoundSettingsStore();
900
905
  const editorSettingsStore = createEditorSettingsStore();
901
906
  registerAudioNotificationFlags(pi);
907
+ registerMessageRoleMarkers(pi, () => state.enabled);
908
+ const todoStore = createTodoSessionStore();
909
+ registerTodoTool(pi, todoStore);
910
+ registerTodosCommand(pi, todoStore);
911
+ registerTodoWidget(pi, todoStore);
902
912
  const audioRouter = createAudioEventRouter(
903
913
  createAudioNotificationService(
904
914
  pi,
@@ -0,0 +1,132 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync } from "node:fs";
3
+ import { registerHooks } from "node:module";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import type { TodoI18n } from "./todo-i18n.js";
8
+
9
+ registerHooks({ resolve(specifier, context, next) {
10
+ if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
11
+ const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
12
+ if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
13
+ }
14
+ return next(specifier, context);
15
+ } });
16
+
17
+ const { registerTodoTool } = await import("./todo-tool.js");
18
+ const { registerTodosCommand } = await import("./todo-command.js");
19
+ const { createTodoSessionStore } = await import("./todo-session.js");
20
+
21
+ type Context = { sessionManager: { getSessionId: () => string; getBranch: () => unknown[] }; mode: string; hasUI: boolean; ui: { notify: (text: string, level: string) => void; custom: (factory: (...args: unknown[]) => unknown, options: unknown) => Promise<void> } };
22
+ function harness(i18n?: TodoI18n) {
23
+ const notices: Array<[string, string]> = [];
24
+ const modals: Array<{ factory: (...args: unknown[]) => unknown; options: unknown }> = [];
25
+ const commands = new Map<string, { handler: (args: string, ctx: Context) => Promise<void> }>();
26
+ let execute: ((...args: unknown[]) => Promise<unknown>) | undefined;
27
+ const events = new Map<string, (event: unknown, ctx: Context) => void>();
28
+ const pi = {
29
+ registerTool: (tool: { execute: (...args: unknown[]) => Promise<unknown> }) => { execute = tool.execute; },
30
+ registerCommand: (name: string, command: { handler: (args: string, ctx: Context) => Promise<void> }) => { commands.set(name, command); },
31
+ on: (name: string, handler: (event: unknown, ctx: Context) => void) => { events.set(name, handler); },
32
+ } as unknown as ExtensionAPI;
33
+ const store = createTodoSessionStore();
34
+ registerTodoTool(pi, store);
35
+ registerTodosCommand(pi, store, i18n);
36
+ const context = (id: string, hasUI = true, mode = "rpc"): Context => ({ sessionManager: { getSessionId: () => id, getBranch: () => [] }, mode, hasUI, ui: { notify: (text, level) => { notices.push([text, level]); }, custom: (factory, options) => { modals.push({ factory, options }); return Promise.resolve(); } } });
37
+ const run = async (ctx: Context, params: Record<string, unknown>) => { assert.ok(execute); await execute("call", params, undefined, undefined, ctx); };
38
+ const show = async (ctx: Context, args = "") => { const command = commands.get("todos"); assert.ok(command); await command.handler(args, ctx); return notices.at(-1); };
39
+ return { commands, notices, modals, context, run, show, events };
40
+ }
41
+
42
+ void test("/todos reads the same store after tool create, without interpreting arguments", async () => {
43
+ const h = harness();
44
+ assert.deepEqual([...h.commands.keys()], ["todos"]);
45
+ const parent = h.context("parent");
46
+ await h.run(parent, { action: "create", subject: "Write tests" });
47
+ assert.deepEqual(await h.show(parent, "clear all"), ["1 pending\n── Pending ──\n ○ #1 Write tests", "info"]);
48
+ assert.deepEqual(await h.show(parent), ["1 pending\n── Pending ──\n ○ #1 Write tests", "info"]);
49
+ const child = h.context("child");
50
+ assert.deepEqual(await h.show(child), ["No todos yet. Ask the agent to add some!", "info"]);
51
+ await h.run(child, { action: "create", subject: "Child only" });
52
+ assert.deepEqual(await h.show(child), ["1 pending\n── Pending ──\n ○ #1 Child only", "info"]);
53
+ assert.deepEqual(await h.show(parent), ["1 pending\n── Pending ──\n ○ #1 Write tests", "info"]);
54
+ assert.deepEqual(await h.show(h.context("parent", false)), ["/todos requires interactive mode", "error"]);
55
+ });
56
+
57
+ void test("/todos groups visible statuses and treats deleted-only as empty", async () => {
58
+ const h = harness();
59
+ const ctx = h.context("parent");
60
+ await h.run(ctx, { action: "create", subject: "First" });
61
+ await h.run(ctx, { action: "create", subject: "Build", blockedBy: [1] });
62
+ await h.run(ctx, { action: "update", id: 2, status: "in_progress", activeForm: "building" });
63
+ await h.run(ctx, { action: "update", id: 1, status: "completed" });
64
+ await h.run(ctx, { action: "create", subject: "Next", blockedBy: [2] });
65
+ assert.deepEqual(await h.show(ctx), ["1/3 completed · 1 in progress · 1 pending\n── Pending ──\n ○ #3 Next ⛓ #2\n── In Progress ──\n ◐ #2 Build (building) ⛓ #1\n── Completed ──\n ✓ #1 First", "info"]);
66
+ await h.run(ctx, { action: "delete", id: 1 });
67
+ await h.run(ctx, { action: "delete", id: 2 });
68
+ await h.run(ctx, { action: "delete", id: 3 });
69
+ assert.deepEqual(await h.show(ctx), ["No todos yet. Ask the agent to add some!", "info"]);
70
+ });
71
+
72
+ void test("/todos translates UI at invocation time without changing tool responses", async () => {
73
+ let language = "es";
74
+ const dictionary: Record<string, string> = { "status.pending": "pendientes", "status.completed": "completadas", "command.section.pending": "── Pendientes ──", "command.no_todos": "Sin tareas", "command.requires_interactive": "Modo interactivo requerido" };
75
+ const i18n: TodoI18n = { t: (key, fallback) => language === "es" ? dictionary[key] ?? fallback : fallback, status: (status) => language === "es" ? dictionary[`status.${status}`] ?? status : status };
76
+ const h = harness(i18n); const ctx = h.context("main");
77
+ assert.deepEqual(await h.show(ctx), ["Sin tareas", "info"]);
78
+ assert.deepEqual(await h.show(h.context("main", false)), ["Modo interactivo requerido", "error"]);
79
+ await h.run(ctx, { action: "create", subject: "Write tests" });
80
+ assert.deepEqual(await h.show(ctx), ["1 pendientes\n── Pendientes ──\n ○ #1 Write tests", "info"]);
81
+ language = "en";
82
+ assert.deepEqual(await h.show(ctx), ["1 pending\n── Pending ──\n ○ #1 Write tests", "info"]);
83
+ });
84
+
85
+ void test("TUI /todos opens the shared overlay for an empty session and never notifies", async () => {
86
+ const h = harness();
87
+ await h.show(h.context("main", true, "tui"));
88
+ assert.deepEqual(h.notices, []);
89
+ assert.equal(h.modals.length, 1);
90
+ assert.deepEqual(h.modals[0]?.options, { overlay: true, overlayOptions: { anchor: "center", width: 96, minWidth: 48, maxHeight: "92%", margin: 1 } });
91
+ const panel = h.modals[0]?.factory({ terminal: { rows: 20 }, requestRender() {} }, { fg: (_color: string, text: string) => text }, null, () => {} ) as { render: (width: number) => string[] };
92
+ assert.match(panel.render(50).join("\n"), /Todos[\s\S]*No todos yet/);
93
+ });
94
+
95
+ void test("TUI /todos gets localized grouped read-only content without mutating the store", async () => {
96
+ const i18n: TodoI18n = { t: (key, fallback) => ({ "overlay.heading": "Tareas", "command.section.pending": "── Pendientes ──" })[key] ?? fallback, status: (status) => status === "pending" ? "pendientes" : status };
97
+ const h = harness(i18n); const ctx = h.context("main", true, "tui");
98
+ await h.run(ctx, { action: "create", subject: "Write tests" });
99
+ await h.show(ctx, "clear all");
100
+ assert.deepEqual(h.notices, []);
101
+ const panel = h.modals[0]?.factory({ terminal: { rows: 20 }, requestRender() {} }, { fg: (_color: string, text: string) => text }, null, () => {}) as { render: (width: number) => string[] };
102
+ assert.match(panel.render(60).join("\n"), /Tareas[\s\S]*1 pendientes[\s\S]*── Pendientes ──[\s\S]*○ #1 Write tests/);
103
+ assert.deepEqual(await h.show(h.context("main", true, "rpc")), ["1 pendientes\n── Pendientes ──\n ○ #1 Write tests", "info"]);
104
+ });
105
+
106
+ void test("TUI /todos supplies semantic rows while RPC keeps its exact grouped notification", async () => {
107
+ const h = harness(); const ctx = h.context("main", true, "tui");
108
+ await h.run(ctx, { action: "create", subject: "First" });
109
+ await h.run(ctx, { action: "create", subject: "Second" });
110
+ await h.run(ctx, { action: "update", id: 2, status: "completed" });
111
+ await h.show(ctx);
112
+ const colors: string[] = [];
113
+ const panel = h.modals[0]?.factory({ terminal: { rows: 20 }, requestRender() {} },
114
+ { fg: (color: string, text: string) => { colors.push(`${color}:${text}`); return text; } }, null, () => {}) as { render(width: number): string[] };
115
+ const frame = panel.render(48).join("\n");
116
+ assert.match(frame, /█████░░░░░ 1\/2 completed/);
117
+ assert.match(frame, /Pending[\s\S]*Completed/);
118
+ assert.ok(colors.some((line) => line === "warning:── Pending ──"));
119
+ assert.ok(colors.some((line) => line === "success: ✓ #2 Second"));
120
+ assert.deepEqual(await h.show(h.context("main")), ["1/2 completed · 1 pending\n── Pending ──\n ○ #1 First\n── Completed ──\n ✓ #2 Second", "info"]);
121
+ });
122
+
123
+ void test("command reflects lifecycle replay and shutdown eviction via shared tool store", async () => {
124
+ const h = harness();
125
+ const parent = h.context("parent");
126
+ await h.run(parent, { action: "create", subject: "Live" });
127
+ h.events.get("session_tree")?.({}, parent);
128
+ assert.deepEqual(await h.show(parent), ["No todos yet. Ask the agent to add some!", "info"]);
129
+ await h.run(parent, { action: "create", subject: "New" });
130
+ h.events.get("session_shutdown")?.({}, parent);
131
+ assert.deepEqual(await h.show(parent), ["No todos yet. Ask the agent to add some!", "info"]);
132
+ });
@@ -0,0 +1,60 @@
1
+ // /todos display adapted from @juicesharp/rpiv-todo@2.11.0 (MIT); see root LICENSE.
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { listTasks, type TodoStatus, type TodoTask } from "./todo-domain.js";
4
+ import { todoSessionId, type createTodoSessionStore } from "./todo-session.js";
5
+ import { todoI18n, type TodoI18n } from "./todo-i18n.js";
6
+ import { safe } from "./todo-tool.js";
7
+ import { showTodoPanel, type TodoPanelRow } from "./todo-panel.js";
8
+
9
+ const sections: readonly { status: TodoStatus; heading: string; glyph: string }[] = [
10
+ { status: "pending", heading: "── Pending ──", glyph: "○" },
11
+ { status: "in_progress", heading: "── In Progress ──", glyph: "◐" },
12
+ { status: "completed", heading: "── Completed ──", glyph: "✓" },
13
+ ];
14
+
15
+ function line(task: TodoTask, glyph: string): string {
16
+ const form = task.status === "in_progress" && task.activeForm ? ` (${safe(task.activeForm)})` : "";
17
+ const block = task.blockedBy?.length ? ` ⛓ ${task.blockedBy.map((id) => `#${id}`).join(",")}` : "";
18
+ return ` ${glyph} #${task.id} ${safe(task.subject)}${form}${block}`;
19
+ }
20
+
21
+ export function registerTodosCommand(pi: ExtensionAPI, store: ReturnType<typeof createTodoSessionStore>, i18n: TodoI18n = todoI18n): void {
22
+ pi.registerCommand("todos", {
23
+ description: "Show all todos on the current branch, grouped by status",
24
+ handler: (_args, ctx) => {
25
+ if (!ctx.hasUI) {
26
+ ctx.ui.notify(i18n.t("command.requires_interactive", "/todos requires interactive mode"), "error");
27
+ return Promise.resolve();
28
+ }
29
+ const tasks = listTasks(store.get(todoSessionId(ctx.sessionManager.getSessionId())));
30
+ if (!tasks.length) {
31
+ const empty = i18n.t("command.no_todos", "No todos yet. Ask the agent to add some!");
32
+ if (ctx.mode === "tui") return showTodoPanel(ctx, () => [empty], i18n.t("overlay.heading", "Todos"));
33
+ ctx.ui.notify(empty, "info");
34
+ return Promise.resolve();
35
+ }
36
+ const pending = tasks.filter((task) => task.status === "pending").length;
37
+ const inProgress = tasks.filter((task) => task.status === "in_progress").length;
38
+ const completed = tasks.filter((task) => task.status === "completed").length;
39
+ const counts = [
40
+ completed ? `${completed}/${tasks.length} ${i18n.status("completed")}` : "",
41
+ inProgress ? `${inProgress} ${i18n.status("in_progress")}` : "",
42
+ pending ? `${pending} ${i18n.status("pending")}` : "",
43
+ ].filter(Boolean);
44
+ const summary = counts.join(" · ");
45
+ const lines = [summary];
46
+ const rows: TodoPanelRow[] = [{ text: summary, kind: "summary", done: completed, total: tasks.length }];
47
+ for (const section of sections) {
48
+ const group = tasks.filter((task) => task.status === section.status);
49
+ if (!group.length) continue;
50
+ const heading = i18n.t(`command.section.${section.status}`, section.heading);
51
+ lines.push(heading, ...group.map((task) => line(task, section.glyph)));
52
+ rows.push({ text: "", kind: "space" }, { text: heading, kind: "section", status: section.status },
53
+ ...group.map((task): TodoPanelRow => ({ text: line(task, section.glyph), kind: "task", status: section.status })));
54
+ }
55
+ if (ctx.mode === "tui") return showTodoPanel(ctx, () => rows, i18n.t("overlay.heading", "Todos"));
56
+ ctx.ui.notify(lines.join("\n"), "info");
57
+ return Promise.resolve();
58
+ },
59
+ });
60
+ }
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync } from "node:fs";
3
+ import { registerHooks } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ registerHooks({ resolve(specifier, context, next) {
10
+ if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
11
+ const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
12
+ if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
13
+ }
14
+ return next(specifier, context);
15
+ } });
16
+
17
+ const { loadTodoConfig, getMaxWidgetLines, resolveCollapseKey, validateGuidance } = await import("./todo-config.js");
18
+
19
+ function fixture(files: Record<string, string>, xdg?: string) {
20
+ const home = join(tmpdir(), "osdy-todo-config-test-home");
21
+ return { home, env: xdg === undefined ? {} : { XDG_CONFIG_HOME: xdg }, readFile(path: string) {
22
+ if (!(path in files)) throw Object.assign(new Error("missing"), { code: "ENOENT" });
23
+ return files[path]!;
24
+ } };
25
+ }
26
+ const legacy = join(tmpdir(), "osdy-todo-config-test-home", ".config", "rpiv-todo", "config.json");
27
+ const xdg = join(tmpdir(), "osdy-todo-config-test-xdg");
28
+ const primary = join(xdg, "rpiv-todo", "config.json");
29
+
30
+ void test("XDG wins, legacy is used only when XDG is missing; relative XDG uses legacy", () => {
31
+ assert.equal(loadTodoConfig(fixture({ [legacy]: '{"maxWidgetLines":5}', [primary]: '{"maxWidgetLines":7}' }, xdg)).maxWidgetLines, 7);
32
+ assert.equal(loadTodoConfig(fixture({ [legacy]: '{"maxWidgetLines":5}' }, xdg)).maxWidgetLines, 5);
33
+ assert.equal(loadTodoConfig(fixture({ [legacy]: '{"maxWidgetLines":5}' }, "relative/path")).maxWidgetLines, 5);
34
+ assert.equal(loadTodoConfig(fixture({ [legacy]: '{"maxWidgetLines":5}' }, "~other/.config")).maxWidgetLines, 5);
35
+ assert.equal(loadTodoConfig(fixture({ [legacy]: '{"maxWidgetLines":5}' }, " ")).maxWidgetLines, 5);
36
+ assert.equal(loadTodoConfig(fixture({ [legacy]: '{"maxWidgetLines":5}' }, "~/not-installed")).maxWidgetLines, 5);
37
+ assert.deepEqual(loadTodoConfig(fixture({}, xdg)), {});
38
+ });
39
+
40
+ void test("invalid JSON warns and defaults without falling back; non-object JSON silently defaults", () => {
41
+ const warnings: string[] = [];
42
+ const bad = fixture({ [primary]: "{", [legacy]: '{"maxWidgetLines":5}' }, xdg);
43
+ assert.equal(getMaxWidgetLines(loadTodoConfig({ ...bad, warn: (message: string) => warnings.push(message) })), 7);
44
+ assert.match(warnings[0] ?? "", /rpiv-config: invalid JSON at .*config.json/);
45
+ for (const value of ["null", "[]", '"hello"', "42"]) {
46
+ assert.equal(getMaxWidgetLines(loadTodoConfig(fixture({ [primary]: value }, xdg))), 7);
47
+ }
48
+ });
49
+
50
+ void test("widget budget and collapse key validate independently", () => {
51
+ for (const value of [undefined, null, "3", 2, -1]) assert.equal(getMaxWidgetLines({ maxWidgetLines: value }), 7);
52
+ assert.equal(getMaxWidgetLines({ maxWidgetLines: 3 }), 3);
53
+ assert.equal(getMaxWidgetLines({ maxWidgetLines: 100 }), 100);
54
+ for (const value of [undefined, null, "", " ", "ctr+]", "ctrl+ctrl+t", "ctrl+f13", "ctrl++t", "ctrl+unknown+t", "~user"]) {
55
+ assert.equal(resolveCollapseKey({ collapseKey: value }), "ctrl+shift+t");
56
+ }
57
+ assert.equal(resolveCollapseKey({ collapseKey: " ALT+F5 " }), "alt+f5");
58
+ assert.equal(resolveCollapseKey({ collapseKey: "off" }), "off");
59
+ assert.equal(resolveCollapseKey({ collapseKey: "ctrl+]" }), "ctrl+]");
60
+ });
61
+
62
+ void test("guidance requires non-empty strings and all-or-nothing guideline arrays", () => {
63
+ assert.deepEqual(validateGuidance({ promptSnippet: "custom", promptGuidelines: ["one", "two"] }), { promptSnippet: "custom", promptGuidelines: ["one", "two"] });
64
+ assert.deepEqual(validateGuidance({ promptSnippet: "", promptGuidelines: ["one", ""] }), {});
65
+ assert.deepEqual(validateGuidance({ promptSnippet: " ", promptGuidelines: [" "] }), { promptSnippet: " ", promptGuidelines: [" "] });
66
+ assert.deepEqual(validateGuidance(null), {});
67
+ });
@@ -0,0 +1,78 @@
1
+ // Configuration behavior implemented against @juicesharp/rpiv-todo@2.11.0 (MIT); see root LICENSE.
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join } from "node:path";
5
+
6
+ export const DEFAULT_MAX_WIDGET_LINES = 7;
7
+ export const DEFAULT_COLLAPSE_KEY = "ctrl+shift+t";
8
+ export const COLLAPSE_KEY_OFF = "off";
9
+
10
+ export interface TodoConfig {
11
+ maxWidgetLines?: unknown;
12
+ collapseKey?: unknown;
13
+ guidance?: unknown;
14
+ }
15
+
16
+ // The optional sources let tests exercise path selection without touching real home files.
17
+ interface ConfigSources {
18
+ home?: string;
19
+ env?: { XDG_CONFIG_HOME?: string };
20
+ readFile?: (path: string) => string;
21
+ warn?: (message: string) => void;
22
+ }
23
+
24
+ export function loadTodoConfig(sources: ConfigSources = {}): TodoConfig {
25
+ const home = sources.home ?? homedir();
26
+ const xdg = (sources.env ?? process.env).XDG_CONFIG_HOME?.trim();
27
+ const expanded = xdg === "~" ? home : xdg?.startsWith("~/") ? join(home, xdg.slice(2)) : xdg;
28
+ const directory = expanded && isAbsolute(expanded) ? expanded : join(home, ".config");
29
+ const primary = join(directory, "rpiv-todo", "config.json");
30
+ const legacy = join(home, ".config", "rpiv-todo", "config.json");
31
+ const read = sources.readFile ?? ((path: string) => readFileSync(path, "utf8"));
32
+ const warn = sources.warn ?? console.warn;
33
+ for (const path of new Set([primary, legacy])) {
34
+ let text: string;
35
+ try { text = read(path); }
36
+ catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
38
+ warn(`rpiv-config: invalid JSON at ${path}, using default ({}) — ${String(error)}`);
39
+ return {};
40
+ }
41
+ try {
42
+ const parsed: unknown = JSON.parse(text);
43
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
44
+ } catch (error) {
45
+ warn(`rpiv-config: invalid JSON at ${path}, using default ({}) — ${String(error)}`);
46
+ return {};
47
+ }
48
+ }
49
+ return {};
50
+ }
51
+
52
+ export function getMaxWidgetLines(config: TodoConfig = loadTodoConfig()): number {
53
+ const lines = config.maxWidgetLines;
54
+ return typeof lines === "number" && lines >= 3 ? lines : DEFAULT_MAX_WIDGET_LINES;
55
+ }
56
+
57
+ const namedKeys = new Set(["escape", "esc", "enter", "return", "tab", "space", "backspace", "delete", "insert", "clear", "home", "end", "pageup", "pagedown", "up", "down", "left", "right", ...Array.from({ length: 12 }, (_, i) => `f${i + 1}`)]);
58
+ const modifiers = new Set(["ctrl", "shift", "alt", "super"]);
59
+
60
+ export function resolveCollapseKey(config: TodoConfig = loadTodoConfig()): string {
61
+ const spec = typeof config.collapseKey === "string" ? config.collapseKey.trim().toLowerCase() : "";
62
+ if (!spec) return DEFAULT_COLLAPSE_KEY;
63
+ if (spec === COLLAPSE_KEY_OFF) return COLLAPSE_KEY_OFF;
64
+ const parts = spec.split("+");
65
+ const base = parts.pop() ?? "";
66
+ const mods = new Set(parts);
67
+ const printable = base.length === 1 && base.charCodeAt(0) >= 33 && base.charCodeAt(0) <= 126;
68
+ return mods.size === parts.length && parts.every((part) => modifiers.has(part)) && (printable || namedKeys.has(base)) ? spec : DEFAULT_COLLAPSE_KEY;
69
+ }
70
+
71
+ export function validateGuidance(value: unknown): { promptSnippet?: string; promptGuidelines?: string[] } {
72
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
73
+ const fields = value as Record<string, unknown>;
74
+ const result: { promptSnippet?: string; promptGuidelines?: string[] } = {};
75
+ if (typeof fields.promptSnippet === "string" && fields.promptSnippet.length > 0) result.promptSnippet = fields.promptSnippet;
76
+ if (Array.isArray(fields.promptGuidelines) && fields.promptGuidelines.length > 0 && fields.promptGuidelines.every((item: unknown) => typeof item === "string" && item.length > 0)) result.promptGuidelines = fields.promptGuidelines as string[];
77
+ return result;
78
+ }