gentle-pi 2.6.3 → 2.7.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.
@@ -0,0 +1,68 @@
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 } 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, 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));
@@ -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,103 @@
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
+ });
@@ -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,16 @@ 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 profile wraps long names without changing the compact bar", () => {
180
+ const profile = "team-" + "x".repeat(59);
181
+ const base = model();
182
+ const active = model({ profile });
183
+ for (const width of [24, 46]) {
184
+ const lines = renderShellSidebarBar(active, plainTheme, width);
185
+ assert.ok(lines.every((line) => visibleWidth(line) <= width));
186
+ assert.match(lines.join("\n"), /Profile/);
187
+ assert.ok(lines.join("").replace(/[│\s]/g, "").includes(profile));
188
+ }
189
+ assert.deepEqual(renderShellBar(active, plainTheme, 120), renderShellBar(base, plainTheme, 120));
190
+ });