gentle-pi 2.4.0 → 2.6.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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,818 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync, execFile } from "node:child_process";
3
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import type { TUI } from "@earendil-works/pi-tui";
9
+ import installGentleShell, { buildShellBarModel, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
10
+ import { CHANGE_STATUS } from "../lib/shell-changes.ts";
11
+ import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
12
+ import type { ShellBarTheme } from "../lib/shell-bar.ts";
13
+ import { stripAnsi } from "../lib/terminal-theme.ts";
14
+
15
+ // The Gentle Shell extension wires the pure bar renderer into pi's footer
16
+ // slot. These tests drive it with a fake ExtensionAPI and context.
17
+
18
+ initTheme("dark");
19
+
20
+ const resolveWorktree = (path: string) => ({ root: path.startsWith("/repo") || path === "." ? "/repo" : path, commonDir: "/clone/git" });
21
+ const gentleShell: typeof installGentleShell = (pi, env, deps) => installGentleShell(pi, env, { resolveWorktree, gitRunner: (cwd) => async (args) => pi.exec("git", ["-C", cwd, ...args], { timeout: 5000 }), ...deps });
22
+
23
+ const plainTheme = {
24
+ fg(_color: string, value: string) {
25
+ return value;
26
+ },
27
+ bold(value: string) {
28
+ return value;
29
+ },
30
+ bg(_color: string, value: string) {
31
+ return value;
32
+ },
33
+ getBgAnsi() {
34
+ return "";
35
+ },
36
+ } satisfies ShellBarTheme & { bg(color: string, value: string): string; getBgAnsi(): string };
37
+
38
+ interface FakeUi {
39
+ footerFactory: unknown;
40
+ editorFactory: unknown;
41
+ widgets: Map<string, unknown>;
42
+ widgetSets: number;
43
+ workingVisible: boolean | undefined;
44
+ notices: string[];
45
+ overlay: unknown;
46
+ overlayView: { render(width: number): string[]; handleInput(data: string): void } | undefined;
47
+ closeOverlay: (() => void) | undefined;
48
+ }
49
+
50
+ interface GitScript {
51
+ numstat: string;
52
+ porcelain: string;
53
+ }
54
+
55
+ interface CommandRegistration {
56
+ handler: (args: string, ctx: ExtensionContext) => Promise<void>;
57
+ }
58
+
59
+ interface ShortcutRegistration {
60
+ handler: (ctx: ExtensionContext) => Promise<void>;
61
+ }
62
+
63
+ type MessageRenderer = (message: { customType: string; content: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] };
64
+ const renderers = new Map<string, MessageRenderer>();
65
+
66
+ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]) {
67
+ const handlers = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>();
68
+ const commands = new Map<string, CommandRegistration>();
69
+ const shortcuts = new Map<string, ShortcutRegistration>();
70
+ const git: string[][] = [];
71
+ let entries: unknown[] = [];
72
+ const tools = new Map<string, { renderShell?: string; execute(id: string, params: unknown, signal: undefined, update: undefined, ctx: ExtensionContext): Promise<unknown> }>();
73
+ const listeners = new Map<string, (data: unknown) => void>();
74
+ let round = 0;
75
+ const pi = {
76
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) {
77
+ handlers.set(event, [...(handlers.get(event) ?? []), (payload, ctx) => {
78
+ entries = ctx.sessionManager.getEntries();
79
+ return handler(payload, ctx);
80
+ }]);
81
+ },
82
+ appendEntry(customType: string, data: unknown) { entries.push({ type: "custom", customType, data }); },
83
+ events: { on(name: string, fn: (data: unknown) => void) { listeners.set(name, fn); return () => listeners.delete(name); }, emit(name: string, data: unknown) { listeners.get(name)?.(data); } },
84
+ registerTool(tool: { name: string }) { tools.set(tool.name, tool as never); },
85
+ registerCommand(name: string, registration: CommandRegistration) {
86
+ commands.set(name, registration);
87
+ },
88
+ registerShortcut(key: string, registration: ShortcutRegistration) {
89
+ shortcuts.set(key, registration);
90
+ },
91
+ registerMessageRenderer(type: string, renderer: MessageRenderer) {
92
+ renderers.set(type, renderer);
93
+ },
94
+ getThinkingLevel() {
95
+ return "medium";
96
+ },
97
+ async exec(_command: string, args: string[]) {
98
+ git.push(args);
99
+ if (args.includes("worktree")) return { stdout: "worktree /repo\0branch refs/heads/main\0\0", stderr: "", code: 0, killed: false };
100
+ const isNumstat = args.includes("diff");
101
+ const step = script[Math.min(isNumstat ? round : round++, script.length - 1)];
102
+ return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
103
+ },
104
+ } as unknown as ExtensionAPI;
105
+ return { pi, handlers, git, commands, shortcuts, tools };
106
+ }
107
+
108
+ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>, event: string, ctx: ExtensionContext): Promise<void> {
109
+ for (const handler of handlers.get(event) ?? []) await handler({}, ctx);
110
+ }
111
+
112
+ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; editorFactory?: unknown; token?: string } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
113
+ const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
114
+ let resolveOverlay: () => void;
115
+ const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
116
+ const entries = options.entries ?? [];
117
+ const ctx = {
118
+ hasUI: options.hasUI ?? true,
119
+ hasPendingMessages: () => options.pending ?? false,
120
+ cwd: "/repo",
121
+ model: { id: "gpt-5.5", provider: "openai-codex", reasoning: true, contextWindow: 272_000 },
122
+ sessionManager: {
123
+ getCwd: () => "/repo",
124
+ getSessionName: () => "Release notes",
125
+ getEntries: () => entries,
126
+ getSessionId: () => "shell-session",
127
+ },
128
+ modelRegistry: { isUsingOAuth: () => options.oauth ?? true, getApiKeyForProvider: async () => options.token },
129
+ getContextUsage: () => ({ tokens: 122_400, contextWindow: 272_000, percent: 45 }),
130
+ ui: {
131
+ theme: plainTheme,
132
+ setFooter(factory: unknown) {
133
+ ui.footerFactory = factory;
134
+ },
135
+ setEditorComponent(factory: unknown) {
136
+ ui.editorFactory = factory;
137
+ },
138
+ getEditorComponent() {
139
+ return ui.editorFactory;
140
+ },
141
+ setWidget(key: string, content: unknown) {
142
+ ui.widgetSets += 1;
143
+ if (content === undefined) ui.widgets.delete(key);
144
+ else ui.widgets.set(key, content);
145
+ },
146
+ notify(message: string) {
147
+ ui.notices.push(message);
148
+ },
149
+ setWorkingVisible(visible: boolean) {
150
+ ui.workingVisible = visible;
151
+ },
152
+ custom(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: null) => void) => { render(width: number): string[]; handleInput(data: string): void }) {
153
+ ui.overlay = factory;
154
+ return new Promise<null>((resolve) => {
155
+ ui.closeOverlay = () => resolve(null);
156
+ ui.overlayView = factory(fakeTui, plainTheme, fakeKeybindings, () => resolve(null));
157
+ resolveOverlay();
158
+ });
159
+ },
160
+ },
161
+ } as unknown as ExtensionContext;
162
+ return { ctx, ui, overlayReady };
163
+ }
164
+
165
+ function assistantEntry(usage: { input: number; output: number; cost: number }) {
166
+ return {
167
+ type: "message",
168
+ message: {
169
+ role: "assistant",
170
+ usage: { input: usage.input, output: usage.output, cacheRead: 0, cacheWrite: 0, cost: { total: usage.cost } },
171
+ },
172
+ };
173
+ }
174
+
175
+ test("buildShellBarModel reads session, model, and footer data", () => {
176
+ const { pi } = fakePi();
177
+ const { ctx } = fakeContext({
178
+ entries: [assistantEntry({ input: 1000, output: 200, cost: 0.5 }), assistantEntry({ input: 500, output: 100, cost: 0.25 }), { type: "message", message: { role: "user" } }],
179
+ });
180
+ const footerData = {
181
+ getGitBranch: () => "main",
182
+ getExtensionStatuses: () => new Map([["mcp", "MCP: 3 servers enabled"]]),
183
+ getAvailableProviderCount: () => 1,
184
+ onBranchChange: () => () => {},
185
+ };
186
+ const built = buildShellBarModel(pi, ctx, footerData, { home: "/home/alan" });
187
+ assert.equal(built.cwd, "/repo");
188
+ assert.equal(built.branch, "main");
189
+ assert.equal(built.sessionName, "Release notes");
190
+ assert.equal(built.modelId, "gpt-5.5");
191
+ assert.equal(built.effort, "medium");
192
+ assert.equal(built.contextPercent, 45);
193
+ assert.equal(built.costTotal, 0.75);
194
+ assert.equal(built.subscription, true);
195
+ assert.deepEqual(built.statuses, ["MCP: 3 servers enabled"]);
196
+ });
197
+
198
+ test("buildShellBarModel shortens the home directory and hides effort for non-reasoning models", () => {
199
+ const { pi } = fakePi();
200
+ const { ctx } = fakeContext();
201
+ (ctx as unknown as { model: { reasoning: boolean } }).model.reasoning = false;
202
+ (ctx.sessionManager as unknown as { getCwd: () => string }).getCwd = () => "/home/alan/work/gentle-pi";
203
+ const footerData = {
204
+ getGitBranch: () => null,
205
+ getExtensionStatuses: () => new Map(),
206
+ getAvailableProviderCount: () => 1,
207
+ onBranchChange: () => () => {},
208
+ };
209
+ const built = buildShellBarModel(pi, ctx, footerData, { home: "/home/alan" });
210
+ assert.equal(built.cwd, "~/work/gentle-pi");
211
+ assert.equal(built.effort, undefined);
212
+ assert.equal(built.branch, null);
213
+ });
214
+
215
+ test("gentleShell installs the footer on session_start when a UI exists", () => {
216
+ const { pi, handlers } = fakePi();
217
+ gentleShell(pi, {});
218
+ const { ctx, ui } = fakeContext();
219
+ for (const handler of handlers.get("session_start") ?? []) handler({}, ctx);
220
+ assert.equal(typeof ui.footerFactory, "function");
221
+
222
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[] };
223
+ const component = factory(
224
+ { requestRender() {} },
225
+ plainTheme,
226
+ { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} },
227
+ );
228
+ const lines = component.render(120);
229
+ assert.equal(lines.length, 1);
230
+ assert.match(lines[0], /main ⟡ gpt-5\.5 · medium/);
231
+ });
232
+
233
+ test("the fullscreen Status rail carries a live digest so a model switch refreshes it", async () => {
234
+ const { pi, handlers } = fakePi();
235
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
236
+ const entries: unknown[] = [];
237
+ const { ctx, ui } = fakeContext({ entries });
238
+ await fire(handlers, "session_start", ctx);
239
+
240
+ const statuses = new Map<string, string>();
241
+ const liveFooterData = { getGitBranch: () => "main", getExtensionStatuses: () => statuses, getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
242
+ const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
243
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void };
244
+ const component = factory(tui, plainTheme, liveFooterData);
245
+ try {
246
+ const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail;
247
+ const live = () => rail.digest?.();
248
+ assert.equal(typeof rail.digest, "function", "the Status card paints live state and must declare a digest");
249
+ assert.match(rail.render(46).join("\n"), /gpt-5\.5/);
250
+
251
+ const beforeModel = live();
252
+ (ctx.model as { id: string }).id = "gpt-5.6";
253
+ assert.notEqual(live(), beforeModel, "/model must change the digest");
254
+ assert.match(rail.render(46).join("\n"), /gpt-5\.6/);
255
+
256
+ const beforeUsage = live();
257
+ (ctx as unknown as { getContextUsage: () => unknown }).getContextUsage = () => ({ tokens: 200_000, contextWindow: 272_000, percent: 74 });
258
+ assert.notEqual(live(), beforeUsage, "context usage must change the digest");
259
+ assert.match(rail.render(46).join("\n"), /74%/);
260
+
261
+ const beforeCost = live();
262
+ entries.push(assistantEntry({ input: 100, output: 20, cost: 0.42 }));
263
+ assert.notEqual(live(), beforeCost, "session cost must change the digest");
264
+ assert.match(rail.render(46).join("\n"), /\$0\.420/);
265
+
266
+ const beforeStatus = live();
267
+ statuses.set("mcp", "MCP: 3 servers enabled");
268
+ assert.notEqual(live(), beforeStatus, "extension statuses have no event and must change the digest");
269
+ assert.match(rail.render(46).join("\n"), /MCP: 3 servers enabled/);
270
+ assert.equal(live(), live(), "an unchanged digest still reuses the prepared rail");
271
+ } finally {
272
+ component.dispose();
273
+ }
274
+ });
275
+
276
+ test("gentleShell stays out of the way without a UI or when disabled", () => {
277
+ const disabled = fakePi();
278
+ gentleShell(disabled.pi, { GENTLE_PI_SHELL: "0" });
279
+ assert.equal(disabled.handlers.size, 0);
280
+
281
+ const headless = fakePi();
282
+ gentleShell(headless.pi, {});
283
+ const { ctx, ui } = fakeContext({ hasUI: false });
284
+ for (const handler of headless.handlers.get("session_start") ?? []) handler({}, ctx);
285
+ assert.equal(ui.footerFactory, undefined);
286
+ });
287
+
288
+ const fakeTui = { terminal: { rows: 40, columns: 120 }, requestRender() {} };
289
+ const editorTheme = { borderColor: (text: string) => text, selectList: {} };
290
+ const fakeKeybindings = { matches: () => false };
291
+
292
+ function installedPrompt(ctx: ExtensionContext, ui: FakeUi, handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>): GentlePromptEditor {
293
+ for (const handler of handlers.get("session_start") ?? []) handler({}, ctx);
294
+ const factory = ui.editorFactory as (tui: unknown, theme: unknown, keybindings: unknown) => GentlePromptEditor;
295
+ return factory(fakeTui, editorTheme, fakeKeybindings);
296
+ }
297
+
298
+ test("gentleShell frames the editor with the petal prompt and a hint while empty", () => {
299
+ const { pi, handlers } = fakePi();
300
+ gentleShell(pi, {});
301
+ const { ctx, ui } = fakeContext();
302
+ const editor = installedPrompt(ctx, ui, handlers);
303
+ assert.equal(ui.workingVisible, false, "pi's own Working row must be hidden");
304
+ editor.focused = true;
305
+ const lines = editor.render(60).map(stripAnsi);
306
+ assert.match(lines[0], /^╭─ ✿ ─+╮$/);
307
+ assert.doesNotMatch(editor.render(60).join("\n"), /\x1b\[44m/, "prompt must not paint passive backgrounds");
308
+ assert.match(lines[1], /^│.*type, or \/ for commands +│$/);
309
+ assert.match(lines[lines.length - 1], /^╰─+╯$/);
310
+ editor.setText("hola");
311
+ assert.doesNotMatch(editor.render(60).map(stripAnsi)[1], /type, or/);
312
+ editor.dispose();
313
+ });
314
+
315
+ test("registered prompt stays transparent while idle, working, and queued", () => {
316
+ const { pi, handlers, tools } = fakePi();
317
+ gentleShell(pi, {});
318
+ const { ctx, ui } = fakeContext();
319
+ ctx.ui.theme = { ...plainTheme, getBgAnsi: () => "\x1b[44m" } as typeof ctx.ui.theme;
320
+ const editor = installedPrompt(ctx, ui, handlers);
321
+ try {
322
+ for (const state of ["idle", "working", "queued"]) {
323
+ if (state === "working") for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
324
+ (ctx as unknown as { hasPendingMessages(): boolean }).hasPendingMessages = () => state === "queued";
325
+ for (const width of [8, 40, 80]) assert.doesNotMatch(editor.render(width).join("\n"), /\x1b\[44m/, state);
326
+ }
327
+ } finally {
328
+ editor.dispose();
329
+ }
330
+ assert.equal(tools.get("session_worktree_register")?.renderShell, "self");
331
+ });
332
+
333
+ test("gentleShell shows working while the agent runs and queued when messages wait", () => {
334
+ const { pi, handlers } = fakePi();
335
+ gentleShell(pi, {});
336
+ const pending = { value: false };
337
+ const { ctx, ui } = fakeContext();
338
+ (ctx as unknown as { hasPendingMessages: () => boolean }).hasPendingMessages = () => pending.value;
339
+ const editor = installedPrompt(ctx, ui, handlers);
340
+
341
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
342
+ assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] working ─+╮$/);
343
+ pending.value = true;
344
+ assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] queued ─+╮$/);
345
+ for (const handler of handlers.get("agent_end") ?? []) handler({}, ctx);
346
+ pending.value = false;
347
+ assert.match(stripAnsi(editor.render(60)[0]), /^╭─ ✿ ─+╮$/);
348
+ editor.dispose();
349
+ });
350
+
351
+ test("gentleShell leaves an editor another extension already installed", () => {
352
+ const { pi, handlers } = fakePi();
353
+ gentleShell(pi, {});
354
+ const theirs = () => ({});
355
+ const { ctx, ui } = fakeContext({ editorFactory: theirs });
356
+ for (const handler of handlers.get("session_start") ?? []) handler({}, ctx);
357
+ assert.equal(ui.editorFactory, theirs);
358
+ });
359
+
360
+ const footerData = { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
361
+
362
+ function renderFooter(ui: FakeUi): string {
363
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[] };
364
+ return factory(fakeTui, plainTheme, footerData).render(160)[0];
365
+ }
366
+
367
+ test("gentleShell shows working-tree changes in a widget below the editor and in the bar", async () => {
368
+ const { pi, handlers, git } = fakePi([
369
+ { numstat: "", porcelain: "" },
370
+ { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
371
+ ]);
372
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
373
+ const { ctx, ui } = fakeContext();
374
+ await fire(handlers, "session_start", ctx);
375
+ assert.deepEqual(git[0].slice(0, 2), ["-C", "/repo"]);
376
+ assert.equal(ui.widgets.has("gentle-shell-changes"), false);
377
+ assert.doesNotMatch(renderFooter(ui), /±/);
378
+
379
+ await fire(handlers, "tool_execution_end", ctx);
380
+ const factory = ui.widgets.get("gentle-shell-changes") as (tui: unknown, theme: ShellBarTheme) => { render(width: number): string[] };
381
+ const [line] = factory(fakeTui, plainTheme).render(120);
382
+ assert.match(line, /^✎ 1 file · \+10 −0 · lib\/b\.ts +\/gentle:changes$/);
383
+ assert.match(renderFooter(ui), /main ±1/);
384
+ });
385
+
386
+ test("gentleShell registers /gentle:changes and opens the overlay only when there are changes", async () => {
387
+ const { pi, handlers, commands } = fakePi([
388
+ { numstat: "", porcelain: "" },
389
+ { numstat: "", porcelain: "" },
390
+ { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
391
+ ]);
392
+ gentleShell(pi, {});
393
+ const { ctx, ui } = fakeContext();
394
+ await fire(handlers, "session_start", ctx);
395
+ const command = commands.get("gentle:changes");
396
+ assert.ok(command, "command not registered");
397
+
398
+ await command.handler("", ctx);
399
+ assert.deepEqual(ui.notices, ["No changes in the working tree."]);
400
+ assert.equal(ui.overlay, undefined);
401
+
402
+ await fire(handlers, "tool_execution_end", ctx);
403
+ const opened = command.handler("", ctx);
404
+ await new Promise((resolve) => setTimeout(resolve, 0));
405
+ assert.equal(typeof ui.overlay, "function");
406
+ ui.closeOverlay?.();
407
+ await opened;
408
+ });
409
+
410
+ test("changes overlay follows registered linked roots on open, refresh and polling and scopes diff git cwd", async () => {
411
+ const { pi, handlers, commands, tools } = fakePi();
412
+ let roots = ["/repo"];
413
+ const diffs: string[][] = [];
414
+ pi.exec = (async (_command: string, args: string[]) => {
415
+ let stdout = "";
416
+ if (args.includes("worktree")) stdout = roots.map((root) => `worktree ${root}\0branch refs/heads/${root.slice(1)}\0\0`).join("");
417
+ else if (args.includes("status")) stdout = " M same.ts\0";
418
+ else if (args.includes("--numstat")) stdout = "1\t1\tsame.ts\n";
419
+ else { diffs.push(args); stdout = `+${args[1]}`; }
420
+ return { stdout, stderr: "", code: 0, killed: false };
421
+ }) as ExtensionAPI["exec"];
422
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off", GENTLE_PI_SHELL_CHANGES_POLL_MS: "5" });
423
+ const { ctx, ui } = fakeContext();
424
+ await fire(handlers, "session_start", ctx);
425
+ roots.push("/linked");
426
+ await tools.get("session_worktree_register")!.execute("register", { path: "/linked" }, undefined, undefined, ctx);
427
+ const open = commands.get("gentle:changes")!.handler("", ctx);
428
+ try {
429
+ await new Promise((resolve) => setTimeout(resolve, 20));
430
+ assert.match(ui.overlayView!.render(120).join("\n"), /linked · linked/);
431
+ assert.equal(diffs.length, 0);
432
+ ui.overlayView!.handleInput("j");
433
+ ui.overlayView!.handleInput("\r");
434
+ ui.overlayView!.handleInput("j");
435
+ await new Promise((resolve) => setTimeout(resolve, 0));
436
+ assert.deepEqual(diffs[0], ["-C", "/linked", "diff", "HEAD", "--", "same.ts"]);
437
+ ui.overlayView!.handleInput("\x1b[D");
438
+ roots.push("/new");
439
+ await tools.get("session_worktree_register")!.execute("register", { path: "/new" }, undefined, undefined, ctx);
440
+ ui.overlayView!.handleInput("r");
441
+ await new Promise((resolve) => setTimeout(resolve, 20));
442
+ assert.match(ui.overlayView!.render(120).join("\n"), /new · new/);
443
+ roots.push("/polled");
444
+ await tools.get("session_worktree_register")!.execute("register", { path: "/polled" }, undefined, undefined, ctx);
445
+ await new Promise((resolve) => setTimeout(resolve, 20));
446
+ assert.match(ui.overlayView!.render(120).join("\n"), /polled · polled/);
447
+ } finally {
448
+ ui.closeOverlay?.();
449
+ await open;
450
+ await fire(handlers, "session_shutdown", ctx);
451
+ }
452
+ });
453
+
454
+ test("successful direct tools register whole roots, failures and shell contents do not, and all UI counts agree", async () => {
455
+ const { pi, handlers, tools, commands } = fakePi();
456
+ const queried: string[] = [];
457
+ pi.exec = (async (_command: string, args: string[]) => {
458
+ const root = args[1];
459
+ if (args.includes("status")) queried.push(root);
460
+ const stdout = args.includes("worktree") ? ["/repo", "/used", "/failed", "/opaque", "/hidden"].map((path) => `worktree ${path}\0\0`).join("")
461
+ : args.includes("status") ? " M preexisting.ts\0?? new.ts\0"
462
+ : args.includes("--numstat") ? "2\t1\tpreexisting.ts\n" : "+diff";
463
+ return { stdout, stderr: "", code: 0, killed: false };
464
+ }) as ExtensionAPI["exec"];
465
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
466
+ const { ctx, ui, overlayReady } = fakeContext();
467
+ await fire(handlers, "session_start", ctx);
468
+ const emit = async (name: string, event: unknown) => {
469
+ for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
470
+ };
471
+ const complete = async (toolName: string, input: unknown, isError = false) => {
472
+ await emit("tool_execution_start", { toolCallId: "call", toolName, args: input });
473
+ await emit("tool_result", { toolCallId: "call", toolName, input, isError: false });
474
+ await emit("tool_execution_end", { toolCallId: "call", toolName, isError });
475
+ };
476
+ await complete("read", { path: "/failed" }, true);
477
+ await complete("bash", { command: "cd /opaque && edit stuff", path: "/opaque" });
478
+ assert.deepEqual(new Set(queried), new Set(["/repo"]));
479
+ await complete("read", { path: "/used" });
480
+ await complete("write", { path: "/used" });
481
+ assert.deepEqual(new Set(queried), new Set(["/repo", "/used"]));
482
+ assert.equal(ctx.sessionManager.getEntries().length, 2, "multiple completed tools dedupe");
483
+ assert.match(renderFooter(ui), /±4/);
484
+ const widget = ui.widgets.get("gentle-shell-changes") as (host: unknown, theme: unknown) => { render(width: number): string[] };
485
+ assert.match(widget(fakeTui, plainTheme).render(140)[0], /4 files/);
486
+ const open = commands.get("gentle:changes")!.handler("", ctx);
487
+ await overlayReady;
488
+ assert.doesNotMatch(ui.overlayView!.render(140).join("\n"), /hidden|opaque|failed/);
489
+ ui.closeOverlay?.();
490
+ await open;
491
+ await tools.get("session_worktree_register")!.execute("explicit", { path: "/opaque" }, undefined, undefined, ctx);
492
+ assert.match(renderFooter(ui), /±6/);
493
+ await fire(handlers, "session_shutdown", ctx);
494
+ });
495
+
496
+ test("session switching drops pending tool and foreign launch events while same-session entries restore", async () => {
497
+ const h = fakePi([{ numstat: "1\t0\ta.ts\n", porcelain: " M a.ts\0" }]);
498
+ gentleShell(h.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
499
+ const first = fakeContext();
500
+ await fire(h.handlers, "session_start", first.ctx);
501
+ for (const handler of h.handlers.get("tool_execution_start") ?? []) await handler({ toolCallId: "old", toolName: "read", args: { path: "/foreign" } }, first.ctx);
502
+ const next = fakeContext({ entries: [...first.ctx.sessionManager.getEntries()] });
503
+ (next.ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "new-session";
504
+ await fire(h.handlers, "session_start", next.ctx);
505
+ for (const handler of h.handlers.get("tool_result") ?? []) await handler({ toolCallId: "old", toolName: "read", input: { path: "/foreign" } }, next.ctx);
506
+ for (const handler of h.handlers.get("tool_execution_end") ?? []) await handler({ toolCallId: "old", toolName: "read", isError: false }, next.ctx);
507
+ const before = h.git.length;
508
+ h.pi.events.emit("gentle-pi:session-worktree-changed", { sessionId: "shell-session", root: "/foreign" });
509
+ assert.equal(h.git.length, before);
510
+ assert.match(renderFooter(next.ui), /±1/);
511
+ assert.ok(!h.git.some((args) => args[1] === "/foreign"));
512
+ await fire(h.handlers, "session_shutdown", next.ctx);
513
+ });
514
+
515
+ test("registered canonical root governs real Git discovery, status and diff despite inherited routing", async (t) => {
516
+ const fixture = realpathSync(mkdtempSync(join(tmpdir(), "shell-git-routing-")));
517
+ t.after(() => rmSync(fixture, { recursive: true, force: true }));
518
+ const selected = join(fixture, "selected");
519
+ const foreign = join(fixture, "foreign");
520
+ const empty = join(fixture, "empty");
521
+ mkdirSync(empty);
522
+ writeFileSync(join(empty, "config"), "");
523
+ const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
524
+ Object.assign(cleanEnv, { GIT_CONFIG_GLOBAL: join(empty, "config"), GIT_CONFIG_NOSYSTEM: "1" });
525
+ const git = (cwd: string, args: string[]) => execFileSync("git", ["-C", cwd, "-c", `core.hooksPath=${empty}`, "-c", "commit.gpgsign=false", ...args], { env: cleanEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
526
+ for (const root of [selected, foreign]) {
527
+ git(fixture, ["init", "--initial-branch=main", `--template=${empty}`, root]);
528
+ writeFileSync(join(root, "tracked.txt"), "before\n");
529
+ git(root, ["add", "tracked.txt"]);
530
+ git(root, ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "commit", "-m", "Fixture"]);
531
+ }
532
+ writeFileSync(join(selected, "tracked.txt"), "selected change\n");
533
+ writeFileSync(join(selected, "selected-only.txt"), "selected untracked\n");
534
+ writeFileSync(join(foreign, "tracked.txt"), "foreign change\n");
535
+ writeFileSync(join(foreign, "foreign-only.txt"), "foreign untracked\n");
536
+ const poisoned = { ...cleanEnv, GIT_DIR: join(foreign, ".git"), GIT_WORK_TREE: foreign, GIT_INDEX_FILE: join(foreign, ".git", "index") };
537
+ const h = fakePi();
538
+ // Pi exec has no env option and inherits routing. Model that boundary with
539
+ // a child-only env, without changing this test process's environment.
540
+ h.pi.exec = ((command: string, args: string[], options: { timeout?: number } = {}) => new Promise((resolve) => {
541
+ execFile(command, args, { env: poisoned, encoding: "utf8", timeout: options.timeout, maxBuffer: Infinity }, (error, stdout, stderr) => resolve({ stdout, stderr, code: error ? typeof error.code === "number" ? error.code : 1 : 0, killed: Boolean(error?.killed) }));
542
+ })) as ExtensionAPI["exec"];
543
+ const { ctx, ui, overlayReady } = fakeContext();
544
+ (ctx as unknown as { cwd: string }).cwd = selected;
545
+ (ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => selected;
546
+ const run = shellGitRunner(selected, poisoned);
547
+ const discovery = await run(["worktree", "list", "--porcelain", "-z"]);
548
+ assert.match(discovery.stdout, new RegExp(`worktree ${selected}`));
549
+ assert.ok(!discovery.stdout.includes(foreign));
550
+ installGentleShell(h.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { devBinary: () => undefined, gitRunner: (cwd) => shellGitRunner(cwd, poisoned) });
551
+ await fire(h.handlers, "session_start", ctx);
552
+ t.after(() => fire(h.handlers, "session_shutdown", ctx));
553
+ const widget = ui.widgets.get("gentle-shell-changes") as (host: unknown, theme: unknown) => { render(width: number): string[] };
554
+ const rendered = widget(fakeTui, plainTheme).render(300).join("\n");
555
+ assert.match(rendered, /selected-only\.txt/);
556
+ assert.doesNotMatch(rendered, /foreign-only\.txt/);
557
+ const diff = await loadFileDiff(run, { path: "tracked.txt", added: 1, deleted: 1, status: CHANGE_STATUS.MODIFIED });
558
+ assert.match(diff, /\+selected change/);
559
+ assert.doesNotMatch(diff, /foreign change/);
560
+ const untracked = await loadFileDiff(run, { path: "selected-only.txt", added: 1, deleted: 0, status: CHANGE_STATUS.UNTRACKED });
561
+ assert.match(untracked, /\+selected untracked/);
562
+ assert.equal((await run(["rev-parse", "--verify", "missing-ref"])).code, 128, "Git failure codes stay intact");
563
+ assert.equal(poisoned.GIT_DIR, join(foreign, ".git"), "caller environment is not mutated");
564
+ assert.equal((await shellGitRunner(selected, { PATH: empty })(["status"])).code, 1, "spawn errors remain failed results rather than uncaught exceptions");
565
+ writeFileSync(join(selected, "tracked.txt"), "selected large line\n".repeat(70_000) + "selected final marker\n");
566
+ const largeDiff = await run(["diff", "HEAD", "--", "tracked.txt"]);
567
+ assert.equal(largeDiff.code, 0);
568
+ assert.ok(largeDiff.stdout.length > 1024 * 1024, "output must not inherit execFile's default one MiB cap");
569
+ assert.match(largeDiff.stdout, /\+selected final marker/);
570
+ const open = h.commands.get("gentle:changes")!.handler("", ctx);
571
+ await overlayReady;
572
+ ui.overlayView!.handleInput("\r");
573
+ ui.overlayView!.handleInput("j");
574
+ ui.closeOverlay?.();
575
+ await open;
576
+ });
577
+
578
+ test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked files", async () => {
579
+ const calls: string[][] = [];
580
+ const git = async (args: string[]) => {
581
+ calls.push(args);
582
+ return { stdout: "@@ -0,0 +1 @@\n+hello", code: args.includes("--no-index") ? 1 : 0 };
583
+ };
584
+ assert.match(await loadFileDiff(git, { path: "lib/a.ts", added: 1, deleted: 0, status: CHANGE_STATUS.MODIFIED }), /\+hello/);
585
+ assert.match(await loadFileDiff(git, { path: "notes.md", added: 0, deleted: 0, status: CHANGE_STATUS.UNTRACKED }), /\+hello/);
586
+ assert.deepEqual(calls, [
587
+ ["diff", "HEAD", "--", "lib/a.ts"],
588
+ ["diff", "--no-index", "--", "/dev/null", "notes.md"],
589
+ ]);
590
+ });
591
+
592
+ test("shell Git runner hides initial and repeated background polling children", async () => {
593
+ const calls: Array<{ command: string; args: readonly string[]; options: Record<string, unknown> }> = [];
594
+ const run = ((command: string, args: readonly string[], options: Record<string, unknown>, callback: (error: Error | null, stdout: string) => void) => {
595
+ calls.push({ command, args, options });
596
+ callback(null, "", "");
597
+ }) as typeof import("node:child_process").execFile;
598
+ const git = shellGitRunner("/repo with spaces & metacharacters", { PATH: process.env.PATH }, run);
599
+ await git(["status", "--porcelain=v1", "-z"]);
600
+ await git(["status", "--porcelain=v1", "-z"]);
601
+ assert.equal(calls.length, 2, "the same safe runner serves startup and repeated polling");
602
+ for (const call of calls) {
603
+ assert.equal(call.command, "git");
604
+ assert.deepEqual(call.args, ["-C", "/repo with spaces & metacharacters", "status", "--porcelain=v1", "-z"]);
605
+ assert.equal(call.options.shell, false);
606
+ assert.equal(call.options.windowsHide, true);
607
+ }
608
+ });
609
+
610
+ test("openInExternalEditor stops the TUI around the editor and honors $VISUAL over $EDITOR", () => {
611
+ const events: string[] = [];
612
+ const host = { stop: () => events.push("stop"), start: () => events.push("start"), requestRender: (force?: boolean) => events.push(`render:${force}`) };
613
+ const spawn = ((command: string, args: string[]) => {
614
+ events.push(`spawn:${command} ${args.join(" ")}`);
615
+ return { status: 0 } as ReturnType<typeof import("node:child_process").spawnSync>;
616
+ }) as typeof import("node:child_process").spawnSync;
617
+ assert.equal(openInExternalEditor(host, "lib/a.ts", { VISUAL: "nvim -u none", EDITOR: "vi" }, spawn), true);
618
+ assert.deepEqual(events, ["stop", "spawn:nvim -u none lib/a.ts", "start", "render:true"]);
619
+ assert.equal(openInExternalEditor(host, "lib/a.ts", {}, spawn), false);
620
+ });
621
+
622
+ test("external editor receives the selected worktree as process cwd", () => {
623
+ let cwd: string | undefined;
624
+ const spawn = ((_command: string, _args: string[], options: { cwd?: string }) => {
625
+ cwd = options.cwd;
626
+ return { status: 0 };
627
+ }) as typeof import("node:child_process").spawnSync;
628
+ openInExternalEditor({ stop() {}, start() {}, requestRender() {} }, "same.ts", { EDITOR: "vi" }, spawn, "/linked");
629
+ assert.equal(cwd, "/linked");
630
+ });
631
+
632
+ test("changesShortcut defaults to alt+g and can be overridden or disabled", () => {
633
+ assert.equal(changesShortcut({}), "alt+g");
634
+ assert.equal(changesShortcut({ GENTLE_PI_SHELL_CHANGES_KEY: "ctrl+shift+g" }), "ctrl+shift+g");
635
+ assert.equal(changesShortcut({ GENTLE_PI_SHELL_CHANGES_KEY: "off" }), undefined);
636
+ assert.equal(changesShortcut({ GENTLE_PI_SHELL_CHANGES_KEY: "" }), undefined);
637
+ });
638
+
639
+ test("gentleShell binds the changes shortcut to the same handler as the command", async () => {
640
+ const { pi, handlers, shortcuts } = fakePi();
641
+ gentleShell(pi, {});
642
+ const { ctx, ui } = fakeContext();
643
+ await fire(handlers, "session_start", ctx);
644
+ const shortcut = shortcuts.get("alt+g");
645
+ assert.ok(shortcut, "alt+g not registered");
646
+ await shortcut.handler(ctx);
647
+ assert.deepEqual(ui.notices, ["No changes in the working tree."]);
648
+
649
+ const silent = fakePi();
650
+ gentleShell(silent.pi, { GENTLE_PI_SHELL_CHANGES_KEY: "off" });
651
+ assert.equal(silent.shortcuts.size, 0);
652
+ });
653
+
654
+ test("gentleShell keeps the open overlay in sync with git while it stays open", async () => {
655
+ const { pi, handlers, commands } = fakePi([
656
+ { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
657
+ { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
658
+ { numstat: "10\t0\tlib/b.ts\n3\t1\tlib/c.ts\n", porcelain: "A lib/b.ts\0 M lib/c.ts\0" },
659
+ ]);
660
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_POLL_MS: "5" });
661
+ const { ctx, ui } = fakeContext();
662
+ await fire(handlers, "session_start", ctx);
663
+ const open = commands.get("gentle:changes")!.handler("", ctx);
664
+ await new Promise((resolve) => setTimeout(resolve, 40));
665
+ ui.overlayView!.handleInput("\r");
666
+ const plain = ui.overlayView!.render(100).map(stripAnsi);
667
+ assert.match(plain[2], /2 files · \+13 −1/);
668
+ assert.match(plain[3], /lib\/c\.ts/);
669
+ assert.match(renderFooter(ui), /main ±2/);
670
+ ui.closeOverlay?.();
671
+ await open;
672
+ });
673
+
674
+ test("gentleShell watches git in the background so the widget and bar follow external edits", async () => {
675
+ const { pi, handlers, git } = fakePi([
676
+ { numstat: "", porcelain: "" },
677
+ { numstat: "", porcelain: "" },
678
+ { numstat: "3\t1\tlib/c.ts\n", porcelain: " M lib/c.ts\0" },
679
+ ]);
680
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "5" });
681
+ const { ctx, ui } = fakeContext();
682
+ await fire(handlers, "session_start", ctx);
683
+ assert.equal(ui.widgets.has("gentle-shell-changes"), false);
684
+ await new Promise((resolve) => setTimeout(resolve, 40));
685
+ assert.equal(ui.widgets.has("gentle-shell-changes"), true);
686
+ assert.match(renderFooter(ui), /main ±1/);
687
+ assert.ok(git.length >= 6, "background watch should keep polling git");
688
+
689
+ const widgetSetsBefore = ui.widgetSets;
690
+ await new Promise((resolve) => setTimeout(resolve, 20));
691
+ assert.equal(ui.widgetSets, widgetSetsBefore, "unchanged tree must not rewrite the widget");
692
+ await fire(handlers, "session_shutdown", ctx);
693
+ const gitCallsAfterShutdown = git.length;
694
+ await new Promise((resolve) => setTimeout(resolve, 20));
695
+ assert.equal(git.length, gitCallsAfterShutdown, "shutdown must stop the watch");
696
+ });
697
+
698
+ const JWT = `h.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } })).toString("base64url")}.s`;
699
+ const USAGE_PAYLOAD = { plan_type: "pro", rate_limit: { primary_window: { used_percent: 40, limit_window_seconds: 604_800, reset_at: 1_788_777_491 } } };
700
+
701
+ function fakeFetch(payload: unknown = USAGE_PAYLOAD, ok = true) {
702
+ const calls: Array<{ url: string; headers: Record<string, string> }> = [];
703
+ const fetchFn = (async (url: string | URL, init?: RequestInit) => {
704
+ calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record<string, string> });
705
+ return { ok, json: async () => payload } as Response;
706
+ }) as typeof fetch;
707
+ return { fetchFn, calls };
708
+ }
709
+
710
+ test("fetchCodexUsage sends the token and account id and parses the payload", async () => {
711
+ const { fetchFn, calls } = fakeFetch();
712
+ const usage = await fetchCodexUsage(JWT, fetchFn, 1_788_600_000_000);
713
+ assert.equal(usage?.plan, "pro");
714
+ assert.equal(calls[0].url, "https://chatgpt.com/backend-api/wham/usage");
715
+ assert.equal(calls[0].headers.Authorization, `Bearer ${JWT}`);
716
+ assert.equal(calls[0].headers["chatgpt-account-id"], "acct-1");
717
+
718
+ const plain = fakeFetch();
719
+ assert.equal(await fetchCodexUsage("sk-plain-api-key", plain.fetchFn, 0), undefined);
720
+ assert.equal(plain.calls.length, 0, "a non-OAuth key must not be sent anywhere");
721
+ assert.equal(await fetchCodexUsage(JWT, fakeFetch({}, false).fetchFn, 0), undefined);
722
+ assert.equal(await fetchCodexUsage(undefined, plain.fetchFn, 0), undefined);
723
+ });
724
+
725
+ test("gentleShell fetches Codex usage on session start and shows it in the bar", async () => {
726
+ const { pi, handlers } = fakePi();
727
+ const { fetchFn, calls } = fakeFetch();
728
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fetchFn, now: () => 1_788_600_000_000 });
729
+ const { ctx, ui } = fakeContext({ token: JWT });
730
+ await fire(handlers, "session_start", ctx);
731
+ await new Promise((resolve) => setTimeout(resolve, 0));
732
+ assert.equal(calls.length, 1);
733
+ assert.match(renderFooter(ui), /\$0\.000 sub ⟡ codex week ▰▰▰▱▱▱▱▱ 40%/);
734
+
735
+ await fire(handlers, "agent_end", ctx);
736
+ await new Promise((resolve) => setTimeout(resolve, 0));
737
+ assert.equal(calls.length, 1, "agent_end must not refetch within the refresh window");
738
+ });
739
+
740
+ test("gentleShell records SSE rate-limit headers from provider responses", async () => {
741
+ const { pi, handlers } = fakePi();
742
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch({}, false).fetchFn, now: () => 0 });
743
+ const { ctx, ui } = fakeContext();
744
+ await fire(handlers, "session_start", ctx);
745
+ for (const handler of handlers.get("after_provider_response") ?? []) {
746
+ handler({ status: 200, headers: { "x-codex-primary-used-percent": "62", "x-codex-primary-window-minutes": "300", "x-codex-secondary-used-percent": "31", "x-codex-secondary-window-minutes": "10080" } }, ctx);
747
+ }
748
+ assert.match(renderFooter(ui), /codex 5h ▰▰▰▰▰▱▱▱ 62% · week 31%/);
749
+
750
+ (ctx as unknown as { model: { provider: string } }).model.provider = "anthropic";
751
+ for (const handler of handlers.get("after_provider_response") ?? []) {
752
+ handler({ status: 200, headers: { "anthropic-ratelimit-unified-5h-utilization": "0.25", "anthropic-ratelimit-unified-7d-utilization": "0.9" } }, ctx);
753
+ }
754
+ assert.match(renderFooter(ui), /claude 5h ▰▰▱▱▱▱▱▱ 25% · week 90%/);
755
+ assert.doesNotMatch(renderFooter(ui), /codex/);
756
+ });
757
+
758
+ test("gentleShell registers /gentle:usage and opens the subscriptions overlay", async () => {
759
+ const { pi, handlers, commands } = fakePi();
760
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch().fetchFn, now: () => 1_788_600_000_000 });
761
+ const { ctx, ui } = fakeContext({ token: JWT });
762
+ await fire(handlers, "session_start", ctx);
763
+ const opened = commands.get("gentle:usage")!.handler("", ctx);
764
+ await new Promise((resolve) => setTimeout(resolve, 0));
765
+ const plain = ui.overlayView!.render(90).map(stripAnsi);
766
+ assert.match(plain[0], /Subscriptions/);
767
+ assert.match(plain[1], /✿ openai-codex · pro/);
768
+ ui.closeOverlay?.();
769
+ await opened;
770
+ });
771
+
772
+ test("gentleShell draws the review preflight message as a Gentle card", () => {
773
+ const { pi } = fakePi();
774
+ gentleShell(pi, {});
775
+ const renderer = renderers.get("gentle-pi.review-preflight");
776
+ assert.ok(renderer, "renderer not registered");
777
+ const message = { customType: "gentle-pi.review-preflight", content: "Receipt-driven development is enabled.\n\nCall the gentle_review tool." };
778
+ const sentinelTheme = { ...plainTheme, bg: (_role: string, text: string) => `\x1b[44m${text}\x1b[49m` };
779
+ for (const expanded of [true, false]) {
780
+ assert.doesNotMatch(renderer(message, { expanded }, sentinelTheme).render(80).join("\n"), /\x1b\[44m/);
781
+ }
782
+ const expanded = renderer(message, { expanded: true }, plainTheme).render(80).map(stripAnsi);
783
+ assert.match(expanded[0], /^╭─ ✿ Gentle AI · review preflight ─+ .*collapse ╮$/);
784
+ assert.match(expanded[1], /^│ Receipt-driven development is enabled\. +│$/);
785
+ assert.ok(expanded.some((line) => line.includes("gentle_review")));
786
+ const collapsed = renderer({ ...message, content: [{ type: "text", text: message.content }] }, { expanded: false }, plainTheme).render(80).map(stripAnsi);
787
+ assert.equal(collapsed.length, 3);
788
+ });
789
+
790
+ test("gentleShell keeps a dev-binary override visible above the editor for the whole session", async () => {
791
+ const { pi, handlers } = fakePi();
792
+ const deps = { fetch: fakeFetch({}, false).fetchFn, now: () => 0, devBinary: () => ({ state: "active" as const, path: "/Users/me/go/bin/gentle-ai", sha256: "6e53bfc6305a3949deadbeef" }) };
793
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, deps);
794
+ const { ctx, ui } = fakeContext();
795
+ await fire(handlers, "session_start", ctx);
796
+ const factory = ui.widgets.get("gentle-shell-dev-binary") as (tui: unknown, theme: unknown) => { render(width: number): string[] };
797
+ assert.ok(factory, "dev binary widget missing");
798
+ const lines = factory(fakeTui, plainTheme).render(100).map(stripAnsi);
799
+ assert.match(lines[0], /^╭─ ✿ Gentle AI · dev binary override · field-test only ─+╮$/);
800
+ assert.match(lines[1], /^│ \/Users\/me\/go\/bin\/gentle-ai · sha256:6e53bfc6305a3949 +│$/);
801
+ assert.match(lines[2], /^╰─+╯$/);
802
+ assert.equal(lines[3], "", "a blank line keeps the card off the prompt frame");
803
+ const painted = factory(fakeTui, { ...plainTheme, bg: (_role: string, text: string) => `\x1b[44m${text}\x1b[49m` }).render(100);
804
+ assert.equal(painted[0], lines[0], "top frame cells have no background");
805
+ assert.equal(painted[1], lines[1], "body interior remains transparent");
806
+ assert.equal(painted[2], lines[2], "bottom frame cells have no background");
807
+ assert.equal(painted[3], "", "external spacer has no background");
808
+ await fire(handlers, "agent_start", ctx);
809
+ assert.equal(ui.widgets.has("gentle-shell-dev-binary"), false, "the startup notice leaves with the first prompt");
810
+
811
+ const clean = fakePi();
812
+ gentleShell(clean.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { ...deps, devBinary: () => undefined });
813
+ const fresh = fakeContext();
814
+ await fire(clean.handlers, "session_start", fresh.ctx);
815
+ assert.equal(fresh.ui.widgets.has("gentle-shell-dev-binary"), false);
816
+
817
+ assert.equal(devBinaryCard({ state: "invalid", reason: "binary missing" }).tone, "error");
818
+ });