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.
Files changed (128) hide show
  1. package/README.md +25 -7
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +41 -19
  18. package/docs/readme-reference.md +175 -36
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +289 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +123 -97
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +74 -113
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +19 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +88 -0
  47. package/lib/session-changes.ts +147 -0
  48. package/lib/shell-bar.ts +24 -10
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes-view.ts +2 -1
  51. package/lib/shell-changes.ts +5 -2
  52. package/lib/shell-prompt.ts +25 -8
  53. package/lib/shell-sidebar-banner.ts +2 -2
  54. package/lib/shell-sidebar-layout.ts +5 -2
  55. package/lib/windows-session-transport.ts +877 -0
  56. package/package.json +3 -3
  57. package/runtime/native-review-cli.mjs +18 -96
  58. package/runtime/windows-session-transport.ps1 +791 -0
  59. package/scripts/gentle-ai-installer.mjs +10 -10
  60. package/scripts/test-packed-runner.mjs +1668 -20
  61. package/scripts/verify-package-files.mjs +2 -3
  62. package/tests/agent-home.test.ts +52 -0
  63. package/tests/agent-profiles.test.ts +30 -1
  64. package/tests/agents-config.test.ts +44 -0
  65. package/tests/agents-history.test.ts +12 -24
  66. package/tests/agents-runner.test.ts +321 -58
  67. package/tests/agents-session-transport-process.test.ts +249 -0
  68. package/tests/agents-session-transport.test.ts +823 -0
  69. package/tests/artifact-language.test.ts +10 -7
  70. package/tests/command-palette.test.ts +378 -0
  71. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  72. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  73. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  74. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  75. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  76. package/tests/gentle-agents.test.ts +883 -356
  77. package/tests/gentle-ai-binary.test.ts +1 -1
  78. package/tests/gentle-ai-installer.test.ts +47 -47
  79. package/tests/gentle-ai.test.ts +472 -4
  80. package/tests/gentle-shell.test.ts +338 -205
  81. package/tests/native-choice-list.test.ts +13 -0
  82. package/tests/native-review-capability-contract.test.ts +15 -1
  83. package/tests/native-review-cli.test.ts +0 -33
  84. package/tests/odd-routing-contract.test.ts +208 -0
  85. package/tests/orchestrator-budget.test.ts +17 -2
  86. package/tests/package-manifest.test.ts +119 -31
  87. package/tests/persona-single-channel.test.ts +3 -3
  88. package/tests/pi-pretty.test.ts +45 -0
  89. package/tests/profile-pin.test.ts +370 -0
  90. package/tests/quiet-tool-rendering.test.ts +32 -5
  91. package/tests/review-contract-prompt.test.ts +9 -0
  92. package/tests/review-controller.test.ts +0 -44
  93. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  94. package/tests/runtime-harness.mjs +4 -4
  95. package/tests/sdd-agent-tools.test.ts +15 -36
  96. package/tests/sdd-archive-replay.test.ts +82 -0
  97. package/tests/sdd-classical-continuation.test.ts +74 -0
  98. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  99. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  100. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  101. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  102. package/tests/sdd-odd-integration.test.ts +33 -0
  103. package/tests/sdd-optional-research.test.ts +124 -0
  104. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  105. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  106. package/tests/sdd-preflight.test.ts +1 -1
  107. package/tests/sdd-research-capabilities.test.ts +20 -162
  108. package/tests/sdd-selection-transport.test.ts +180 -88
  109. package/tests/sdd-status.test.ts +5 -778
  110. package/tests/sdd-task-truth.test.ts +43 -0
  111. package/tests/session-change-capture.test.ts +86 -0
  112. package/tests/session-changes-shell.test.ts +38 -0
  113. package/tests/session-changes.test.ts +114 -0
  114. package/tests/shell-bar.test.ts +35 -0
  115. package/tests/shell-card.test.ts +8 -6
  116. package/tests/shell-changes.test.ts +8 -0
  117. package/tests/shell-prompt.test.ts +41 -7
  118. package/tests/shell-sidebar-banner.test.ts +4 -4
  119. package/tests/shell-sidebar-layout.test.ts +97 -13
  120. package/tests/startup-banner.test.ts +55 -2
  121. package/tests/windows-hidden-processes.test.ts +303 -0
  122. package/tests/windows-session-bootstrap.test.ts +1772 -0
  123. package/tests/windows-session-compile.test.ts +170 -0
  124. package/tests/windows-session-transport.test.ts +754 -0
  125. package/assets/agents/sdd-sync.md +0 -146
  126. package/lib/openspec-guardrails.ts +0 -99
  127. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  128. 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 dirty = model.dirty ? theme.fg(ROLE.DIRTY, `±${model.dirty}`) : "";
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
- ...((branch || dirty) ? [[branch, dirty].filter(Boolean).join(" ")] : []),
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: "Model",
141
- lines: [value(model.modelId), ...(model.effort ? [`${label("Effort")} ${theme.fg(ROLE.EFFORT, model.effort)}`] : [])],
142
- },
143
- {
144
- title: "Context",
145
- lines: [`${paintGauge(model.contextPercent, theme)} ${value(percent)} ${capacity}`],
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: [`${label("Cost")} ${value(formatCost(model.costTotal, model.subscription))}`, ...(usage ? [usage] : [])],
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
- ...(model.statuses.length ? [{ title: "Integrations", lines: model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status))) }] : []),
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
- // The left rail, corners included, carries the tone at full strength; the
64
- // rest of the frame stays in the theme's border color, so the state reads from the rail.
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 + soft(theme, card.tone, `${rule(targetWidth - 2)}╮`);
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], "╭") + soft(theme, card.tone, "─ ") + styledTitle + soft(theme, card.tone, ` ${fill}`) + tail + soft(theme, 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 + soft(theme, tone, "│");
98
- if (targetWidth === 3) return `${left} ${soft(theme, tone, "│")}`;
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} ${soft(theme, tone, "│")}`;
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 + soft(theme, tone, `${rule(targetWidth - 2)}╯`);
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 {
@@ -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}`;
@@ -23,9 +23,24 @@ const PETAL_TONE = {
23
23
 
24
24
  export type PetalTone = (typeof PETAL_TONE)[keyof typeof PETAL_TONE];
25
25
 
26
- // The Gentle themes map these to the rose ramp: active pink, rose, soft
27
- // rose, deep pink. The spin walks the ramp one shade per frame.
28
- const PETAL_TONE_FRAMES = [PETAL_TONE.BRIGHT, PETAL_TONE.ROSE, PETAL_TONE.SOFT, PETAL_TONE.DEEP] as const;
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 labelText = label ? ` ${options.fg(LABEL_ROLE, label)}` : "";
77
- const labelWidth = label ? label.length + 1 : 0;
78
- const fill = width - 3 - visibleWidth(glyph) - labelWidth - 1 - 1;
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("╭─ ") + petal + labelText + options.borderColor(` ${rule(fill)}╮`);
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-Pi ✿";
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-Pi") + " " + theme.fg("accent", "✿");
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", "changes", "agents", "todo"].map((key) => {
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
- const left = { render: (width: number) => root.render(width), invalidate() {}, [NODE]: () => original.call(root) };
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!;