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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import promptHistoryExtension from "../extensions/history/index.ts";
|
|
7
|
+
import { projectHash } from "../extensions/history/store.ts";
|
|
8
|
+
|
|
9
|
+
// Home/End routing in the history selector: with text in the search box
|
|
10
|
+
// they move the search caret (End never jumps or loads the list); with an
|
|
11
|
+
// empty search box they keep the documented list jumps (§B2/§D7). The real
|
|
12
|
+
// selector is driven through the history command with a fake overlay host;
|
|
13
|
+
// every fixture lives under os.tmpdir(), never the user's real ~/.pi.
|
|
14
|
+
|
|
15
|
+
const CWD = "/pi-history-fixtures/project-caret-keys";
|
|
16
|
+
const HOME = "\x1b[H";
|
|
17
|
+
const END = "\x1b[F";
|
|
18
|
+
const LEFT = "\x1b[D";
|
|
19
|
+
const ENTER = "\r";
|
|
20
|
+
// 15 prompts, oldest p00 .. newest p14: more than the initial 10-row window.
|
|
21
|
+
const PROMPT_COUNT = 15;
|
|
22
|
+
|
|
23
|
+
interface Selector {
|
|
24
|
+
handleInput(data: string): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeRoot(): string {
|
|
28
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-caret-"));
|
|
29
|
+
const dir = path.join(root, "projects", projectHash(CWD));
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
const lines = Array.from({ length: PROMPT_COUNT }, (_, i) =>
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
v: 1,
|
|
34
|
+
text: `p${String(i).padStart(2, "0")}`,
|
|
35
|
+
ts: 1_700_000_000_000 + i,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
fs.writeFileSync(path.join(dir, "store.jsonl"), `${lines.join("\n")}\n`);
|
|
39
|
+
return root;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Open the selector, feed `keys` one at a time, and return the prompt the
|
|
44
|
+
* selector pasted into the editor (null when it pasted nothing).
|
|
45
|
+
*/
|
|
46
|
+
async function selectAfter(keys: string[]): Promise<string | null> {
|
|
47
|
+
const root = makeRoot();
|
|
48
|
+
const commands: Array<[string, { handler: unknown }]> = [];
|
|
49
|
+
promptHistoryExtension(
|
|
50
|
+
{
|
|
51
|
+
on: () => {},
|
|
52
|
+
registerShortcut: () => {},
|
|
53
|
+
registerCommand: (name: string, def: { handler: unknown }) => {
|
|
54
|
+
commands.push([name, def]);
|
|
55
|
+
},
|
|
56
|
+
} as never,
|
|
57
|
+
{
|
|
58
|
+
env: { GENTLE_PI_HISTORY_CAPTURE: "1" },
|
|
59
|
+
gentlePiConfigHome: fs.mkdtempSync(
|
|
60
|
+
path.join(os.tmpdir(), "pi-history-caret-config-"),
|
|
61
|
+
),
|
|
62
|
+
root,
|
|
63
|
+
cwd: CWD,
|
|
64
|
+
instanceId: "inst-caret",
|
|
65
|
+
agentDir: path.join(root, "agent"),
|
|
66
|
+
sessionsRoot: path.join(root, "sessions"),
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
const command = commands.find(([name]) => name === "history");
|
|
70
|
+
assert.ok(command, "the history command must be registered");
|
|
71
|
+
const handler = command[1].handler as (
|
|
72
|
+
args: unknown,
|
|
73
|
+
ctx: unknown,
|
|
74
|
+
) => Promise<void>;
|
|
75
|
+
|
|
76
|
+
let pasted: string | null = null;
|
|
77
|
+
const plain = (_color: string, text: string) => text;
|
|
78
|
+
const ctx = {
|
|
79
|
+
ui: {
|
|
80
|
+
notify: (message: string) => {
|
|
81
|
+
assert.fail(`unexpected notification: ${message}`);
|
|
82
|
+
},
|
|
83
|
+
pasteToEditor: (text: string) => {
|
|
84
|
+
pasted = text;
|
|
85
|
+
},
|
|
86
|
+
custom: (
|
|
87
|
+
factory: (
|
|
88
|
+
tui: unknown,
|
|
89
|
+
theme: unknown,
|
|
90
|
+
keybindings: unknown,
|
|
91
|
+
done: (result: unknown) => void,
|
|
92
|
+
) => Selector,
|
|
93
|
+
) =>
|
|
94
|
+
new Promise((resolve) => {
|
|
95
|
+
const selector = factory(
|
|
96
|
+
{ requestRender: () => {} },
|
|
97
|
+
{ fg: plain, bg: plain, bold: (text: string) => text },
|
|
98
|
+
undefined,
|
|
99
|
+
resolve,
|
|
100
|
+
);
|
|
101
|
+
for (const key of keys) selector.handleInput(key);
|
|
102
|
+
// Enter selects the highlighted row; with no match it resolves
|
|
103
|
+
// nothing, so close explicitly to finish the command.
|
|
104
|
+
selector.handleInput(ENTER);
|
|
105
|
+
resolve(null);
|
|
106
|
+
}),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
await handler([], ctx);
|
|
110
|
+
// The paste path schedules one render tick; let it drain.
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
112
|
+
return pasted;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
test("with a query, Home moves the search caret instead of the list", async () => {
|
|
116
|
+
// Typed "1", caret to the start, then "p": the query is "p1", whose
|
|
117
|
+
// newest match is p14. A list jump would leave the query as "1p".
|
|
118
|
+
assert.equal(await selectAfter(["1", HOME, "p"]), "p14");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("with a query, End moves the search caret instead of the list", async () => {
|
|
122
|
+
// Caret moved to the start, End brings it back: "p1" + "3" = "p13".
|
|
123
|
+
// A list jump would leave the caret at 0 and type "3p1".
|
|
124
|
+
assert.equal(await selectAfter(["p", "1", LEFT, LEFT, END, "3"]), "p13");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("with a query, End neither jumps to the last match nor loads the list", async () => {
|
|
128
|
+
// A real query: the highlighted row stays the newest match, not p00.
|
|
129
|
+
assert.equal(await selectAfter(["p", END]), "p14");
|
|
130
|
+
// A whitespace-only query filters nothing and keeps the 10-row window:
|
|
131
|
+
// End must not load every record and select the oldest one.
|
|
132
|
+
assert.equal(await selectAfter([" ", END]), "p14");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("with an empty query, Home and End keep the list jumps", async () => {
|
|
136
|
+
// End loads every record and selects the oldest one (§D7).
|
|
137
|
+
assert.equal(await selectAfter([END]), "p00");
|
|
138
|
+
// Home jumps back to the newest.
|
|
139
|
+
assert.equal(await selectAfter([END, HOME]), "p14");
|
|
140
|
+
// Emptying the query hands Home/End back to the list.
|
|
141
|
+
assert.equal(await selectAfter(["p", "\x7f", END]), "p00");
|
|
142
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
bootstrapProjectSeed,
|
|
8
|
+
projectHash,
|
|
9
|
+
seedFilePath,
|
|
10
|
+
} from "../extensions/history/store.ts";
|
|
11
|
+
|
|
12
|
+
// Fake project cwd (never created on disk): projectHash falls back to
|
|
13
|
+
// raw-string hashing for nonexistent paths, and the transcript dirName
|
|
14
|
+
// encoding derives from the same string.
|
|
15
|
+
const CWD = "/pi-history-test/seed-project";
|
|
16
|
+
const DIR = "--pi-history-test-seed-project--";
|
|
17
|
+
|
|
18
|
+
function makeDirs(): { root: string; sessionsRoot: string } {
|
|
19
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-seed-"));
|
|
20
|
+
return {
|
|
21
|
+
root: path.join(base, "pi-history"),
|
|
22
|
+
sessionsRoot: path.join(base, "sessions"),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeSession(
|
|
27
|
+
sessionsRoot: string,
|
|
28
|
+
dirName: string,
|
|
29
|
+
fileName: string,
|
|
30
|
+
userTexts: string[],
|
|
31
|
+
mtimeMs?: number,
|
|
32
|
+
): string {
|
|
33
|
+
const dir = path.join(sessionsRoot, dirName);
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
const file = path.join(dir, fileName);
|
|
36
|
+
const lines: string[] = [
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
type: "session",
|
|
39
|
+
version: 1,
|
|
40
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
41
|
+
}),
|
|
42
|
+
];
|
|
43
|
+
let ms = 1700000000000;
|
|
44
|
+
for (const text of userTexts) {
|
|
45
|
+
lines.push(
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
type: "message",
|
|
48
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
49
|
+
message: { role: "user", content: text, timestamp: ms },
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
ms += 1;
|
|
53
|
+
}
|
|
54
|
+
fs.writeFileSync(file, `${lines.join("\n")}\n`, "utf8");
|
|
55
|
+
if (mtimeMs !== undefined) {
|
|
56
|
+
fs.utimesSync(file, new Date(mtimeMs), new Date(mtimeMs));
|
|
57
|
+
}
|
|
58
|
+
return file;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function seedTexts(root: string): string[] {
|
|
62
|
+
const file = seedFilePath(root, CWD);
|
|
63
|
+
if (!fs.existsSync(file)) return [];
|
|
64
|
+
return fs
|
|
65
|
+
.readFileSync(file, "utf8")
|
|
66
|
+
.trim()
|
|
67
|
+
.split("\n")
|
|
68
|
+
.filter((l) => l.length > 0)
|
|
69
|
+
.map((l) => (JSON.parse(l) as { text: string }).text);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test("no sessions and no project dir: bootstrap seeds nothing", () => {
|
|
73
|
+
const { root, sessionsRoot } = makeDirs();
|
|
74
|
+
const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
|
|
75
|
+
assert.deepEqual(result, { seeded: 0, ran: false });
|
|
76
|
+
assert.equal(fs.existsSync(seedFilePath(root, CWD)), false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("empty project dir bootstraps from the project's transcripts", () => {
|
|
80
|
+
const { root, sessionsRoot } = makeDirs();
|
|
81
|
+
writeSession(sessionsRoot, DIR, "s1.jsonl", [
|
|
82
|
+
"real prompt",
|
|
83
|
+
"/compact",
|
|
84
|
+
" ",
|
|
85
|
+
"second prompt",
|
|
86
|
+
]);
|
|
87
|
+
const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
|
|
88
|
+
assert.deepEqual(result, { seeded: 2, ran: true });
|
|
89
|
+
// Chronological order (oldest first) in the seed file.
|
|
90
|
+
assert.deepEqual(seedTexts(root), ["real prompt", "second prompt"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("only the project's own session dir is scanned", () => {
|
|
94
|
+
const { root, sessionsRoot } = makeDirs();
|
|
95
|
+
writeSession(sessionsRoot, DIR, "s1.jsonl", ["mine"]);
|
|
96
|
+
writeSession(sessionsRoot, "--Other--", "s2.jsonl", ["not mine"]);
|
|
97
|
+
bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
|
|
98
|
+
assert.deepEqual(seedTexts(root), ["mine"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("caps at the target keeping the newest", () => {
|
|
102
|
+
const { root, sessionsRoot } = makeDirs();
|
|
103
|
+
const texts: string[] = [];
|
|
104
|
+
for (let i = 1; i <= 600; i++) texts.push(`p${i}`);
|
|
105
|
+
writeSession(sessionsRoot, DIR, "big.jsonl", texts);
|
|
106
|
+
const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
|
|
107
|
+
assert.deepEqual(result, { seeded: 500, ran: true });
|
|
108
|
+
const all = seedTexts(root);
|
|
109
|
+
assert.equal(all.length, 500);
|
|
110
|
+
assert.equal(all[0], "p101"); // oldest kept
|
|
111
|
+
assert.equal(all[499], "p600"); // newest
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("project dir already populated above target: no scan, seed untouched", () => {
|
|
115
|
+
const { root, sessionsRoot } = makeDirs();
|
|
116
|
+
const dir = path.join(root, "projects", projectHash(CWD));
|
|
117
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
118
|
+
const existing = path.join(dir, "existing.jsonl");
|
|
119
|
+
fs.writeFileSync(
|
|
120
|
+
existing,
|
|
121
|
+
`${Array.from({ length: 500 }, (_, i) =>
|
|
122
|
+
JSON.stringify({ v: 1, text: `e${i}` }),
|
|
123
|
+
).join("\n")}\n`,
|
|
124
|
+
"utf8",
|
|
125
|
+
);
|
|
126
|
+
const marker = writeSession(sessionsRoot, DIR, "s.jsonl", ["marker"]);
|
|
127
|
+
fs.utimesSync(
|
|
128
|
+
marker,
|
|
129
|
+
new Date(Date.now() + 5000),
|
|
130
|
+
new Date(Date.now() + 5000),
|
|
131
|
+
);
|
|
132
|
+
const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
|
|
133
|
+
assert.deepEqual(result, { seeded: 0, ran: false });
|
|
134
|
+
assert.deepEqual(seedTexts(root), []);
|
|
135
|
+
assert.equal(fs.readFileSync(existing, "utf8").includes("marker"), false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// node:test has no test.skipIf (Bun-ism): root skips via the options
|
|
139
|
+
// object — chmod 000 is invisible to the superuser.
|
|
140
|
+
const sealedStoreTest = (name: string, fn: () => void) =>
|
|
141
|
+
test(
|
|
142
|
+
name,
|
|
143
|
+
{ skip: process.getuid?.() === 0 ? "requires non-root" : false },
|
|
144
|
+
fn,
|
|
145
|
+
);
|
|
146
|
+
sealedStoreTest(
|
|
147
|
+
"an unreadable existing store file is skipped during counting; seeding still runs from transcripts",
|
|
148
|
+
() => {
|
|
149
|
+
const { root, sessionsRoot } = makeDirs();
|
|
150
|
+
const dir = path.join(root, "projects", projectHash(CWD));
|
|
151
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
152
|
+
const sealed = path.join(dir, "sealed.jsonl");
|
|
153
|
+
fs.writeFileSync(
|
|
154
|
+
sealed,
|
|
155
|
+
`${JSON.stringify({ v: 1, text: "sealed-entry" })}\n`,
|
|
156
|
+
"utf8",
|
|
157
|
+
);
|
|
158
|
+
fs.chmodSync(sealed, 0o000);
|
|
159
|
+
writeSession(sessionsRoot, DIR, "s1.jsonl", ["from transcript"]);
|
|
160
|
+
try {
|
|
161
|
+
// The unreadable file contributes zero to existingCount, so the count
|
|
162
|
+
// stays under target and the transcript scan still runs. No throw.
|
|
163
|
+
const result = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
|
|
164
|
+
assert.deepEqual(result, { seeded: 1, ran: true });
|
|
165
|
+
assert.deepEqual(seedTexts(root), ["from transcript"]);
|
|
166
|
+
} finally {
|
|
167
|
+
fs.chmodSync(sealed, 0o644); // restore before cleanup
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { bootstrapProjectSeed, seedFilePath } from "../extensions/history/store.ts";
|
|
7
|
+
|
|
8
|
+
// Fake project cwd (never created on disk): projectHash falls back to
|
|
9
|
+
// raw-string hashing for nonexistent paths, and the transcript dirName
|
|
10
|
+
// encoding derives from the same string.
|
|
11
|
+
const CWD = "/pi-history-test/seed-regen-project";
|
|
12
|
+
const DIR = "--pi-history-test-seed-regen-project--";
|
|
13
|
+
|
|
14
|
+
function setup() {
|
|
15
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "seed2-"));
|
|
16
|
+
return {
|
|
17
|
+
root: path.join(base, "h"),
|
|
18
|
+
sessionsRoot: path.join(base, "sessions"),
|
|
19
|
+
stateDir: path.join(base, "state"),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeSession(sessionsRoot: string, texts: string[]): void {
|
|
24
|
+
const dir = path.join(sessionsRoot, DIR);
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
const lines = [
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
type: "session",
|
|
29
|
+
version: 1,
|
|
30
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
31
|
+
}),
|
|
32
|
+
];
|
|
33
|
+
let ms = 1700000000000;
|
|
34
|
+
for (const text of texts) {
|
|
35
|
+
lines.push(
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
type: "message",
|
|
38
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
39
|
+
message: { role: "user", content: text, timestamp: ms++ },
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
fs.writeFileSync(path.join(dir, "s1.jsonl"), `${lines.join("\n")}\n`, "utf8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("an existing seed is never regenerated (deleted prompts stay gone)", () => {
|
|
47
|
+
const { root, sessionsRoot } = setup();
|
|
48
|
+
writeSession(sessionsRoot, ["keep", "delete-me"]);
|
|
49
|
+
bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
|
|
50
|
+
// User deletes "delete-me" from the seed file (scope delete).
|
|
51
|
+
const seed = seedFilePath(root, CWD);
|
|
52
|
+
const kept = fs
|
|
53
|
+
.readFileSync(seed, "utf8")
|
|
54
|
+
.split("\n")
|
|
55
|
+
.filter((l) => !l.includes("delete-me"))
|
|
56
|
+
.join("\n");
|
|
57
|
+
fs.writeFileSync(seed, kept, "utf8");
|
|
58
|
+
// A NEW session bootstraps again -> must not resurrect from transcripts.
|
|
59
|
+
const again = bootstrapProjectSeed(root, CWD, sessionsRoot, 500);
|
|
60
|
+
assert.deepEqual(again, { seeded: 0, ran: false });
|
|
61
|
+
const texts = fs
|
|
62
|
+
.readFileSync(seed, "utf8")
|
|
63
|
+
.trim()
|
|
64
|
+
.split("\n")
|
|
65
|
+
.map((l) => (JSON.parse(l) as { text: string }).text);
|
|
66
|
+
assert.deepEqual(texts, ["keep"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("untrusted tombstones defer seed until repaired, then suppress deleted prompts", () => {
|
|
70
|
+
const { root, sessionsRoot, stateDir } = setup();
|
|
71
|
+
writeSession(sessionsRoot, ["visible", "hidden-prompt"]);
|
|
72
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
73
|
+
const hiddenFile = path.join(stateDir, "hidden.json");
|
|
74
|
+
fs.writeFileSync(hiddenFile, "{broken");
|
|
75
|
+
assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), {
|
|
76
|
+
seeded: 0, ran: false,
|
|
77
|
+
});
|
|
78
|
+
assert.equal(fs.existsSync(seedFilePath(root, CWD)), false);
|
|
79
|
+
fs.writeFileSync(hiddenFile, JSON.stringify(["hidden-prompt"]));
|
|
80
|
+
assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), {
|
|
81
|
+
seeded: 1, ran: true,
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(
|
|
84
|
+
fs.readFileSync(seedFilePath(root, CWD), "utf8").trim().split("\n")
|
|
85
|
+
.map((line) => (JSON.parse(line) as { text: string }).text),
|
|
86
|
+
["visible"],
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a failed seed rename leaves no gate and retries with all prompts", () => {
|
|
91
|
+
const { root, sessionsRoot, stateDir } = setup();
|
|
92
|
+
writeSession(sessionsRoot, ["visible", "hidden-prompt"]);
|
|
93
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
94
|
+
fs.writeFileSync(path.join(stateDir, "hidden.json"), JSON.stringify(["hidden-prompt"]));
|
|
95
|
+
const seed = seedFilePath(root, CWD);
|
|
96
|
+
const original = fs.renameSync;
|
|
97
|
+
fs.renameSync = ((from: fs.PathLike, to: fs.PathLike) => {
|
|
98
|
+
if (to === seed) throw new Error("injected seed rename failure");
|
|
99
|
+
return original(from, to);
|
|
100
|
+
}) as typeof fs.renameSync;
|
|
101
|
+
try {
|
|
102
|
+
assert.throws(() => bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), /injected/);
|
|
103
|
+
assert.equal(fs.existsSync(seed), false);
|
|
104
|
+
} finally {
|
|
105
|
+
fs.renameSync = original;
|
|
106
|
+
}
|
|
107
|
+
assert.deepEqual(bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir), {
|
|
108
|
+
seeded: 1, ran: true,
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("tombstoned prompts are not seeded from transcripts", () => {
|
|
113
|
+
const { root, sessionsRoot, stateDir } = setup();
|
|
114
|
+
writeSession(sessionsRoot, ["visible", "hidden-prompt"]);
|
|
115
|
+
// Tombstone "hidden-prompt" (same key shape hide-prompts writes).
|
|
116
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
117
|
+
fs.writeFileSync(
|
|
118
|
+
path.join(stateDir, "hidden.json"),
|
|
119
|
+
JSON.stringify(["hidden-prompt"]),
|
|
120
|
+
"utf8",
|
|
121
|
+
);
|
|
122
|
+
bootstrapProjectSeed(root, CWD, sessionsRoot, 500, stateDir);
|
|
123
|
+
const texts = fs
|
|
124
|
+
.readFileSync(seedFilePath(root, CWD), "utf8")
|
|
125
|
+
.trim()
|
|
126
|
+
.split("\n")
|
|
127
|
+
.map((l) => (JSON.parse(l) as { text: string }).text);
|
|
128
|
+
assert.deepEqual(texts, ["visible"]);
|
|
129
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
buildPromptRecords,
|
|
5
|
+
clampPreviewOffset,
|
|
6
|
+
clampSelectedIndex,
|
|
7
|
+
computeVisibleRange,
|
|
8
|
+
getVisiblePromptRecords,
|
|
9
|
+
moveSelectedIndex,
|
|
10
|
+
pageSelectedIndex,
|
|
11
|
+
} from "../extensions/history/selector-helpers.ts";
|
|
12
|
+
|
|
13
|
+
test("buildPromptRecords lowercases search text", () => {
|
|
14
|
+
assert.deepEqual(buildPromptRecords(["Hello"]), [
|
|
15
|
+
{ text: "Hello", searchText: "hello" },
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("computeVisibleRange centers when possible", () => {
|
|
20
|
+
assert.deepEqual(computeVisibleRange(8, 30, 10), { start: 3, end: 13 });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("computeVisibleRange pins near end", () => {
|
|
24
|
+
assert.deepEqual(computeVisibleRange(28, 30, 10), { start: 20, end: 30 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("computeVisibleRange handles small lists", () => {
|
|
28
|
+
assert.deepEqual(computeVisibleRange(1, 3, 10), { start: 0, end: 3 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("clampSelectedIndex stays within filtered record bounds", () => {
|
|
32
|
+
assert.equal(clampSelectedIndex(8, 3), 2);
|
|
33
|
+
assert.equal(clampSelectedIndex(1, 0), 0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("clampPreviewOffset clamps offsets past the last page", () => {
|
|
37
|
+
assert.equal(clampPreviewOffset(50, 47, 10), 37);
|
|
38
|
+
assert.equal(clampPreviewOffset(5, 47, 10), 5);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("clampPreviewOffset pins to zero when content fits the viewport", () => {
|
|
42
|
+
assert.equal(clampPreviewOffset(3, 8, 10), 0);
|
|
43
|
+
assert.equal(clampPreviewOffset(7, 0, 10), 0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("moveSelectedIndex wraps around the list", () => {
|
|
47
|
+
assert.equal(moveSelectedIndex(0, 3, -1), 2);
|
|
48
|
+
assert.equal(moveSelectedIndex(2, 3, 1), 0);
|
|
49
|
+
assert.equal(moveSelectedIndex(0, 0, 1), 0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("pageSelectedIndex clamps within the list", () => {
|
|
53
|
+
assert.equal(pageSelectedIndex(8, 30, -10), 0);
|
|
54
|
+
assert.equal(pageSelectedIndex(2, 3, 10), 2);
|
|
55
|
+
assert.equal(pageSelectedIndex(0, 0, 10), 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("pageSelectedIndex end-clamps a downward page at the last entry (AC-P2-1.1)", () => {
|
|
59
|
+
assert.equal(pageSelectedIndex(28, 30, 10), 29);
|
|
60
|
+
assert.equal(pageSelectedIndex(25, 30, 10), 29);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("pageSelectedIndex clamps |pageSize| greater than total in both directions (AC-P2-1.2)", () => {
|
|
64
|
+
assert.equal(pageSelectedIndex(0, 3, 10), 2);
|
|
65
|
+
assert.equal(pageSelectedIndex(2, 3, -10), 0);
|
|
66
|
+
assert.equal(pageSelectedIndex(0, 30, -50), 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("pageSelectedIndex never wraps past the ends (AC-P2-1.2)", () => {
|
|
70
|
+
assert.equal(pageSelectedIndex(29, 30, 10), 29);
|
|
71
|
+
assert.equal(pageSelectedIndex(0, 30, -10), 0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("getVisiblePromptRecords returns visible records with selection state", () => {
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
getVisiblePromptRecords(
|
|
77
|
+
buildPromptRecords(["one", "two", "three", "four"]),
|
|
78
|
+
2,
|
|
79
|
+
2,
|
|
80
|
+
),
|
|
81
|
+
[
|
|
82
|
+
{
|
|
83
|
+
index: 1,
|
|
84
|
+
record: { text: "two", searchText: "two" },
|
|
85
|
+
isSelected: false,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
index: 2,
|
|
89
|
+
record: { text: "three", searchText: "three" },
|
|
90
|
+
isSelected: true,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
);
|
|
94
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { listSessionFiles } from "../extensions/history/session-scan.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* WU1b-carried T7 (AC-S1-7): the one-level directory exclusion matrix. The
|
|
10
|
+
* fixture tree mirrors the pi sessions root — encoded-cwd directories with
|
|
11
|
+
* top-level jsonl session files, nested run-N/session.jsonl subagent
|
|
12
|
+
* payloads, a subagent-artifacts subtree, and a stray root-level file.
|
|
13
|
+
* Fixture files carry garbage content: the scanner must LIST paths only, so
|
|
14
|
+
* exact path equality proves nested payloads are never ingested (a
|
|
15
|
+
* recursive scanner would emit them).
|
|
16
|
+
*/
|
|
17
|
+
function makeSessionsRoot(): string {
|
|
18
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "session-scan-dirs-"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeFileAt(filePath: string): void {
|
|
22
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
23
|
+
fs.writeFileSync(filePath, "garbage-not-json\n", "utf8");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("one-level scan rule: only top-level jsonl of cwd dirs; nested payloads, subagent-artifacts, and stray root files never ingested (AC-S1-7)", () => {
|
|
27
|
+
const root = makeSessionsRoot();
|
|
28
|
+
|
|
29
|
+
// two cwd directories, each holding top-level jsonl session files
|
|
30
|
+
const cwdA = path.join(root, "--home-user-project-a--");
|
|
31
|
+
const cwdB = path.join(root, "--home-user-project-b--");
|
|
32
|
+
writeFileAt(path.join(cwdA, "2026-01-01t10-00-00aaa.jsonl"));
|
|
33
|
+
writeFileAt(path.join(cwdA, "2026-01-02t11-00-00bbb.jsonl"));
|
|
34
|
+
writeFileAt(path.join(cwdB, "2026-01-03t12-00-00ccc.jsonl"));
|
|
35
|
+
|
|
36
|
+
// nested run-N/session.jsonl subagent payloads — never descended
|
|
37
|
+
writeFileAt(path.join(cwdA, "run-1", "session.jsonl"));
|
|
38
|
+
writeFileAt(path.join(cwdB, "run-2", "session.jsonl"));
|
|
39
|
+
|
|
40
|
+
// a subagent-artifacts subtree holding a jsonl file — never descended
|
|
41
|
+
writeFileAt(path.join(cwdA, "subagent-artifacts", "artifact.jsonl"));
|
|
42
|
+
|
|
43
|
+
// one stray root-level FILE (not a directory) — skipped
|
|
44
|
+
writeFileAt(path.join(root, "stray.jsonl"));
|
|
45
|
+
|
|
46
|
+
const files = listSessionFiles(root);
|
|
47
|
+
|
|
48
|
+
// exactly the three top-level jsonl paths of the cwd directories,
|
|
49
|
+
// sorted, absolute
|
|
50
|
+
assert.deepEqual(files, [
|
|
51
|
+
path.join(cwdA, "2026-01-01t10-00-00aaa.jsonl"),
|
|
52
|
+
path.join(cwdA, "2026-01-02t11-00-00bbb.jsonl"),
|
|
53
|
+
path.join(cwdB, "2026-01-03t12-00-00ccc.jsonl"),
|
|
54
|
+
]);
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
assert.equal(path.isAbsolute(file), true);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// node:test has no test.skipIf (Bun-ism): root skips via the options
|
|
61
|
+
// object — chmod 000 is invisible to the superuser.
|
|
62
|
+
const sealedDirTest = (name: string, fn: () => void) =>
|
|
63
|
+
test(
|
|
64
|
+
name,
|
|
65
|
+
{ skip: process.getuid?.() === 0 ? "requires non-root" : false },
|
|
66
|
+
fn,
|
|
67
|
+
);
|
|
68
|
+
sealedDirTest(
|
|
69
|
+
"an unreadable child dir (chmod 000) is skipped; sibling dirs still list",
|
|
70
|
+
() => {
|
|
71
|
+
const root = makeSessionsRoot();
|
|
72
|
+
const sealed = path.join(root, "--sealed--");
|
|
73
|
+
const open = path.join(root, "--open--");
|
|
74
|
+
writeFileAt(path.join(sealed, "hidden-session.jsonl"));
|
|
75
|
+
writeFileAt(path.join(open, "visible-session.jsonl"));
|
|
76
|
+
fs.chmodSync(sealed, 0o000);
|
|
77
|
+
try {
|
|
78
|
+
// One directory whose readdir fails skips itself — never fatal — and
|
|
79
|
+
// the sibling directories still contribute their files.
|
|
80
|
+
assert.deepEqual(listSessionFiles(root), [
|
|
81
|
+
path.join(open, "visible-session.jsonl"),
|
|
82
|
+
]);
|
|
83
|
+
} finally {
|
|
84
|
+
fs.chmodSync(sealed, 0o755); // restore before cleanup
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
);
|