gentle-pi 3.7.0 → 4.0.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 +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +92 -28
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +112 -206
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +429 -262
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +96 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +49 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +49 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +378 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1047 -252
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
package/tests/shell-todo.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import test from "node:test";
|
|
2
|
+
import test, { after, before, type TestContext } from "node:test";
|
|
3
3
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import {
|
|
5
5
|
applyTodo,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
todoSummary,
|
|
13
13
|
type TodoState,
|
|
14
14
|
} from "../lib/shell-todo.ts";
|
|
15
|
+
import { CARD_STYLE, cardStyle, setCardStyle, type CardStyle } from "../lib/shell-card.ts";
|
|
15
16
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
16
17
|
|
|
17
18
|
// Gentle Todo: a task list the model rewrites as it works. The reducer is
|
|
@@ -27,6 +28,42 @@ const plainTheme = {
|
|
|
27
28
|
},
|
|
28
29
|
};
|
|
29
30
|
|
|
31
|
+
// The card style defaults to float; these assertions pin the outlined (neon)
|
|
32
|
+
// panels unless a test switches the style itself.
|
|
33
|
+
const initialCardStyle = cardStyle();
|
|
34
|
+
before(() => setCardStyle(CARD_STYLE.NEON));
|
|
35
|
+
after(() => setCardStyle(initialCardStyle));
|
|
36
|
+
|
|
37
|
+
function useCardStyle(t: TestContext, style: CardStyle): void {
|
|
38
|
+
const found = cardStyle();
|
|
39
|
+
t.after(() => setCardStyle(found));
|
|
40
|
+
setCardStyle(style);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const BG_OPEN = "\x1b[48;5;22m";
|
|
44
|
+
const BG_CLOSE = "\x1b[49m";
|
|
45
|
+
|
|
46
|
+
/** The same theme with a background, so the float style applies (without one panels keep the frame). */
|
|
47
|
+
function withBackground<T extends object>(theme: T): T & { bg(color: string, text: string): string } {
|
|
48
|
+
return { ...theme, bg: (_color: string, text: string) => `${BG_OPEN}${text}${BG_CLOSE}` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Float panel rows: a painted panel inside transparent one-column margins, between padding rows that keep the accent bar. */
|
|
52
|
+
function assertFloatRows(lines: readonly string[], width: number): void {
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
|
|
55
|
+
assert.ok(line.startsWith(` ${BG_OPEN}`) && line.endsWith(`${BG_CLOSE} `), `painted inside the margins: ${JSON.stringify(line)}`);
|
|
56
|
+
}
|
|
57
|
+
const padding = ` ▎${" ".repeat(width - 3)} `;
|
|
58
|
+
assert.equal(stripAnsi(lines[0]!), padding, "a padding row with the accent bar sits above the header");
|
|
59
|
+
assert.equal(stripAnsi(lines.at(-1)!), padding, "a padding row with the accent bar replaces the bottom rule");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The text of a body row, without the neon side rails or the float accent bar. */
|
|
63
|
+
function bodyText(row: string): string {
|
|
64
|
+
return stripAnsi(row).replace(/^ ?[│▎] /u, "").replace(/ ?│? ?$/u, "").trimEnd();
|
|
65
|
+
}
|
|
66
|
+
|
|
30
67
|
function seeded(): TodoState {
|
|
31
68
|
return applyTodo(
|
|
32
69
|
emptyTodo(),
|
|
@@ -293,3 +330,52 @@ test("renderTodoCard uses custom shortcuts, omits disabled shortcuts, and remain
|
|
|
293
330
|
for (const line of lines) assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
|
|
294
331
|
}
|
|
295
332
|
});
|
|
333
|
+
|
|
334
|
+
test("renderTodoCard in the float style keeps its clickable control on the header row, one row below the top padding, above a separator row", (t) => {
|
|
335
|
+
const theme = withBackground(plainTheme);
|
|
336
|
+
for (const options of [
|
|
337
|
+
{ collapsed: false, staleTurns: 0, collapseKey: "ctrl+shift+t" },
|
|
338
|
+
{ collapsed: true, staleTurns: 0, collapseKey: "ctrl+shift+t" },
|
|
339
|
+
{ collapsed: false, staleTurns: 4 },
|
|
340
|
+
{ collapsed: false, staleTurns: 0, scrollable: true },
|
|
341
|
+
]) {
|
|
342
|
+
const neon = renderTodoCard(seeded(), theme, 60, options);
|
|
343
|
+
useCardStyle(t, CARD_STYLE.FLOAT);
|
|
344
|
+
const float = renderTodoCard(seeded(), theme, 60, options);
|
|
345
|
+
setCardStyle(CARD_STYLE.NEON);
|
|
346
|
+
assert.equal(float.length, neon.length + 2, "the top padding and separator rows add two rows");
|
|
347
|
+
const action = options.collapsed ? "▸ Expand" : "▾ Collapse";
|
|
348
|
+
const hint = options.collapseKey ? `ctrl\\+shift\\+t {3}` : "";
|
|
349
|
+
assert.match(stripAnsi(float[1]!), new RegExp(`^ ▎ ❀ Todos ${action} 1 of 3 +${hint}$`), "row 1 is the clickable header");
|
|
350
|
+
assert.match(stripAnsi(float[2]!), /^ ▎ +$/, "a blank separator row follows the header");
|
|
351
|
+
assertFloatRows(float, 60);
|
|
352
|
+
assert.deepEqual(float.slice(3, -1).map(bodyText), neon.slice(1, -1).map(bodyText));
|
|
353
|
+
}
|
|
354
|
+
useCardStyle(t, CARD_STYLE.FLOAT);
|
|
355
|
+
const tagged = withBackground({ ...plainTheme, fg: (color: string, text: string) => `<${color}>${text}</${color}>` });
|
|
356
|
+
const [, header] = renderTodoCard(seeded(), tagged, 120, { collapsed: false, staleTurns: 0, hovered: true });
|
|
357
|
+
assert.match(stripAnsi(header!), /^ <border>▎<\/border> <accent>❀ Todos <[a-zA-Z]+>▾ Collapse<\/[a-zA-Z]+><\/accent> <muted>1 of 3<\/muted> +$/, "the hovered control keeps its own role and case");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("float Todos keeps the configured shortcut visible at rail width without repeating the action", (t) => {
|
|
361
|
+
useCardStyle(t, CARD_STYLE.FLOAT);
|
|
362
|
+
const theme = withBackground(plainTheme);
|
|
363
|
+
for (const collapsed of [false, true]) {
|
|
364
|
+
for (const collapseKey of ["ctrl+shift+t", "alt+t", undefined]) {
|
|
365
|
+
const rows = renderTodoCard(seeded(), theme, 48, { collapsed, staleTurns: 0, collapseKey });
|
|
366
|
+
const header = stripAnsi(rows[1]!);
|
|
367
|
+
assert.match(header, collapsed ? /▸ Expand/ : /▾ Collapse/);
|
|
368
|
+
if (collapseKey) assert.ok(header.includes(collapseKey));
|
|
369
|
+
else assert.doesNotMatch(header, /ctrl\+shift\+t|alt\+t/);
|
|
370
|
+
assert.equal(visibleWidth(rows[1]!), 48);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("a done todo wraps to the float body so the strikethrough never re-wraps", (t) => {
|
|
376
|
+
useCardStyle(t, CARD_STYLE.FLOAT);
|
|
377
|
+
const state = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "word ".repeat(20).trim(), status: "done" }] }, 1).state;
|
|
378
|
+
const lines = renderTodoCard(state, withBackground(plainTheme), 40, { collapsed: false, staleTurns: 0 });
|
|
379
|
+
assertFloatRows(lines, 40);
|
|
380
|
+
for (const row of lines.slice(3, -1)) assert.match(stripAnsi(row), /^ ▎ [✓ ] ~[^~]+~ +$/u, "each physical row closes its own strikethrough");
|
|
381
|
+
});
|
|
@@ -62,6 +62,33 @@ test("UsageView refetches on r and closes on escape or q", async () => {
|
|
|
62
62
|
assert.equal(events.filter((event) => event === "close").length, 2);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
test("UsageView starts a refresh at open and repaints when it settles", async () => {
|
|
66
|
+
const store = new UsageStore();
|
|
67
|
+
const events: string[] = [];
|
|
68
|
+
let resolveRefresh: (() => void) | undefined;
|
|
69
|
+
const view = new UsageView(store, {
|
|
70
|
+
theme: plainTheme,
|
|
71
|
+
now: () => NOW,
|
|
72
|
+
active: () => undefined,
|
|
73
|
+
onRefresh: () =>
|
|
74
|
+
new Promise<void>((resolve) => {
|
|
75
|
+
events.push("refresh");
|
|
76
|
+
resolveRefresh = resolve;
|
|
77
|
+
}),
|
|
78
|
+
onClose: () => events.push("close"),
|
|
79
|
+
requestRender: () => events.push("render"),
|
|
80
|
+
});
|
|
81
|
+
view.refresh();
|
|
82
|
+
assert.deepEqual(events, ["render", "refresh"], "opening the panel dispatches the refresh instead of waiting for it");
|
|
83
|
+
assert.match(stripAnsi(view.render(90)[0]), /✿ Subscriptions · refreshing…/, "the panel says it is refreshing while the dispatch is in flight");
|
|
84
|
+
store.record(parseCodexUsage(payload(40), NOW));
|
|
85
|
+
resolveRefresh!();
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
87
|
+
assert.deepEqual(events, ["render", "refresh", "render"], "settling repaints once");
|
|
88
|
+
assert.match(stripAnsi(view.render(90)[0]), /^╭─ ✿ Subscriptions ─+╮$/, "the title returns once the refresh settles");
|
|
89
|
+
assert.match(stripAnsi(view.render(90)[2]), /40%/, "the settled snapshot is drawn");
|
|
90
|
+
});
|
|
91
|
+
|
|
65
92
|
function click(x: number, y: number, width: number, height: number): TuiMouseEvent {
|
|
66
93
|
return { type: "click", button: "left", x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
|
|
67
94
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
renderUsageBar,
|
|
16
16
|
renderUsagePanel,
|
|
17
17
|
SUPPORTED_USAGE_PROVIDERS,
|
|
18
|
+
usageScopeProviders,
|
|
18
19
|
UsageSourceRegistry,
|
|
19
20
|
USAGE_SOURCE_SCHEMA,
|
|
20
21
|
UsageStore,
|
|
@@ -572,3 +573,75 @@ test("parseUsageHeaders picks whichever provider the headers belong to", () => {
|
|
|
572
573
|
assert.equal(parseUsageHeaders({ "anthropic-ratelimit-unified-5h-utilization": "0.1" }, NOW)?.provider, "anthropic");
|
|
573
574
|
assert.equal(parseUsageHeaders({ "content-type": "application/json" }, NOW), undefined);
|
|
574
575
|
});
|
|
576
|
+
|
|
577
|
+
// Targeted providers: the session's own provider plus every provider the
|
|
578
|
+
// active profile's subagent routing names. A qualified ref ("provider/id")
|
|
579
|
+
// resolves directly; a bare model id resolves only through the registry
|
|
580
|
+
// callback, so an ambiguous ref is dropped instead of guessed.
|
|
581
|
+
|
|
582
|
+
test("usageScopeProviders targets the main provider and every qualified routing provider", () => {
|
|
583
|
+
assert.deepEqual(
|
|
584
|
+
usageScopeProviders("openai-codex", ["nan/glm5.3", "openai-codex/gpt-5.5", undefined], undefined),
|
|
585
|
+
["openai-codex", "nan"],
|
|
586
|
+
);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("usageScopeProviders resolves bare model ids only through the registry callback", () => {
|
|
590
|
+
assert.deepEqual(usageScopeProviders(undefined, ["glm5.3"], (id) => (id === "glm5.3" ? "nan" : undefined)), ["nan"]);
|
|
591
|
+
assert.deepEqual(
|
|
592
|
+
usageScopeProviders(undefined, ["glm5.3", "mystery"], () => undefined),
|
|
593
|
+
[],
|
|
594
|
+
"an id the registry cannot resolve to exactly one provider is dropped, never guessed",
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
function scopePanel(usages: ProviderUsage[], providers: string[], failed: string[] = []): string[] {
|
|
599
|
+
return renderUsagePanel(usages, plainTheme, 100, NOW, { provider: "openai-codex" }, undefined, {
|
|
600
|
+
providers,
|
|
601
|
+
failed: new Set(failed),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
test("renderUsagePanel draws the targeted scope in scope order with no-data and failed-fetch notes", () => {
|
|
606
|
+
const codex = parseCodexUsage(CODEX_PAYLOAD, NOW);
|
|
607
|
+
const nan = parseNanQuota(NAN_QUOTA, NOW);
|
|
608
|
+
const lines = scopePanel([nan, codex], ["openai-codex", "nan", "acme-cloud"], ["acme-cloud"]);
|
|
609
|
+
assert.match(lines[0], /^✿ openai-codex · pro/);
|
|
610
|
+
assert.match(lines.find((line) => line.startsWith("nan")) ?? "", /^nan · /, "a targeted provider with data renders like any other");
|
|
611
|
+
assert.match(lines.find((line) => line.startsWith("acme-cloud")) ?? "", /^acme-cloud · fetch failed · r to retry$/);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("renderUsagePanel keeps a headers-only targeted provider pending, never a false failure", () => {
|
|
615
|
+
const pending = (providers: string[], failed: string[] = []) =>
|
|
616
|
+
renderUsagePanel([], plainTheme, 100, NOW, undefined, undefined, { providers, failed: new Set(failed) });
|
|
617
|
+
assert.deepEqual(
|
|
618
|
+
pending(["anthropic"]),
|
|
619
|
+
["anthropic · usage arrives with the first response"],
|
|
620
|
+
);
|
|
621
|
+
assert.deepEqual(
|
|
622
|
+
pending(["openai-codex"]),
|
|
623
|
+
["openai-codex · no usage yet · r to fetch"],
|
|
624
|
+
"an unattempted fetch still says pending, not failed",
|
|
625
|
+
);
|
|
626
|
+
assert.deepEqual(
|
|
627
|
+
pending(["openai-codex"], ["openai-codex"]),
|
|
628
|
+
["openai-codex · fetch failed · r to retry"],
|
|
629
|
+
);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("renderUsagePanel never presents a provider outside the current scope after a profile switch", () => {
|
|
633
|
+
const codex = parseCodexUsage(CODEX_PAYLOAD, NOW);
|
|
634
|
+
const nan = parseNanQuota(NAN_QUOTA, NOW);
|
|
635
|
+
const lines = scopePanel([nan, codex], ["openai-codex"]);
|
|
636
|
+
assert.ok(lines.some((line) => line.startsWith("✿ openai-codex · pro")));
|
|
637
|
+
assert.equal(lines.some((line) => line.startsWith("nan ·")), false, "the previous profile's provider is not current scope");
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("renderUsagePanel renders the failure note beside a retained snapshot, and only after a real failure", () => {
|
|
641
|
+
const codex = parseCodexUsage(CODEX_PAYLOAD, NOW);
|
|
642
|
+
const afterFailure = scopePanel([codex], ["openai-codex"], ["openai-codex"]);
|
|
643
|
+
assert.match(afterFailure[0], /^✿ openai-codex · pro/, "the retained snapshot stays the provider's headline");
|
|
644
|
+
assert.match(afterFailure[1], /^ {2}codex week /, "the last good snapshot survives the failed refresh");
|
|
645
|
+
assert.ok(afterFailure.some((line) => /^ {2}fetch failed · r to retry$/.test(line)), "the failure is visible beside the retained snapshot");
|
|
646
|
+
assert.equal(scopePanel([codex], ["openai-codex"]).some((line) => line.includes("fetch failed")), false, "a provider whose latest refresh succeeded carries no failure note");
|
|
647
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
5
6
|
import test from "node:test";
|
|
@@ -316,3 +317,51 @@ test("non-forced regeneration invalidates cache when skill bytes change but path
|
|
|
316
317
|
assert.match(secondRegistry, /Variant two\. Body B\./);
|
|
317
318
|
assert.doesNotMatch(secondRegistry, /Variant one\. Body A\./);
|
|
318
319
|
});
|
|
320
|
+
|
|
321
|
+
test("ensureAtlIgnored creates .atl/.gitignore with * and leaves root .gitignore untouched (#1387)", async (t) => {
|
|
322
|
+
const cwd = mkdtempSync(join(tmpdir(), `gentle-pi-git-atl-${Date.now()}-`));
|
|
323
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
324
|
+
|
|
325
|
+
// Initialize real git repo with a commit
|
|
326
|
+
execSync("git init", { cwd, stdio: "ignore" });
|
|
327
|
+
writeFileSync(join(cwd, "README.md"), "# Test\n");
|
|
328
|
+
execSync("git add README.md && git -c user.name='Test' -c user.email='test@example.com' commit -m \"initial\"", { cwd, stdio: "ignore" });
|
|
329
|
+
|
|
330
|
+
const rootGitignore = join(cwd, ".gitignore");
|
|
331
|
+
const atlGitignore = join(cwd, ".atl", ".gitignore");
|
|
332
|
+
|
|
333
|
+
// Run ensureAtlIgnored
|
|
334
|
+
await __testing.ensureAtlIgnored(cwd);
|
|
335
|
+
|
|
336
|
+
// Root .gitignore must NOT be created
|
|
337
|
+
assert.equal(existsSync(rootGitignore), false, "root .gitignore must not be created");
|
|
338
|
+
|
|
339
|
+
// .atl/.gitignore must exist with * rule
|
|
340
|
+
assert.equal(existsSync(atlGitignore), true, ".atl/.gitignore must exist");
|
|
341
|
+
assert.equal(readFileSync(atlGitignore, "utf8").trim(), "*");
|
|
342
|
+
|
|
343
|
+
// Write generated registry file inside .atl
|
|
344
|
+
writeFileSync(join(cwd, ".atl", "skill-registry.md"), "## Skills\n");
|
|
345
|
+
|
|
346
|
+
// Verify that git status reports no untracked files
|
|
347
|
+
const status = execSync("git status --porcelain", { cwd, encoding: "utf8" });
|
|
348
|
+
assert.equal(status.trim(), "", ".atl/ files must not appear in git status");
|
|
349
|
+
|
|
350
|
+
// Idempotency: calling ensureAtlIgnored again does not duplicate or alter the rule
|
|
351
|
+
await __testing.ensureAtlIgnored(cwd);
|
|
352
|
+
assert.equal(readFileSync(atlGitignore, "utf8").trim(), "*");
|
|
353
|
+
|
|
354
|
+
// Existing root .gitignore is preserved unmodified
|
|
355
|
+
writeFileSync(rootGitignore, "node_modules/\n");
|
|
356
|
+
await __testing.ensureAtlIgnored(cwd);
|
|
357
|
+
assert.equal(readFileSync(rootGitignore, "utf8"), "node_modules/\n", "existing root .gitignore must remain untouched");
|
|
358
|
+
|
|
359
|
+
// If .atl/.gitignore already has intermediate rules ending with a negation, ensure * is appended
|
|
360
|
+
writeFileSync(atlGitignore, "*\n!*.md\n");
|
|
361
|
+
await __testing.ensureAtlIgnored(cwd);
|
|
362
|
+
const updatedRules = readFileSync(atlGitignore, "utf8")
|
|
363
|
+
.split("\n")
|
|
364
|
+
.map((l) => l.trim())
|
|
365
|
+
.filter((l) => l !== "" && !l.startsWith("#"));
|
|
366
|
+
assert.equal(updatedRules.at(-1), "*", "final active ignore rule must be *");
|
|
367
|
+
});
|
|
@@ -5,7 +5,7 @@ import fs from "node:fs/promises";
|
|
|
5
5
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import startup, { readGitBranch } from "../extensions/startup-banner.ts";
|
|
8
|
+
import startup, { isPiCliSubcommandInvocation, readGitBranch } from "../extensions/startup-banner.ts";
|
|
9
9
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
11
11
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
@@ -210,6 +210,12 @@ for (const showRose of [false, true]) for (const showTextLogo of [false, true])
|
|
|
210
210
|
test(`startup art respects rose=${showRose}, logo=${showTextLogo} and cyan palette`, async (t) => {
|
|
211
211
|
t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
|
|
212
212
|
t.mock.method(fs, "readFile", async () => JSON.stringify({ showRose, showTextLogo, color: "cyan" }));
|
|
213
|
+
t.mock.method(fs, "readdir", async () => [
|
|
214
|
+
{ name: "sdd-apply.md", isFile: () => true },
|
|
215
|
+
{ name: "sdd-status.md", isFile: () => true },
|
|
216
|
+
{ name: "gentle-ai-worker.md", isFile: () => true },
|
|
217
|
+
{ name: "notes.txt", isFile: () => true },
|
|
218
|
+
] as any);
|
|
213
219
|
syncBuiltinESMExports();
|
|
214
220
|
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
215
221
|
const argv = process.argv;
|
|
@@ -233,7 +239,8 @@ for (const showRose of [false, true]) for (const showTextLogo of [false, true])
|
|
|
233
239
|
await start!({}, { hasUI: true, cwd: "/fixture", ui: { setHeader: (factory: Function) => {
|
|
234
240
|
header = factory({ requestRender() {} }, { fg: (_role: string, text: string) => text });
|
|
235
241
|
} } });
|
|
236
|
-
t.mock.timers.tick(
|
|
242
|
+
t.mock.timers.tick(200);
|
|
243
|
+
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
237
244
|
try {
|
|
238
245
|
for (const width of [40, 80, 160, 200]) {
|
|
239
246
|
const lines = header!.render(width);
|
|
@@ -241,6 +248,8 @@ for (const showRose of [false, true]) for (const showTextLogo of [false, true])
|
|
|
241
248
|
const text = stripAnsi(lines.join("\n"));
|
|
242
249
|
assert.match(text, /GIT:/);
|
|
243
250
|
assert.match(text, /PATH:/);
|
|
251
|
+
assert.doesNotMatch(text, /phases\b/i, "historical SDD files never appear as active phases");
|
|
252
|
+
assert.match(text, /AGENTS:\s+1 agents/, "only the installed background agent is counted");
|
|
244
253
|
if (width >= 160) {
|
|
245
254
|
assert.equal(/[\u2800-\u28ff]/.test(text), showRose);
|
|
246
255
|
assert.equal(/[▒▄▀█]/.test(text), showTextLogo);
|
|
@@ -268,3 +277,122 @@ for (const showRose of [false, true]) for (const showTextLogo of [false, true])
|
|
|
268
277
|
}
|
|
269
278
|
});
|
|
270
279
|
}
|
|
280
|
+
|
|
281
|
+
test("startup banner counts MCP servers from the active Pi agent dir", async (t) => {
|
|
282
|
+
const agentDir = join(tmpdir(), "gp-banner-agent-dir");
|
|
283
|
+
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
284
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
285
|
+
t.after(() => {
|
|
286
|
+
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
287
|
+
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
|
288
|
+
});
|
|
289
|
+
t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
|
|
290
|
+
// Only the active agent dir's mcp.json declares two servers; any other
|
|
291
|
+
// mcp.json (for example ~/.pi/agent/mcp.json) declares five.
|
|
292
|
+
t.mock.method(fs, "readFile", async (path: string) => {
|
|
293
|
+
if (String(path) === join(agentDir, "mcp.json")) return JSON.stringify({ mcpServers: { one: {}, two: {} } });
|
|
294
|
+
if (String(path).endsWith("mcp.json")) return JSON.stringify({ mcpServers: { a: {}, b: {}, c: {}, d: {}, e: {} } });
|
|
295
|
+
return JSON.stringify({ showRose: false, showTextLogo: false, color: "pink" });
|
|
296
|
+
});
|
|
297
|
+
t.mock.method(fs, "readdir", async () => [] as any);
|
|
298
|
+
syncBuiltinESMExports();
|
|
299
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
300
|
+
const argv = process.argv;
|
|
301
|
+
process.argv = ["node"];
|
|
302
|
+
t.after(() => { process.argv = argv; });
|
|
303
|
+
for (const [key, value] of [["rows", 40], ["columns", 160]] as const) {
|
|
304
|
+
const descriptor = Object.getOwnPropertyDescriptor(process.stdout, key);
|
|
305
|
+
Object.defineProperty(process.stdout, key, { configurable: true, writable: true, value });
|
|
306
|
+
t.after(() => descriptor ? Object.defineProperty(process.stdout, key, descriptor) : Reflect.deleteProperty(process.stdout, key));
|
|
307
|
+
}
|
|
308
|
+
let start: Function;
|
|
309
|
+
let shutdown: Function;
|
|
310
|
+
let header: { render(width: number): string[]; dispose(): void };
|
|
311
|
+
const { default: coldStartup } = await import(new URL("../extensions/startup-banner.ts?mcp-agent-dir", import.meta.url).href) as typeof import("../extensions/startup-banner.ts");
|
|
312
|
+
coldStartup({ on: (name: string, fn: Function) => {
|
|
313
|
+
if (name === "session_start") start = fn;
|
|
314
|
+
if (name === "session_shutdown") shutdown = fn;
|
|
315
|
+
}, registerCommand() {}, getCommands: () => [], getAllTools: () => [] } as unknown as ExtensionAPI);
|
|
316
|
+
await start!({}, { hasUI: true, cwd: "/fixture", ui: { setHeader: (factory: Function) => {
|
|
317
|
+
header = factory({ requestRender() {} }, { fg: (_role: string, text: string) => text });
|
|
318
|
+
} } });
|
|
319
|
+
t.mock.timers.tick(200);
|
|
320
|
+
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
321
|
+
try {
|
|
322
|
+
assert.match(stripAnsi(header!.render(160).join("\n")), /MCP:\s+2 server\(s\)/);
|
|
323
|
+
} finally {
|
|
324
|
+
header!.dispose();
|
|
325
|
+
shutdown!();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("startup banner counts packages, extensions and agents from the active Pi agent dir", async (t) => {
|
|
330
|
+
const agentDir = join(tmpdir(), "gp-banner-active-agent-dir");
|
|
331
|
+
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
332
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
333
|
+
t.after(() => {
|
|
334
|
+
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
335
|
+
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
|
336
|
+
});
|
|
337
|
+
t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
|
|
338
|
+
// The active agent dir declares one package with two extensions and three
|
|
339
|
+
// agents; any other agent dir (for example ~/.pi/agent) declares more.
|
|
340
|
+
t.mock.method(fs, "readFile", async (path: string) => {
|
|
341
|
+
const file = String(path);
|
|
342
|
+
if (file === join(agentDir, "settings.json")) return JSON.stringify({ packages: ["npm:@acme/pi-kit@1.0.0"] });
|
|
343
|
+
if (file.endsWith("settings.json")) return JSON.stringify({ packages: ["npm:a", "npm:b", "npm:c", "npm:d"] });
|
|
344
|
+
if (file === join(agentDir, "npm", "node_modules", "@acme/pi-kit", "package.json")) {
|
|
345
|
+
return JSON.stringify({ pi: { extensions: ["one.ts", "two.ts"] } });
|
|
346
|
+
}
|
|
347
|
+
if (file.endsWith("package.json")) return JSON.stringify({ pi: { extensions: ["x.ts", "y.ts", "z.ts", "w.ts", "v.ts"] } });
|
|
348
|
+
if (file.endsWith("mcp.json")) return JSON.stringify({ mcpServers: {} });
|
|
349
|
+
return JSON.stringify({ showRose: false, showTextLogo: false, color: "pink" });
|
|
350
|
+
});
|
|
351
|
+
const agentFile = (name: string) => ({ name, isFile: () => true });
|
|
352
|
+
t.mock.method(fs, "readdir", async (path: string) => String(path) === join(agentDir, "agents")
|
|
353
|
+
? [agentFile("one.md"), agentFile("two.md"), agentFile("three.md"), agentFile("sdd-apply.md")] as any
|
|
354
|
+
: [agentFile("a.md"), agentFile("b.md"), agentFile("c.md"), agentFile("d.md"), agentFile("e.md"), agentFile("f.md"), agentFile("g.md")] as any);
|
|
355
|
+
syncBuiltinESMExports();
|
|
356
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
357
|
+
const argv = process.argv;
|
|
358
|
+
process.argv = ["node"];
|
|
359
|
+
t.after(() => { process.argv = argv; });
|
|
360
|
+
for (const [key, value] of [["rows", 40], ["columns", 160]] as const) {
|
|
361
|
+
const descriptor = Object.getOwnPropertyDescriptor(process.stdout, key);
|
|
362
|
+
Object.defineProperty(process.stdout, key, { configurable: true, writable: true, value });
|
|
363
|
+
t.after(() => descriptor ? Object.defineProperty(process.stdout, key, descriptor) : Reflect.deleteProperty(process.stdout, key));
|
|
364
|
+
}
|
|
365
|
+
let start: Function;
|
|
366
|
+
let shutdown: Function;
|
|
367
|
+
let header: { render(width: number): string[]; dispose(): void };
|
|
368
|
+
const { default: coldStartup } = await import(new URL("../extensions/startup-banner.ts?active-agent-dir-counts", import.meta.url).href) as typeof import("../extensions/startup-banner.ts");
|
|
369
|
+
coldStartup({ on: (name: string, fn: Function) => {
|
|
370
|
+
if (name === "session_start") start = fn;
|
|
371
|
+
if (name === "session_shutdown") shutdown = fn;
|
|
372
|
+
}, registerCommand() {}, getCommands: () => [], getAllTools: () => [] } as unknown as ExtensionAPI);
|
|
373
|
+
await start!({}, { hasUI: true, cwd: "/fixture", ui: { setHeader: (factory: Function) => {
|
|
374
|
+
header = factory({ requestRender() {} }, { fg: (_role: string, text: string) => text });
|
|
375
|
+
} } });
|
|
376
|
+
t.mock.timers.tick(200);
|
|
377
|
+
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
378
|
+
try {
|
|
379
|
+
const text = stripAnsi(header!.render(160).join("\n"));
|
|
380
|
+
assert.match(text, /PLUGINS:\s+1 package\(s\)/);
|
|
381
|
+
assert.match(text, /EXTENSIONS:\s+2 active/);
|
|
382
|
+
assert.match(text, /AGENTS:\s+3 agents/);
|
|
383
|
+
} finally {
|
|
384
|
+
header!.dispose();
|
|
385
|
+
shutdown!();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("launcher-injected extension directories do not suppress the startup banner", () => {
|
|
390
|
+
// Gentle Shell launches `pi -e <package-root-dir>`; a directory path is not a subcommand.
|
|
391
|
+
assert.equal(isPiCliSubcommandInvocation(["node", "pi", "-e", "/opt/gentle-pi"]), false);
|
|
392
|
+
assert.equal(isPiCliSubcommandInvocation(["node", "pi", "--no-extensions", "-e", "/a", "-e", "/b/ext.mjs"]), false);
|
|
393
|
+
assert.equal(isPiCliSubcommandInvocation(["node", "pi"]), false);
|
|
394
|
+
assert.equal(isPiCliSubcommandInvocation(["node", "pi", "-e", "/opt/gentle-pi", "install"]), false);
|
|
395
|
+
for (const sub of ["install", "remove", "uninstall", "update", "list", "config", "auth"]) {
|
|
396
|
+
assert.equal(isPiCliSubcommandInvocation(["node", "pi", sub, "npm:x"]), true, sub);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { aggregateStats, createStatsLoader, currentSessionStats, parseSessionLines, shiftDay, STATS_RANGE, STATS_SCOPE, weekdayOf, type StatsFilter } from "../lib/stats-collector.ts";
|
|
8
|
+
import { SESSION_CHANGE_ENTRY } from "../lib/session-changes.ts";
|
|
9
|
+
|
|
10
|
+
// The /gentle:stats collector: a streaming loader over Pi's session files and
|
|
11
|
+
// pure aggregation over the parsed records. Fixtures carry exact numbers so
|
|
12
|
+
// every figure the panel shows is pinned here.
|
|
13
|
+
|
|
14
|
+
const SESSIONS = fileURLToPath(new URL("./fixtures/stats/sessions", import.meta.url));
|
|
15
|
+
// A second home (the user's regular Pi) holding an OpenAI session and copies of aaa and bbb.
|
|
16
|
+
const USER_SESSIONS = fileURLToPath(new URL("./fixtures/stats/user-pi/sessions", import.meta.url));
|
|
17
|
+
const NOW = Date.parse("2026-10-01T12:00:00.000Z");
|
|
18
|
+
const utcDay = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
|
19
|
+
const filter = (overrides: Partial<StatsFilter> = {}): StatsFilter => ({ range: STATS_RANGE.ALL, scope: STATS_SCOPE.ALL, cwd: "/work/alpha", now: NOW, dayKey: utcDay, ...overrides });
|
|
20
|
+
|
|
21
|
+
test("loader reads top-level session files only and tolerates malformed lines", async () => {
|
|
22
|
+
const sessions = await createStatsLoader().load([SESSIONS]);
|
|
23
|
+
assert.deepEqual(sessions.map((session) => session.id).sort(), ["aaa", "bbb", "ccc", "ddd"]);
|
|
24
|
+
const aaa = sessions.find((session) => session.id === "aaa")!;
|
|
25
|
+
assert.equal(aaa.cwd, "/work/alpha");
|
|
26
|
+
assert.equal(aaa.messages.length, 2);
|
|
27
|
+
assert.deepEqual(aaa.messages[0], { timestamp: Date.parse("2026-09-28T10:01:00.000Z"), provider: "claude-bridge", model: "claude-opus-5-5", input: 100, output: 50, cacheRead: 1000, cacheWrite: 200, total: 1350, cost: 0.5 });
|
|
28
|
+
// Older usage without totalTokens falls back to the sum of its parts.
|
|
29
|
+
assert.equal(sessions.find((session) => session.id === "ccc")!.messages[0].total, 1000);
|
|
30
|
+
assert.equal(sessions.some((session) => session.messages.some((message) => message.model === "nested-model")), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("loader returns nothing for a missing or empty sessions root", async () => {
|
|
34
|
+
const loader = createStatsLoader();
|
|
35
|
+
assert.deepEqual(await loader.load([join(tmpdir(), "gentle-stats-missing-root")]), []);
|
|
36
|
+
assert.deepEqual(await loader.load([mkdtempSync(join(tmpdir(), "gentle-stats-empty-"))]), []);
|
|
37
|
+
assert.deepEqual(await loader.load([]), []);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("two roots combine into one history: OpenAI usage from the second root counts, duplicate session IDs count once", async () => {
|
|
41
|
+
const sessions = await createStatsLoader().load([SESSIONS, USER_SESSIONS]);
|
|
42
|
+
assert.deepEqual(sessions.map((session) => session.id).sort(), ["aaa", "bbb", "ccc", "ddd", "fff"]);
|
|
43
|
+
const stats = aggregateStats(sessions, filter());
|
|
44
|
+
assert.deepEqual(stats.totals, { input: 2810, output: 1840, cacheRead: 1000, cacheWrite: 200, total: 5850, cost: 3.85 });
|
|
45
|
+
assert.deepEqual(stats.models.map(({ model, tokens, messages }) => [model, tokens, messages]), [["gpt-5.3-codex", 3000, 1], ["claude-opus-5-5", 1550, 4], ["gpt-5.5", 1000, 1], ["gpt-6.1-sol", 300, 1]]);
|
|
46
|
+
assert.deepEqual(stats.days, { "2026-09-01": 1000, "2026-09-20": 3000, "2026-09-28": 1650, "2026-09-29": 30, "2026-09-30": 170 });
|
|
47
|
+
assert.equal(stats.sessions, 4);
|
|
48
|
+
const gamma = aggregateStats(sessions, filter({ scope: STATS_SCOPE.PROJECT, cwd: "/work/gamma" }));
|
|
49
|
+
assert.deepEqual(gamma.models.map((model) => model.model), ["gpt-5.3-codex"]);
|
|
50
|
+
assert.equal(gamma.totals.total, 3000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a duplicate session keeps the copy with the most usage records, then the latest usage, whatever the root order", async () => {
|
|
54
|
+
for (const roots of [[SESSIONS, USER_SESSIONS], [USER_SESSIONS, SESSIONS]]) {
|
|
55
|
+
const sessions = await createStatsLoader().load(roots);
|
|
56
|
+
// aaa: the active copy has 2 usage records, the user copy 1 → the active copy wins.
|
|
57
|
+
assert.deepEqual(sessions.find((session) => session.id === "aaa")!.messages.map((message) => message.total), [1350, 300]);
|
|
58
|
+
// bbb: the user copy has 3 usage records, the active copy 2 → the user copy wins.
|
|
59
|
+
assert.deepEqual(sessions.find((session) => session.id === "bbb")!.messages.map((message) => message.total), [30, 70, 100]);
|
|
60
|
+
assert.equal(sessions.filter((session) => session.id === "aaa" || session.id === "bbb").length, 2);
|
|
61
|
+
}
|
|
62
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-stats-tie-"));
|
|
63
|
+
const header = (id: string) => JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-09-01T00:00:00.000Z", cwd: "/t" });
|
|
64
|
+
const usage = (at: string, total: number) => JSON.stringify({ type: "message", timestamp: at, message: { role: "assistant", model: "m", usage: { output: total } } });
|
|
65
|
+
const write = (dir: string, name: string, lines: string[]) => { mkdirSync(join(root, dir), { recursive: true }); writeFileSync(join(root, dir, name), `${lines.join("\n")}\n`); };
|
|
66
|
+
write("a", "1_tie.jsonl", [header("tie"), usage("2026-09-01T00:01:00.000Z", 1)]);
|
|
67
|
+
write("b", "2_tie.jsonl", [header("tie"), usage("2026-09-01T00:02:00.000Z", 2)]);
|
|
68
|
+
// Equal record counts fall back to the latest usage timestamp.
|
|
69
|
+
assert.deepEqual((await createStatsLoader().load([root])).map((session) => session.messages[0].total), [2]);
|
|
70
|
+
// Headers without an ID are never merged with each other.
|
|
71
|
+
write("a", "3_noid.jsonl", [JSON.stringify({ type: "session", version: 3, cwd: "/t" }), usage("2026-09-02T00:00:00.000Z", 5)]);
|
|
72
|
+
write("b", "4_noid.jsonl", [JSON.stringify({ type: "session", version: 3, cwd: "/t" }), usage("2026-09-03T00:00:00.000Z", 7)]);
|
|
73
|
+
assert.deepEqual((await createStatsLoader().load([root])).map((session) => session.messages[0].total).sort(), [2, 5, 7]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("alias roots (trailing slash, dot segments, symlinks) and missing roots never double count", async () => {
|
|
77
|
+
const link = join(mkdtempSync(join(tmpdir(), "gentle-stats-alias-")), "sessions-link");
|
|
78
|
+
symlinkSync(SESSIONS, link, "dir");
|
|
79
|
+
const missing = join(tmpdir(), "gentle-stats-missing-root");
|
|
80
|
+
const once = aggregateStats(await createStatsLoader().load([SESSIONS]), filter());
|
|
81
|
+
const aliased = aggregateStats(await createStatsLoader().load([SESSIONS, `${SESSIONS}/`, join(SESSIONS, "..", "sessions"), link, missing]), filter());
|
|
82
|
+
assert.deepEqual(aliased.totals, once.totals);
|
|
83
|
+
assert.equal(aliased.sessions, 3);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("parseSessionLines ignores files without a session header", () => {
|
|
87
|
+
assert.equal(parseSessionLines(["garbage", "{\"type\":\"message\"}"]), undefined);
|
|
88
|
+
const parsed = parseSessionLines([
|
|
89
|
+
"{\"type\":\"session\",\"id\":\"x\",\"timestamp\":\"2026-09-01T00:00:00.000Z\",\"cwd\":\"/x\"}",
|
|
90
|
+
"{\"type\":\"message\",\"timestamp\":\"2026-09-01T00:01:00.000Z\",\"message\":{\"role\":\"assistant\",\"model\":\"m\",\"usage\":{\"input\":\"bad\",\"output\":3}}}",
|
|
91
|
+
]);
|
|
92
|
+
assert.equal(parsed?.messages[0].input, 0);
|
|
93
|
+
assert.equal(parsed?.messages[0].total, 3);
|
|
94
|
+
assert.equal(parsed?.messages[0].provider, "unknown");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("all-time stats across every project match the fixture exactly", async () => {
|
|
98
|
+
const stats = aggregateStats(await createStatsLoader().load([SESSIONS]), filter());
|
|
99
|
+
assert.deepEqual(stats.totals, { input: 810, output: 740, cacheRead: 1000, cacheWrite: 200, total: 2750, cost: 2.05 });
|
|
100
|
+
assert.deepEqual(stats.models.map(({ model, tokens, messages }) => [model, tokens, messages]), [["claude-opus-5-5", 1450, 3], ["gpt-5.5", 1000, 1], ["gpt-6.1-sol", 300, 1]]);
|
|
101
|
+
assert.equal(stats.models[0].cost.toFixed(2), "0.80");
|
|
102
|
+
assert.equal(stats.models[0].share, 1450 / 2750);
|
|
103
|
+
assert.deepEqual(stats.days, { "2026-09-01": 1000, "2026-09-28": 1650, "2026-09-29": 30, "2026-09-30": 70 });
|
|
104
|
+
assert.equal(stats.sessions, 3);
|
|
105
|
+
assert.equal(stats.activeDays, 4);
|
|
106
|
+
assert.equal(stats.daysInRange, 31);
|
|
107
|
+
assert.equal(stats.firstDay, "2026-09-01");
|
|
108
|
+
assert.equal(stats.today, "2026-10-01");
|
|
109
|
+
assert.deepEqual(stats.mostActiveDay, { day: "2026-09-28", tokens: 1650 });
|
|
110
|
+
assert.deepEqual(stats.longestSession, { id: "bbb", durationMs: 2 * 60 * 60 * 1000 });
|
|
111
|
+
assert.equal(stats.longestStreak, 3);
|
|
112
|
+
assert.equal(stats.currentStreak, 3);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("range filters count only messages inside the selected local days", async () => {
|
|
116
|
+
const sessions = await createStatsLoader().load([SESSIONS]);
|
|
117
|
+
const week = aggregateStats(sessions, filter({ range: STATS_RANGE.WEEK }));
|
|
118
|
+
assert.equal(week.totals.total, 1750);
|
|
119
|
+
assert.equal(week.totals.cost.toFixed(2), "1.05");
|
|
120
|
+
assert.equal(week.sessions, 2);
|
|
121
|
+
assert.equal(week.activeDays, 3);
|
|
122
|
+
assert.equal(week.daysInRange, 7);
|
|
123
|
+
assert.equal(week.firstDay, "2026-09-25");
|
|
124
|
+
// 2026-09-01 sits one day before the 30-day window that ends today.
|
|
125
|
+
const month = aggregateStats(sessions, filter({ range: STATS_RANGE.MONTH }));
|
|
126
|
+
assert.equal(month.totals.total, 1750);
|
|
127
|
+
assert.equal(month.daysInRange, 30);
|
|
128
|
+
assert.equal(month.firstDay, "2026-09-02");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("project scope keeps only sessions started in the current cwd", async () => {
|
|
132
|
+
const stats = aggregateStats(await createStatsLoader().load([SESSIONS]), filter({ scope: STATS_SCOPE.PROJECT, cwd: "/work/beta" }));
|
|
133
|
+
assert.equal(stats.totals.total, 1000);
|
|
134
|
+
assert.equal(stats.sessions, 1);
|
|
135
|
+
assert.equal(stats.longestStreak, 1);
|
|
136
|
+
assert.equal(stats.currentStreak, 0);
|
|
137
|
+
assert.deepEqual(stats.models.map((model) => model.model), ["gpt-5.5"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("current streak survives an idle today but breaks after a missed day", () => {
|
|
141
|
+
const session = (id: string, ...days: string[]) => ({
|
|
142
|
+
id, cwd: "/x", startedAt: Date.parse(`${days[0]}T10:00:00.000Z`),
|
|
143
|
+
messages: days.map((day) => ({ timestamp: Date.parse(`${day}T10:00:00.000Z`), provider: "p", model: "m", input: 1, output: 0, cacheRead: 0, cacheWrite: 0, total: 1, cost: 0 })),
|
|
144
|
+
});
|
|
145
|
+
const active = aggregateStats([session("a", "2026-09-29", "2026-09-30", "2026-10-01")], filter());
|
|
146
|
+
assert.equal(active.currentStreak, 3);
|
|
147
|
+
const broken = aggregateStats([session("a", "2026-09-28", "2026-09-29")], filter());
|
|
148
|
+
assert.equal(broken.currentStreak, 0);
|
|
149
|
+
assert.equal(broken.longestStreak, 2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("empty input aggregates to zeroes without throwing", () => {
|
|
153
|
+
const stats = aggregateStats([], filter());
|
|
154
|
+
assert.deepEqual(stats.totals, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 });
|
|
155
|
+
assert.deepEqual(stats.models, []);
|
|
156
|
+
assert.equal(stats.sessions, 0);
|
|
157
|
+
assert.equal(stats.activeDays, 0);
|
|
158
|
+
assert.equal(stats.daysInRange, 1);
|
|
159
|
+
assert.equal(stats.mostActiveDay, undefined);
|
|
160
|
+
assert.equal(stats.longestSession, undefined);
|
|
161
|
+
assert.equal(stats.longestStreak, 0);
|
|
162
|
+
assert.equal(stats.currentStreak, 0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("day keys shift across month boundaries and report their weekday", () => {
|
|
166
|
+
assert.equal(shiftDay("2026-10-01", -1), "2026-09-30");
|
|
167
|
+
assert.equal(shiftDay("2026-12-31", 1), "2027-01-01");
|
|
168
|
+
assert.equal(weekdayOf("2026-10-01"), 4); // Thursday
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("loader reuses unchanged files from its cache", async () => {
|
|
172
|
+
const loader = createStatsLoader();
|
|
173
|
+
const first = await loader.load([SESSIONS]);
|
|
174
|
+
const second = await loader.load([SESSIONS]);
|
|
175
|
+
assert.equal(first.find((session) => session.id === "aaa"), second.find((session) => session.id === "aaa"));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("currentSessionStats summarizes the live session including captured line changes", () => {
|
|
179
|
+
const entries = [
|
|
180
|
+
{ type: "model_change", timestamp: "2026-10-01T11:00:00.000Z", provider: "claude-bridge", modelId: "claude-opus-5-5" },
|
|
181
|
+
{ type: "message", timestamp: "2026-10-01T11:01:00.000Z", message: { role: "assistant", provider: "claude-bridge", model: "claude-opus-5-5", usage: { input: 10, output: 5, cacheRead: 100, cacheWrite: 20, totalTokens: 135, cost: { total: 0.125 } } } },
|
|
182
|
+
{ type: "message", timestamp: "2026-10-01T11:02:00.000Z", message: { role: "user", content: "x" } },
|
|
183
|
+
{ type: "custom", customType: SESSION_CHANGE_ENTRY, timestamp: "2026-10-01T11:03:00.000Z", data: { sessionId: "live", evidence: { id: "e1", root: "/repo", path: "a.txt", before: { kind: "text", text: "a\nb\n" }, after: { kind: "text", text: "a\nc\nd\n" } } } },
|
|
184
|
+
];
|
|
185
|
+
const stats = currentSessionStats(entries, { sessionId: "live", now: Date.parse("2026-10-01T11:30:00.000Z") });
|
|
186
|
+
assert.deepEqual(stats.totals, { input: 10, output: 5, cacheRead: 100, cacheWrite: 20, total: 135, cost: 0.125 });
|
|
187
|
+
assert.equal(stats.messages, 1);
|
|
188
|
+
assert.equal(stats.model, "claude-opus-5-5");
|
|
189
|
+
assert.equal(stats.durationMs, 30 * 60 * 1000);
|
|
190
|
+
assert.deepEqual(stats.lines, { added: 2, removed: 1 });
|
|
191
|
+
const empty = currentSessionStats([], { sessionId: "none", now: NOW });
|
|
192
|
+
assert.equal(empty.durationMs, 0);
|
|
193
|
+
assert.equal(empty.lines, undefined);
|
|
194
|
+
assert.equal(empty.model, undefined);
|
|
195
|
+
});
|