gentle-pi 2.4.0 → 2.6.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.
- package/README.md +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -0,0 +1,259 @@
|
|
|
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 ▾ Collapse · 1 of 3 ─+ ctrl\+shift\+t collapse ╮$/);
|
|
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 ▾ Collapse · 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 keeps the configured collapse shortcut in the header while stale state remains visible", () => {
|
|
161
|
+
const freshExpanded = renderTodoCard(seeded(), plainTheme, 70, { collapsed: false, staleTurns: 0, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
|
|
162
|
+
assert.match(freshExpanded[0], /^╭─ ❀ Todos ▾ Collapse · 1 of 3 ─+ ctrl\+shift\+t collapse ╮$/);
|
|
163
|
+
|
|
164
|
+
const freshCollapsed = renderTodoCard(seeded(), plainTheme, 70, { collapsed: true, staleTurns: 0, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
|
|
165
|
+
assert.equal(freshCollapsed.length, 3);
|
|
166
|
+
assert.match(freshCollapsed[0], /^╭─ ❀ Todos ▸ Expand · 1 of 3 ─+ ctrl\+shift\+t expand ╮$/);
|
|
167
|
+
assert.match(freshCollapsed[1], /^│ ◐ Fix quiet tools conflict · fixing conflict +│$/);
|
|
168
|
+
|
|
169
|
+
const staleExpanded = renderTodoCard(seeded(), plainTheme, 70, { collapsed: false, staleTurns: 2, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
|
|
170
|
+
assert.match(staleExpanded[0], /^╭─ ❀ Todos ▾ Collapse · 1 of 3 ─+ ctrl\+shift\+t collapse ╮$/);
|
|
171
|
+
assert.match(staleExpanded[1], /^│ stale · 2 turns +│$/);
|
|
172
|
+
|
|
173
|
+
const staleCollapsed = renderTodoCard(seeded(), plainTheme, 70, { collapsed: true, staleTurns: 2, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
|
|
174
|
+
assert.match(staleCollapsed[0], /^╭─ ❀ Todos ▸ Expand · 1 of 3 ─+ ctrl\+shift\+t expand ╮$/);
|
|
175
|
+
assert.match(staleCollapsed[1], /^│ stale · 2 turns +│$/);
|
|
176
|
+
assert.match(staleCollapsed[2], /^│ ◐ Fix quiet tools conflict · fixing conflict +│$/);
|
|
177
|
+
|
|
178
|
+
const fallback = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "Finished first", status: "done" }, { title: "First pending" }, { title: "Later pending" }] }, 1).state;
|
|
179
|
+
assert.match(renderTodoCard(fallback, plainTheme, 70, { collapsed: true, staleTurns: 0 }).map(stripAnsi)[1], /^│ ○ First pending +│$/);
|
|
180
|
+
|
|
181
|
+
const activeAfterPending = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "Pending first" }, { title: "Active second", status: "in_progress" }, { title: "Pending third" }] }, 1).state;
|
|
182
|
+
assert.match(renderTodoCard(activeAfterPending, plainTheme, 70, { collapsed: true, staleTurns: 0 }).map(stripAnsi)[1], /^│ ◐ Active second +│$/);
|
|
183
|
+
assert.deepEqual(renderTodoCard(emptyTodo(), plainTheme, 70, { collapsed: false, staleTurns: 0 }), []);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("renderTodoCard keeps English controls visible and falls back to their icons at narrow widths", () => {
|
|
187
|
+
const expanded = renderTodoCard(seeded(), plainTheme, 70, { collapsed: false, staleTurns: 0, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
|
|
188
|
+
assert.match(expanded[0], /Todos ▾ Collapse/);
|
|
189
|
+
assert.match(expanded[0], /ctrl\+shift\+t collapse/);
|
|
190
|
+
const collapsed = renderTodoCard(seeded(), plainTheme, 70, { collapsed: true, staleTurns: 0, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
|
|
191
|
+
assert.match(collapsed[0], /Todos ▸ Expand/);
|
|
192
|
+
for (const [collapsedState, icon] of [[false, "▾"], [true, "▸"]] as const) {
|
|
193
|
+
const line = renderTodoCard(seeded(), plainTheme, 16, { collapsed: collapsedState, staleTurns: 0, collapseKey: "ctrl+shift+t" })[0]!;
|
|
194
|
+
assert.match(stripAnsi(line), new RegExp(`Todos ${icon}`));
|
|
195
|
+
assert.equal(visibleWidth(line), 16);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("completed titles strike only title cells across wrapped lines, never padding or rails", () => {
|
|
200
|
+
const theme = {
|
|
201
|
+
fg: (_color: string, text: string) => `\x1b[32m${text}\x1b[39m`,
|
|
202
|
+
strikethrough: (text: string) => `\x1b[9m${text}\x1b[29m`,
|
|
203
|
+
};
|
|
204
|
+
const state = applyTodo(emptyTodo(), { action: "write", tasks: [
|
|
205
|
+
{ title: "Alpha beta gamma delta epsilon", status: "done" },
|
|
206
|
+
{ title: "Pending", status: "pending" },
|
|
207
|
+
] }, 1).state;
|
|
208
|
+
for (const width of [12, 20, 60]) {
|
|
209
|
+
const lines = renderTodoCard(state, theme, width, { collapsed: false, staleTurns: 0 });
|
|
210
|
+
let struckLetters = "";
|
|
211
|
+
for (const line of lines) {
|
|
212
|
+
let strike = false, column = 0;
|
|
213
|
+
const plain = stripAnsi(line);
|
|
214
|
+
const titleEnd = plain.slice(0, -1).trimEnd().length;
|
|
215
|
+
for (const token of line.match(/\x1b\[[\d;]*m|[^\x1b]/gu) ?? []) {
|
|
216
|
+
if (token.startsWith("\x1b")) {
|
|
217
|
+
for (const code of token.slice(2, -1).split(";").map(Number)) {
|
|
218
|
+
if (code === 0 || code === 29) strike = false;
|
|
219
|
+
if (code === 9) strike = true;
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
if (column < 2 || column >= titleEnd || /[│╭╮╰╯─✓]/u.test(token)) assert.equal(strike, false, `struck frame/padding: ${JSON.stringify(line)}`);
|
|
223
|
+
if (strike && /[A-Za-z]/.test(token)) struckLetters += token;
|
|
224
|
+
column += visibleWidth(token);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
assert.equal(strike, false, "SGR 9 must close before the host appends padding");
|
|
228
|
+
assert.equal(visibleWidth(line), width);
|
|
229
|
+
}
|
|
230
|
+
assert.equal(struckLetters, "Alphabetagammadeltaepsilon");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("renderTodoCard keeps stale indicators and collapse hints in scrollable lists", () => {
|
|
235
|
+
const tasks = Array.from({ length: 40 }, (_, index) => ({ title: `Task ${index + 1}`, status: index < 25 ? "done" : "pending" }));
|
|
236
|
+
const state = applyTodo(emptyTodo(), { action: "write", tasks }, 1).state;
|
|
237
|
+
const lines = renderTodoCard(state, plainTheme, 70, { scrollable: true, collapsed: false, staleTurns: 2, collapseKey: "alt+t" }).map(stripAnsi);
|
|
238
|
+
assert.equal(lines.length, 43, "top rule, stale row, every task, bottom rule");
|
|
239
|
+
assert.match(lines[0], /^╭─ ❀ Todos ▾ Collapse · 25 of 40 ─+ alt\+t collapse ╮$/);
|
|
240
|
+
assert.match(lines[1], /^│ stale · 2 turns +│$/);
|
|
241
|
+
assert.match(lines[2], /^│ ✓ ~Task 1~ +│$/);
|
|
242
|
+
assert.match(lines[41], /^│ ○ Task 40 +│$/);
|
|
243
|
+
for (const line of lines) assert.equal(visibleWidth(line), 70);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("renderTodoCard uses custom shortcuts, omits disabled shortcuts, and remains width-safe when hints cannot fit", () => {
|
|
247
|
+
const custom = renderTodoCard(seeded(), plainTheme, 70, { collapsed: false, staleTurns: 2, collapseKey: "alt+t" }).map(stripAnsi);
|
|
248
|
+
assert.match(custom[0], /alt\+t collapse/);
|
|
249
|
+
assert.match(custom[1], /stale · 2 turns/);
|
|
250
|
+
|
|
251
|
+
const disabled = renderTodoCard(seeded(), plainTheme, 70, { collapsed: true, staleTurns: 2 }).map(stripAnsi);
|
|
252
|
+
assert.doesNotMatch(disabled[0], /(?:collapse|expand|ctrl\+shift\+t)/);
|
|
253
|
+
assert.match(disabled[1], /stale · 2 turns/);
|
|
254
|
+
|
|
255
|
+
for (const width of [0, 1, 2, 3, 4, 5, 8, 16]) {
|
|
256
|
+
const lines = renderTodoCard(seeded(), plainTheme, width, { collapsed: false, staleTurns: 2, collapseKey: "ctrl+shift+t" });
|
|
257
|
+
for (const line of lines) assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import startup, { readGitBranch } from "../extensions/startup-banner.ts";
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
9
|
+
|
|
10
|
+
test("startup branch lookup uses direct git argv and hides its Windows child", async () => {
|
|
11
|
+
const calls: Array<{ command: string; args: readonly string[]; options: Record<string, unknown> }> = [];
|
|
12
|
+
const run = ((command: string, args: readonly string[], options: Record<string, unknown>, callback: (error: Error | null, stdout: string) => void) => {
|
|
13
|
+
calls.push({ command, args, options });
|
|
14
|
+
callback(null, "main\n");
|
|
15
|
+
}) as typeof import("node:child_process").execFile;
|
|
16
|
+
assert.equal(await readGitBranch("/repo with spaces & metacharacters", run), "On branch main");
|
|
17
|
+
assert.deepEqual(calls, [{
|
|
18
|
+
command: "git",
|
|
19
|
+
args: ["-C", "/repo with spaces & metacharacters", "branch", "--show-current"],
|
|
20
|
+
options: { encoding: "utf8", shell: false, windowsHide: true },
|
|
21
|
+
}]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("startup banner keeps animating after invalidate and cleans up on dispose", async (t) => {
|
|
25
|
+
t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
|
|
26
|
+
t.mock.method(fs, "readFile", async () => JSON.stringify({ showRose: true, showTextLogo: true, color: "pink" }));
|
|
27
|
+
syncBuiltinESMExports();
|
|
28
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
29
|
+
const argv = process.argv;
|
|
30
|
+
process.argv = ["node"];
|
|
31
|
+
t.after(() => { process.argv = argv; });
|
|
32
|
+
for (const [key, value] of [["rows", 40], ["columns", 160]] as const) {
|
|
33
|
+
const descriptor = Object.getOwnPropertyDescriptor(process.stdout, key);
|
|
34
|
+
Object.defineProperty(process.stdout, key, { configurable: true, writable: true, value });
|
|
35
|
+
t.after(() => descriptor ? Object.defineProperty(process.stdout, key, descriptor) : Reflect.deleteProperty(process.stdout, key));
|
|
36
|
+
}
|
|
37
|
+
let start: Function;
|
|
38
|
+
let shutdown: Function;
|
|
39
|
+
let header: { render(width: number): string[]; invalidate(): void; dispose(): void };
|
|
40
|
+
let renders = 0;
|
|
41
|
+
startup({ on: (name: string, fn: Function) => {
|
|
42
|
+
if (name === "session_start") start = fn;
|
|
43
|
+
if (name === "session_shutdown") shutdown = fn;
|
|
44
|
+
}, registerCommand() {}, getCommands: () => [], getAllTools: () => [] } as unknown as ExtensionAPI);
|
|
45
|
+
await start!({}, { hasUI: true, cwd: "/fixture", ui: { setHeader: (factory: Function) => {
|
|
46
|
+
header = factory({ requestRender() { renders++; } }, { fg: (_role: string, text: string) => text });
|
|
47
|
+
} } });
|
|
48
|
+
t.mock.timers.tick(50);
|
|
49
|
+
const afterBoot = renders;
|
|
50
|
+
t.mock.timers.tick(25);
|
|
51
|
+
assert.ok(renders > afterBoot, "animation timer requests renders");
|
|
52
|
+
header!.invalidate();
|
|
53
|
+
const afterInvalidate = renders;
|
|
54
|
+
t.mock.timers.tick(25);
|
|
55
|
+
assert.ok(renders > afterInvalidate, "invalidate() must not stop the animation timer");
|
|
56
|
+
header!.dispose();
|
|
57
|
+
const afterDispose = renders;
|
|
58
|
+
t.mock.timers.tick(25);
|
|
59
|
+
assert.equal(renders, afterDispose, "dispose() stops the animation timer");
|
|
60
|
+
shutdown!();
|
|
61
|
+
t.mock.timers.tick(25);
|
|
62
|
+
assert.equal(renders, afterDispose, "session_shutdown cleanup stays idle");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Drive the real header factory; background git/home reads never run.
|
|
66
|
+
for (const showRose of [false, true]) for (const showTextLogo of [false, true]) {
|
|
67
|
+
test(`startup art respects rose=${showRose}, logo=${showTextLogo} and cyan palette`, async (t) => {
|
|
68
|
+
t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
|
|
69
|
+
t.mock.method(fs, "readFile", async () => JSON.stringify({ showRose, showTextLogo, color: "cyan" }));
|
|
70
|
+
syncBuiltinESMExports();
|
|
71
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
72
|
+
const argv = process.argv;
|
|
73
|
+
process.argv = ["node"];
|
|
74
|
+
t.after(() => { process.argv = argv; });
|
|
75
|
+
for (const [key, value] of [["rows", 40], ["columns", 160]] as const) {
|
|
76
|
+
const descriptor = Object.getOwnPropertyDescriptor(process.stdout, key);
|
|
77
|
+
Object.defineProperty(process.stdout, key, { configurable: true, writable: true, value });
|
|
78
|
+
t.after(() => descriptor ? Object.defineProperty(process.stdout, key, descriptor) : Reflect.deleteProperty(process.stdout, key));
|
|
79
|
+
}
|
|
80
|
+
let start: Function;
|
|
81
|
+
let shutdown: Function;
|
|
82
|
+
let header: { render(width: number): string[]; dispose(): void };
|
|
83
|
+
const writes: string[] = [];
|
|
84
|
+
startup({ on: (name: string, fn: Function) => {
|
|
85
|
+
if (name === "session_start") start = fn;
|
|
86
|
+
if (name === "session_shutdown") shutdown = fn;
|
|
87
|
+
}, registerCommand() {}, getCommands: () => [], getAllTools: () => [] } as unknown as ExtensionAPI);
|
|
88
|
+
const write = t.mock.method(process.stdout, "write", (text: string) => { writes.push(String(text)); return true; });
|
|
89
|
+
await start!({}, { hasUI: true, cwd: "/fixture", ui: { setHeader: (factory: Function) => {
|
|
90
|
+
header = factory({ requestRender() {} }, { fg: (_role: string, text: string) => text });
|
|
91
|
+
} } });
|
|
92
|
+
t.mock.timers.tick(50);
|
|
93
|
+
try {
|
|
94
|
+
for (const width of [40, 80, 160]) {
|
|
95
|
+
const lines = header!.render(width);
|
|
96
|
+
assert.ok(lines.every((line) => visibleWidth(line) <= width));
|
|
97
|
+
const text = stripAnsi(lines.join("\n"));
|
|
98
|
+
assert.match(text, /GIT:/);
|
|
99
|
+
assert.match(text, /PATH:/);
|
|
100
|
+
if (width === 160) {
|
|
101
|
+
assert.equal(/[\u2800-\u28ff]/.test(text), showRose);
|
|
102
|
+
assert.equal(/[▒▄▀█]/.test(text), showTextLogo);
|
|
103
|
+
}
|
|
104
|
+
assert.match(lines.join("\n"), /\x1b\[38;2;85;170;205m/, "startup labels use the saved cyan palette");
|
|
105
|
+
}
|
|
106
|
+
// Cancel pending context reads before advancing the resize clock.
|
|
107
|
+
t.mock.timers.reset();
|
|
108
|
+
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: Date.now() + 1000 });
|
|
109
|
+
process.stdout.rows = 10;
|
|
110
|
+
process.stdout.emit("resize");
|
|
111
|
+
t.mock.timers.tick(150);
|
|
112
|
+
assert.deepEqual(header!.render(80), []);
|
|
113
|
+
process.stdout.rows = 25;
|
|
114
|
+
process.stdout.columns = 80;
|
|
115
|
+
process.stdout.emit("resize");
|
|
116
|
+
t.mock.timers.tick(150);
|
|
117
|
+
const minimal = stripAnsi(header!.render(80).join("\n"));
|
|
118
|
+
assert.doesNotMatch(minimal, /[\u2800-\u28ff]/);
|
|
119
|
+
assert.equal(/[▒▄▀█]/.test(minimal), showTextLogo);
|
|
120
|
+
assert.deepEqual(writes, [], "Pi owns stdout during startup and resize");
|
|
121
|
+
} finally {
|
|
122
|
+
shutdown!();
|
|
123
|
+
write.mock.restore();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|