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,650 @@
1
+ import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
3
+ import { execFile, spawnSync } from "node:child_process";
4
+ import { readFile } from "node:fs/promises";
5
+ import * as os from "node:os";
6
+ import { join } from "node:path";
7
+ import { renderShellBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
8
+ import { CHANGE_STATUS, WorktreeChangesTracker, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type LineCounter, type WorktreeChanges } from "../lib/shell-changes.ts";
9
+ import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
10
+ import { SessionWorktreeRegistry, SESSION_WORKTREE_CHANGED, resolveSessionWorktree, toolWorktreePath, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
11
+ import { CARD_TONE, renderCard, type Card, type CardTheme } from "../lib/shell-card.ts";
12
+ import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
13
+ import { framePromptLines, PROMPT_HINT, PROMPT_STATE, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
14
+ import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, parseCodexUsage, parseUsageHeaders, UsageStore, type ProviderUsage } from "../lib/shell-usage.ts";
15
+ import { UsageView } from "../lib/shell-usage-view.ts";
16
+ import { sidebarPart } from "../lib/shell-sidebar.ts";
17
+ import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
18
+
19
+ // Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the
20
+ // status bar, the petal prompt, the working-tree changes widget and overlay,
21
+ // the subscription usage view, and the cards Gentle notices are drawn with.
22
+
23
+ export interface ShellFooterData {
24
+ getGitBranch(): string | null;
25
+ getExtensionStatuses(): ReadonlyMap<string, string>;
26
+ getAvailableProviderCount(): number;
27
+ onBranchChange(callback: () => void): () => void;
28
+ }
29
+
30
+ interface ShellRenderHost {
31
+ requestRender(): void;
32
+ invalidateSidebar?(): void;
33
+ }
34
+
35
+ interface ShellBarComponent {
36
+ render(width: number): string[];
37
+ invalidate(): void;
38
+ dispose(): void;
39
+ }
40
+
41
+ interface BuildOptions {
42
+ home?: string;
43
+ dirty?: number;
44
+ usage?: ProviderUsage;
45
+ }
46
+
47
+ export type DevBinaryNotice = { state: "active"; path: string; sha256: string } | { state: "invalid"; reason: string };
48
+
49
+ export interface ShellDeps {
50
+ fetch: typeof fetch;
51
+ now(): number;
52
+ devBinary(): DevBinaryNotice | undefined;
53
+ resolveWorktree: WorktreeResolver;
54
+ gitRunner(cwd: string): GitRunner;
55
+ }
56
+
57
+ function ambientDevBinary(): DevBinaryNotice | undefined {
58
+ try {
59
+ const override = resolveGentleAiDevBinaryOverride();
60
+ return override ? { state: "active", path: override.path, sha256: override.sha256 } : undefined;
61
+ } catch (error) {
62
+ if (error instanceof GentleAiDevBinaryOverrideError) return { state: "invalid", reason: error.message };
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ const defaultShellDeps: ShellDeps = { fetch: (...args) => globalThis.fetch(...args), now: () => Date.now(), devBinary: ambientDevBinary, resolveWorktree: resolveSessionWorktree, gitRunner: shellGitRunner };
68
+
69
+ interface AssistantUsageEntry {
70
+ type: string;
71
+ message?: {
72
+ role?: string;
73
+ usage?: {
74
+ cost?: { total?: number };
75
+ };
76
+ };
77
+ }
78
+
79
+ function shortenHome(cwd: string, home: string | undefined): string {
80
+ if (home && cwd.startsWith(home)) return `~${cwd.slice(home.length)}`;
81
+ return cwd;
82
+ }
83
+
84
+ function sessionCost(ctx: ExtensionContext): number {
85
+ let total = 0;
86
+ for (const entry of ctx.sessionManager.getEntries() as AssistantUsageEntry[]) {
87
+ if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
88
+ total += entry.message.usage?.cost?.total ?? 0;
89
+ }
90
+ return total;
91
+ }
92
+
93
+ export function buildShellBarModel(
94
+ pi: ExtensionAPI,
95
+ ctx: ExtensionContext,
96
+ footerData: ShellFooterData,
97
+ options: BuildOptions = {},
98
+ ): ShellBarModel {
99
+ const home = options.home ?? os.homedir();
100
+ const usage = ctx.getContextUsage();
101
+ const model = ctx.model;
102
+ const statuses = Array.from(footerData.getExtensionStatuses().entries())
103
+ .sort(([a], [b]) => a.localeCompare(b))
104
+ .map(([, text]) => text);
105
+ return {
106
+ cwd: shortenHome(ctx.sessionManager.getCwd(), home),
107
+ branch: footerData.getGitBranch(),
108
+ dirty: options.dirty,
109
+ sessionName: ctx.sessionManager.getSessionName(),
110
+ modelId: model?.id ?? "no-model",
111
+ effort: model?.reasoning ? pi.getThinkingLevel() : undefined,
112
+ contextPercent: usage?.percent ?? null,
113
+ contextWindow: usage?.contextWindow ?? model?.contextWindow ?? 0,
114
+ costTotal: sessionCost(ctx),
115
+ subscription: model ? ctx.modelRegistry.isUsingOAuth(model) : false,
116
+ usage: options.usage,
117
+ statuses,
118
+ };
119
+ }
120
+
121
+ export function createShellBarComponent(
122
+ pi: ExtensionAPI,
123
+ ctx: ExtensionContext,
124
+ host: ShellRenderHost,
125
+ theme: ShellBarTheme,
126
+ footerData: ShellFooterData,
127
+ dirty: () => number | undefined = () => undefined,
128
+ usage: () => ProviderUsage | undefined = () => undefined,
129
+ ): ShellBarComponent {
130
+ const unsubscribe = footerData.onBranchChange(() => {
131
+ host.invalidateSidebar?.();
132
+ host.requestRender();
133
+ });
134
+ return {
135
+ render(width: number) {
136
+ return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width);
137
+ },
138
+ invalidate() {},
139
+ dispose() {
140
+ unsubscribe();
141
+ },
142
+ };
143
+ }
144
+
145
+ interface PromptEditorDeps {
146
+ fg: (color: string, text: string) => string;
147
+ bold: (text: string) => string;
148
+ requestRender(): void;
149
+ pending(): boolean;
150
+ }
151
+
152
+ const PROMPT_FRAME_ROLE = "border";
153
+
154
+ const PETAL_PULSE_MS = 160;
155
+
156
+ export class GentlePromptEditor extends CustomEditor {
157
+ private promptState: PromptState = PROMPT_STATE.IDLE;
158
+ private tick = 0;
159
+ private pulse: NodeJS.Timeout | undefined;
160
+ private readonly deps: PromptEditorDeps;
161
+
162
+ constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
163
+ super(tui, theme, keybindings);
164
+ this.deps = deps;
165
+ }
166
+
167
+ setWorking(working: boolean): void {
168
+ this.promptState = working ? PROMPT_STATE.WORKING : PROMPT_STATE.IDLE;
169
+ this.stopPulse();
170
+ if (working) {
171
+ this.pulse = setInterval(() => {
172
+ this.tick += 1;
173
+ this.deps.requestRender();
174
+ }, PETAL_PULSE_MS);
175
+ this.pulse.unref();
176
+ }
177
+ this.deps.requestRender();
178
+ }
179
+
180
+ render(width: number): string[] {
181
+ const lines = super.render(Math.max(1, width - 2));
182
+ if (this.getText() === "" && lines.length === 3) lines[1] = withPromptHint(lines[1], PROMPT_HINT, this.deps.fg);
183
+ const state = this.promptState === PROMPT_STATE.WORKING && this.deps.pending() ? PROMPT_STATE.QUEUED : this.promptState;
184
+ // The frame keeps the theme's border color rather than pi's thinking-level
185
+ // color, so the prompt reads as one panel with the cards around it.
186
+ return framePromptLines(lines, width, {
187
+ state,
188
+ tick: this.tick,
189
+ borderColor: (text) => this.deps.fg(PROMPT_FRAME_ROLE, text),
190
+ fg: this.deps.fg,
191
+ bold: this.deps.bold,
192
+ });
193
+ }
194
+
195
+ dispose(): void {
196
+ this.stopPulse();
197
+ }
198
+
199
+ private stopPulse(): void {
200
+ if (this.pulse) clearInterval(this.pulse);
201
+ this.pulse = undefined;
202
+ this.tick = 0;
203
+ }
204
+ }
205
+
206
+ function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEditor) => void): void {
207
+ if (ctx.ui.getEditorComponent()) return;
208
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
209
+ const prompt = new GentlePromptEditor(tui, theme, keybindings, {
210
+ fg: (color, text) => ctx.ui.theme.fg(color as Parameters<typeof ctx.ui.theme.fg>[0], text),
211
+ bold: (text) => ctx.ui.theme.bold(text),
212
+ requestRender: () => tui.requestRender(),
213
+ pending: () => ctx.hasPendingMessages(),
214
+ });
215
+ onCreated(prompt);
216
+ return prompt;
217
+ });
218
+ }
219
+
220
+ const CHANGES_WIDGET_KEY = "gentle-shell-changes";
221
+ const CHANGES_COMMAND_NAME = "gentle:changes";
222
+ const CHANGES_SHORTCUT_DEFAULT = "alt+g";
223
+ const CHANGES_POLL_DEFAULT_MS = 2000;
224
+ const CHANGES_WATCH_DEFAULT_MS = 5000;
225
+ const GIT_TIMEOUT_MS = 5000;
226
+ const OVERLAY_HEIGHT_RATIO = 0.8;
227
+ const OVERLAY_MIN_ROWS = 8;
228
+
229
+ export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env, run: typeof execFile = execFile): GitRunner {
230
+ // Pi exec cannot replace the inherited environment. Use argv directly and
231
+ // a complete sanitized environment for discovery, status, and lazy diffs.
232
+ const childEnv = worktreeGitEnvironment(env);
233
+ return (args) => new Promise((resolve) => {
234
+ run("git", ["-C", cwd, ...args], {
235
+ env: childEnv,
236
+ encoding: "utf8",
237
+ shell: false,
238
+ windowsHide: true,
239
+ timeout: GIT_TIMEOUT_MS,
240
+ // Pi exec accumulates output without a maxBuffer cap. In particular,
241
+ // large porcelain inventories must not become partial successful scans.
242
+ maxBuffer: Infinity,
243
+ }, (error, stdout) => {
244
+ resolve({ stdout, code: error ? typeof error.code === "number" ? error.code : 1 : 0 });
245
+ });
246
+ });
247
+ }
248
+
249
+ function lineCounter(cwd: string): LineCounter {
250
+ return async (path: string) => {
251
+ const text = await readFile(join(cwd, path), "utf8");
252
+ if (text.length === 0) return 0;
253
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
254
+ };
255
+ }
256
+
257
+ export async function loadFileDiff(git: GitRunner, file: ChangedFile): Promise<string> {
258
+ const args = file.status === CHANGE_STATUS.UNTRACKED ? ["diff", "--no-index", "--", "/dev/null", file.path] : ["diff", "HEAD", "--", file.path];
259
+ const result = await git(args);
260
+ return result.code === 0 || result.code === 1 ? result.stdout : "";
261
+ }
262
+
263
+ export interface ExternalEditorHost {
264
+ stop(): void;
265
+ start(): void;
266
+ requestRender(force?: boolean): void;
267
+ }
268
+
269
+ export function openInExternalEditor(host: ExternalEditorHost, path: string, env: NodeJS.ProcessEnv = process.env, spawn: typeof spawnSync = spawnSync, cwd?: string): boolean {
270
+ const command = env.VISUAL || env.EDITOR;
271
+ if (!command) return false;
272
+ const [editor, ...editorArgs] = command.split(" ");
273
+ host.stop();
274
+ try {
275
+ spawn(editor, [...editorArgs, path], { cwd, stdio: "inherit", shell: process.platform === "win32" });
276
+ } finally {
277
+ host.start();
278
+ host.requestRender(true);
279
+ }
280
+ return true;
281
+ }
282
+
283
+ export function changesShortcut(env: NodeJS.ProcessEnv = process.env): string | undefined {
284
+ const value = env.GENTLE_PI_SHELL_CHANGES_KEY?.trim();
285
+ if (value === undefined) return CHANGES_SHORTCUT_DEFAULT;
286
+ return value === "" || value.toLowerCase() === "off" ? undefined : value;
287
+ }
288
+
289
+ function positiveMs(value: string | undefined, fallback: number): number {
290
+ const parsed = Number.parseInt(value ?? "", 10);
291
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
292
+ }
293
+
294
+ function changesPollMs(env: NodeJS.ProcessEnv): number {
295
+ return positiveMs(env.GENTLE_PI_SHELL_CHANGES_POLL_MS, CHANGES_POLL_DEFAULT_MS);
296
+ }
297
+
298
+ // Background watch: the widget and the bar follow edits made outside pi.
299
+ // 0 or "off" disables it; tool events still refresh.
300
+ function changesWatchMs(env: NodeJS.ProcessEnv): number | undefined {
301
+ const value = env.GENTLE_PI_SHELL_CHANGES_WATCH_MS?.trim().toLowerCase();
302
+ if (value === "0" || value === "off") return undefined;
303
+ return positiveMs(value, CHANGES_WATCH_DEFAULT_MS);
304
+ }
305
+
306
+ function changesFingerprint(model: ChangesModel): string {
307
+ return model.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}`).join("|");
308
+ }
309
+
310
+ interface OverlayDeps {
311
+ git(root: string): GitRunner;
312
+ worktrees(): WorktreeChanges[];
313
+ refresh(): Promise<ChangesModel>;
314
+ apply(ctx: ExtensionContext, model: ChangesModel): void;
315
+ pollMs: number;
316
+ }
317
+
318
+ // While the overlay is open, git is polled so edits made outside pi (nvim,
319
+ // another agent, a git checkout) show up without reopening it.
320
+ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
321
+ let host: ExternalEditorHost | undefined;
322
+ let view: WorktreeChangesView | undefined;
323
+ const refresh = async () => {
324
+ const latest = await deps.refresh();
325
+ view?.update(deps.worktrees());
326
+ deps.apply(ctx, latest);
327
+ };
328
+ const poll = setInterval(() => void refresh(), deps.pollMs);
329
+ poll.unref();
330
+ try {
331
+ const chosen = await ctx.ui.custom<{ root: string; file: ChangedFile } | null>(
332
+ (tui, theme, _keybindings, done) => {
333
+ host = tui;
334
+ view = new WorktreeChangesView(deps.worktrees(), {
335
+ theme,
336
+ rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
337
+ loadDiff: (root, file) => loadFileDiff(deps.git(root), file),
338
+ onOpen: (root, file) => done({ root, file }),
339
+ onRefresh: () => void refresh(),
340
+ onClose: () => done(null),
341
+ requestRender: () => tui.requestRender(),
342
+ });
343
+ return view;
344
+ },
345
+ { overlay: true, overlayOptions: { width: "92%", anchor: "center" } },
346
+ );
347
+ if (!chosen || !host) return;
348
+ if (!openInExternalEditor(host, chosen.file.path, process.env, spawnSync, chosen.root)) ctx.ui.notify("No editor configured. Set $VISUAL or $EDITOR.", "warning");
349
+ } finally {
350
+ clearInterval(poll);
351
+ view?.dispose();
352
+ view = undefined;
353
+ }
354
+ }
355
+
356
+ function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
357
+ if (model.files.length === 0) {
358
+ ctx.ui.setWidget(CHANGES_WIDGET_KEY, undefined);
359
+ return;
360
+ }
361
+ ctx.ui.setWidget(
362
+ CHANGES_WIDGET_KEY,
363
+ (tui, theme) => sidebarPart(tui, "changes", {
364
+ render(width: number) {
365
+ return renderChangesWidget(model, theme, width);
366
+ },
367
+ invalidate() {},
368
+ }, {
369
+ render(width: number) {
370
+ const noun = model.files.length === 1 ? "file" : "files";
371
+ return renderCard({
372
+ title: "Changes",
373
+ tone: CARD_TONE.INFO,
374
+ body: [
375
+ `${model.files.length} ${noun} · ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}`,
376
+ "",
377
+ theme.fg("muted", `/${CHANGES_COMMAND_NAME}`),
378
+ ],
379
+ }, theme, width, { expanded: true });
380
+ },
381
+ invalidate() {},
382
+ }),
383
+ { placement: "belowEditor" },
384
+ );
385
+ }
386
+
387
+ const USAGE_COMMAND_NAME = "gentle:usage";
388
+ const REVIEW_PREFLIGHT_TYPE = "gentle-pi.review-preflight";
389
+ const DEV_BINARY_WIDGET_KEY = "gentle-shell-dev-binary";
390
+ const SHA_PREFIX_LENGTH = 16;
391
+
392
+ function messageText(content: string | Array<{ type: string; text?: string }>): string {
393
+ if (typeof content === "string") return content;
394
+ return content.map((part) => (part.type === "text" ? (part.text ?? "") : "")).join("\n");
395
+ }
396
+
397
+ interface CardComponentOptions {
398
+ expanded: boolean;
399
+ hint?: string;
400
+ }
401
+
402
+ function cardComponent(card: Card, theme: CardTheme, options: CardComponentOptions) {
403
+ return {
404
+ render(width: number) {
405
+ return renderCard(card, theme, width, options);
406
+ },
407
+ invalidate() {},
408
+ };
409
+ }
410
+
411
+ // Widgets above the editor sit flush against the prompt frame; a blank line
412
+ // after the card keeps the two frames apart.
413
+ function spaced(component: { render(width: number): string[]; invalidate(): void }) {
414
+ return {
415
+ render(width: number) {
416
+ return [...component.render(width), ""];
417
+ },
418
+ invalidate() {},
419
+ };
420
+ }
421
+
422
+ export function devBinaryCard(notice: DevBinaryNotice): Card {
423
+ if (notice.state === "invalid") {
424
+ return { title: "Gentle AI", subtitle: "dev binary override invalid", body: [notice.reason], tone: CARD_TONE.ERROR };
425
+ }
426
+ return {
427
+ title: "Gentle AI",
428
+ subtitle: "dev binary override · field-test only",
429
+ body: [`${notice.path} · sha256:${notice.sha256.slice(0, SHA_PREFIX_LENGTH)}`],
430
+ tone: CARD_TONE.WARNING,
431
+ };
432
+ }
433
+
434
+ const USAGE_REFRESH_MS = 5 * 60_000;
435
+
436
+ // The Codex usage endpoint is what the Codex CLI itself reads. The OAuth
437
+ // token pi already holds carries the account id; nothing else is sent.
438
+ export async function fetchCodexUsage(token: string | undefined, fetchFn: typeof fetch, now: number): Promise<ProviderUsage | undefined> {
439
+ if (!token) return undefined;
440
+ const accountId = accountIdFromToken(token);
441
+ if (!accountId) return undefined;
442
+ try {
443
+ const response = await fetchFn(CODEX_USAGE_URL, {
444
+ headers: { Authorization: `Bearer ${token}`, "chatgpt-account-id": accountId, originator: "pi", "User-Agent": "gentle-pi" },
445
+ });
446
+ if (!response.ok) return undefined;
447
+ return parseCodexUsage(await response.json(), now);
448
+ } catch {
449
+ return undefined;
450
+ }
451
+ }
452
+
453
+ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
454
+ if (!shellEnabled(env)) return;
455
+ const deps: ShellDeps = { ...defaultShellDeps, ...overrides };
456
+ const usage = new UsageStore();
457
+ let renderHost: ShellRenderHost | undefined;
458
+ let usageFetchedAt = 0;
459
+ const refreshUsage = async (ctx: ExtensionContext, force: boolean) => {
460
+ const provider = ctx.model?.provider;
461
+ if (provider !== CODEX_PROVIDER) return;
462
+ const now = deps.now();
463
+ if (!force && now - usageFetchedAt < USAGE_REFRESH_MS) return;
464
+ usageFetchedAt = now;
465
+ const token = await ctx.modelRegistry.getApiKeyForProvider(CODEX_PROVIDER).catch(() => undefined);
466
+ const fetched = await fetchCodexUsage(token, deps.fetch, deps.now());
467
+ if (!fetched) return;
468
+ usage.record(fetched);
469
+ renderHost?.invalidateSidebar?.();
470
+ renderHost?.requestRender();
471
+ };
472
+ pi.on("after_provider_response", (event) => {
473
+ const parsed = parseUsageHeaders(event.headers, deps.now());
474
+ if (!parsed) return;
475
+ usage.record(parsed);
476
+ renderHost?.invalidateSidebar?.();
477
+ renderHost?.requestRender();
478
+ });
479
+ pi.registerMessageRenderer(REVIEW_PREFLIGHT_TYPE, (message, options, theme) => {
480
+ const body = messageText(message.content as string | Array<{ type: string; text?: string }>).split("\n");
481
+ const hint = keyHint("app.tools.expand", options.expanded ? "collapse" : "expand");
482
+ return cardComponent({ title: "Gentle AI", subtitle: "review preflight", body, tone: CARD_TONE.INFO }, theme, { expanded: options.expanded, hint });
483
+ });
484
+ pi.registerCommand(USAGE_COMMAND_NAME, {
485
+ description: "Show subscription usage windows for the connected providers. Press r to refetch.",
486
+ handler: async (_args, ctx) => {
487
+ await refreshUsage(ctx, true);
488
+ await ctx.ui.custom<null>(
489
+ (tui, theme, _keybindings, done) =>
490
+ new UsageView(usage, {
491
+ theme,
492
+ now: () => deps.now(),
493
+ active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
494
+ onRefresh: () => refreshUsage(ctx, true),
495
+ onClose: () => done(null),
496
+ requestRender: () => tui.requestRender(),
497
+ }),
498
+ { overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
499
+ );
500
+ },
501
+ });
502
+ let prompt: GentlePromptEditor | undefined;
503
+ let changes: WorktreeChangesTracker | undefined;
504
+ let registry: SessionWorktreeRegistry | undefined;
505
+ let currentContext: ExtensionContext | undefined;
506
+ const pendingTools = new Map<string, { sessionId: string; path?: string }>();
507
+ let watch: NodeJS.Timeout | undefined;
508
+ let shown = "";
509
+ const applyChanges = (ctx: ExtensionContext, model: ChangesModel) => {
510
+ const fingerprint = changesFingerprint(model);
511
+ if (fingerprint === shown) return;
512
+ shown = fingerprint;
513
+ showChanges(ctx, model);
514
+ };
515
+ const refreshChanges = async (ctx: ExtensionContext) => {
516
+ const tracker = changes;
517
+ if (!tracker || !ctx.hasUI || registry?.sessionId !== ctx.sessionManager.getSessionId()) return;
518
+ const model = await tracker.refresh();
519
+ if (changes === tracker) applyChanges(ctx, model);
520
+ };
521
+ const stopWatch = () => {
522
+ if (watch) clearInterval(watch);
523
+ watch = undefined;
524
+ };
525
+ const unsubscribeWorktrees = pi.events.on(SESSION_WORKTREE_CHANGED, (data) => {
526
+ if (!currentContext || !registry || (data as { sessionId?: string } | undefined)?.sessionId !== registry.sessionId) return;
527
+ void refreshChanges(currentContext);
528
+ });
529
+ pi.registerTool({
530
+ name: "session_worktree_register",
531
+ renderShell: "self",
532
+ label: "Register session worktree",
533
+ description: "Register a worktree used by this session, including earlier work or opaque shell use. Only the same Git clone is accepted. Shows ALL dirty files in that root, including preexisting and untracked files; never infers roots from shell commands or prose.",
534
+ parameters: { type: "object", required: ["path"], additionalProperties: false, properties: { path: { type: "string", description: "Worktree path to include in this session." } } } as never,
535
+ async execute(_id, params, _signal, _onUpdate, ctx) {
536
+ if (!registry || registry.sessionId !== ctx.sessionManager.getSessionId()) throw new Error("No active session worktree registry.");
537
+ const root = registry.register((params as { path: string }).path, "explicit");
538
+ await refreshChanges(ctx);
539
+ return { content: [{ type: "text", text: `Registered session worktree: ${root}` }], details: { root, sessionId: registry.sessionId } };
540
+ },
541
+ });
542
+ pi.on("session_start", async (_event, ctx) => {
543
+ stopWatch();
544
+ registry?.close();
545
+ pendingTools.clear();
546
+ currentContext = ctx;
547
+ changes = undefined;
548
+ registry = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.cwd, deps.resolveWorktree);
549
+ registry.start();
550
+ const sessionRegistry = registry;
551
+ if (!ctx.hasUI) return;
552
+ changes = new WorktreeChangesTracker(deps.gitRunner(ctx.cwd), deps.gitRunner, lineCounter, () => sessionRegistry.roots());
553
+ const tracker = changes;
554
+ ctx.ui.setFooter((tui, theme, footerData) => {
555
+ renderHost = { requestRender: () => tui.requestRender(), invalidateSidebar: () => invalidateSidebar(tui) };
556
+ const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""));
557
+ // The Status card paints live session state that no event re-registers a
558
+ // part for: model, effort, context, cost, session name and extension
559
+ // statuses. The digest is what keeps the fullscreen memo honest, and it
560
+ // rebuilds the model exactly as the narrow bottom bar does every frame.
561
+ const footerModel = () => buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? "") });
562
+ const part = sidebarPart(tui, "footer", bottom, {
563
+ digest: () => JSON.stringify(footerModel()),
564
+ render: (width) => renderShellSidebarBar(footerModel(), theme, width),
565
+ invalidate() {},
566
+ });
567
+ const uninstall = installSidebar(tui, theme);
568
+ return { ...part, dispose() { uninstall(); part.dispose(); } };
569
+ });
570
+ void refreshUsage(ctx, true);
571
+ installPrompt(ctx, (created) => {
572
+ prompt = created;
573
+ });
574
+ // The petal already says the agent is working; pi's own "Working" row would say it twice.
575
+ ctx.ui.setWorkingVisible(false);
576
+ const notice = deps.devBinary();
577
+ ctx.ui.setWidget(
578
+ DEV_BINARY_WIDGET_KEY,
579
+ notice
580
+ ? (_tui, theme) => spaced(cardComponent(devBinaryCard(notice), theme, { expanded: true }))
581
+ : undefined,
582
+ );
583
+ await tracker.start();
584
+ if (changes !== tracker) return;
585
+ shown = "";
586
+ applyChanges(ctx, tracker.model);
587
+ stopWatch();
588
+ const watchMs = changesWatchMs(env);
589
+ if (watchMs) {
590
+ watch = setInterval(() => void refreshChanges(ctx), watchMs);
591
+ watch.unref();
592
+ }
593
+ });
594
+ pi.on("session_shutdown", () => {
595
+ stopWatch();
596
+ registry?.close();
597
+ registry = undefined;
598
+ changes = undefined;
599
+ currentContext = undefined;
600
+ pendingTools.clear();
601
+ unsubscribeWorktrees();
602
+ });
603
+ const openChanges = async (ctx: ExtensionContext) => {
604
+ if (!changes) return;
605
+ const tracker = changes;
606
+ const model = await tracker.refresh();
607
+ if (model.files.length === 0) {
608
+ ctx.ui.notify("No changes in the working tree.", "info");
609
+ return;
610
+ }
611
+ await showChangesOverlay(ctx, { git: deps.gitRunner, worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
612
+ };
613
+ pi.registerCommand(CHANGES_COMMAND_NAME, {
614
+ description: "Browse this session's registered dirty worktrees and their changes against HEAD. Press o on a file to open $EDITOR.",
615
+ handler: async (_args, ctx) => openChanges(ctx),
616
+ });
617
+ const shortcut = changesShortcut(env);
618
+ if (shortcut) {
619
+ pi.registerShortcut(shortcut as Parameters<ExtensionAPI["registerShortcut"]>[0], {
620
+ description: "Open the working tree changes",
621
+ handler: async (ctx) => openChanges(ctx),
622
+ });
623
+ }
624
+ pi.on("agent_start", (_event, ctx) => {
625
+ prompt?.setWorking(true);
626
+ // The dev-binary card is a startup notice: it leaves with the first prompt.
627
+ if (ctx.hasUI) ctx.ui.setWidget(DEV_BINARY_WIDGET_KEY, undefined);
628
+ });
629
+ pi.on("agent_end", async (_event, ctx) => {
630
+ prompt?.setWorking(false);
631
+ await refreshChanges(ctx);
632
+ void refreshUsage(ctx, false);
633
+ });
634
+ pi.on("tool_execution_start", (event, ctx) => {
635
+ pendingTools.set(event.toolCallId, { sessionId: ctx.sessionManager.getSessionId() });
636
+ });
637
+ pi.on("tool_result", (event, ctx) => {
638
+ const pending = pendingTools.get(event.toolCallId);
639
+ if (pending?.sessionId === ctx.sessionManager.getSessionId()) pending.path = toolWorktreePath(event.toolName, event.input);
640
+ });
641
+ pi.on("tool_execution_end", async (event, ctx) => {
642
+ const pending = pendingTools.get(event.toolCallId);
643
+ pendingTools.delete(event.toolCallId);
644
+ if (!event.isError && pending?.path !== undefined && pending.sessionId === registry?.sessionId && pending.sessionId === ctx.sessionManager.getSessionId()) {
645
+ try { registry.register(pending.path, `tool:${event.toolName}`); }
646
+ catch { /* Non-project paths and missing roots do not expand the registry. */ }
647
+ }
648
+ await refreshChanges(ctx);
649
+ });
650
+ }