gentle-pi 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -11
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +21 -10
- package/assets/orchestrator.md +8 -12
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- package/docs/delegated-verification.md +25 -0
- package/docs/review-integration.md +1 -1
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +1421 -143
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agent-home.ts +8 -0
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +103 -12
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +92 -13
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/sdd-preflight.ts +2 -2
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +102 -11
- package/runtime/review-integration-v2.mjs +92 -13
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +18 -13
- package/skills/_shared/review-ledger-contract.md +9 -1
- package/skills/issue-creation/SKILL.md +53 -93
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +31 -14
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +33 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +71 -2
- package/tests/orchestrator-rdd-ownership.test.ts +10 -1
- package/tests/package-manifest.test.ts +134 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +142 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-routing.test.ts +229 -11
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/runtime-harness.mjs +1 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
|
@@ -0,0 +1,547 @@
|
|
|
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 { 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, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
|
|
8
|
+
import { CHANGE_STATUS, ChangesTracker, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type LineCounter } from "../lib/shell-changes.ts";
|
|
9
|
+
import { ChangesView } from "../lib/shell-changes-view.ts";
|
|
10
|
+
import { CARD_TONE, renderCard, type Card, type CardTheme } from "../lib/shell-card.ts";
|
|
11
|
+
import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
|
|
12
|
+
import { framePromptLines, panelPainter, PROMPT_HINT, PROMPT_STATE, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
|
|
13
|
+
import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, parseCodexUsage, parseUsageHeaders, UsageStore, type ProviderUsage } from "../lib/shell-usage.ts";
|
|
14
|
+
import { UsageView } from "../lib/shell-usage-view.ts";
|
|
15
|
+
|
|
16
|
+
// Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the
|
|
17
|
+
// status bar, the petal prompt, the working-tree changes widget and overlay,
|
|
18
|
+
// the subscription usage view, and the cards Gentle notices are drawn with.
|
|
19
|
+
|
|
20
|
+
export interface ShellFooterData {
|
|
21
|
+
getGitBranch(): string | null;
|
|
22
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
23
|
+
getAvailableProviderCount(): number;
|
|
24
|
+
onBranchChange(callback: () => void): () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ShellRenderHost {
|
|
28
|
+
requestRender(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ShellBarComponent {
|
|
32
|
+
render(width: number): string[];
|
|
33
|
+
invalidate(): void;
|
|
34
|
+
dispose(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface BuildOptions {
|
|
38
|
+
home?: string;
|
|
39
|
+
dirty?: number;
|
|
40
|
+
usage?: ProviderUsage;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type DevBinaryNotice = { state: "active"; path: string; sha256: string } | { state: "invalid"; reason: string };
|
|
44
|
+
|
|
45
|
+
export interface ShellDeps {
|
|
46
|
+
fetch: typeof fetch;
|
|
47
|
+
now(): number;
|
|
48
|
+
devBinary(): DevBinaryNotice | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ambientDevBinary(): DevBinaryNotice | undefined {
|
|
52
|
+
try {
|
|
53
|
+
const override = resolveGentleAiDevBinaryOverride();
|
|
54
|
+
return override ? { state: "active", path: override.path, sha256: override.sha256 } : undefined;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error instanceof GentleAiDevBinaryOverrideError) return { state: "invalid", reason: error.message };
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const defaultShellDeps: ShellDeps = { fetch: (...args) => globalThis.fetch(...args), now: () => Date.now(), devBinary: ambientDevBinary };
|
|
62
|
+
|
|
63
|
+
interface AssistantUsageEntry {
|
|
64
|
+
type: string;
|
|
65
|
+
message?: {
|
|
66
|
+
role?: string;
|
|
67
|
+
usage?: {
|
|
68
|
+
cost?: { total?: number };
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function shortenHome(cwd: string, home: string | undefined): string {
|
|
74
|
+
if (home && cwd.startsWith(home)) return `~${cwd.slice(home.length)}`;
|
|
75
|
+
return cwd;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sessionCost(ctx: ExtensionContext): number {
|
|
79
|
+
let total = 0;
|
|
80
|
+
for (const entry of ctx.sessionManager.getEntries() as AssistantUsageEntry[]) {
|
|
81
|
+
if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
|
|
82
|
+
total += entry.message.usage?.cost?.total ?? 0;
|
|
83
|
+
}
|
|
84
|
+
return total;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildShellBarModel(
|
|
88
|
+
pi: ExtensionAPI,
|
|
89
|
+
ctx: ExtensionContext,
|
|
90
|
+
footerData: ShellFooterData,
|
|
91
|
+
options: BuildOptions = {},
|
|
92
|
+
): ShellBarModel {
|
|
93
|
+
const home = options.home ?? os.homedir();
|
|
94
|
+
const usage = ctx.getContextUsage();
|
|
95
|
+
const model = ctx.model;
|
|
96
|
+
const statuses = Array.from(footerData.getExtensionStatuses().entries())
|
|
97
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
98
|
+
.map(([, text]) => text);
|
|
99
|
+
return {
|
|
100
|
+
cwd: shortenHome(ctx.sessionManager.getCwd(), home),
|
|
101
|
+
branch: footerData.getGitBranch(),
|
|
102
|
+
dirty: options.dirty,
|
|
103
|
+
sessionName: ctx.sessionManager.getSessionName(),
|
|
104
|
+
modelId: model?.id ?? "no-model",
|
|
105
|
+
effort: model?.reasoning ? pi.getThinkingLevel() : undefined,
|
|
106
|
+
contextPercent: usage?.percent ?? null,
|
|
107
|
+
contextWindow: usage?.contextWindow ?? model?.contextWindow ?? 0,
|
|
108
|
+
costTotal: sessionCost(ctx),
|
|
109
|
+
subscription: model ? ctx.modelRegistry.isUsingOAuth(model) : false,
|
|
110
|
+
usage: options.usage,
|
|
111
|
+
statuses,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createShellBarComponent(
|
|
116
|
+
pi: ExtensionAPI,
|
|
117
|
+
ctx: ExtensionContext,
|
|
118
|
+
host: ShellRenderHost,
|
|
119
|
+
theme: ShellBarTheme,
|
|
120
|
+
footerData: ShellFooterData,
|
|
121
|
+
dirty: () => number | undefined = () => undefined,
|
|
122
|
+
usage: () => ProviderUsage | undefined = () => undefined,
|
|
123
|
+
): ShellBarComponent {
|
|
124
|
+
const unsubscribe = footerData.onBranchChange(() => host.requestRender());
|
|
125
|
+
return {
|
|
126
|
+
render(width: number) {
|
|
127
|
+
return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width);
|
|
128
|
+
},
|
|
129
|
+
invalidate() {},
|
|
130
|
+
dispose() {
|
|
131
|
+
unsubscribe();
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface PromptEditorDeps {
|
|
137
|
+
fg: (color: string, text: string) => string;
|
|
138
|
+
bold: (text: string) => string;
|
|
139
|
+
paint?: (line: string) => string;
|
|
140
|
+
requestRender(): void;
|
|
141
|
+
pending(): boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const PROMPT_FRAME_ROLE = "border";
|
|
145
|
+
|
|
146
|
+
const PETAL_PULSE_MS = 160;
|
|
147
|
+
|
|
148
|
+
export class GentlePromptEditor extends CustomEditor {
|
|
149
|
+
private promptState: PromptState = PROMPT_STATE.IDLE;
|
|
150
|
+
private tick = 0;
|
|
151
|
+
private pulse: NodeJS.Timeout | undefined;
|
|
152
|
+
private readonly deps: PromptEditorDeps;
|
|
153
|
+
|
|
154
|
+
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
|
|
155
|
+
super(tui, theme, keybindings);
|
|
156
|
+
this.deps = deps;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setWorking(working: boolean): void {
|
|
160
|
+
this.promptState = working ? PROMPT_STATE.WORKING : PROMPT_STATE.IDLE;
|
|
161
|
+
this.stopPulse();
|
|
162
|
+
if (working) {
|
|
163
|
+
this.pulse = setInterval(() => {
|
|
164
|
+
this.tick += 1;
|
|
165
|
+
this.deps.requestRender();
|
|
166
|
+
}, PETAL_PULSE_MS);
|
|
167
|
+
this.pulse.unref();
|
|
168
|
+
}
|
|
169
|
+
this.deps.requestRender();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
render(width: number): string[] {
|
|
173
|
+
const lines = super.render(Math.max(1, width - 2));
|
|
174
|
+
if (this.getText() === "" && lines.length === 3) lines[1] = withPromptHint(lines[1], PROMPT_HINT, this.deps.fg);
|
|
175
|
+
const state = this.promptState === PROMPT_STATE.WORKING && this.deps.pending() ? PROMPT_STATE.QUEUED : this.promptState;
|
|
176
|
+
// The frame keeps the theme's border color rather than pi's thinking-level
|
|
177
|
+
// color, so the prompt reads as one panel with the cards around it.
|
|
178
|
+
return framePromptLines(lines, width, {
|
|
179
|
+
state,
|
|
180
|
+
tick: this.tick,
|
|
181
|
+
borderColor: (text) => this.deps.fg(PROMPT_FRAME_ROLE, text),
|
|
182
|
+
fg: this.deps.fg,
|
|
183
|
+
bold: this.deps.bold,
|
|
184
|
+
paint: this.deps.paint,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
dispose(): void {
|
|
189
|
+
this.stopPulse();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private stopPulse(): void {
|
|
193
|
+
if (this.pulse) clearInterval(this.pulse);
|
|
194
|
+
this.pulse = undefined;
|
|
195
|
+
this.tick = 0;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEditor) => void): void {
|
|
200
|
+
if (ctx.ui.getEditorComponent()) return;
|
|
201
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
202
|
+
const prompt = new GentlePromptEditor(tui, theme, keybindings, {
|
|
203
|
+
fg: (color, text) => ctx.ui.theme.fg(color as Parameters<typeof ctx.ui.theme.fg>[0], text),
|
|
204
|
+
bold: (text) => ctx.ui.theme.bold(text),
|
|
205
|
+
paint: panelPainter(ctx.ui.theme.getBgAnsi("customMessageBg")),
|
|
206
|
+
requestRender: () => tui.requestRender(),
|
|
207
|
+
pending: () => ctx.hasPendingMessages(),
|
|
208
|
+
});
|
|
209
|
+
onCreated(prompt);
|
|
210
|
+
return prompt;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const CHANGES_WIDGET_KEY = "gentle-shell-changes";
|
|
215
|
+
const CHANGES_COMMAND_NAME = "gentle:changes";
|
|
216
|
+
const CHANGES_SHORTCUT_DEFAULT = "alt+g";
|
|
217
|
+
const CHANGES_POLL_DEFAULT_MS = 2000;
|
|
218
|
+
const CHANGES_WATCH_DEFAULT_MS = 5000;
|
|
219
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
220
|
+
const OVERLAY_HEIGHT_RATIO = 0.8;
|
|
221
|
+
const OVERLAY_MIN_ROWS = 8;
|
|
222
|
+
|
|
223
|
+
function gitRunner(pi: ExtensionAPI, cwd: string): GitRunner {
|
|
224
|
+
return async (args: string[]) => {
|
|
225
|
+
const result = await pi.exec("git", ["-C", cwd, ...args], { timeout: GIT_TIMEOUT_MS });
|
|
226
|
+
return { stdout: result.stdout, code: result.code };
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function lineCounter(cwd: string): LineCounter {
|
|
231
|
+
return async (path: string) => {
|
|
232
|
+
const text = await readFile(join(cwd, path), "utf8");
|
|
233
|
+
if (text.length === 0) return 0;
|
|
234
|
+
return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function loadFileDiff(git: GitRunner, file: ChangedFile): Promise<string> {
|
|
239
|
+
const args = file.status === CHANGE_STATUS.UNTRACKED ? ["diff", "--no-index", "--", "/dev/null", file.path] : ["diff", "HEAD", "--", file.path];
|
|
240
|
+
const result = await git(args);
|
|
241
|
+
return result.code === 0 || result.code === 1 ? result.stdout : "";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface ExternalEditorHost {
|
|
245
|
+
stop(): void;
|
|
246
|
+
start(): void;
|
|
247
|
+
requestRender(force?: boolean): void;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function openInExternalEditor(host: ExternalEditorHost, path: string, env: NodeJS.ProcessEnv = process.env, spawn: typeof spawnSync = spawnSync): boolean {
|
|
251
|
+
const command = env.VISUAL || env.EDITOR;
|
|
252
|
+
if (!command) return false;
|
|
253
|
+
const [editor, ...editorArgs] = command.split(" ");
|
|
254
|
+
host.stop();
|
|
255
|
+
try {
|
|
256
|
+
spawn(editor, [...editorArgs, path], { stdio: "inherit", shell: process.platform === "win32" });
|
|
257
|
+
} finally {
|
|
258
|
+
host.start();
|
|
259
|
+
host.requestRender(true);
|
|
260
|
+
}
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function changesShortcut(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
265
|
+
const value = env.GENTLE_PI_SHELL_CHANGES_KEY?.trim();
|
|
266
|
+
if (value === undefined) return CHANGES_SHORTCUT_DEFAULT;
|
|
267
|
+
return value === "" || value.toLowerCase() === "off" ? undefined : value;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function positiveMs(value: string | undefined, fallback: number): number {
|
|
271
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
272
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function changesPollMs(env: NodeJS.ProcessEnv): number {
|
|
276
|
+
return positiveMs(env.GENTLE_PI_SHELL_CHANGES_POLL_MS, CHANGES_POLL_DEFAULT_MS);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Background watch: the widget and the bar follow edits made outside pi.
|
|
280
|
+
// 0 or "off" disables it; tool events still refresh.
|
|
281
|
+
function changesWatchMs(env: NodeJS.ProcessEnv): number | undefined {
|
|
282
|
+
const value = env.GENTLE_PI_SHELL_CHANGES_WATCH_MS?.trim().toLowerCase();
|
|
283
|
+
if (value === "0" || value === "off") return undefined;
|
|
284
|
+
return positiveMs(value, CHANGES_WATCH_DEFAULT_MS);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function changesFingerprint(model: ChangesModel): string {
|
|
288
|
+
return model.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}`).join("|");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
interface OverlayDeps {
|
|
292
|
+
git: GitRunner;
|
|
293
|
+
refresh(): Promise<ChangesModel>;
|
|
294
|
+
apply(ctx: ExtensionContext, model: ChangesModel): void;
|
|
295
|
+
pollMs: number;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// While the overlay is open, git is polled so edits made outside pi (nvim,
|
|
299
|
+
// another agent, a git checkout) show up without reopening it.
|
|
300
|
+
async function showChangesOverlay(ctx: ExtensionContext, model: ChangesModel, deps: OverlayDeps): Promise<void> {
|
|
301
|
+
let host: ExternalEditorHost | undefined;
|
|
302
|
+
let view: ChangesView | undefined;
|
|
303
|
+
const poll = setInterval(async () => {
|
|
304
|
+
const latest = await deps.refresh();
|
|
305
|
+
view?.update(latest);
|
|
306
|
+
deps.apply(ctx, latest);
|
|
307
|
+
}, deps.pollMs);
|
|
308
|
+
poll.unref();
|
|
309
|
+
try {
|
|
310
|
+
const chosen = await ctx.ui.custom<ChangedFile | null>(
|
|
311
|
+
(tui, theme, _keybindings, done) => {
|
|
312
|
+
host = tui;
|
|
313
|
+
view = new ChangesView(model, {
|
|
314
|
+
theme,
|
|
315
|
+
rows: Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
|
|
316
|
+
loadDiff: (file) => loadFileDiff(deps.git, file),
|
|
317
|
+
onOpen: (file) => done(file),
|
|
318
|
+
onClose: () => done(null),
|
|
319
|
+
requestRender: () => tui.requestRender(),
|
|
320
|
+
});
|
|
321
|
+
return view;
|
|
322
|
+
},
|
|
323
|
+
{ overlay: true, overlayOptions: { width: "92%", anchor: "center" } },
|
|
324
|
+
);
|
|
325
|
+
if (!chosen || !host) return;
|
|
326
|
+
if (!openInExternalEditor(host, chosen.path)) ctx.ui.notify("No editor configured. Set $VISUAL or $EDITOR.", "warning");
|
|
327
|
+
} finally {
|
|
328
|
+
clearInterval(poll);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
|
|
333
|
+
if (model.files.length === 0) {
|
|
334
|
+
ctx.ui.setWidget(CHANGES_WIDGET_KEY, undefined);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
ctx.ui.setWidget(
|
|
338
|
+
CHANGES_WIDGET_KEY,
|
|
339
|
+
(_tui, theme) => ({
|
|
340
|
+
render(width: number) {
|
|
341
|
+
return renderChangesWidget(model, theme, width);
|
|
342
|
+
},
|
|
343
|
+
invalidate() {},
|
|
344
|
+
}),
|
|
345
|
+
{ placement: "belowEditor" },
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const USAGE_COMMAND_NAME = "gentle:usage";
|
|
350
|
+
const REVIEW_PREFLIGHT_TYPE = "gentle-pi.review-preflight";
|
|
351
|
+
const DEV_BINARY_WIDGET_KEY = "gentle-shell-dev-binary";
|
|
352
|
+
const SHA_PREFIX_LENGTH = 16;
|
|
353
|
+
|
|
354
|
+
function messageText(content: string | Array<{ type: string; text?: string }>): string {
|
|
355
|
+
if (typeof content === "string") return content;
|
|
356
|
+
return content.map((part) => (part.type === "text" ? (part.text ?? "") : "")).join("\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
interface CardComponentOptions {
|
|
360
|
+
expanded: boolean;
|
|
361
|
+
hint?: string;
|
|
362
|
+
paint?: (line: string) => string;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function cardComponent(card: Card, theme: CardTheme, options: CardComponentOptions) {
|
|
366
|
+
return {
|
|
367
|
+
render(width: number) {
|
|
368
|
+
return renderCard(card, theme, width, options);
|
|
369
|
+
},
|
|
370
|
+
invalidate() {},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Widgets above the editor sit flush against the prompt frame; a blank line
|
|
375
|
+
// after the card keeps the two frames apart.
|
|
376
|
+
function spaced(component: { render(width: number): string[]; invalidate(): void }) {
|
|
377
|
+
return {
|
|
378
|
+
render(width: number) {
|
|
379
|
+
return [...component.render(width), ""];
|
|
380
|
+
},
|
|
381
|
+
invalidate() {},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function devBinaryCard(notice: DevBinaryNotice): Card {
|
|
386
|
+
if (notice.state === "invalid") {
|
|
387
|
+
return { title: "Gentle AI", subtitle: "dev binary override invalid", body: [notice.reason], tone: CARD_TONE.ERROR };
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
title: "Gentle AI",
|
|
391
|
+
subtitle: "dev binary override · field-test only",
|
|
392
|
+
body: [`${notice.path} · sha256:${notice.sha256.slice(0, SHA_PREFIX_LENGTH)}`],
|
|
393
|
+
tone: CARD_TONE.WARNING,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const USAGE_REFRESH_MS = 5 * 60_000;
|
|
398
|
+
|
|
399
|
+
// The Codex usage endpoint is what the Codex CLI itself reads. The OAuth
|
|
400
|
+
// token pi already holds carries the account id; nothing else is sent.
|
|
401
|
+
export async function fetchCodexUsage(token: string | undefined, fetchFn: typeof fetch, now: number): Promise<ProviderUsage | undefined> {
|
|
402
|
+
if (!token) return undefined;
|
|
403
|
+
const accountId = accountIdFromToken(token);
|
|
404
|
+
if (!accountId) return undefined;
|
|
405
|
+
try {
|
|
406
|
+
const response = await fetchFn(CODEX_USAGE_URL, {
|
|
407
|
+
headers: { Authorization: `Bearer ${token}`, "chatgpt-account-id": accountId, originator: "pi", "User-Agent": "gentle-pi" },
|
|
408
|
+
});
|
|
409
|
+
if (!response.ok) return undefined;
|
|
410
|
+
return parseCodexUsage(await response.json(), now);
|
|
411
|
+
} catch {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
|
|
417
|
+
if (!shellEnabled(env)) return;
|
|
418
|
+
const deps: ShellDeps = { ...defaultShellDeps, ...overrides };
|
|
419
|
+
const usage = new UsageStore();
|
|
420
|
+
let renderHost: ShellRenderHost | undefined;
|
|
421
|
+
let usageFetchedAt = 0;
|
|
422
|
+
const refreshUsage = async (ctx: ExtensionContext, force: boolean) => {
|
|
423
|
+
const provider = ctx.model?.provider;
|
|
424
|
+
if (provider !== CODEX_PROVIDER) return;
|
|
425
|
+
const now = deps.now();
|
|
426
|
+
if (!force && now - usageFetchedAt < USAGE_REFRESH_MS) return;
|
|
427
|
+
usageFetchedAt = now;
|
|
428
|
+
const token = await ctx.modelRegistry.getApiKeyForProvider(CODEX_PROVIDER).catch(() => undefined);
|
|
429
|
+
const fetched = await fetchCodexUsage(token, deps.fetch, deps.now());
|
|
430
|
+
if (!fetched) return;
|
|
431
|
+
usage.record(fetched);
|
|
432
|
+
renderHost?.requestRender();
|
|
433
|
+
};
|
|
434
|
+
pi.on("after_provider_response", (event) => {
|
|
435
|
+
const parsed = parseUsageHeaders(event.headers, deps.now());
|
|
436
|
+
if (!parsed) return;
|
|
437
|
+
usage.record(parsed);
|
|
438
|
+
renderHost?.requestRender();
|
|
439
|
+
});
|
|
440
|
+
pi.registerMessageRenderer(REVIEW_PREFLIGHT_TYPE, (message, options, theme) => {
|
|
441
|
+
const body = messageText(message.content as string | Array<{ type: string; text?: string }>).split("\n");
|
|
442
|
+
const hint = keyHint("app.tools.expand", options.expanded ? "collapse" : "expand");
|
|
443
|
+
return cardComponent({ title: "Gentle AI", subtitle: "review preflight", body, tone: CARD_TONE.INFO }, theme, { expanded: options.expanded, hint });
|
|
444
|
+
});
|
|
445
|
+
pi.registerCommand(USAGE_COMMAND_NAME, {
|
|
446
|
+
description: "Show subscription usage windows for the connected providers. Press r to refetch.",
|
|
447
|
+
handler: async (_args, ctx) => {
|
|
448
|
+
await refreshUsage(ctx, true);
|
|
449
|
+
await ctx.ui.custom<null>(
|
|
450
|
+
(tui, theme, _keybindings, done) =>
|
|
451
|
+
new UsageView(usage, {
|
|
452
|
+
theme,
|
|
453
|
+
now: () => deps.now(),
|
|
454
|
+
active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
|
|
455
|
+
onRefresh: () => refreshUsage(ctx, true),
|
|
456
|
+
onClose: () => done(null),
|
|
457
|
+
requestRender: () => tui.requestRender(),
|
|
458
|
+
}),
|
|
459
|
+
{ overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
|
|
460
|
+
);
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
let prompt: GentlePromptEditor | undefined;
|
|
464
|
+
let changes: ChangesTracker | undefined;
|
|
465
|
+
let watch: NodeJS.Timeout | undefined;
|
|
466
|
+
let shown = "";
|
|
467
|
+
const applyChanges = (ctx: ExtensionContext, model: ChangesModel) => {
|
|
468
|
+
const fingerprint = changesFingerprint(model);
|
|
469
|
+
if (fingerprint === shown) return;
|
|
470
|
+
shown = fingerprint;
|
|
471
|
+
showChanges(ctx, model);
|
|
472
|
+
};
|
|
473
|
+
const refreshChanges = async (ctx: ExtensionContext) => {
|
|
474
|
+
if (!changes || !ctx.hasUI) return;
|
|
475
|
+
applyChanges(ctx, await changes.refresh());
|
|
476
|
+
};
|
|
477
|
+
const stopWatch = () => {
|
|
478
|
+
if (watch) clearInterval(watch);
|
|
479
|
+
watch = undefined;
|
|
480
|
+
};
|
|
481
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
482
|
+
if (!ctx.hasUI) return;
|
|
483
|
+
changes = new ChangesTracker(gitRunner(pi, ctx.cwd), lineCounter(ctx.cwd));
|
|
484
|
+
const tracker = changes;
|
|
485
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
486
|
+
renderHost = tui;
|
|
487
|
+
return createShellBarComponent(pi, ctx, tui, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""));
|
|
488
|
+
});
|
|
489
|
+
void refreshUsage(ctx, true);
|
|
490
|
+
installPrompt(ctx, (created) => {
|
|
491
|
+
prompt = created;
|
|
492
|
+
});
|
|
493
|
+
// The petal already says the agent is working; pi's own "Working" row would say it twice.
|
|
494
|
+
ctx.ui.setWorkingVisible(false);
|
|
495
|
+
const notice = deps.devBinary();
|
|
496
|
+
ctx.ui.setWidget(
|
|
497
|
+
DEV_BINARY_WIDGET_KEY,
|
|
498
|
+
notice
|
|
499
|
+
? (_tui, theme) => spaced(cardComponent(devBinaryCard(notice), theme, { expanded: true, paint: (line) => theme.bg("customMessageBg", line) }))
|
|
500
|
+
: undefined,
|
|
501
|
+
);
|
|
502
|
+
await tracker.start();
|
|
503
|
+
shown = "";
|
|
504
|
+
applyChanges(ctx, tracker.model);
|
|
505
|
+
stopWatch();
|
|
506
|
+
const watchMs = changesWatchMs(env);
|
|
507
|
+
if (watchMs) {
|
|
508
|
+
watch = setInterval(() => void refreshChanges(ctx), watchMs);
|
|
509
|
+
watch.unref();
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
pi.on("session_shutdown", () => stopWatch());
|
|
513
|
+
const openChanges = async (ctx: ExtensionContext) => {
|
|
514
|
+
if (!changes) return;
|
|
515
|
+
const tracker = changes;
|
|
516
|
+
const model = await tracker.refresh();
|
|
517
|
+
if (model.files.length === 0) {
|
|
518
|
+
ctx.ui.notify("No changes in the working tree.", "info");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
await showChangesOverlay(ctx, model, { git: gitRunner(pi, ctx.cwd), refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
|
|
522
|
+
};
|
|
523
|
+
pi.registerCommand(CHANGES_COMMAND_NAME, {
|
|
524
|
+
description: "Show the working tree changes against HEAD, with diffs. Press o to open a file in $EDITOR.",
|
|
525
|
+
handler: async (_args, ctx) => openChanges(ctx),
|
|
526
|
+
});
|
|
527
|
+
const shortcut = changesShortcut(env);
|
|
528
|
+
if (shortcut) {
|
|
529
|
+
pi.registerShortcut(shortcut as Parameters<ExtensionAPI["registerShortcut"]>[0], {
|
|
530
|
+
description: "Open the working tree changes",
|
|
531
|
+
handler: async (ctx) => openChanges(ctx),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
535
|
+
prompt?.setWorking(true);
|
|
536
|
+
// The dev-binary card is a startup notice: it leaves with the first prompt.
|
|
537
|
+
if (ctx.hasUI) ctx.ui.setWidget(DEV_BINARY_WIDGET_KEY, undefined);
|
|
538
|
+
});
|
|
539
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
540
|
+
prompt?.setWorking(false);
|
|
541
|
+
await refreshChanges(ctx);
|
|
542
|
+
void refreshUsage(ctx, false);
|
|
543
|
+
});
|
|
544
|
+
pi.on("tool_execution_end", async (_event, ctx) => {
|
|
545
|
+
await refreshChanges(ctx);
|
|
546
|
+
});
|
|
547
|
+
}
|