gentle-pi 2.3.0 → 2.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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,118 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { stripAnsi } from "../lib/terminal-theme.ts";
5
+ import {
6
+ framePromptLines,
7
+ panelPainter,
8
+ PROMPT_STATE,
9
+ petalGlyph,
10
+ petalTone,
11
+ withPromptHint,
12
+ type PromptFrameOptions,
13
+ } from "../lib/shell-prompt.ts";
14
+
15
+ // The Gentle Shell prompt wraps pi's editor output (a top rule, padded content
16
+ // lines, a bottom rule) in a rounded frame with a petal that shows the agent
17
+ // state. Framing is pure: it takes the editor's lines and returns new ones.
18
+
19
+ const CURSOR = "\x1b[7m \x1b[0m";
20
+
21
+ function options(overrides: Partial<PromptFrameOptions> = {}): PromptFrameOptions {
22
+ return {
23
+ state: PROMPT_STATE.IDLE,
24
+ tick: 0,
25
+ borderColor: (text) => text,
26
+ fg: (color, text) => `<${color}>${text}</${color}>`,
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ test("framePromptLines sets the petal in bold when the theme offers it", () => {
32
+ const lines = framePromptLines(editorLines(40), 40, options({ bold: (text) => `*${text}*` }));
33
+ assert.match(lines[0], /<borderAccent>\*✿\*<\/borderAccent>/);
34
+ });
35
+
36
+ function editorLines(width: number, content: string[] = [` ${CURSOR}`]): string[] {
37
+ const inner = width - 2;
38
+ return ["─".repeat(inner), ...content.map((line) => line + " ".repeat(inner - visibleWidth(line))), "─".repeat(inner)];
39
+ }
40
+
41
+ test("framePromptLines draws rounded corners, side rules, and keeps every line at width", () => {
42
+ const width = 40;
43
+ const lines = framePromptLines(editorLines(width), width, options({ fg: (_c, t) => t }));
44
+ assert.equal(lines.length, 3);
45
+ for (const line of lines) assert.equal(visibleWidth(line), width, `line "${stripAnsi(line)}" is not ${width} wide`);
46
+ assert.match(stripAnsi(lines[0]), /^╭─ ✿ ─+╮$/);
47
+ assert.match(stripAnsi(lines[1]), /^│ .* │$/);
48
+ assert.match(stripAnsi(lines[2]), /^╰─+╯$/);
49
+ });
50
+
51
+ test("framePromptLines paints the frame with the editor border color and the petal with the state tone", () => {
52
+ const lines = framePromptLines(editorLines(40), 40, options({ borderColor: (text) => `[b]${text}[/b]`, bold: (text) => `*${text}*` }));
53
+ assert.match(lines[0], /^\[b\]╭─ \[\/b\]<borderAccent>\*✿\*<\/borderAccent>\[b\] ─+╮\[\/b\]$/);
54
+ assert.match(lines[1], /^\[b\]│\[\/b\].*\[b\]│\[\/b\]$/);
55
+ assert.match(lines[2], /^\[b\]╰─+╯\[\/b\]$/);
56
+ });
57
+
58
+ test("petalTone rests bright, walks the rose ramp while working, and turns to warning when queued", () => {
59
+ assert.equal(petalTone(PROMPT_STATE.IDLE, 2), "borderAccent");
60
+ assert.deepEqual([0, 1, 2, 3, 4].map((tick) => petalTone(PROMPT_STATE.WORKING, tick)), ["borderAccent", "accent", "thinkingHigh", "mdQuoteBorder", "borderAccent"]);
61
+ assert.equal(petalTone(PROMPT_STATE.QUEUED, 1), "warning");
62
+ });
63
+
64
+ test("petalGlyph spins through the flowers while working and rests otherwise", () => {
65
+ assert.equal(petalGlyph(PROMPT_STATE.IDLE, 3), "✿");
66
+ assert.deepEqual([0, 1, 2, 3, 4].map((tick) => petalGlyph(PROMPT_STATE.WORKING, tick)), ["✿", "❀", "❁", "✾", "✿"]);
67
+ assert.equal(petalGlyph(PROMPT_STATE.QUEUED, 1), "❀");
68
+ });
69
+
70
+ test("framePromptLines spins the petal and labels the working and queued states", () => {
71
+ const plain = (_color: string, text: string) => text;
72
+ const working = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 1 }));
73
+ assert.match(working[0], /<accent>❀<\/accent>/);
74
+ assert.match(working[0], /<muted>working<\/muted>/);
75
+ const workingPlain = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 1, fg: plain }));
76
+ assert.match(stripAnsi(workingPlain[0]), /^╭─ ❀ working ─+╮$/);
77
+ assert.equal(visibleWidth(workingPlain[0]), 40);
78
+
79
+ const queued = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.QUEUED }));
80
+ assert.match(queued[0], /<warning>✿<\/warning>/);
81
+ assert.match(queued[0], /<muted>queued<\/muted>/);
82
+ const queuedPlain = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.QUEUED, fg: plain }));
83
+ assert.match(stripAnsi(queuedPlain[0]), /^╭─ ✿ queued ─+╮$/);
84
+ assert.equal(visibleWidth(queuedPlain[0]), 40);
85
+ });
86
+
87
+ test("framePromptLines keeps the editor scroll indicators inside the frame", () => {
88
+ const width = 40;
89
+ const inner = width - 2;
90
+ const top = `─── ↑ 2 more ${"─".repeat(inner - 13)}`;
91
+ const bottom = `─── ↓ 3 more ${"─".repeat(inner - 13)}`;
92
+ const lines = framePromptLines([top, ` x${" ".repeat(inner - 2)}`, bottom], width, options({ fg: (_c, t) => t }));
93
+ assert.match(stripAnsi(lines[0]), /^╭─ ✿ ↑ 2 more ─+╮$/);
94
+ assert.match(stripAnsi(lines[2]), /^╰─ ↓ 3 more ─+╯$/);
95
+ for (const line of lines) assert.equal(visibleWidth(line), width);
96
+ });
97
+
98
+ test("withPromptHint places a dim hint after the cursor on an empty editor line", () => {
99
+ const inner = 38;
100
+ const line = ` ${CURSOR}${" ".repeat(inner - 2)}`;
101
+ const hinted = withPromptHint(line, "type, or / for commands", (color, text) => `<${color}>${text}</${color}>`);
102
+ assert.match(hinted, /\x1b\[7m \x1b\[0m <dim>type, or \/ for commands<\/dim> +$/);
103
+ const hintedPlain = withPromptHint(line, "type, or / for commands", (_c, t) => t);
104
+ assert.equal(visibleWidth(hintedPlain), inner);
105
+ });
106
+
107
+ test("withPromptHint leaves the line alone when the hint does not fit", () => {
108
+ const line = ` ${CURSOR}${" ".repeat(6)}`;
109
+ const hinted = withPromptHint(line, "type, or / for commands", (_c, t) => t);
110
+ assert.equal(hinted, line);
111
+ });
112
+
113
+ test("panelPainter keeps the background running after pi's cursor reset", () => {
114
+ const paint = panelPainter("<bg>");
115
+ assert.equal(paint("a\x1b[0mb"), "<bg>a\x1b[0m<bg>b\x1b[49m");
116
+ const lines = framePromptLines(editorLines(40), 40, options({ fg: (_c, t) => t, paint }));
117
+ assert.ok(lines.every((line) => line.startsWith("<bg>") && line.endsWith("\x1b[49m")));
118
+ });
@@ -0,0 +1,170 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ applyTodo,
6
+ emptyTodo,
7
+ renderTodoCard,
8
+ replayTodo,
9
+ staleTurns,
10
+ TODO_STATUS,
11
+ todoPromptBlock,
12
+ todoSummary,
13
+ type TodoState,
14
+ } from "../lib/shell-todo.ts";
15
+ import { stripAnsi } from "../lib/terminal-theme.ts";
16
+
17
+ // Gentle Todo: a task list the model rewrites as it works. The reducer is
18
+ // pure, the state replays from the session branch, and staleness is derived
19
+ // from how many turns passed since the last write while tasks stay open.
20
+
21
+ const plainTheme = {
22
+ fg(_color: string, text: string) {
23
+ return text;
24
+ },
25
+ strikethrough(text: string) {
26
+ return `~${text}~`;
27
+ },
28
+ };
29
+
30
+ function seeded(): TodoState {
31
+ return applyTodo(
32
+ emptyTodo(),
33
+ { action: "write", tasks: [{ title: "Add quiet tool rendering", status: "done" }, { title: "Fix quiet tools conflict", status: "in_progress", note: "fixing conflict" }, { title: "Show git bash tails" }] },
34
+ 3,
35
+ ).state;
36
+ }
37
+
38
+ test("applyTodo write replaces the list, assigns ids, defaults to pending, and records the turn", () => {
39
+ const { state, text, error } = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "First" }, { title: "Second", status: "in_progress", note: "working" }] }, 2);
40
+ assert.equal(error, undefined);
41
+ assert.deepEqual(state.tasks, [
42
+ { id: 1, title: "First", status: TODO_STATUS.PENDING },
43
+ { id: 2, title: "Second", status: TODO_STATUS.IN_PROGRESS, note: "working" },
44
+ ]);
45
+ assert.equal(state.nextId, 3);
46
+ assert.equal(state.updatedTurn, 2);
47
+ assert.match(text, /2 tasks · 0 done · 1 in progress/);
48
+ });
49
+
50
+ test("applyTodo write keeps ids the model passes back and drops the rest", () => {
51
+ const first = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "A" }, { title: "B" }] }, 1).state;
52
+ const second = applyTodo(first, { action: "write", tasks: [{ id: 2, title: "B", status: "done" }, { title: "C" }] }, 2).state;
53
+ assert.deepEqual(second.tasks.map((task) => `${task.id}:${task.title}:${task.status}`), ["2:B:done", "3:C:pending"]);
54
+ });
55
+
56
+ test("applyTodo add, update, and clear mutate one task at a time and validate ids", () => {
57
+ let state = applyTodo(emptyTodo(), { action: "add", title: "Write tests" }, 1).state;
58
+ assert.equal(state.tasks[0].id, 1);
59
+ const updated = applyTodo(state, { action: "update", id: 1, status: "in_progress", note: "writing tests" }, 2);
60
+ assert.equal(updated.error, undefined);
61
+ assert.equal(updated.state.tasks[0].status, TODO_STATUS.IN_PROGRESS);
62
+ assert.equal(updated.state.tasks[0].note, "writing tests");
63
+ assert.match(updated.text, /#1 Write tests → in_progress/);
64
+ const missing = applyTodo(updated.state, { action: "update", id: 9, status: "done" }, 2);
65
+ assert.match(missing.error ?? "", /no task #9/);
66
+ assert.strictEqual(missing.state, updated.state);
67
+ const noField = applyTodo(updated.state, { action: "update", id: 1 }, 2);
68
+ assert.match(noField.error ?? "", /nothing to update/);
69
+ state = applyTodo(updated.state, { action: "clear" }, 3).state;
70
+ assert.deepEqual(state.tasks, []);
71
+ assert.equal(state.nextId, 1);
72
+ });
73
+
74
+ test("applyTodo accepts completed as an alias of done, rejects unknown statuses, and sanitizes text", () => {
75
+ const alias = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "X", status: "completed" }] }, 1);
76
+ assert.equal(alias.state.tasks[0].status, TODO_STATUS.DONE);
77
+ const bad = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "X", status: "later" }] }, 1);
78
+ assert.match(bad.error ?? "", /unknown status "later"/);
79
+ const dirty = applyTodo(emptyTodo(), { action: "add", title: "Evil\x1b[31m title\n" }, 1).state;
80
+ assert.equal(dirty.tasks[0].title, "Evil title");
81
+ const empty = applyTodo(emptyTodo(), { action: "add", title: " " }, 1);
82
+ assert.match(empty.error ?? "", /title is required/);
83
+ });
84
+
85
+ test("applyTodo list reports the state without changing it", () => {
86
+ const state = seeded();
87
+ const listed = applyTodo(state, { action: "list" }, 5);
88
+ assert.strictEqual(listed.state, state);
89
+ assert.match(listed.text, /\[done\] #1 Add quiet tool rendering/);
90
+ assert.match(listed.text, /\[in_progress\] #2 Fix quiet tools conflict — fixing conflict/);
91
+ assert.match(listed.text, /\[pending\] #3 Show git bash tails/);
92
+ });
93
+
94
+ test("todoSummary and staleTurns describe progress and how long the list went untouched", () => {
95
+ const state = seeded();
96
+ assert.deepEqual(todoSummary(state), { done: 1, total: 3, open: 2 });
97
+ assert.equal(staleTurns(state, 3), 0);
98
+ assert.equal(staleTurns(state, 5), 2);
99
+ const allDone = applyTodo(state, { action: "write", tasks: state.tasks.map((task) => ({ ...task, status: "done" })) }, 4).state;
100
+ assert.equal(staleTurns(allDone, 9), 0, "a finished list never goes stale");
101
+ assert.equal(staleTurns(emptyTodo(), 9), 0);
102
+ });
103
+
104
+ test("replayTodo rebuilds the latest state from Gentle and rpiv tool results alike", () => {
105
+ const gentle = { type: "message", message: { role: "toolResult", toolName: "todo", isError: false, details: { gentleTodo: { tasks: [{ id: 4, title: "Ours", status: "pending" }], nextId: 5, updatedTurn: 7 } } } };
106
+ const rpiv = { type: "message", message: { role: "toolResult", toolName: "todo", isError: false, details: { action: "update", params: {}, tasks: [{ id: 1, subject: "Theirs", status: "completed", activeForm: "done" }, { id: 2, subject: "Gone", status: "deleted" }, { id: 3, subject: "Now", status: "in_progress", activeForm: "doing it" }], nextId: 4 } } };
107
+ const other = { type: "message", message: { role: "toolResult", toolName: "bash", details: { tasks: [] } } };
108
+ assert.deepEqual(replayTodo([other, rpiv]).tasks, [
109
+ { id: 1, title: "Theirs", status: TODO_STATUS.DONE },
110
+ { id: 3, title: "Now", status: TODO_STATUS.IN_PROGRESS, note: "doing it" },
111
+ ]);
112
+ assert.equal(replayTodo([other, rpiv]).nextId, 4);
113
+ assert.equal(replayTodo([rpiv, gentle]).tasks[0].title, "Ours", "the last write wins");
114
+ assert.equal(replayTodo([rpiv, gentle]).updatedTurn, 7);
115
+ assert.deepEqual(replayTodo([]), emptyTodo());
116
+ });
117
+
118
+ test("todoPromptBlock lists open work with the rules, and stays silent when there is nothing open", () => {
119
+ const block = todoPromptBlock(seeded(), 0);
120
+ assert.ok(block);
121
+ assert.match(block, /^## Todo list/m);
122
+ assert.match(block, /mark a task in_progress before starting/);
123
+ assert.match(block, /1\. \[done\] Add quiet tool rendering/);
124
+ assert.match(block, /2\. \[in_progress\] Fix quiet tools conflict — fixing conflict/);
125
+ assert.match(block, /3\. \[pending\] Show git bash tails/);
126
+ assert.doesNotMatch(block, /stale/);
127
+ assert.match(todoPromptBlock(seeded(), 2) ?? "", /stale: 2 turns without an update/);
128
+ assert.equal(todoPromptBlock(emptyTodo(), 0), undefined);
129
+ const allDone = applyTodo(seeded(), { action: "write", tasks: [{ title: "A", status: "done" }] }, 1).state;
130
+ assert.equal(todoPromptBlock(allDone, 0), undefined);
131
+ });
132
+
133
+ test("renderTodoCard draws the framed list with status glyphs and keeps every line at width", () => {
134
+ const lines = renderTodoCard(seeded(), plainTheme, 60, { collapsed: false, staleTurns: 0, collapseKey: "ctrl+shift+t" });
135
+ for (const line of lines) assert.equal(visibleWidth(line), 60, `"${stripAnsi(line)}" is not 60 wide`);
136
+ const plain = lines.map(stripAnsi);
137
+ assert.match(plain[0], /^╭─ ❀ Todos · 1 of 3 ─+╮$/);
138
+ assert.match(plain[1], /^│ ✓ ~Add quiet tool rendering~ +│$/);
139
+ assert.match(plain[2], /^│ ◐ Fix quiet tools conflict · fixing conflict +│$/);
140
+ assert.match(plain[3], /^│ ○ Show git bash tails +│$/);
141
+ assert.match(plain[4], /^╰─+╯$/);
142
+ });
143
+
144
+ test("renderTodoCard folds a long list: done tasks become one row and the open ones are capped", () => {
145
+ const tasks = Array.from({ length: 40 }, (_, index) => ({ title: `Task ${index + 1}`, status: index < 25 ? "done" : index === 25 ? "in_progress" : "pending" }));
146
+ const state = applyTodo(emptyTodo(), { action: "write", tasks }, 1).state;
147
+ const plain = renderTodoCard(state, plainTheme, 60, { collapsed: false, staleTurns: 0 }).map(stripAnsi);
148
+ assert.equal(plain.length, 14, "top rule, twelve rows, bottom rule");
149
+ assert.match(plain[0], /Todos · 25 of 40/);
150
+ assert.match(plain[1], /^│ ✓ 25 done +│$/);
151
+ assert.match(plain[2], /^│ ◐ Task 26 +│$/);
152
+ assert.match(plain[11], /^│ ○ Task 35 +│$/);
153
+ assert.match(plain[12], /^│ … 5 more +│$/);
154
+ const allDone = applyTodo(emptyTodo(), { action: "write", tasks: tasks.map((task) => ({ ...task, status: "done" })) }, 1).state;
155
+ const folded = renderTodoCard(allDone, plainTheme, 60, { collapsed: false, staleTurns: 0 }).map(stripAnsi);
156
+ assert.equal(folded.length, 3);
157
+ assert.match(folded[1], /^│ ✓ 40 done +│$/);
158
+ });
159
+
160
+ test("renderTodoCard marks a stale list in the top rule and collapses to the task in progress", () => {
161
+ const stale = renderTodoCard(seeded(), plainTheme, 70, { collapsed: false, staleTurns: 2, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
162
+ assert.match(stale[0], /^╭─ ❀ Todos · 1 of 3 ─+ stale · 2 turns ╮$/);
163
+ const collapsed = renderTodoCard(seeded(), plainTheme, 70, { collapsed: true, staleTurns: 0, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
164
+ assert.equal(collapsed.length, 3);
165
+ assert.match(collapsed[0], /^╭─ ❀ Todos · 1 of 3 ─+ ctrl\+shift\+t expand ╮$/);
166
+ assert.match(collapsed[1], /^│ ◐ Fix quiet tools conflict · fixing conflict +│$/);
167
+ const idle = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "Only pending" }] }, 1).state;
168
+ assert.match(renderTodoCard(idle, plainTheme, 70, { collapsed: true, staleTurns: 0 }).map(stripAnsi)[1], /^│ ○ 1 open +│$/);
169
+ assert.deepEqual(renderTodoCard(emptyTodo(), plainTheme, 70, { collapsed: false, staleTurns: 0 }), []);
170
+ });
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { stripAnsi } from "../lib/terminal-theme.ts";
5
+ import { parseCodexUsage, UsageStore } from "../lib/shell-usage.ts";
6
+ import { UsageView } from "../lib/shell-usage-view.ts";
7
+
8
+ // The subscriptions overlay: one framed panel listing every provider the
9
+ // store knows, with r to refetch and esc to close.
10
+
11
+ const NOW = 1_788_600_000_000;
12
+ const plainTheme = {
13
+ fg(_color: string, text: string) {
14
+ return text;
15
+ },
16
+ };
17
+
18
+ function payload(percent: number) {
19
+ return { plan_type: "pro", rate_limit: { primary_window: { used_percent: percent, limit_window_seconds: 604_800, reset_at: NOW / 1000 + 7200 } } };
20
+ }
21
+
22
+ test("UsageView frames the panel, keeps every line at width, and shows the empty state", () => {
23
+ const store = new UsageStore();
24
+ const events: string[] = [];
25
+ const view = new UsageView(store, { theme: plainTheme, now: () => NOW, active: () => undefined, onRefresh: async () => events.push("refresh"), onClose: () => events.push("close"), requestRender: () => events.push("render") });
26
+ const empty = view.render(90).map(stripAnsi);
27
+ assert.match(empty[0], /^╭─ ✿ Subscriptions ─+╮$/);
28
+ assert.match(empty[1], /No subscription usage yet/);
29
+ assert.match(empty[empty.length - 2], /r refresh .* esc close/);
30
+ assert.match(empty[empty.length - 1], /^╰─+╯$/);
31
+
32
+ store.record(parseCodexUsage(payload(40), NOW));
33
+ const lines = view.render(90);
34
+ for (const line of lines) assert.equal(visibleWidth(line), 90, `"${stripAnsi(line)}" is not 90 wide`);
35
+ const plain = lines.map(stripAnsi);
36
+ assert.match(plain[1], /^│ openai-codex · pro · updated just now +│$/);
37
+ assert.match(plain[3], /^│ {5}week +▰+▱+ +40% +resets in 2h 0m +│$/);
38
+ });
39
+
40
+ test("UsageView refetches on r and closes on escape or q", async () => {
41
+ const store = new UsageStore();
42
+ const events: string[] = [];
43
+ const view = new UsageView(store, {
44
+ theme: plainTheme,
45
+ now: () => NOW,
46
+ active: () => ({ provider: "openai-codex" }),
47
+ onRefresh: async () => {
48
+ store.record(parseCodexUsage(payload(55), NOW));
49
+ events.push("refresh");
50
+ },
51
+ onClose: () => events.push("close"),
52
+ requestRender: () => events.push("render"),
53
+ });
54
+ view.handleInput("r");
55
+ await new Promise((resolve) => setTimeout(resolve, 0));
56
+ assert.deepEqual(events, ["render", "refresh", "render"]);
57
+ assert.match(stripAnsi(view.render(90)[3]), /55%/);
58
+ assert.match(stripAnsi(view.render(90)[1]), /^│ ✿ openai-codex · pro · updated just now/);
59
+ view.handleInput("\x1b");
60
+ view.handleInput("q");
61
+ assert.equal(events.filter((event) => event === "close").length, 2);
62
+ });
@@ -0,0 +1,197 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ accountIdFromToken,
6
+ formatReset,
7
+ parseAnthropicHeaders,
8
+ parseCodexHeaders,
9
+ parseUsageHeaders,
10
+ parseCodexUsage,
11
+ renderUsageBar,
12
+ renderUsagePanel,
13
+ UsageStore,
14
+ windowLabel,
15
+ type ProviderUsage,
16
+ } from "../lib/shell-usage.ts";
17
+
18
+ // Subscription usage: what each connected provider says about its windows.
19
+ // Parsers are pure; the store only remembers the latest snapshot.
20
+
21
+ const NOW = 1_788_600_000_000;
22
+
23
+ const plainTheme = {
24
+ fg(_color: string, text: string) {
25
+ return text;
26
+ },
27
+ };
28
+
29
+ const taggedTheme = {
30
+ fg(color: string, text: string) {
31
+ return `<${color}>${text}</${color}>`;
32
+ },
33
+ };
34
+
35
+ const CODEX_PAYLOAD = {
36
+ plan_type: "pro",
37
+ rate_limit: {
38
+ allowed: true,
39
+ limit_reached: false,
40
+ primary_window: { used_percent: 40, limit_window_seconds: 604_800, reset_after_seconds: 175_331, reset_at: 1_788_777_491 },
41
+ secondary_window: null,
42
+ },
43
+ additional_rate_limits: [
44
+ {
45
+ limit_name: "codex_spark",
46
+ metered_feature: "spark",
47
+ rate_limit: {
48
+ allowed: true,
49
+ limit_reached: false,
50
+ primary_window: { used_percent: 12, limit_window_seconds: 18_000, reset_after_seconds: 18_000, reset_at: 1_788_620_161 },
51
+ secondary_window: { used_percent: 3, limit_window_seconds: 604_800, reset_after_seconds: 604_800, reset_at: 1_789_206_961 },
52
+ },
53
+ },
54
+ ],
55
+ credits: { has_credits: false, unlimited: false, balance: "0" },
56
+ email: "someone@example.com",
57
+ };
58
+
59
+ test("windowLabel names the common windows and falls back to hours or days", () => {
60
+ assert.equal(windowLabel(18_000), "5h");
61
+ assert.equal(windowLabel(604_800), "week");
62
+ assert.equal(windowLabel(10_800), "3h");
63
+ assert.equal(windowLabel(172_800), "2d");
64
+ assert.equal(windowLabel(1_800), "30m");
65
+ });
66
+
67
+ test("formatReset speaks in minutes, hours, or days", () => {
68
+ assert.equal(formatReset(NOW + 25 * 60_000, NOW), "resets in 25m");
69
+ assert.equal(formatReset(NOW + (1 * 3600 + 48 * 60) * 1000, NOW), "resets in 1h 48m");
70
+ assert.equal(formatReset(NOW + (2 * 86_400 + 5 * 3600) * 1000, NOW), "resets in 2d 5h");
71
+ assert.equal(formatReset(NOW - 1000, NOW), "resets now");
72
+ assert.equal(formatReset(null, NOW), "");
73
+ });
74
+
75
+ test("parseCodexUsage keeps plan, windows, and named limits, and never keeps the email", () => {
76
+ const usage = parseCodexUsage(CODEX_PAYLOAD, NOW);
77
+ assert.equal(usage.provider, "openai-codex");
78
+ assert.equal(usage.plan, "pro");
79
+ assert.equal(usage.fetchedAt, NOW);
80
+ assert.deepEqual(
81
+ usage.limits.map((limit) => ({ name: limit.name, windows: limit.windows.map((w) => `${w.label}:${w.usedPercent}`) })),
82
+ [
83
+ { name: "codex", windows: ["week:40"] },
84
+ { name: "codex_spark", windows: ["5h:12", "week:3"] },
85
+ ],
86
+ );
87
+ assert.equal(usage.limits[0].windows[0].resetAt, 1_788_777_491_000);
88
+ assert.equal(JSON.stringify(usage).includes("example.com"), false);
89
+ });
90
+
91
+ test("parseCodexUsage tolerates a payload without rate limits", () => {
92
+ const usage = parseCodexUsage({ plan_type: "free" }, NOW);
93
+ assert.equal(usage.plan, "free");
94
+ assert.deepEqual(usage.limits, []);
95
+ });
96
+
97
+ test("parseCodexHeaders reads the SSE rate-limit headers when a provider sends them", () => {
98
+ const usage = parseCodexHeaders(
99
+ {
100
+ "x-codex-primary-used-percent": "62",
101
+ "x-codex-primary-window-minutes": "300",
102
+ "x-codex-primary-reset-at": "1788620161",
103
+ "x-codex-secondary-used-percent": "31",
104
+ "x-codex-secondary-window-minutes": "10080",
105
+ "x-codex-secondary-reset-at": "1789206961",
106
+ "content-type": "text/event-stream",
107
+ },
108
+ NOW,
109
+ );
110
+ assert.ok(usage);
111
+ assert.deepEqual(usage.limits[0].windows.map((w) => `${w.label}:${w.usedPercent}:${w.resetAt}`), ["5h:62:1788620161000", "week:31:1789206961000"]);
112
+ assert.equal(parseCodexHeaders({ "content-type": "text/event-stream" }, NOW), undefined);
113
+ });
114
+
115
+ test("accountIdFromToken decodes the chatgpt account claim from an OAuth JWT", () => {
116
+ const claims = Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-123" } })).toString("base64url");
117
+ assert.equal(accountIdFromToken(`header.${claims}.sig`), "acct-123");
118
+ assert.equal(accountIdFromToken("sk-not-a-jwt"), undefined);
119
+ assert.equal(accountIdFromToken("a.!!!.c"), undefined);
120
+ });
121
+
122
+ test("renderUsageBar summarizes the main limit with a gauge and the rest as percentages", () => {
123
+ const usage = parseCodexUsage(CODEX_PAYLOAD, NOW);
124
+ assert.equal(renderUsageBar(usage, plainTheme), "codex week ▰▰▰▱▱▱▱▱ 40%");
125
+ const twoWindows = parseCodexUsage({ ...CODEX_PAYLOAD, rate_limit: CODEX_PAYLOAD.additional_rate_limits[0].rate_limit }, NOW);
126
+ assert.equal(renderUsageBar(twoWindows, plainTheme), "codex 5h ▰▱▱▱▱▱▱▱ 12% · week 3%");
127
+ const hot = renderUsageBar(parseCodexUsage({ rate_limit: { primary_window: { used_percent: 91, limit_window_seconds: 18_000, reset_at: 1 } } }, NOW), taggedTheme);
128
+ assert.match(hot, /<warning>▰▰▰▰▰▰▰<\/warning>/);
129
+ assert.equal(renderUsageBar(parseCodexUsage({}, NOW), plainTheme), undefined);
130
+ });
131
+
132
+ test("renderUsagePanel lists each provider with meters, resets, and a stale marker", () => {
133
+ const usage = parseCodexUsage(CODEX_PAYLOAD, NOW);
134
+ const lines = renderUsagePanel([usage], plainTheme, 70, NOW + 3 * 60_000);
135
+ for (const line of lines) assert.ok(visibleWidth(line) <= 70, `too wide: ${line}`);
136
+ assert.match(lines[0], /^openai-codex · pro · updated 3m ago$/);
137
+ assert.match(lines[1], /^ {2}codex$/);
138
+ assert.match(lines[2], /^ {4}week +▰+▱+ +40% +resets in 2d 1h$/);
139
+ assert.match(lines[3], /^ {2}codex_spark$/);
140
+ assert.match(lines[4], /^ {4}5h /);
141
+ assert.match(lines[5], /^ {4}week /);
142
+ assert.deepEqual(renderUsagePanel([], plainTheme, 120, NOW), ["No subscription usage yet. Usage arrives with the next response, or press r to fetch it."]);
143
+ });
144
+
145
+ test("renderUsagePanel puts the active provider first and explains missing data", () => {
146
+ const codex = parseCodexUsage(CODEX_PAYLOAD, NOW);
147
+ const claude = parseAnthropicHeaders({ "anthropic-ratelimit-unified-5h-utilization": "0.2" }, NOW);
148
+ assert.ok(claude);
149
+ const both = renderUsagePanel([codex, claude], plainTheme, 100, NOW, { provider: "anthropic" });
150
+ assert.match(both[0], /^✿ anthropic · updated just now$/);
151
+ assert.match(both[1], /^ {2}claude$/);
152
+ assert.match(both.find((line) => line.startsWith("openai-codex")) ?? "", /^openai-codex · pro/);
153
+
154
+ const apiKey = renderUsagePanel([codex], plainTheme, 100, NOW, { provider: "openai" });
155
+ assert.match(apiKey[0], /^✿ openai · no subscription usage for this provider$/);
156
+ assert.match(apiKey[1], /^openai-codex · pro/);
157
+
158
+ const pending = renderUsagePanel([], plainTheme, 100, NOW, { provider: "anthropic" });
159
+ assert.deepEqual(pending, ["✿ anthropic · usage arrives with the first response"]);
160
+ assert.deepEqual(renderUsagePanel([], plainTheme, 100, NOW, { provider: "openai-codex" }), ["✿ openai-codex · no usage yet · r to fetch"]);
161
+ });
162
+
163
+ test("UsageStore keeps the latest snapshot per provider and lists them in order", () => {
164
+ const store = new UsageStore();
165
+ const first: ProviderUsage = { provider: "openai-codex", plan: "pro", limits: [], fetchedAt: 1 };
166
+ const second: ProviderUsage = { provider: "openai-codex", plan: "pro", limits: [], fetchedAt: 2 };
167
+ store.record(first);
168
+ store.record({ provider: "anthropic", plan: undefined, limits: [], fetchedAt: 1 });
169
+ store.record(second);
170
+ assert.equal(store.get("openai-codex"), second);
171
+ assert.deepEqual(store.all().map((usage) => usage.provider), ["openai-codex", "anthropic"]);
172
+ });
173
+
174
+ test("parseAnthropicHeaders turns the unified utilization fractions into 5h and weekly windows", () => {
175
+ const headers = {
176
+ "anthropic-ratelimit-unified-status": "allowed_warning",
177
+ "anthropic-ratelimit-unified-5h-utilization": "0.42",
178
+ "anthropic-ratelimit-unified-5h-reset": "1788620161",
179
+ "anthropic-ratelimit-unified-7d-utilization": "0.875",
180
+ "anthropic-ratelimit-unified-7d-reset": "1789206961",
181
+ "anthropic-ratelimit-unified-representative-claim": "seven_day",
182
+ };
183
+ const usage = parseAnthropicHeaders(headers, NOW);
184
+ assert.ok(usage);
185
+ assert.equal(usage.provider, "anthropic");
186
+ assert.deepEqual(usage.limits.map((limit) => limit.name), ["claude"]);
187
+ assert.deepEqual(usage.limits[0].windows.map((w) => `${w.label}:${w.usedPercent}:${w.resetAt}`), ["5h:42:1788620161000", "week:87.5:1789206961000"]);
188
+ assert.equal(usage.limits[0].limitReached, false);
189
+ assert.equal(parseAnthropicHeaders({ ...headers, "anthropic-ratelimit-unified-status": "rejected" }, NOW)?.limits[0].limitReached, true);
190
+ assert.equal(parseAnthropicHeaders({ "anthropic-ratelimit-requests-remaining": "99" }, NOW), undefined);
191
+ });
192
+
193
+ test("parseUsageHeaders picks whichever provider the headers belong to", () => {
194
+ assert.equal(parseUsageHeaders({ "x-codex-primary-used-percent": "10", "x-codex-primary-window-minutes": "300" }, NOW)?.provider, "openai-codex");
195
+ assert.equal(parseUsageHeaders({ "anthropic-ratelimit-unified-5h-utilization": "0.1" }, NOW)?.provider, "anthropic");
196
+ assert.equal(parseUsageHeaders({ "content-type": "application/json" }, NOW), undefined);
197
+ });