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,88 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
4
|
+
import { dirname, relative, resolve } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { resolveSessionWorktree, type WorktreeResolver } from "./session-worktree-registry.ts";
|
|
7
|
+
import { SessionChanges, readChangeSnapshot, sameSnapshot, textSnapshot, isSessionChangeEvidence,
|
|
8
|
+
SESSION_CHANGE_ENTRY, SESSION_CHANGE_EVENT, SESSION_CHANGE_RELAY, type SessionChangeEvidence, type ChangeSnapshot } from "./session-changes.ts";
|
|
9
|
+
|
|
10
|
+
interface Pending { sessionId: string; inputPath: string; path: string; root: string; relativePath: string; before: ChangeSnapshot; toolName: string; evidence?: SessionChangeEvidence }
|
|
11
|
+
const normalized = (text: string) => text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
12
|
+
const unknown = (): ChangeSnapshot => ({ kind: "unavailable", reason: "Tool snapshots could not be verified; diff unavailable." });
|
|
13
|
+
|
|
14
|
+
/** Observe explicit write/edit outcomes, never infer ownership from Git status or shell text. */
|
|
15
|
+
export function installSessionChangeCapture(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, resolver: WorktreeResolver = resolveSessionWorktree): void {
|
|
16
|
+
const child = env.GENTLE_PI_AGENTS_CHILD === "1";
|
|
17
|
+
const pending = new Map<string, Pending>();
|
|
18
|
+
let current: ExtensionContext | undefined;
|
|
19
|
+
let store: SessionChanges | undefined;
|
|
20
|
+
const publish = (evidence: SessionChangeEvidence) => {
|
|
21
|
+
if (!current || !store || current.sessionManager.getSessionId() !== store.sessionId) return;
|
|
22
|
+
if (store.record(evidence)) pi.appendEntry(SESSION_CHANGE_ENTRY, { sessionId: store.sessionId, evidence });
|
|
23
|
+
const notice = store.takeNotice();
|
|
24
|
+
pi.events.emit(SESSION_CHANGE_EVENT, { sessionId: store.sessionId, ...(notice ? { notice } : {}) });
|
|
25
|
+
};
|
|
26
|
+
pi.on("session_start", (_event, ctx) => {
|
|
27
|
+
pending.clear(); current = ctx;
|
|
28
|
+
store = new SessionChanges(ctx.sessionManager.getSessionId(), ctx.sessionManager.getEntries());
|
|
29
|
+
});
|
|
30
|
+
const off = pi.events.on(SESSION_CHANGE_RELAY, (value) => {
|
|
31
|
+
const data = value as { sessionId?: string; evidence?: unknown };
|
|
32
|
+
if (child || !current || data?.sessionId !== current.sessionManager.getSessionId() || !isSessionChangeEvidence(data.evidence)) return;
|
|
33
|
+
try {
|
|
34
|
+
const own = resolver(current.cwd, current.cwd);
|
|
35
|
+
const target = resolver(data.evidence.root, current.cwd);
|
|
36
|
+
if (own && target?.root === data.evidence.root && own.commonDir === target.commonDir) publish(data.evidence);
|
|
37
|
+
} catch { /* Observation cannot change tool outcomes. */ }
|
|
38
|
+
});
|
|
39
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
40
|
+
const input = event.input as Record<string, unknown>;
|
|
41
|
+
if (!["write", "edit"].includes(event.toolName) || typeof input.path !== "string" || pending.size >= 32) return;
|
|
42
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
43
|
+
try {
|
|
44
|
+
let spelling = input.path.replace(/^@/, "").replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
|
|
45
|
+
if (spelling === "~" || spelling.startsWith("~/")) spelling = homedir() + spelling.slice(1);
|
|
46
|
+
const path = resolve(ctx.cwd, spelling);
|
|
47
|
+
try { if ((await lstat(path)).isSymbolicLink()) return; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return; }
|
|
48
|
+
let ancestor = dirname(path);
|
|
49
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
50
|
+
try { await lstat(ancestor); break; } catch { if (dirname(ancestor) === ancestor) return; ancestor = dirname(ancestor); }
|
|
51
|
+
}
|
|
52
|
+
const canonicalAncestor = await realpath(ancestor);
|
|
53
|
+
const canonicalPath = resolve(canonicalAncestor, relative(ancestor, path));
|
|
54
|
+
const own = resolver(ctx.cwd, ctx.cwd), target = resolver(canonicalAncestor, ctx.cwd);
|
|
55
|
+
if (!own || !target || own.commonDir !== target.commonDir) return;
|
|
56
|
+
const before = await readChangeSnapshot(canonicalPath);
|
|
57
|
+
if (ctx.sessionManager.getSessionId() !== sessionId || current?.sessionManager.getSessionId() !== sessionId) return;
|
|
58
|
+
pending.set(event.toolCallId, { sessionId, inputPath: input.path, path: canonicalPath, root: target.root, relativePath: relative(target.root, canonicalPath), before, toolName: event.toolName });
|
|
59
|
+
} catch { /* No bookkeeping error blocks an edit. */ }
|
|
60
|
+
});
|
|
61
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
62
|
+
const item = pending.get(event.toolCallId);
|
|
63
|
+
if (!item || item.sessionId !== ctx.sessionManager.getSessionId() || item.toolName !== event.toolName || event.input.path !== item.inputPath || event.isError !== false) { pending.delete(event.toolCallId); return; }
|
|
64
|
+
try {
|
|
65
|
+
const after = await readChangeSnapshot(item.path);
|
|
66
|
+
let before = item.before;
|
|
67
|
+
let verified = after;
|
|
68
|
+
if (event.toolName === "write" && after.kind === "text" && (typeof event.input.content !== "string" || !sameSnapshot(after, textSnapshot(event.input.content)))) {
|
|
69
|
+
before = unknown(); verified = unknown();
|
|
70
|
+
}
|
|
71
|
+
if (event.toolName === "edit" && before.kind === "text" && after.kind === "text") {
|
|
72
|
+
const patch = (event.details as { patch?: unknown } | undefined)?.patch;
|
|
73
|
+
if (typeof event.input.path !== "string" || patch !== generateUnifiedPatch(event.input.path, normalized(before.text), normalized(after.text))) { before = unknown(); verified = unknown(); }
|
|
74
|
+
}
|
|
75
|
+
if (item.sessionId !== ctx.sessionManager.getSessionId() || pending.get(event.toolCallId) !== item) return;
|
|
76
|
+
item.evidence = { id: event.toolCallId, root: item.root, path: item.relativePath, before, after: verified };
|
|
77
|
+
// Use the existing RPC tool-result envelope, not a new IPC channel or a model message.
|
|
78
|
+
if (child) return { details: { ...(event.details && typeof event.details === "object" ? event.details : {}), gentleSessionChange: item.evidence } };
|
|
79
|
+
} catch { pending.delete(event.toolCallId); }
|
|
80
|
+
});
|
|
81
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
82
|
+
const item = pending.get(event.toolCallId); pending.delete(event.toolCallId);
|
|
83
|
+
if (!child && event.isError === false && item?.evidence && item.sessionId === ctx.sessionManager.getSessionId()) {
|
|
84
|
+
try { publish(item.evidence); } catch { /* Preserve the tool's outcome. */ }
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
pi.on("session_shutdown", () => { pending.clear(); current = undefined; store = undefined; off(); });
|
|
88
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute } from "node:path";
|
|
5
|
+
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { changesModel, type ChangedFile, type WorktreeChanges } from "./shell-changes.ts";
|
|
7
|
+
|
|
8
|
+
export const SESSION_CHANGE_ENTRY = "gentle-pi.session-change/v1";
|
|
9
|
+
export const SESSION_CHANGE_EVENT = "gentle-pi:session-change";
|
|
10
|
+
export const SESSION_CHANGE_RELAY = "gentle-pi:child-session-change";
|
|
11
|
+
export const MAX_CHANGE_BYTES = 64 * 1024;
|
|
12
|
+
const MAX_RECORDS = 256;
|
|
13
|
+
const MAX_SESSION_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
const MAX_LINES = 2000;
|
|
15
|
+
export type ChangeSnapshot = { kind: "text"; text: string } | { kind: "absent" } | { kind: "unavailable"; reason: string };
|
|
16
|
+
export interface SessionChangeEvidence {
|
|
17
|
+
id: string;
|
|
18
|
+
root: string;
|
|
19
|
+
path: string;
|
|
20
|
+
before: ChangeSnapshot;
|
|
21
|
+
after: ChangeSnapshot;
|
|
22
|
+
}
|
|
23
|
+
type Entry = { type?: string; customType?: string; data?: unknown };
|
|
24
|
+
interface FileState { before: ChangeSnapshot; after: ChangeSnapshot; unavailable?: string; diff?: string }
|
|
25
|
+
|
|
26
|
+
const unavailable = (reason: string): ChangeSnapshot => ({ kind: "unavailable", reason });
|
|
27
|
+
export function textSnapshot(text: string): ChangeSnapshot {
|
|
28
|
+
if (Buffer.byteLength(text) > MAX_CHANGE_BYTES || text.split("\n", MAX_LINES + 1).length > MAX_LINES) return unavailable("File exceeds the session diff limit.");
|
|
29
|
+
if (text.includes("\0")) return unavailable("Binary file; line counts unavailable.");
|
|
30
|
+
return { kind: "text", text };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read only the named mutation target. Never traverse, follow symlinks, or open a FIFO for blocking I/O. */
|
|
34
|
+
export async function readChangeSnapshot(path: string): Promise<ChangeSnapshot> {
|
|
35
|
+
let file: Awaited<ReturnType<typeof open>> | undefined;
|
|
36
|
+
try {
|
|
37
|
+
file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
|
|
38
|
+
const stat = await file.stat();
|
|
39
|
+
if (!stat.isFile() || stat.size > MAX_CHANGE_BYTES) return unavailable("Nonregular or large file; diff unavailable.");
|
|
40
|
+
const buffer = Buffer.alloc(MAX_CHANGE_BYTES + 1);
|
|
41
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
42
|
+
if (bytesRead !== stat.size) return unavailable("File changed while its snapshot was being read.");
|
|
43
|
+
return textSnapshot(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "absent" } : unavailable("Snapshot unavailable.");
|
|
46
|
+
} finally { await file?.close().catch(() => {}); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validSnapshot(value: unknown): value is ChangeSnapshot {
|
|
50
|
+
if (!value || typeof value !== "object") return false;
|
|
51
|
+
const v = value as ChangeSnapshot;
|
|
52
|
+
return v.kind === "absent" || (v.kind === "text" && typeof v.text === "string" && textSnapshot(v.text).kind === "text") ||
|
|
53
|
+
(v.kind === "unavailable" && typeof v.reason === "string" && v.reason.length <= 200);
|
|
54
|
+
}
|
|
55
|
+
export function isSessionChangeEvidence(value: unknown): value is SessionChangeEvidence {
|
|
56
|
+
if (!value || typeof value !== "object") return false;
|
|
57
|
+
const v = value as SessionChangeEvidence;
|
|
58
|
+
return typeof v.id === "string" && v.id.length > 0 && v.id.length <= 256 &&
|
|
59
|
+
typeof v.root === "string" && isAbsolute(v.root) && v.root.length <= 4096 &&
|
|
60
|
+
typeof v.path === "string" && v.path.length > 0 && v.path.length <= 4096 &&
|
|
61
|
+
!isAbsolute(v.path) && !/[\0\r\n]/.test(v.root + v.path) &&
|
|
62
|
+
!v.path.split(/[\\/]/).some(part => part === ".." || part === ".git") &&
|
|
63
|
+
validSnapshot(v.before) && validSnapshot(v.after);
|
|
64
|
+
}
|
|
65
|
+
export const sameSnapshot = (a: ChangeSnapshot, b: ChangeSnapshot): boolean =>
|
|
66
|
+
a.kind === b.kind && (a.kind === "absent" || (a.kind === "text" && b.kind === "text" && a.text === b.text));
|
|
67
|
+
const snapshotText = (value: ChangeSnapshot) => value.kind === "text" ? value.text : "";
|
|
68
|
+
|
|
69
|
+
export class SessionChanges {
|
|
70
|
+
private readonly seen = new Set<string>();
|
|
71
|
+
private readonly files = new Map<string, Map<string, FileState>>();
|
|
72
|
+
private bytes = 0;
|
|
73
|
+
private announced = false;
|
|
74
|
+
notice: string | undefined;
|
|
75
|
+
readonly sessionId: string;
|
|
76
|
+
private cached: WorktreeChanges[] | undefined;
|
|
77
|
+
constructor(sessionId: string, entries: readonly Entry[] = []) { this.sessionId = sessionId; this.restore(entries); }
|
|
78
|
+
|
|
79
|
+
restore(entries: readonly Entry[]): void {
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.type !== "custom" || entry.customType !== SESSION_CHANGE_ENTRY) continue;
|
|
82
|
+
const data = entry.data as { sessionId?: string; evidence?: unknown } | undefined;
|
|
83
|
+
if (data?.sessionId === this.sessionId && isSessionChangeEvidence(data.evidence)) this.record(data.evidence);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
record(evidence: SessionChangeEvidence): boolean {
|
|
87
|
+
if (!isSessionChangeEvidence(evidence) || this.seen.has(evidence.id)) return false;
|
|
88
|
+
const bytes = Buffer.byteLength(JSON.stringify(evidence));
|
|
89
|
+
if (this.seen.size >= MAX_RECORDS || this.bytes + bytes > MAX_SESSION_BYTES) {
|
|
90
|
+
this.notice = "Session change capture limit reached; additional changes are not displayed.";
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
this.seen.add(evidence.id);
|
|
94
|
+
this.bytes += bytes;
|
|
95
|
+
if (sameSnapshot(evidence.before, evidence.after)) return false;
|
|
96
|
+
let files = this.files.get(evidence.root);
|
|
97
|
+
if (!files) this.files.set(evidence.root, files = new Map());
|
|
98
|
+
const previous = files.get(evidence.path);
|
|
99
|
+
const state: FileState = { before: previous?.before ?? structuredClone(evidence.before), after: structuredClone(evidence.after) };
|
|
100
|
+
state.unavailable = previous?.unavailable;
|
|
101
|
+
if (previous && !sameSnapshot(previous.after, evidence.before)) state.unavailable = "Snapshot continuity lost (external or unobserved edit); session diff unavailable.";
|
|
102
|
+
if (evidence.before.kind === "unavailable") state.unavailable ??= evidence.before.reason;
|
|
103
|
+
if (evidence.after.kind === "unavailable") state.unavailable ??= evidence.after.reason;
|
|
104
|
+
// Keep the final state even after an own revert, to detect later external edits.
|
|
105
|
+
this.patch(evidence.path, state);
|
|
106
|
+
files.set(evidence.path, state);
|
|
107
|
+
this.cached = undefined;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
private changed(root: string): ChangedFile[] {
|
|
111
|
+
return [...(this.files.get(root) ?? [])].flatMap(([path, state]) => {
|
|
112
|
+
if (!state.unavailable && sameSnapshot(state.before, state.after)) return [];
|
|
113
|
+
const patch = this.patch(path, state);
|
|
114
|
+
const patchLines = patch.split("\n");
|
|
115
|
+
const firstHunk = patchLines.findIndex(line => line.startsWith("@@"));
|
|
116
|
+
const lines = firstHunk < 0 ? [] : patchLines.slice(firstHunk + 1);
|
|
117
|
+
return [{
|
|
118
|
+
path, diffRevision: createHash("sha256").update(patch).digest("hex"), status: state.before.kind === "absent" ? "added" as const : state.after.kind === "absent" ? "deleted" as const : "modified" as const,
|
|
119
|
+
added: state.unavailable ? 0 : lines.filter(line => line.startsWith("+")).length,
|
|
120
|
+
deleted: state.unavailable ? 0 : lines.filter(line => line.startsWith("-")).length,
|
|
121
|
+
...(state.unavailable ? { countsUnavailable: state.unavailable } : {}),
|
|
122
|
+
}];
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
private patch(path: string, state: FileState): string {
|
|
126
|
+
if (state.unavailable) return state.unavailable;
|
|
127
|
+
return state.diff ??= generateUnifiedPatch(path, snapshotText(state.before), snapshotText(state.after));
|
|
128
|
+
}
|
|
129
|
+
get worktrees(): WorktreeChanges[] {
|
|
130
|
+
return this.cached ??= [...this.files.keys()].map(root => ({ root, model: changesModel(this.changed(root)) })).filter(tree => tree.model.files.length > 0);
|
|
131
|
+
}
|
|
132
|
+
get model() {
|
|
133
|
+
const trees = this.worktrees;
|
|
134
|
+
return { ...changesModel(trees.flatMap(tree => tree.model.files.map(file => ({ ...file, path: trees.length === 1 ? file.path : tree.root + "/" + file.path })))), ...(this.notice ? { notice: this.notice } : {}) };
|
|
135
|
+
}
|
|
136
|
+
/** Return the capture-limit notice once, for a single user notification. The `notice` field stays set. */
|
|
137
|
+
takeNotice(): string | undefined {
|
|
138
|
+
if (!this.notice || this.announced) return undefined;
|
|
139
|
+
this.announced = true;
|
|
140
|
+
return this.notice;
|
|
141
|
+
}
|
|
142
|
+
async refresh() { return this.model; }
|
|
143
|
+
loadDiff(root: string, file: ChangedFile): string {
|
|
144
|
+
const state = this.files.get(root)?.get(file.path);
|
|
145
|
+
return state ? this.patch(file.path, state) : "No captured agent diff for this file.";
|
|
146
|
+
}
|
|
147
|
+
}
|
package/lib/shell-bar.ts
CHANGED
|
@@ -11,6 +11,8 @@ export { gaugeTone, renderGauge, type GaugeTone };
|
|
|
11
11
|
// verified without a live TUI.
|
|
12
12
|
|
|
13
13
|
export interface ShellBarModel {
|
|
14
|
+
profile?: string;
|
|
15
|
+
changes?: { files: number; added: number; deleted: number; notice?: string };
|
|
14
16
|
cwd: string;
|
|
15
17
|
branch: string | null;
|
|
16
18
|
dirty: number | undefined;
|
|
@@ -122,7 +124,7 @@ function joinSegments(segments: string[], theme: ShellBarTheme): string {
|
|
|
122
124
|
export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme, width: number): string[] {
|
|
123
125
|
const value = (text: string) => theme.fg(ROLE.VALUE, theme.bold(text));
|
|
124
126
|
const label = (text: string) => theme.fg(ROLE.LABEL, text);
|
|
125
|
-
const
|
|
127
|
+
const changes = model.changes;
|
|
126
128
|
const branch = model.branch ? `${label("Branch")} ${value(model.branch)}` : "";
|
|
127
129
|
const percent = model.contextPercent === null ? "?%" : `${Math.round(model.contextPercent)}%`;
|
|
128
130
|
const capacity = label(`${formatTokens(model.contextWindow)} tokens`);
|
|
@@ -132,23 +134,35 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
132
134
|
title: "Project",
|
|
133
135
|
lines: [
|
|
134
136
|
value(model.cwd),
|
|
135
|
-
...(
|
|
137
|
+
...(branch ? [branch] : []),
|
|
136
138
|
...(model.sessionName ? [`${label("Session")} ${value(model.sessionName)}`] : []),
|
|
139
|
+
`${label("Model")} ${value(model.modelId)}`,
|
|
140
|
+
...(model.effort ? [`${label("Effort")} ${theme.fg(ROLE.EFFORT, model.effort)}`] : []),
|
|
141
|
+
...(model.profile ? [`${label("Profile")} ${value(sanitizeStatus(model.profile))}`] : []),
|
|
137
142
|
],
|
|
138
143
|
},
|
|
139
144
|
{
|
|
140
|
-
title: "
|
|
141
|
-
lines: [
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
title: "Changes",
|
|
146
|
+
lines: [
|
|
147
|
+
changes?.files
|
|
148
|
+
? `${changes.files} ${changes.files === 1 ? "file" : "files"} · ${theme.fg("success", `+${changes.added}`)} ${theme.fg("error", `−${changes.deleted}`)}`
|
|
149
|
+
: label("No captured changes"),
|
|
150
|
+
...(changes?.notice ? [theme.fg("warning", sanitizeStatus(changes.notice))] : []),
|
|
151
|
+
label("/gentle:changes"),
|
|
152
|
+
],
|
|
146
153
|
},
|
|
147
154
|
{
|
|
148
155
|
title: "Usage",
|
|
149
|
-
lines: [
|
|
156
|
+
lines: [
|
|
157
|
+
`${label("Context")} ${paintGauge(model.contextPercent, theme)} ${value(percent)}`,
|
|
158
|
+
capacity,
|
|
159
|
+
`${label("Cost")} ${value(formatCost(model.costTotal, model.subscription))}`,
|
|
160
|
+
...(usage ? [usage] : []),
|
|
161
|
+
],
|
|
150
162
|
},
|
|
151
|
-
|
|
163
|
+
{ title: "Integrations", lines: model.statuses.length
|
|
164
|
+
? model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)))
|
|
165
|
+
: [label("No status reported")] },
|
|
152
166
|
];
|
|
153
167
|
// Pre-wrap values before indenting so Unicode/ANSI continuation lines keep
|
|
154
168
|
// the same inset without consuming the card's right border.
|
package/lib/shell-card.ts
CHANGED
|
@@ -60,12 +60,8 @@ function bodyLines(card: Card, innerWidth: number): string[] {
|
|
|
60
60
|
return card.body.flatMap((paragraph) => (paragraph === "" ? [""] : wrapTextWithAnsi(paragraph, innerWidth)));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const FRAME_ROLE = "border";
|
|
66
|
-
|
|
67
|
-
function soft(theme: CardTheme, _tone: CardTone, text: string): string {
|
|
68
|
-
return theme.fg(FRAME_ROLE, text);
|
|
63
|
+
function frame(theme: CardTheme, tone: CardTone, text: string): string {
|
|
64
|
+
return theme.fg(TONE_ROLE[tone], text);
|
|
69
65
|
}
|
|
70
66
|
|
|
71
67
|
export function cardTop(card: Card, theme: CardTheme, width: number, hint?: string): string {
|
|
@@ -74,7 +70,7 @@ export function cardTop(card: Card, theme: CardTheme, width: number, hint?: stri
|
|
|
74
70
|
if (targetWidth < 5) {
|
|
75
71
|
const left = theme.fg(TONE_ROLE[card.tone], "╭");
|
|
76
72
|
if (targetWidth === 1) return left;
|
|
77
|
-
return left +
|
|
73
|
+
return left + frame(theme, card.tone, `${rule(targetWidth - 2)}╮`);
|
|
78
74
|
}
|
|
79
75
|
|
|
80
76
|
const title = titleText(card, theme);
|
|
@@ -86,7 +82,7 @@ export function cardTop(card: Card, theme: CardTheme, width: number, hint?: stri
|
|
|
86
82
|
const styledTitleWidth = title.width <= titleWidth ? title.width : visibleWidth(styledTitle);
|
|
87
83
|
const fill = rule(targetWidth - styledTitleWidth - 5 - hintWidth);
|
|
88
84
|
const tail = shownHint ? ` ${theme.fg(HINT_ROLE, shownHint)} ` : "";
|
|
89
|
-
return theme.fg(TONE_ROLE[card.tone], "╭") +
|
|
85
|
+
return theme.fg(TONE_ROLE[card.tone], "╭") + frame(theme, card.tone, "─ ") + styledTitle + frame(theme, card.tone, ` ${fill}`) + tail + frame(theme, card.tone, "╮");
|
|
90
86
|
}
|
|
91
87
|
|
|
92
88
|
export function cardLine(text: string, tone: CardTone, theme: CardTheme, width: number): string {
|
|
@@ -94,13 +90,13 @@ export function cardLine(text: string, tone: CardTone, theme: CardTheme, width:
|
|
|
94
90
|
if (targetWidth === 0) return "";
|
|
95
91
|
const left = theme.fg(TONE_ROLE[tone], "│");
|
|
96
92
|
if (targetWidth === 1) return left;
|
|
97
|
-
if (targetWidth === 2) return left +
|
|
98
|
-
if (targetWidth === 3) return `${left} ${
|
|
93
|
+
if (targetWidth === 2) return left + frame(theme, tone, "│");
|
|
94
|
+
if (targetWidth === 3) return `${left} ${frame(theme, tone, "│")}`;
|
|
99
95
|
|
|
100
96
|
const innerWidth = targetWidth - FRAME_COLUMNS;
|
|
101
97
|
const clipped = innerWidth === 0 ? "" : truncateToWidth(text, innerWidth, "…");
|
|
102
98
|
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
|
|
103
|
-
return `${left} ${clipped}${padding} ${
|
|
99
|
+
return `${left} ${clipped}${padding} ${frame(theme, tone, "│")}`;
|
|
104
100
|
}
|
|
105
101
|
|
|
106
102
|
export function cardBottom(tone: CardTone, theme: CardTheme, width: number): string {
|
|
@@ -108,7 +104,7 @@ export function cardBottom(tone: CardTone, theme: CardTheme, width: number): str
|
|
|
108
104
|
if (targetWidth === 0) return "";
|
|
109
105
|
const left = theme.fg(TONE_ROLE[tone], "╰");
|
|
110
106
|
if (targetWidth === 1) return left;
|
|
111
|
-
return left +
|
|
107
|
+
return left + frame(theme, tone, `${rule(targetWidth - 2)}╯`);
|
|
112
108
|
}
|
|
113
109
|
|
|
114
110
|
export function cardInnerWidth(width: number): number {
|
|
@@ -74,6 +74,7 @@ const FILE_STATUS = {
|
|
|
74
74
|
} as const;
|
|
75
75
|
|
|
76
76
|
function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
|
|
77
|
+
if (file.countsUnavailable) return theme.fg("dim", "counts unavailable");
|
|
77
78
|
return `${theme.fg(ROLE.ADDED, `+${file.added}`)} ${theme.fg(ROLE.REMOVED, `-${file.deleted}`)}`;
|
|
78
79
|
}
|
|
79
80
|
|
|
@@ -126,7 +127,7 @@ function displayText(text: string): string {
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
function fingerprint(file: ChangedFile): string {
|
|
129
|
-
return `${file.status}:${file.added}:${file.deleted}`;
|
|
130
|
+
return `${file.status}:${file.added}:${file.deleted}:${file.diffRevision ?? ""}:${file.countsUnavailable ?? ""}`;
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
export interface WorktreeChangesViewDeps {
|
package/lib/shell-changes.ts
CHANGED
|
@@ -19,12 +19,15 @@ export interface ChangedFile {
|
|
|
19
19
|
added: number;
|
|
20
20
|
deleted: number;
|
|
21
21
|
status: ChangeStatus;
|
|
22
|
+
countsUnavailable?: string;
|
|
23
|
+
diffRevision?: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface ChangesModel {
|
|
25
27
|
files: ChangedFile[];
|
|
26
28
|
added: number;
|
|
27
29
|
deleted: number;
|
|
30
|
+
notice?: string;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export interface ChangesSnapshot {
|
|
@@ -115,7 +118,7 @@ export function changesModel(changed: ChangedFile[]): ChangesModel {
|
|
|
115
118
|
|
|
116
119
|
export function changesSummary(model: ChangesModel): string {
|
|
117
120
|
const noun = model.files.length === 1 ? "file" : "files";
|
|
118
|
-
return `${model.files.length} ${noun} · +${model.added} −${model.deleted}`;
|
|
121
|
+
return `${model.files.length} ${noun} · +${model.added} −${model.deleted}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}${model.notice ? " · capture limit reached" : ""}`;
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
// One line: summary, the files joined by dots, and the command pushed to
|
|
@@ -124,7 +127,7 @@ export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, wi
|
|
|
124
127
|
if (model.files.length === 0) return [];
|
|
125
128
|
const noun = model.files.length === 1 ? "file" : "files";
|
|
126
129
|
const dot = theme.fg("muted", "·");
|
|
127
|
-
const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}`;
|
|
130
|
+
const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}${model.notice ? ` ${dot} ${theme.fg("warning", "capture limit reached")}` : ""}`;
|
|
128
131
|
const hint = theme.fg("dim", CHANGES_COMMAND);
|
|
129
132
|
const list = model.files.map((file) => theme.fg("muted", file.path)).join(` ${dot} `);
|
|
130
133
|
const left = `${head} ${dot} ${list}`;
|
package/lib/shell-prompt.ts
CHANGED
|
@@ -23,9 +23,24 @@ const PETAL_TONE = {
|
|
|
23
23
|
|
|
24
24
|
export type PetalTone = (typeof PETAL_TONE)[keyof typeof PETAL_TONE];
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
26
|
+
// Keep Pi's original loader cadence while carrying the banner's reflected-light
|
|
27
|
+
// language into the compact working labels. The highlight enters before the
|
|
28
|
+
// word, crosses it as a soft symmetric wave, then exits before wrapping.
|
|
29
|
+
export const SHELL_PULSE_MS = 80;
|
|
30
|
+
export const SHELL_SCANNER_STEPS = 15;
|
|
31
|
+
const SCANNER_ENTRY_OFFSET = 3;
|
|
32
|
+
|
|
33
|
+
/** Banner-style reflected-light wave across text; foreground only. */
|
|
34
|
+
export function scanWorkingText(text: string, tick: number, fg: (role: string, text: string) => string): string {
|
|
35
|
+
const phase = ((Math.floor(tick) % SHELL_SCANNER_STEPS) + SHELL_SCANNER_STEPS) % SHELL_SCANNER_STEPS;
|
|
36
|
+
const head = phase - SCANNER_ENTRY_OFFSET;
|
|
37
|
+
return Array.from(text, (char, index) => {
|
|
38
|
+
const distance = Math.abs(head - index);
|
|
39
|
+
const role = distance === 0 ? "borderAccent" : distance === 1 ? "accent" : distance === 2 ? "thinkingHigh" : "muted";
|
|
40
|
+
return fg(role, char);
|
|
41
|
+
}).join("");
|
|
42
|
+
}
|
|
43
|
+
const PETAL_TONE_FRAMES = [PETAL_TONE.DEEP, PETAL_TONE.SOFT, PETAL_TONE.ROSE, PETAL_TONE.BRIGHT, PETAL_TONE.ROSE, PETAL_TONE.SOFT, PETAL_TONE.DEEP, PETAL_TONE.DEEP] as const;
|
|
29
44
|
|
|
30
45
|
export interface PromptFrameOptions {
|
|
31
46
|
state: PromptState;
|
|
@@ -46,7 +61,7 @@ const FAKE_CURSOR = "\x1b[7m \x1b[0m";
|
|
|
46
61
|
const SCROLL_INDICATOR = /[↑↓] \d+ more/;
|
|
47
62
|
const STATE_LABEL: Record<PromptState, string | undefined> = {
|
|
48
63
|
[PROMPT_STATE.IDLE]: undefined,
|
|
49
|
-
[PROMPT_STATE.WORKING]: "working",
|
|
64
|
+
[PROMPT_STATE.WORKING]: "working…",
|
|
50
65
|
[PROMPT_STATE.QUEUED]: "queued",
|
|
51
66
|
};
|
|
52
67
|
|
|
@@ -71,13 +86,15 @@ function rule(length: number): string {
|
|
|
71
86
|
|
|
72
87
|
function topRule(width: number, options: PromptFrameOptions, indicator: string | undefined): string {
|
|
73
88
|
const label = indicator ?? STATE_LABEL[options.state];
|
|
89
|
+
const scanning = options.state === PROMPT_STATE.WORKING;
|
|
74
90
|
const glyph = petalGlyph(options.state, options.tick);
|
|
75
91
|
const petal = options.fg(petalTone(options.state, options.tick), options.bold ? options.bold(glyph) : glyph);
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
92
|
+
const paintedLabel = label && scanning && !indicator ? scanWorkingText(label, options.tick, options.fg) : label ? options.fg(LABEL_ROLE, label) : "";
|
|
93
|
+
const title = [petal, paintedLabel].filter(Boolean).join(" ");
|
|
94
|
+
const titleWidth = visibleWidth(glyph) + (label ? visibleWidth(label) + (glyph ? 1 : 0) : 0);
|
|
95
|
+
const fill = width - titleWidth - 5;
|
|
79
96
|
if (fill < 0) return options.borderColor(`╭${rule(width - 2)}╮`);
|
|
80
|
-
return options.borderColor("╭─ ") +
|
|
97
|
+
return options.borderColor("╭─ ") + title + options.borderColor(` ${rule(fill)}╮`);
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
function bottomRule(width: number, options: PromptFrameOptions, indicator: string | undefined): string {
|
|
@@ -2,10 +2,10 @@ import { visibleWidth } from "@earendil-works/pi-tui";
|
|
|
2
2
|
import type { ShellBarTheme } from "./shell-bar.ts";
|
|
3
3
|
|
|
4
4
|
export function renderSidebarBanner(theme: ShellBarTheme, width: number): string[] {
|
|
5
|
-
const label = "✿ Gentle
|
|
5
|
+
const label = "✿ Gentle Shell ✿";
|
|
6
6
|
const space = width - visibleWidth(label);
|
|
7
7
|
if (space < 0) return [];
|
|
8
8
|
// Gentleman's accent is pink; card titles use a separate champagne role.
|
|
9
|
-
const title = theme.fg("accent", "✿") + " " + theme.fg("text", "Gentle
|
|
9
|
+
const title = theme.fg("accent", "✿") + " " + theme.fg("text", "Gentle Shell") + " " + theme.fg("accent", "✿");
|
|
10
10
|
return [" ".repeat(Math.floor(space / 2)) + title + " ".repeat(Math.ceil(space / 2))];
|
|
11
11
|
}
|
|
@@ -134,7 +134,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
|
|
|
134
134
|
}
|
|
135
135
|
try {
|
|
136
136
|
const contentWidth = scroll.getContentWidth(RAIL_WIDTH);
|
|
137
|
-
const sections = ["footer", "
|
|
137
|
+
const sections = ["footer", "agents", "todo"].map((key) => {
|
|
138
138
|
const component = state.parts.get(key);
|
|
139
139
|
const lines = [...(component?.render(contentWidth - RAIL_PADDING * 2) ?? [])];
|
|
140
140
|
while (lines.length && lines[lines.length - 1]?.trim() === "") lines.pop();
|
|
@@ -171,7 +171,10 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme): () => void {
|
|
|
171
171
|
if (roots.has(root)) return;
|
|
172
172
|
const original = root[NODE]!;
|
|
173
173
|
const descriptor = Object.getOwnPropertyDescriptor(root, NODE);
|
|
174
|
-
|
|
174
|
+
// Fullscreen gives this stretched stack an explicit viewport height.
|
|
175
|
+
// Its intrinsic-height probe is unused; real painting traverses NODE.
|
|
176
|
+
// Delegating that probe to root.render would render the transcript twice.
|
|
177
|
+
const left = { render: () => [], invalidate() {}, [NODE]: () => original.call(root) };
|
|
175
178
|
const replacement = () => {
|
|
176
179
|
if (!prepare(tui.terminal.columns, root)) return original.call(root);
|
|
177
180
|
const current = prepared!;
|