gentle-pi 2.6.4 → 3.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 +25 -7
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +41 -19
- package/docs/readme-reference.md +175 -36
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +289 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +123 -97
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +74 -113
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +19 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +88 -0
- package/lib/session-changes.ts +147 -0
- package/lib/shell-bar.ts +24 -10
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-changes.ts +5 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +18 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +2 -3
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +321 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +883 -356
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +338 -205
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-capability-contract.test.ts +15 -1
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +119 -31
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +86 -0
- package/tests/session-changes-shell.test.ts +38 -0
- package/tests/session-changes.test.ts +114 -0
- package/tests/shell-bar.test.ts +35 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- package/tests/openspec-guardrails.test.ts +0 -71
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
const asset = (name: string) => readFileSync(new URL(`../assets/${name}`, import.meta.url), "utf8");
|
|
6
|
+
|
|
7
|
+
test("task truth uses ordinary checkboxes without local owner-marker admission", () => {
|
|
8
|
+
const tasks = asset("agents/sdd-tasks.md"), apply = asset("agents/sdd-apply.md");
|
|
9
|
+
assert.match(tasks, /- \[ \] 1\. Implement and verify the behavior\./);
|
|
10
|
+
assert.doesNotMatch(tasks, /<!-- sdd-owner:/);
|
|
11
|
+
assert.doesNotMatch(apply, /fix-task-ownership-marker|unsupported, duplicate, or non-terminal/);
|
|
12
|
+
assert.match(apply, /Preserve historical ownership comments/);
|
|
13
|
+
assert.match(apply, /native task progress and authorized scope/);
|
|
14
|
+
assert.match(apply, /mark each completed implementation task.*immediately after completion/);
|
|
15
|
+
assert.match(apply, /Before returning, re-read the persisted tasks artifact/);
|
|
16
|
+
assert.match(apply, /never overwrite completed work/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("task planning honors configured TDD rather than test availability", () => {
|
|
20
|
+
const tasks = asset("agents/sdd-tasks.md");
|
|
21
|
+
assert.doesNotMatch(tasks, /If tests exist or strict TDD/);
|
|
22
|
+
assert.match(tasks, /Only when configured strict TDD is active/);
|
|
23
|
+
assert.match(tasks, /Test availability alone does not enable TDD/);
|
|
24
|
+
assert.match(tasks, /400 changed lines/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("verification reports remaining task truth without manufacturing archive authority", () => {
|
|
28
|
+
const verify = asset("agents/sdd-verify.md");
|
|
29
|
+
assert.doesNotMatch(verify, /mark each as a CRITICAL completeness issue and archive blocker|Archive exceptions are limited/);
|
|
30
|
+
assert.match(verify, /Report the exact unchecked lines as remaining work/);
|
|
31
|
+
assert.match(verify, /Do not return a clean `PASS`/);
|
|
32
|
+
assert.match(verify, /Archive admission follows fresh native status and real permissions/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("selected stores preserve partial persistence and cumulative readback truth", () => {
|
|
36
|
+
const apply = asset("agents/sdd-apply.md"), memory = asset("orchestrator-memory.md");
|
|
37
|
+
assert.match(apply, /`openspec`\s*\/\s*`both`: write\/update/);
|
|
38
|
+
assert.match(apply, /`engram`\s*\/\s*`both`: update the/);
|
|
39
|
+
assert.match(apply, /read back each selected backend/);
|
|
40
|
+
assert.match(apply, /not atomic/);
|
|
41
|
+
assert.match(memory, /do not switch the selected store/);
|
|
42
|
+
assert.doesNotMatch(memory, /return artifacts inline and\/or write OpenSpec files/);
|
|
43
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, writeFile, rm, realpath } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
7
|
+
import { SessionChanges, SESSION_CHANGE_ENTRY, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
|
|
8
|
+
|
|
9
|
+
async function fixture(run: (f: any) => Promise<void>, child = false) {
|
|
10
|
+
const root = await realpath(await mkdtemp(join(tmpdir(), "change-capture-")));
|
|
11
|
+
const handlers = new Map<string, Function>();
|
|
12
|
+
const entries: any[] = [];
|
|
13
|
+
const listeners = new Map<string, Function>();
|
|
14
|
+
const pi = { on: (key, fn) => handlers.set(key, fn), appendEntry: (customType, data) => entries.push({type:"custom",customType,data}),
|
|
15
|
+
events: { on: (key, fn) => { listeners.set(key, fn); return () => listeners.delete(key); }, emit: (key, data) => listeners.get(key)?.(data) } };
|
|
16
|
+
let id = "session";
|
|
17
|
+
const ctx = { cwd:root, sessionManager: { getSessionId: () => id, getEntries: () => entries } };
|
|
18
|
+
installSessionChangeCapture(pi as never, child ? {GENTLE_PI_AGENTS_CHILD:"1"} : {}, () => ({root,commonDir:root}));
|
|
19
|
+
const fire = (key, event = {}) => handlers.get(key)?.(event, ctx);
|
|
20
|
+
try { await fire("session_start"); await run({root, entries, ctx, fire, pi, switchSession: () => id = "other"}); }
|
|
21
|
+
finally { await rm(root, {recursive:true,force:true}); }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("capture reads no inventory at startup and ignores read-only tools", async () => fixture(async ({fire, entries}) => {
|
|
25
|
+
await fire("tool_call", {toolCallId:"r",toolName:"read",input:{path:"missing"}});
|
|
26
|
+
await fire("tool_result", {toolCallId:"r",toolName:"read",input:{path:"missing"},isError:false});
|
|
27
|
+
await fire("tool_execution_end", {toolCallId:"r",toolName:"read",isError:false});
|
|
28
|
+
assert.deepEqual(entries, []);
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
test("successful writes capture only the exact tool target and persist for reload", async () => fixture(async ({root,fire,entries}) => {
|
|
32
|
+
await writeFile(join(root,"file"),"human baseline\n");
|
|
33
|
+
const event = {toolCallId:"w",toolName:"write",input:{path:"file",content:"agent output\n"}};
|
|
34
|
+
await fire("tool_call",event);
|
|
35
|
+
await writeFile(join(root,"file"),event.input.content);
|
|
36
|
+
await fire("tool_result",{...event,isError:false});
|
|
37
|
+
assert.equal(entries.length,0);
|
|
38
|
+
await fire("tool_execution_end",{toolCallId:"w",toolName:"write",isError:false});
|
|
39
|
+
assert.equal(entries[0].customType,SESSION_CHANGE_ENTRY);
|
|
40
|
+
await writeFile(join(root,"file"),"later human output\n");
|
|
41
|
+
const changes = new SessionChanges("session",entries);
|
|
42
|
+
assert.match(changes.loadDiff(root,changes.model.files[0]),/\+agent output/);
|
|
43
|
+
assert.doesNotMatch(changes.loadDiff(root,changes.model.files[0]),/later human/);
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
test("failed and stale-session tool outcomes never add session changes", async () => fixture(async ({root,fire,entries,switchSession}) => {
|
|
47
|
+
const event={toolCallId:"w",toolName:"write",input:{path:"new",content:"agent\n"}};
|
|
48
|
+
await fire("tool_call",event);
|
|
49
|
+
await writeFile(join(root,"new"),event.input.content);
|
|
50
|
+
await fire("tool_result",{...event,isError:false});
|
|
51
|
+
await fire("tool_execution_end",{toolCallId:"w",toolName:"write",isError:true});
|
|
52
|
+
assert.equal(entries.length,0);
|
|
53
|
+
await fire("tool_call",{...event,toolCallId:"x"});
|
|
54
|
+
switchSession();
|
|
55
|
+
await fire("tool_result",{...event,toolCallId:"x",isError:false});
|
|
56
|
+
await fire("tool_execution_end",{toolCallId:"x",toolName:"write",isError:false});
|
|
57
|
+
assert.equal(entries.length,0);
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
test("child carries bounded evidence in the existing tool-result details transport", async () => fixture(async ({root,fire,entries}) => {
|
|
61
|
+
const event={toolCallId:"w",toolName:"write",input:{path:"new",content:"agent\n"}};
|
|
62
|
+
await fire("tool_call",event); await writeFile(join(root,"new"),event.input.content);
|
|
63
|
+
const result=await fire("tool_result",{...event,isError:false,details:{original:"preserved"}});
|
|
64
|
+
assert.equal(result.details.original,"preserved");
|
|
65
|
+
assert.equal(result.details.gentleSessionChange.id,"w");
|
|
66
|
+
assert.equal(result.details.gentleSessionChange.path,"new");
|
|
67
|
+
assert.deepEqual(entries,[]);
|
|
68
|
+
},true));
|
|
69
|
+
|
|
70
|
+
test("the capture-limit warning is emitted once, not on every later write", async () => fixture(async ({root,fire,pi}) => {
|
|
71
|
+
const events: Array<{notice?: string}> = [];
|
|
72
|
+
pi.events.on(SESSION_CHANGE_EVENT, (data: {notice?: string}) => events.push(data));
|
|
73
|
+
for (let i = 0; i < 80; i++) {
|
|
74
|
+
const path = `f${i}`;
|
|
75
|
+
const content = "x".repeat(60 * 1024) + "\n";
|
|
76
|
+
const event = {toolCallId:`w${i}`,toolName:"write",input:{path,content}};
|
|
77
|
+
await fire("tool_call",event);
|
|
78
|
+
await writeFile(join(root,path),content);
|
|
79
|
+
await fire("tool_result",{...event,isError:false});
|
|
80
|
+
await fire("tool_execution_end",{toolCallId:`w${i}`,toolName:"write",isError:false});
|
|
81
|
+
}
|
|
82
|
+
const withNotice = events.filter(event => event.notice !== undefined);
|
|
83
|
+
assert.equal(withNotice.length,1);
|
|
84
|
+
assert.match(withNotice[0].notice!,/limit reached/);
|
|
85
|
+
assert.equal(events.length,80);
|
|
86
|
+
}));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import shell from "../extensions/gentle-shell.ts";
|
|
4
|
+
import { SESSION_CHANGE_ENTRY, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
|
|
5
|
+
|
|
6
|
+
function fixture(entries: any[] = []) {
|
|
7
|
+
const handlers = new Map<string, Function[]>(), listeners = new Map<string, Function[]>();
|
|
8
|
+
const commands = new Map<string, any>(), widgets = new Map<string, any>();
|
|
9
|
+
let gitCalls = 0;
|
|
10
|
+
const pi: any = { on: (key, fn) => handlers.set(key, [...(handlers.get(key) ?? []), fn]),
|
|
11
|
+
events: { on: (key, fn) => { listeners.set(key,[...(listeners.get(key) ?? []), fn]); return () => {}; }, emit: (key,data) => listeners.get(key)?.forEach(fn=>fn(data)) },
|
|
12
|
+
appendEntry: (customType,data) => entries.push({type:"custom",customType,data}), registerTool() {}, registerShortcut() {}, registerMessageRenderer() {},
|
|
13
|
+
registerCommand: (key, registration) => commands.set(key,registration) };
|
|
14
|
+
const notices: string[] = [];
|
|
15
|
+
const ctx: any = { hasUI:true, cwd:"/repo", sessionManager:{getSessionId:()=>"session",getEntries:()=>entries},
|
|
16
|
+
ui: { setFooter() {}, getEditorComponent:()=>({}), setWorkingVisible() {}, setWidget:(key,value)=>widgets.set(key,value), notify:(text)=>notices.push(text) } };
|
|
17
|
+
shell(pi,{}, {resolveWorktree:()=>({root:"/repo",commonDir:"/git"}),devBinary:()=>undefined,
|
|
18
|
+
gitRunner:()=>async()=>{gitCalls++; return await new Promise<any>(()=>{});} });
|
|
19
|
+
const fire=async(key,event={})=>{for(const fn of handlers.get(key)??[]) await fn(event,ctx);};
|
|
20
|
+
return {pi,ctx,entries,notices,commands,widgets,fire,gitCalls:()=>gitCalls};
|
|
21
|
+
}
|
|
22
|
+
test("Gentle Shell startup never waits for a repository scan",async()=>{
|
|
23
|
+
const f=fixture();
|
|
24
|
+
await Promise.race([f.fire("session_start"),new Promise((_,reject)=>setTimeout(()=>reject(new Error("startup blocked by Git inventory")),100))]);
|
|
25
|
+
assert.equal(f.gitCalls(),0);
|
|
26
|
+
await f.commands.get("gentle:changes").handler("",f.ctx);
|
|
27
|
+
assert.match(f.notices.join("\n"),/captured.*agent|agent.*changes/i);
|
|
28
|
+
await f.fire("session_shutdown");
|
|
29
|
+
});
|
|
30
|
+
test("reload and new evidence refresh Changes without Git or live file reads",async()=>{
|
|
31
|
+
const f=fixture([{type:"custom",customType:SESSION_CHANGE_ENTRY,data:{sessionId:"session",evidence:{id:"child:1",root:"/repo",path:"own.ts",before:{kind:"absent"},after:{kind:"text",text:"own\n"}}}}]);
|
|
32
|
+
await Promise.race([f.fire("session_start"),new Promise((_,reject)=>setTimeout(()=>reject(new Error("startup blocked")),100))]);
|
|
33
|
+
f.pi.events.emit(SESSION_CHANGE_EVENT,{sessionId:"session"});
|
|
34
|
+
await new Promise(resolve=>setImmediate(resolve));
|
|
35
|
+
assert.equal(f.gitCalls(),0);
|
|
36
|
+
assert.equal(typeof f.widgets.get("gentle-shell-changes"),"function");
|
|
37
|
+
await f.fire("session_shutdown");
|
|
38
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { SessionChanges, readChangeSnapshot, SESSION_CHANGE_ENTRY, MAX_CHANGE_BYTES } from "../lib/session-changes.ts";
|
|
7
|
+
|
|
8
|
+
const text = (text: string) => ({ kind: "text" as const, text });
|
|
9
|
+
const absent = { kind: "absent" as const };
|
|
10
|
+
const evidence = (id = "a", root = "/repo", path = "file.ts", before = text("old\n"), after = text("new\n")) => ({ id, root, path, before, after });
|
|
11
|
+
|
|
12
|
+
test("session changes starts empty without reading the worktree or its untracked contents", async () => {
|
|
13
|
+
const changes = new SessionChanges("session");
|
|
14
|
+
assert.deepEqual(await changes.refresh(), { files: [], added: 0, deleted: 0 });
|
|
15
|
+
assert.deepEqual(changes.worktrees, []);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("only attributed changes appear, against the pre-tool content rather than HEAD", () => {
|
|
19
|
+
const changes = new SessionChanges("session");
|
|
20
|
+
changes.record(evidence("a", "/repo", "file.ts", text("preexisting human edit\nold\n"), text("preexisting human edit\nnew\n")));
|
|
21
|
+
assert.equal(changes.model.files.length, 1);
|
|
22
|
+
assert.equal(changes.model.added, 1);
|
|
23
|
+
assert.equal(changes.model.deleted, 1);
|
|
24
|
+
assert.match(changes.loadDiff("/repo", changes.model.files[0]), /\+new/);
|
|
25
|
+
assert.doesNotMatch(changes.loadDiff("/repo", changes.model.files[0]), /\+preexisting/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("diff counts include content lines that resemble patch headers", () => {
|
|
29
|
+
const changes = new SessionChanges("session");
|
|
30
|
+
changes.record(evidence("headers", "/repo", "patch.txt", text("-- old content\n"), text("++ new content\n")));
|
|
31
|
+
assert.equal(changes.model.added, 1);
|
|
32
|
+
assert.equal(changes.model.deleted, 1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("contiguous own edits combine and own reverts remove the file and worktree", () => {
|
|
36
|
+
const changes = new SessionChanges("session");
|
|
37
|
+
changes.record(evidence());
|
|
38
|
+
changes.record(evidence("b", "/repo", "file.ts", text("new\n"), text("final\n")));
|
|
39
|
+
assert.equal(changes.model.added, 1);
|
|
40
|
+
assert.match(changes.loadDiff("/repo", changes.model.files[0]), /\+final/);
|
|
41
|
+
changes.record(evidence("c", "/repo", "file.ts", text("final\n"), text("old\n")));
|
|
42
|
+
assert.deepEqual(changes.worktrees, []);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("external edits between agent operations are not folded into an attributed diff", () => {
|
|
46
|
+
const changes = new SessionChanges("session");
|
|
47
|
+
changes.record(evidence());
|
|
48
|
+
changes.record(evidence("b", "/repo", "file.ts", text("external\n"), text("agent again\n")));
|
|
49
|
+
const file = changes.model.files[0];
|
|
50
|
+
assert.match(file.countsUnavailable!, /external|continuity/i);
|
|
51
|
+
assert.doesNotMatch(changes.loadDiff("/repo", file), /\+external|\-external/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("new files and child worktrees are admitted only by positive mutation evidence", () => {
|
|
55
|
+
const changes = new SessionChanges("session");
|
|
56
|
+
changes.record({ ...evidence("child:1", "/linked", "new.ts"), before: absent });
|
|
57
|
+
assert.deepEqual(changes.worktrees.map(tree => tree.root), ["/linked"]);
|
|
58
|
+
assert.equal(changes.model.files[0].status, "added");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("reload restores only the exact session and deduplicates operation identities", () => {
|
|
62
|
+
const row = { type: "custom", customType: SESSION_CHANGE_ENTRY, data: { sessionId: "session", evidence: evidence() } };
|
|
63
|
+
const changes = new SessionChanges("session", [row, row, { ...row, data: { sessionId: "other", evidence: evidence("b", "/other") } }]);
|
|
64
|
+
assert.equal(changes.model.files.length, 1);
|
|
65
|
+
changes.record(evidence());
|
|
66
|
+
assert.equal(changes.model.added, 1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("oversized, binary, nonregular and missing snapshots are bounded and explicit", async () => {
|
|
70
|
+
const dir = await mkdtemp(join(tmpdir(), "session-changes-"));
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(join(dir, "large"), Buffer.alloc(MAX_CHANGE_BYTES + 1, 65));
|
|
73
|
+
await writeFile(join(dir, "binary"), Buffer.from([65, 0, 66]));
|
|
74
|
+
assert.equal((await readChangeSnapshot(join(dir, "large"))).kind, "unavailable");
|
|
75
|
+
assert.equal((await readChangeSnapshot(join(dir, "binary"))).kind, "unavailable");
|
|
76
|
+
assert.equal((await readChangeSnapshot(dir)).kind, "unavailable");
|
|
77
|
+
assert.deepEqual(await readChangeSnapshot(join(dir, "missing")), absent);
|
|
78
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("bounded captures mark partial counts, including the narrow widget", async () => {
|
|
82
|
+
const { renderChangesWidget } = await import("../lib/shell-changes.ts");
|
|
83
|
+
const changes = new SessionChanges("session");
|
|
84
|
+
changes.record({ ...evidence("large", "/repo", "a-very-long-path-that-does-not-fit-in-the-widget.bin"), before: {kind:"unavailable",reason:"Binary file"}, after:{kind:"unavailable",reason:"Binary file"} });
|
|
85
|
+
assert.match(renderChangesWidget(changes.model,{fg:(_color,text)=>text},75).join("\n"),/partial counts/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("same-count edits change preview revisions and malformed evidence is rejected", () => {
|
|
89
|
+
const changes = new SessionChanges("session");
|
|
90
|
+
changes.record(evidence());
|
|
91
|
+
const first = changes.model.files[0].diffRevision;
|
|
92
|
+
changes.record(evidence("b","/repo","file.ts",text("new\n"),text("other\n")));
|
|
93
|
+
assert.notEqual(changes.model.files[0].diffRevision,first);
|
|
94
|
+
assert.equal(changes.record({...evidence("bad"),path:"../escape"}),false);
|
|
95
|
+
assert.equal(changes.record({...evidence("bad"),path:".git/config"}),false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("capture capacity is explicit instead of growing without limit", () => {
|
|
99
|
+
const changes = new SessionChanges("session");
|
|
100
|
+
for(let i=0;i<257;i++) changes.record(evidence(String(i),"/repo",String(i)+".ts"));
|
|
101
|
+
assert.equal(changes.model.files.length,256);
|
|
102
|
+
assert.match(changes.notice!,/limit reached/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("capture-limit notice is delivered once but stays on the model", () => {
|
|
106
|
+
const changes = new SessionChanges("session");
|
|
107
|
+
for(let i=0;i<257;i++) changes.record(evidence(String(i),"/repo",String(i)+".ts"));
|
|
108
|
+
assert.match(changes.takeNotice()!,/limit reached/);
|
|
109
|
+
assert.equal(changes.takeNotice(),undefined);
|
|
110
|
+
assert.match(changes.notice!,/limit reached/);
|
|
111
|
+
assert.match(changes.model.notice!,/limit reached/);
|
|
112
|
+
assert.equal(new SessionChanges("s").takeNotice(),undefined);
|
|
113
|
+
assert.equal(new SessionChanges("s").model.notice,undefined);
|
|
114
|
+
});
|
package/tests/shell-bar.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
gaugeTone,
|
|
8
8
|
renderGauge,
|
|
9
9
|
renderShellBar,
|
|
10
|
+
renderShellSidebarBar,
|
|
10
11
|
shellEnabled,
|
|
11
12
|
type ShellBarModel,
|
|
12
13
|
type ShellBarTheme,
|
|
@@ -174,3 +175,37 @@ test("shellEnabled honors GENTLE_PI_SHELL=0", () => {
|
|
|
174
175
|
assert.equal(shellEnabled({ GENTLE_PI_SHELL: "0" }), false);
|
|
175
176
|
assert.equal(shellEnabled({ GENTLE_PI_SHELL: "false" }), false);
|
|
176
177
|
});
|
|
178
|
+
|
|
179
|
+
test("sidebar unifies project, captured changes, usage and integrations in one frame", () => {
|
|
180
|
+
const data = model({ changes: { files: 2, added: 7, deleted: 3, notice: "capture warning" }, statuses: ["MCP connected"] });
|
|
181
|
+
const lines = renderShellSidebarBar(data, plainTheme, 46);
|
|
182
|
+
const text = lines.join("\n");
|
|
183
|
+
assert.equal(lines.filter((line) => line.startsWith("╭")).length, 1);
|
|
184
|
+
let previous = -1;
|
|
185
|
+
for (const heading of ["Status", "Project", "Changes", "Usage", "Integrations"]) {
|
|
186
|
+
const index = text.indexOf(heading);
|
|
187
|
+
assert.ok(index > previous, heading);
|
|
188
|
+
previous = index;
|
|
189
|
+
}
|
|
190
|
+
assert.match(text, /2 files.*\+7.*−3/);
|
|
191
|
+
assert.match(text, /capture warning/);
|
|
192
|
+
assert.match(text, /\/gentle:changes/);
|
|
193
|
+
assert.match(text, /gpt-5\.5/);
|
|
194
|
+
for (const width of [1, 8, 24, 46]) assert.ok(renderShellSidebarBar(data, plainTheme, width).every((line) => visibleWidth(line) <= width));
|
|
195
|
+
const empty = renderShellSidebarBar(model(), plainTheme, 46).join("\n");
|
|
196
|
+
assert.match(empty, /No captured changes/);
|
|
197
|
+
assert.match(empty, /Integrations/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("sidebar profile wraps long names without changing the compact bar", () => {
|
|
201
|
+
const profile = "team-" + "x".repeat(59);
|
|
202
|
+
const base = model();
|
|
203
|
+
const active = model({ profile });
|
|
204
|
+
for (const width of [24, 46]) {
|
|
205
|
+
const lines = renderShellSidebarBar(active, plainTheme, width);
|
|
206
|
+
assert.ok(lines.every((line) => visibleWidth(line) <= width));
|
|
207
|
+
assert.match(lines.join("\n"), /Profile/);
|
|
208
|
+
assert.ok(lines.join("").replace(/[│\s]/g, "").includes(profile));
|
|
209
|
+
}
|
|
210
|
+
assert.deepEqual(renderShellBar(active, plainTheme, 120), renderShellBar(base, plainTheme, 120));
|
|
211
|
+
});
|
package/tests/shell-card.test.ts
CHANGED
|
@@ -53,11 +53,11 @@ test("renderCard collapses to the frame and the first body line with an expand h
|
|
|
53
53
|
assert.equal(visibleWidth(lines[1]), 60);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
test("renderCard
|
|
56
|
+
test("renderCard uses the card tone across the full frame while preserving content roles", () => {
|
|
57
57
|
const info = renderCard(card(), taggedTheme, 80, { expanded: true });
|
|
58
|
-
assert.match(info[0], /^<customMessageLabel>╭<\/customMessageLabel><
|
|
59
|
-
assert.match(info[1], /^<customMessageLabel>│<\/customMessageLabel> <text>.*<
|
|
60
|
-
assert.match(info[info.length - 1], /^<customMessageLabel>╰<\/customMessageLabel><
|
|
58
|
+
assert.match(info[0], /^<customMessageLabel>╭<\/customMessageLabel><customMessageLabel>─ <\/customMessageLabel><customMessageLabel>✿ Gentle AI<\/customMessageLabel> <muted>·<\/muted> <muted>review preflight<\/muted><customMessageLabel> ─+<\/customMessageLabel><customMessageLabel>╮<\/customMessageLabel>$/);
|
|
59
|
+
assert.match(info[1], /^<customMessageLabel>│<\/customMessageLabel> <text>.*<customMessageLabel>│<\/customMessageLabel>$/);
|
|
60
|
+
assert.match(info[info.length - 1], /^<customMessageLabel>╰<\/customMessageLabel><customMessageLabel>─+╯<\/customMessageLabel>$/);
|
|
61
61
|
|
|
62
62
|
const warning = renderCard(card({ tone: CARD_TONE.WARNING, subtitle: undefined }), taggedTheme, 80, { expanded: true });
|
|
63
63
|
assert.match(warning[0], /^<warning>╭<\/warning>/);
|
|
@@ -65,8 +65,10 @@ test("renderCard keeps the rail and its corners in the tone and the rest of the
|
|
|
65
65
|
assert.match(warning[1], /^<warning>│<\/warning> /);
|
|
66
66
|
assert.match(warning[warning.length - 1], /^<warning>╰<\/warning>/);
|
|
67
67
|
|
|
68
|
-
const error = renderCard(card({ tone: CARD_TONE.ERROR }), taggedTheme, 80, { expanded: true });
|
|
69
|
-
assert.match(error[0],
|
|
68
|
+
const error = renderCard(card({ tone: CARD_TONE.ERROR }), taggedTheme, 80, { expanded: true, hint: "ctrl+o to expand" });
|
|
69
|
+
assert.match(error[0], /^<error>╭<\/error><error>─ <\/error><error>✿ Gentle AI<\/error> <muted>·<\/muted> <muted>review preflight<\/muted><error> ─+<\/error> <dim>ctrl\+o to expand<\/dim> <error>╮<\/error>$/);
|
|
70
|
+
assert.match(error[1], /^<error>│<\/error> <text>.*<error>│<\/error>$/);
|
|
71
|
+
assert.match(error[error.length - 1], /^<error>╰<\/error><error>─+╯<\/error>$/);
|
|
70
72
|
assert.equal(CARD_TONE.SUCCESS, "success");
|
|
71
73
|
});
|
|
72
74
|
|
|
@@ -98,6 +98,14 @@ test("changesSummary and the widget describe the session at a glance", () => {
|
|
|
98
98
|
assert.equal(visibleWidth(line), 120, "the command sits on the right edge");
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
test("changesSummary and the widget surface the capture limit", () => {
|
|
102
|
+
const model = changesModel([file("a.ts", 1, 0)]);
|
|
103
|
+
model.notice = "Session change capture limit reached; additional changes are not displayed.";
|
|
104
|
+
assert.equal(changesSummary(model), "1 file · +1 −0 · capture limit reached");
|
|
105
|
+
assert.match(renderChangesWidget(model, plainTheme, 200)[0], /capture limit reached/);
|
|
106
|
+
assert.doesNotMatch(changesSummary(changesModel([file("a.ts", 1, 0)])), /capture limit reached/);
|
|
107
|
+
});
|
|
108
|
+
|
|
101
109
|
test("renderChangesWidget colors counts by direction and yields nothing when clean", () => {
|
|
102
110
|
const model = changesModel([file("a.ts", 1, 2)]);
|
|
103
111
|
const [line] = renderChangesWidget(model, taggedTheme, 400);
|
|
@@ -4,13 +4,46 @@ import { visibleWidth } from "@earendil-works/pi-tui";
|
|
|
4
4
|
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
5
5
|
import {
|
|
6
6
|
framePromptLines,
|
|
7
|
+
scanWorkingText,
|
|
7
8
|
PROMPT_STATE,
|
|
8
9
|
petalGlyph,
|
|
9
10
|
petalTone,
|
|
11
|
+
SHELL_PULSE_MS,
|
|
12
|
+
SHELL_SCANNER_STEPS,
|
|
10
13
|
withPromptHint,
|
|
11
14
|
type PromptFrameOptions,
|
|
12
15
|
} from "../lib/shell-prompt.ts";
|
|
13
16
|
|
|
17
|
+
test("scanner reflects a symmetric light wave across working text at Pi's original cadence", () => {
|
|
18
|
+
const fg = (role: string, char: string) => `<${role}>${char}</${role}>`;
|
|
19
|
+
assert.equal(SHELL_PULSE_MS, 80);
|
|
20
|
+
assert.equal(SHELL_SCANNER_STEPS, 15);
|
|
21
|
+
for (const word of ["working…", "Thinking…"]) {
|
|
22
|
+
for (let tick = 0; tick < SHELL_SCANNER_STEPS; tick++) {
|
|
23
|
+
const frame = scanWorkingText(word, tick, fg);
|
|
24
|
+
assert.equal(frame.replace(/<[^>]+>/g, ""), word);
|
|
25
|
+
}
|
|
26
|
+
assert.match(scanWorkingText(word, 0, fg), /^(<muted>.<\/muted>)+$/);
|
|
27
|
+
assert.match(scanWorkingText(word, 3, fg), new RegExp(`^<borderAccent>${word[0]}</borderAccent><accent>${word[1]}</accent><thinkingHigh>${word[2]}</thinkingHigh>`));
|
|
28
|
+
assert.match(scanWorkingText(word, 4, fg), new RegExp(`^<accent>${word[0]}</accent><borderAccent>${word[1]}</borderAccent><accent>${word[2]}</accent>`));
|
|
29
|
+
assert.match(scanWorkingText(word, 5, fg), new RegExp(`^<thinkingHigh>${word[0]}</thinkingHigh><accent>${word[1]}</accent><borderAccent>${word[2]}</borderAccent>`));
|
|
30
|
+
assert.match(scanWorkingText(word, 14, fg), /^(<muted>.<\/muted>)+$/);
|
|
31
|
+
assert.equal(scanWorkingText(word, SHELL_SCANNER_STEPS, fg), scanWorkingText(word, 0, fg));
|
|
32
|
+
}
|
|
33
|
+
for (const width of [1, 8, 20, 40]) {
|
|
34
|
+
for (let tick = 0; tick < SHELL_SCANNER_STEPS; tick++) {
|
|
35
|
+
const lines = framePromptLines(editorLines(Math.max(4, width)), width, options({ state: PROMPT_STATE.WORKING, tick, fg: (_role, text) => text }));
|
|
36
|
+
assert.ok(lines.every((line) => visibleWidth(line) <= width));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("compact banner pulse rises from ink through fresh color to a bright tip then fades", () => {
|
|
42
|
+
const tones = Array.from({ length: 8 }, (_, tick) => petalTone(PROMPT_STATE.WORKING, tick));
|
|
43
|
+
assert.deepEqual(tones, ["mdQuoteBorder", "thinkingHigh", "accent", "borderAccent", "accent", "thinkingHigh", "mdQuoteBorder", "mdQuoteBorder"]);
|
|
44
|
+
assert.equal(petalTone(PROMPT_STATE.WORKING, 8), tones[0]);
|
|
45
|
+
});
|
|
46
|
+
|
|
14
47
|
// The Gentle Shell prompt wraps pi's editor output (a top rule, padded content
|
|
15
48
|
// lines, a bottom rule) in a rounded frame with a petal that shows the agent
|
|
16
49
|
// state. Framing is pure: it takes the editor's lines and returns new ones.
|
|
@@ -56,7 +89,7 @@ test("framePromptLines paints the frame with the editor border color and the pet
|
|
|
56
89
|
|
|
57
90
|
test("petalTone rests bright, walks the rose ramp while working, and turns to warning when queued", () => {
|
|
58
91
|
assert.equal(petalTone(PROMPT_STATE.IDLE, 2), "borderAccent");
|
|
59
|
-
assert.deepEqual([0, 1, 2, 3, 4].map((tick) => petalTone(PROMPT_STATE.WORKING, tick)), ["
|
|
92
|
+
assert.deepEqual([0, 1, 2, 3, 4].map((tick) => petalTone(PROMPT_STATE.WORKING, tick)), ["mdQuoteBorder", "thinkingHigh", "accent", "borderAccent", "accent"]);
|
|
60
93
|
assert.equal(petalTone(PROMPT_STATE.QUEUED, 1), "warning");
|
|
61
94
|
});
|
|
62
95
|
|
|
@@ -66,13 +99,14 @@ test("petalGlyph spins through the flowers while working and rests otherwise", (
|
|
|
66
99
|
assert.equal(petalGlyph(PROMPT_STATE.QUEUED, 1), "❀");
|
|
67
100
|
});
|
|
68
101
|
|
|
69
|
-
test("framePromptLines
|
|
102
|
+
test("framePromptLines scans the working word while preserving the frame and queued state", () => {
|
|
70
103
|
const plain = (_color: string, text: string) => text;
|
|
71
|
-
const working = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick:
|
|
72
|
-
assert.match(working[0], /<
|
|
73
|
-
assert.match(working[0], /<
|
|
74
|
-
|
|
75
|
-
|
|
104
|
+
const working = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 3 }));
|
|
105
|
+
assert.match(working[0], /<borderAccent>✾<\/borderAccent>/);
|
|
106
|
+
assert.match(working[0], /<borderAccent>w<\/borderAccent><accent>o<\/accent><thinkingHigh>r<\/thinkingHigh>/);
|
|
107
|
+
assert.equal(working[0].replace(/<[^>]+>/g, "").includes("working…"), true);
|
|
108
|
+
const workingPlain = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.WORKING, tick: 3, fg: plain }));
|
|
109
|
+
assert.match(stripAnsi(workingPlain[0]), /^╭─ ✾ working… ─+╮$/);
|
|
76
110
|
assert.equal(visibleWidth(workingPlain[0]), 40);
|
|
77
111
|
|
|
78
112
|
const queued = framePromptLines(editorLines(40), 40, options({ state: PROMPT_STATE.QUEUED }));
|
|
@@ -5,8 +5,8 @@ import { renderSidebarBanner } from "../lib/shell-sidebar-banner.ts";
|
|
|
5
5
|
|
|
6
6
|
const plain = { fg: (_role: string, text: string) => text, bold: (text: string) => text };
|
|
7
7
|
test("sidebar heading is only the centered literal product label", () => {
|
|
8
|
-
const label = "✿ Gentle
|
|
9
|
-
for (const width of [0, 1, 8, 11, 20, 21, 30, 45, 46, 80]) {
|
|
8
|
+
const label = "✿ Gentle Shell ✿";
|
|
9
|
+
for (const width of [0, 1, 8, 11, 15, 16, 17, 20, 21, 30, 45, 46, 80]) {
|
|
10
10
|
const lines = renderSidebarBanner(plain, width);
|
|
11
11
|
const space = width - visibleWidth(label);
|
|
12
12
|
assert.deepEqual(lines, space >= 0 ? [" ".repeat(Math.floor(space / 2)) + label + " ".repeat(Math.ceil(space / 2))] : []);
|
|
@@ -17,7 +17,7 @@ test("sidebar heading is only the centered literal product label", () => {
|
|
|
17
17
|
test("heading uses the current theme on every render", () => {
|
|
18
18
|
let palette = "dark";
|
|
19
19
|
const theme = { ...plain, fg: (role: string, text: string) => `<${palette}:${role}>${text}` };
|
|
20
|
-
assert.equal(renderSidebarBanner(theme, 46)[0].trim(), "<dark:accent>✿ <dark:text>Gentle
|
|
20
|
+
assert.equal(renderSidebarBanner(theme, 46)[0].trim(), "<dark:accent>✿ <dark:text>Gentle Shell <dark:accent>✿");
|
|
21
21
|
palette = "light";
|
|
22
|
-
assert.equal(renderSidebarBanner(theme, 46)[0].trim(), "<light:accent>✿ <light:text>Gentle
|
|
22
|
+
assert.equal(renderSidebarBanner(theme, 46)[0].trim(), "<light:accent>✿ <light:text>Gentle Shell <light:accent>✿");
|
|
23
23
|
});
|