gentle-pi 3.2.1 → 3.4.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 +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-shell.md +52 -15
- package/docs/readme-reference.md +67 -7
- package/docs/review-integration.md +22 -17
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +93 -18
- package/extensions/gentle-ai.ts +180 -37
- package/extensions/gentle-shell.ts +476 -39
- package/extensions/gentle-todo.ts +19 -1
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +297 -0
- package/lib/native-review-cli.ts +50 -10
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +256 -171
- package/lib/review-integration-v2.ts +114 -27
- package/lib/shell-bar.ts +163 -75
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +118 -16
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +103 -12
- package/lib/shell-usage.ts +120 -6
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +49 -9
- package/runtime/review-integration-v2.mjs +114 -27
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/verify-package-files.mjs +3 -4
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +10 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +599 -7
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +125 -9
- package/tests/gentle-shell.test.ts +1149 -24
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +460 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +34 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +297 -299
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +146 -1
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +129 -26
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +200 -124
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +301 -8
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +120 -1
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/lib/opaque-pi-reviewer-adapter.ts +0 -404
- package/skills/release/SKILL.md +0 -137
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
|
@@ -5,8 +5,8 @@ import { statSync } from "node:fs";
|
|
|
5
5
|
import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts";
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { renderShellBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
|
|
9
|
-
import { CHANGE_STATUS, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
|
|
8
|
+
import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellHeaderRule, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
|
|
9
|
+
import { CHANGE_STATUS, RootBranchLabels, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
|
|
10
10
|
import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
|
|
11
11
|
import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
|
|
12
12
|
import { CARD_TONE, renderCard, type Card, type CardTheme } from "../lib/shell-card.ts";
|
|
@@ -14,10 +14,19 @@ import { CommandPalette, commandsKey, type CommandPaletteResult } from "../lib/c
|
|
|
14
14
|
import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
|
|
15
15
|
import { agentsViewKey } from "../lib/agents-keys.ts";
|
|
16
16
|
import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
|
|
17
|
-
import { framePromptLines, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
|
|
18
|
-
import {
|
|
17
|
+
import { DOUBLE_ESC_CANCEL_HINT, framePromptLines, IDLE_ESC_CLEAR_HINT, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
|
|
18
|
+
import { gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
19
|
+
import { resolveAnimationPolicy, writeAnimationPolicy, type AnimationPolicy } from "../lib/animation-policy.ts";
|
|
20
|
+
import {
|
|
21
|
+
DOUBLE_ESC_CANCEL_WINDOW_MS,
|
|
22
|
+
resolveDoubleEscCancelPolicy,
|
|
23
|
+
writeDoubleEscCancelPolicy,
|
|
24
|
+
type DoubleEscCancelPolicy,
|
|
25
|
+
type DoubleEscCancelResolution,
|
|
26
|
+
} from "../lib/double-esc-cancel-policy.ts";
|
|
27
|
+
import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseProviderUsage, parseUsageHeaders, parseUsageSource, UsageSourceRegistry, UsageStore, USAGE_SOURCE_EVENT, type ProviderUsage, type UsageSource } from "../lib/shell-usage.ts";
|
|
19
28
|
import { UsageView } from "../lib/shell-usage-view.ts";
|
|
20
|
-
import { sidebarPart } from "../lib/shell-sidebar.ts";
|
|
29
|
+
import { sidebarHeader, sidebarPart } from "../lib/shell-sidebar.ts";
|
|
21
30
|
import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
22
31
|
import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
|
|
23
32
|
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
@@ -181,34 +190,202 @@ interface PromptEditorDeps {
|
|
|
181
190
|
bold: (text: string) => string;
|
|
182
191
|
requestRender(): void;
|
|
183
192
|
pending(): boolean;
|
|
193
|
+
now(): number;
|
|
194
|
+
/** Read fresh on every keypress: the command handler updates this in-memory, the editor never re-reads the file. */
|
|
195
|
+
doubleEscCancelEnabled(): boolean;
|
|
196
|
+
/** Hand off text reconstructed from Pi's Esc-abort restore so it is sent as the next turn instead of sitting in the editor. */
|
|
197
|
+
dispatchQueuedText(text: string): void;
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
const PROMPT_FRAME_ROLE = "border";
|
|
201
|
+
// Matches Pi's own idle double-Esc window (empty editor -> /tree or /fork);
|
|
202
|
+
// this is the same muscle memory applied to clearing a non-empty draft.
|
|
203
|
+
const IDLE_ESC_CLEAR_WINDOW_MS = 500;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Pi's own Esc-abort handler (`restoreQueuedMessagesToEditor({ abort: true
|
|
207
|
+
* })`) rebuilds the editor text as
|
|
208
|
+
* `[queuedText, currentText].filter((t) => t.trim()).join("\n\n")`, where
|
|
209
|
+
* `currentText` is the draft captured just before the abort. Reverse that
|
|
210
|
+
* join to recover the queued text alone, so the draft can be restored by
|
|
211
|
+
* itself and the queued text dispatched as the next turn. `draft` is empty
|
|
212
|
+
* (including whitespace-only) whenever `.trim() === ""`, matching the
|
|
213
|
+
* `filter` predicate above exactly.
|
|
214
|
+
*
|
|
215
|
+
* Returns `""` only for the genuine no-queue case (`combined === draft`, or
|
|
216
|
+
* both empty). Returns `undefined` when `combined` does not match Pi's join
|
|
217
|
+
* shape at all — a future Pi change, or anything else that touched the
|
|
218
|
+
* editor during the abort. That distinction matters to the caller: an empty
|
|
219
|
+
* queue means "nothing to restore," while an unrecognized shape means "do
|
|
220
|
+
* not touch what Pi already wrote," so a mismatch is never silently treated
|
|
221
|
+
* as an empty queue.
|
|
222
|
+
*/
|
|
223
|
+
export function extractQueuedText(combined: string, draft: string): string | undefined {
|
|
224
|
+
if (combined === draft) return "";
|
|
225
|
+
if (draft.trim() === "") return combined;
|
|
226
|
+
const suffix = `\n\n${draft}`;
|
|
227
|
+
return combined.endsWith(suffix) ? combined.slice(0, combined.length - suffix.length) : undefined;
|
|
228
|
+
}
|
|
187
229
|
|
|
188
230
|
export class GentlePromptEditor extends CustomEditor {
|
|
189
231
|
private promptState: PromptState = PROMPT_STATE.IDLE;
|
|
190
232
|
private tick = 0;
|
|
233
|
+
private animationPolicy: AnimationPolicy = "quality";
|
|
191
234
|
private pulse: NodeJS.Timeout | undefined;
|
|
192
235
|
private readonly deps: PromptEditorDeps;
|
|
236
|
+
// CustomEditor keeps its own `keybindings` private, so this class holds
|
|
237
|
+
// its own reference to run the same app.interrupt match before deciding
|
|
238
|
+
// whether to swallow the keystroke.
|
|
239
|
+
private readonly keybindingsManager: KeybindingsManager;
|
|
240
|
+
private pendingEscapeCancelDeadline: number | undefined;
|
|
241
|
+
private pendingIdleClearDeadline: number | undefined;
|
|
242
|
+
// Snapshot of the draft at the first Esc; the second Esc only clears when
|
|
243
|
+
// the text is still exactly this, so an edit in between never gets
|
|
244
|
+
// silently discarded (issue #1218 review).
|
|
245
|
+
private pendingIdleClearText: string | undefined;
|
|
193
246
|
|
|
194
247
|
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
|
|
195
248
|
super(tui, theme, keybindings);
|
|
196
249
|
this.deps = deps;
|
|
250
|
+
this.keybindingsManager = keybindings;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
setAnimationPolicy(policy: AnimationPolicy): void {
|
|
254
|
+
if (this.animationPolicy === policy) return;
|
|
255
|
+
this.animationPolicy = policy;
|
|
256
|
+
this.stopPulse();
|
|
257
|
+
if (this.promptState === PROMPT_STATE.WORKING) this.startPulse();
|
|
258
|
+
this.deps.requestRender();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private startPulse(): void {
|
|
262
|
+
if (this.animationPolicy === "potato") return;
|
|
263
|
+
this.pulse = setInterval(() => {
|
|
264
|
+
this.tick += 1;
|
|
265
|
+
this.deps.requestRender();
|
|
266
|
+
}, this.animationPolicy === "performance" ? 1000 : SHELL_PULSE_MS);
|
|
267
|
+
this.pulse.unref();
|
|
197
268
|
}
|
|
198
269
|
|
|
199
270
|
setWorking(working: boolean): void {
|
|
200
271
|
this.promptState = working ? PROMPT_STATE.WORKING : PROMPT_STATE.IDLE;
|
|
201
272
|
this.stopPulse();
|
|
202
|
-
if (working) {
|
|
203
|
-
this.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
this.
|
|
273
|
+
if (!working) {
|
|
274
|
+
this.pendingEscapeCancelDeadline = undefined;
|
|
275
|
+
} else {
|
|
276
|
+
this.pendingIdleClearDeadline = undefined;
|
|
277
|
+
this.pendingIdleClearText = undefined;
|
|
278
|
+
this.startPulse();
|
|
208
279
|
}
|
|
209
280
|
this.deps.requestRender();
|
|
210
281
|
}
|
|
211
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Swallow the first Esc while working (issue #1163), opt-in via
|
|
285
|
+
* doubleEscCancelEnabled(). `pi.registerShortcut("escape")` is not viable
|
|
286
|
+
* here: Pi reserves app.interrupt and skips colliding extension
|
|
287
|
+
* shortcuts, so this has to sit in front of CustomEditor's own
|
|
288
|
+
* handleInput instead. The Esc that actually aborts the turn (the single
|
|
289
|
+
* Esc when double-esc-cancel is off, or the confirming second Esc when
|
|
290
|
+
* it is on) always goes through abortAndDispatchQueued so the queued
|
|
291
|
+
* text Pi would otherwise dump back into the editor is sent as the next
|
|
292
|
+
* turn instead (issue #1218). Idle double-Esc (tree/fork), bash-mode
|
|
293
|
+
* Esc, and autocomplete cancel are all decided by CustomEditor/onEscape
|
|
294
|
+
* and never reach this branch.
|
|
295
|
+
*/
|
|
296
|
+
override handleInput(data: string): void {
|
|
297
|
+
// Any keystroke that is not the confirming Esc ends the pending idle
|
|
298
|
+
// clear, even one that leaves the text identical (type, then delete).
|
|
299
|
+
if (this.pendingIdleClearDeadline !== undefined && !this.keybindingsManager.matches(data, "app.interrupt")) {
|
|
300
|
+
this.pendingIdleClearDeadline = undefined;
|
|
301
|
+
this.pendingIdleClearText = undefined;
|
|
302
|
+
}
|
|
303
|
+
if (
|
|
304
|
+
this.promptState === PROMPT_STATE.WORKING &&
|
|
305
|
+
!this.isShowingAutocomplete() &&
|
|
306
|
+
this.keybindingsManager.matches(data, "app.interrupt")
|
|
307
|
+
) {
|
|
308
|
+
if (!this.deps.doubleEscCancelEnabled()) {
|
|
309
|
+
this.abortAndDispatchQueued(data);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (this.isPendingEscapeCancel()) {
|
|
313
|
+
this.pendingEscapeCancelDeadline = undefined;
|
|
314
|
+
this.abortAndDispatchQueued(data);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// Pi's own idle double-Esc (empty editor -> /tree or /fork) uses a
|
|
318
|
+
// 500ms window; canceling a running turn is a heavier, harder-to-undo
|
|
319
|
+
// action, so this confirmation deliberately gets double that time.
|
|
320
|
+
this.pendingEscapeCancelDeadline = this.deps.now() + DOUBLE_ESC_CANCEL_WINDOW_MS;
|
|
321
|
+
this.deps.requestRender();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Idle with a non-empty draft: Pi's own idle double-Esc only acts on an
|
|
325
|
+
// empty editor (tree/fork), so a draft's first Esc would otherwise do
|
|
326
|
+
// nothing. Mirror the same swallow-then-confirm shape as the
|
|
327
|
+
// working-cancel gate above, on the same 500ms window as Pi's own idle
|
|
328
|
+
// double-Esc (issue #1218). Bash-mode drafts ("!...", the same rule
|
|
329
|
+
// Pi's own interactive-mode uses to detect bash mode) are Pi's own
|
|
330
|
+
// bash-mode Esc territory and must never reach this gate.
|
|
331
|
+
if (
|
|
332
|
+
this.promptState === PROMPT_STATE.IDLE &&
|
|
333
|
+
!this.isShowingAutocomplete() &&
|
|
334
|
+
this.keybindingsManager.matches(data, "app.interrupt")
|
|
335
|
+
) {
|
|
336
|
+
const text = this.getText();
|
|
337
|
+
if (text.trim() !== "" && !text.trimStart().startsWith("!")) {
|
|
338
|
+
// The second Esc only clears when the text is still exactly what
|
|
339
|
+
// it was at the first Esc; an edit in between starts a fresh
|
|
340
|
+
// first press on the new text instead of silently discarding it.
|
|
341
|
+
if (this.isPendingIdleClear() && this.pendingIdleClearText === text) {
|
|
342
|
+
this.pendingIdleClearDeadline = undefined;
|
|
343
|
+
this.pendingIdleClearText = undefined;
|
|
344
|
+
this.addToHistory(text);
|
|
345
|
+
this.setText("");
|
|
346
|
+
this.deps.requestRender();
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
this.pendingIdleClearDeadline = this.deps.now() + IDLE_ESC_CLEAR_WINDOW_MS;
|
|
350
|
+
this.pendingIdleClearText = text;
|
|
351
|
+
this.deps.requestRender();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
super.handleInput(data);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Runs the Esc that actually aborts the turn. Pi's own onEscape (invoked
|
|
360
|
+
* synchronously by `super.handleInput`) restores `queuedText + draft`
|
|
361
|
+
* into the editor and aborts; snapshot the draft first, reconstruct the
|
|
362
|
+
* queued text from what comes back, restore the draft alone, and hand
|
|
363
|
+
* the queued text to the dispatcher so it is sent once the aborted run
|
|
364
|
+
* settles (see the `agent_settled` handler in `gentleShell`). Images
|
|
365
|
+
* inside queued messages are already dropped by Pi's own restore, before
|
|
366
|
+
* this code ever sees the text.
|
|
367
|
+
*
|
|
368
|
+
* `extractQueuedText` returning `undefined` means the restored text does
|
|
369
|
+
* not match Pi's own join shape; Pi's own text wins and is left exactly
|
|
370
|
+
* as it is, nothing is dispatched. An empty string means a genuine empty
|
|
371
|
+
* queue: there is nothing to restore, so `setText` is not called at all
|
|
372
|
+
* on the common no-queue path. Only a recognized, non-empty queue
|
|
373
|
+
* restores the draft and dispatches.
|
|
374
|
+
*/
|
|
375
|
+
private abortAndDispatchQueued(data: string): void {
|
|
376
|
+
const draft = this.getText();
|
|
377
|
+
super.handleInput(data);
|
|
378
|
+
const queued = extractQueuedText(this.getText(), draft);
|
|
379
|
+
// undefined: unrecognized shape, Pi's own text stays untouched.
|
|
380
|
+
if (queued === undefined) return;
|
|
381
|
+
// "": nothing was queued, and the editor already holds the draft, so no
|
|
382
|
+
// redundant write. Anything else was recognized: the draft comes back
|
|
383
|
+
// alone, and only real text (not whitespace) is worth a turn.
|
|
384
|
+
if (queued !== "") this.setText(draft);
|
|
385
|
+
if (queued.trim() === "") return;
|
|
386
|
+
this.deps.dispatchQueuedText(queued);
|
|
387
|
+
}
|
|
388
|
+
|
|
212
389
|
render(width: number): string[] {
|
|
213
390
|
const lines = super.render(Math.max(1, width - 2));
|
|
214
391
|
if (this.getText() === "" && lines.length === 3) lines[1] = withPromptHint(lines[1], PROMPT_HINT, this.deps.fg);
|
|
@@ -221,6 +398,11 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
221
398
|
borderColor: (text) => this.deps.fg(PROMPT_FRAME_ROLE, text),
|
|
222
399
|
fg: this.deps.fg,
|
|
223
400
|
bold: this.deps.bold,
|
|
401
|
+
escHint: this.promptState === PROMPT_STATE.WORKING && this.isPendingEscapeCancel()
|
|
402
|
+
? DOUBLE_ESC_CANCEL_HINT
|
|
403
|
+
: this.promptState === PROMPT_STATE.IDLE && this.isPendingIdleClear()
|
|
404
|
+
? IDLE_ESC_CLEAR_HINT
|
|
405
|
+
: undefined,
|
|
224
406
|
});
|
|
225
407
|
}
|
|
226
408
|
|
|
@@ -228,6 +410,18 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
228
410
|
this.stopPulse();
|
|
229
411
|
}
|
|
230
412
|
|
|
413
|
+
private isPendingEscapeCancel(): boolean {
|
|
414
|
+
return this.pendingEscapeCancelDeadline !== undefined && this.deps.now() < this.pendingEscapeCancelDeadline;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private isPendingIdleClear(): boolean {
|
|
418
|
+
return (
|
|
419
|
+
this.pendingIdleClearDeadline !== undefined &&
|
|
420
|
+
this.deps.now() < this.pendingIdleClearDeadline &&
|
|
421
|
+
this.pendingIdleClearText === this.getText()
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
231
425
|
private stopPulse(): void {
|
|
232
426
|
if (this.pulse) clearInterval(this.pulse);
|
|
233
427
|
this.pulse = undefined;
|
|
@@ -239,7 +433,11 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
239
433
|
const PROMPT_OWNER = Symbol.for("gentle-pi.prompt-owner");
|
|
240
434
|
type PromptFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>> & { [PROMPT_OWNER]?: boolean };
|
|
241
435
|
|
|
242
|
-
function installPrompt(
|
|
436
|
+
function installPrompt(
|
|
437
|
+
ctx: ExtensionContext,
|
|
438
|
+
onCreated: (prompt: GentlePromptEditor) => void,
|
|
439
|
+
promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void },
|
|
440
|
+
): boolean {
|
|
243
441
|
const previous = ctx.ui.getEditorComponent() as PromptFactory | undefined;
|
|
244
442
|
if (previous && !previous[PROMPT_OWNER]) return false;
|
|
245
443
|
const factory: PromptFactory = (tui, theme, keybindings) => {
|
|
@@ -248,6 +446,9 @@ function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEd
|
|
|
248
446
|
bold: (text) => ctx.ui.theme.bold(text),
|
|
249
447
|
requestRender: () => tui.requestRender(),
|
|
250
448
|
pending: () => ctx.hasPendingMessages(),
|
|
449
|
+
now: promptDeps.now,
|
|
450
|
+
doubleEscCancelEnabled: promptDeps.doubleEscCancelEnabled,
|
|
451
|
+
dispatchQueuedText: promptDeps.dispatchQueuedText,
|
|
251
452
|
});
|
|
252
453
|
onCreated(prompt);
|
|
253
454
|
return prompt;
|
|
@@ -257,6 +458,45 @@ function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEd
|
|
|
257
458
|
return true;
|
|
258
459
|
}
|
|
259
460
|
|
|
461
|
+
const DOUBLE_ESC_CANCEL_COMMAND_NAME = "gentle:double-esc-cancel";
|
|
462
|
+
|
|
463
|
+
function describeDoubleEscCancelSource(resolution: DoubleEscCancelResolution): string {
|
|
464
|
+
switch (resolution.source) {
|
|
465
|
+
case "global_file":
|
|
466
|
+
return `global file ${resolution.globalFile}`;
|
|
467
|
+
case "environment":
|
|
468
|
+
return "GENTLE_PI_DOUBLE_ESC_CANCEL";
|
|
469
|
+
default:
|
|
470
|
+
return "built-in default";
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Report the effective policy, the source that decided it, and (when this
|
|
476
|
+
* invocation just wrote one) the policy it wrote. Unlike background-subagents
|
|
477
|
+
* there is no project-file layer to outrank the write, so a write always
|
|
478
|
+
* takes effect immediately.
|
|
479
|
+
*/
|
|
480
|
+
function renderDoubleEscCancelReport(
|
|
481
|
+
resolution: DoubleEscCancelResolution,
|
|
482
|
+
wrote?: DoubleEscCancelPolicy,
|
|
483
|
+
): { message: string; type: "info" | "warning" } {
|
|
484
|
+
const lines = [`double-esc-cancel: ${resolution.policy} (decided by ${describeDoubleEscCancelSource(resolution)})`];
|
|
485
|
+
if (wrote !== undefined) lines.push(`Wrote ${wrote} to the global file ${resolution.globalFile}.`);
|
|
486
|
+
if (resolution.malformed) {
|
|
487
|
+
lines.push(`${resolution.globalFile} is present but malformed, so the policy fails closed to off and the environment variable is not consulted.`);
|
|
488
|
+
}
|
|
489
|
+
if (resolution.envValue !== undefined && resolution.source !== "environment") {
|
|
490
|
+
lines.push(
|
|
491
|
+
resolution.envValue === "on" || resolution.envValue === "off"
|
|
492
|
+
? `GENTLE_PI_DOUBLE_ESC_CANCEL=${resolution.envValue} is set, but the global file exists and decides; the env var applies only when no file exists.`
|
|
493
|
+
: `GENTLE_PI_DOUBLE_ESC_CANCEL="${resolution.envValue}" is not a recognized value ("on" or "off"), so it is ignored.`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
lines.push("Resolution order (first hit wins): global file, GENTLE_PI_DOUBLE_ESC_CANCEL, built-in default off.");
|
|
497
|
+
return { message: lines.join("\n"), type: resolution.malformed ? "warning" : "info" };
|
|
498
|
+
}
|
|
499
|
+
|
|
260
500
|
const CHANGES_WIDGET_KEY = "gentle-shell-changes";
|
|
261
501
|
const CHANGES_COMMAND_NAME = "gentle:changes";
|
|
262
502
|
const CHANGES_SHORTCUT_DEFAULT = "alt+g";
|
|
@@ -318,6 +558,12 @@ export function changesShortcut(env: NodeJS.ProcessEnv = process.env): string |
|
|
|
318
558
|
return value === "" || value.toLowerCase() === "off" ? undefined : value;
|
|
319
559
|
}
|
|
320
560
|
|
|
561
|
+
export function usageShortcut(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
562
|
+
const value = env.GENTLE_PI_SHELL_USAGE_KEY?.trim();
|
|
563
|
+
if (value === undefined) return USAGE_SHORTCUT_DEFAULT;
|
|
564
|
+
return value === "" || value.toLowerCase() === "off" ? undefined : value;
|
|
565
|
+
}
|
|
566
|
+
|
|
321
567
|
function positiveMs(value: string | undefined, fallback: number): number {
|
|
322
568
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
323
569
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
@@ -337,15 +583,24 @@ interface OverlayDeps {
|
|
|
337
583
|
refresh(): Promise<ChangesModel>;
|
|
338
584
|
apply(ctx: ExtensionContext, model: ChangesModel): void;
|
|
339
585
|
pollMs: number;
|
|
586
|
+
gitForRoot(root: string): GitRunner;
|
|
340
587
|
}
|
|
341
588
|
|
|
342
|
-
// Refresh only the captured session model. Never read live files
|
|
589
|
+
// Refresh only the captured session model. Never read live files here; the
|
|
590
|
+
// only Git the overlay touches is each root's HEAD, to label its tree.
|
|
343
591
|
async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
|
|
344
592
|
let host: ExternalEditorHost | undefined;
|
|
345
593
|
let view: WorktreeChangesView | undefined;
|
|
594
|
+
// Session evidence knows roots, not branches; label them while the overlay
|
|
595
|
+
// is open and repaint when Git answers.
|
|
596
|
+
const labels = new RootBranchLabels(deps.gitForRoot, () => {
|
|
597
|
+
view?.update(labels.decorate(deps.worktrees()));
|
|
598
|
+
host?.requestRender();
|
|
599
|
+
});
|
|
600
|
+
const worktrees = () => labels.decorate(deps.worktrees());
|
|
346
601
|
const refresh = async () => {
|
|
347
602
|
const latest = await deps.refresh();
|
|
348
|
-
view?.update(
|
|
603
|
+
view?.update(worktrees());
|
|
349
604
|
deps.apply(ctx, latest);
|
|
350
605
|
};
|
|
351
606
|
const poll = setInterval(() => void refresh(), deps.pollMs);
|
|
@@ -354,7 +609,7 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
|
|
|
354
609
|
const chosen = await ctx.ui.custom<{ root: string; file: ChangedFile } | null>(
|
|
355
610
|
(tui, theme, _keybindings, done) => {
|
|
356
611
|
host = tui;
|
|
357
|
-
view = new WorktreeChangesView(
|
|
612
|
+
view = new WorktreeChangesView(worktrees(), {
|
|
358
613
|
theme,
|
|
359
614
|
rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
|
|
360
615
|
loadDiff: (root, file) => Promise.resolve(deps.loadDiff(root, file)),
|
|
@@ -417,6 +672,7 @@ function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
|
|
|
417
672
|
}
|
|
418
673
|
|
|
419
674
|
const USAGE_COMMAND_NAME = "gentle:usage";
|
|
675
|
+
const USAGE_SHORTCUT_DEFAULT = "alt+u";
|
|
420
676
|
const REVIEW_PREFLIGHT_TYPE = "gentle-pi.review-preflight";
|
|
421
677
|
const DEV_BINARY_WIDGET_KEY = "gentle-shell-dev-binary";
|
|
422
678
|
const SHA_PREFIX_LENGTH = 16;
|
|
@@ -502,28 +758,67 @@ export async function fetchNanUsage(apiKey: string | undefined, fetchFn: typeof
|
|
|
502
758
|
}
|
|
503
759
|
}
|
|
504
760
|
|
|
761
|
+
// A registered source is foreign code running inside a fire-and-forget
|
|
762
|
+
// refresh: it must degrade exactly like the built-in fetchers above, never
|
|
763
|
+
// throw past this call, and never leave an unhandled rejection behind.
|
|
764
|
+
async function fetchFromSource(source: UsageSource, apiKey: string | undefined, fetchFn: typeof fetch, now: number): Promise<ProviderUsage | undefined> {
|
|
765
|
+
try {
|
|
766
|
+
const result = await source.fetch(apiKey, fetchFn, now);
|
|
767
|
+
return result === undefined ? undefined : parseProviderUsage(result, source.provider);
|
|
768
|
+
} catch {
|
|
769
|
+
return undefined;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
505
773
|
export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
|
|
506
774
|
installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
|
|
507
775
|
if (!shellEnabled(env)) return;
|
|
508
776
|
const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides };
|
|
509
777
|
const usage = new UsageStore();
|
|
778
|
+
// Providers gentle-shell has never heard of get a usage source too, when
|
|
779
|
+
// the extension that owns them registers one on pi.events; see the
|
|
780
|
+
// USAGE_SOURCE_EVENT subscription below.
|
|
781
|
+
const usageSources = new UsageSourceRegistry();
|
|
510
782
|
let renderHost: ShellRenderHost | undefined;
|
|
511
783
|
// The 5-minute rule is per provider: one provider's fetch cannot leave the
|
|
512
784
|
// next one waiting for an interval it never used.
|
|
513
785
|
const usageFetchedAt = new Map<string, number>();
|
|
514
786
|
const refreshUsage = async (ctx: ExtensionContext, force: boolean) => {
|
|
515
787
|
const provider = ctx.model?.provider;
|
|
516
|
-
if (provider
|
|
788
|
+
if (!provider) return;
|
|
789
|
+
const source = usageSources.get(provider);
|
|
790
|
+
if (!source && provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return;
|
|
517
791
|
const now = deps.now();
|
|
518
792
|
if (!force && now - (usageFetchedAt.get(provider) ?? 0) < USAGE_REFRESH_MS) return;
|
|
519
793
|
usageFetchedAt.set(provider, now);
|
|
520
794
|
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider).catch(() => undefined);
|
|
521
|
-
const fetched =
|
|
795
|
+
const fetched = source
|
|
796
|
+
? await fetchFromSource(source, apiKey, deps.fetch, deps.now())
|
|
797
|
+
: provider === NAN_PROVIDER
|
|
798
|
+
? await fetchNanUsage(apiKey, deps.fetch, deps.now())
|
|
799
|
+
: await fetchCodexUsage(apiKey, deps.fetch, deps.now());
|
|
522
800
|
if (!fetched) return;
|
|
801
|
+
// A registered source can be replaced while its own fetch is still in
|
|
802
|
+
// flight; the identity captured above is this call's source, so a stale
|
|
803
|
+
// answer that outlives its replacement is discarded instead of
|
|
804
|
+
// overwriting whatever the replacement already recorded.
|
|
805
|
+
if (source && usageSources.get(provider) !== source) return;
|
|
523
806
|
usage.record(fetched);
|
|
524
807
|
renderHost?.invalidateSidebar?.();
|
|
525
808
|
renderHost?.requestRender();
|
|
526
809
|
};
|
|
810
|
+
// Subscribed once, for the life of the extension: a registration can
|
|
811
|
+
// arrive before the first session_start (the owning extension's factory
|
|
812
|
+
// runs first) or after it (its own session_start fires later, or it
|
|
813
|
+
// registers lazily). Either order is fine: a registration for the
|
|
814
|
+
// currently active provider forces exactly one refresh, so the panel
|
|
815
|
+
// never waits for the 5-minute window or the next turn to notice it.
|
|
816
|
+
pi.events.on(USAGE_SOURCE_EVENT, (payload) => {
|
|
817
|
+
const source = parseUsageSource(payload);
|
|
818
|
+
if (!source) return;
|
|
819
|
+
usageSources.register(source);
|
|
820
|
+
if (currentContext?.model?.provider === source.provider) void refreshUsage(currentContext, true);
|
|
821
|
+
});
|
|
527
822
|
pi.on("after_provider_response", (event) => {
|
|
528
823
|
const parsed = parseUsageHeaders(event.headers, deps.now());
|
|
529
824
|
if (!parsed) return;
|
|
@@ -536,25 +831,54 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
536
831
|
const hint = keyHint("app.tools.expand", options.expanded ? "collapse" : "expand");
|
|
537
832
|
return cardComponent({ title: "Gentle AI", subtitle: "review preflight", body, tone: CARD_TONE.INFO }, theme, { expanded: options.expanded, hint });
|
|
538
833
|
});
|
|
834
|
+
const openUsage = async (ctx: ExtensionContext) => {
|
|
835
|
+
await refreshUsage(ctx, true);
|
|
836
|
+
await ctx.ui.custom<null>(
|
|
837
|
+
(tui, theme, _keybindings, done) =>
|
|
838
|
+
new UsageView(usage, {
|
|
839
|
+
theme,
|
|
840
|
+
now: () => deps.now(),
|
|
841
|
+
active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
|
|
842
|
+
registry: () => usageSources,
|
|
843
|
+
onRefresh: () => refreshUsage(ctx, true),
|
|
844
|
+
onClose: () => done(null),
|
|
845
|
+
requestRender: () => tui.requestRender(),
|
|
846
|
+
}),
|
|
847
|
+
{ overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
|
|
848
|
+
);
|
|
849
|
+
};
|
|
539
850
|
pi.registerCommand(USAGE_COMMAND_NAME, {
|
|
540
851
|
description: "Show subscription usage windows for the connected providers. Press r to refetch.",
|
|
541
|
-
handler: async (_args, ctx) =>
|
|
542
|
-
await refreshUsage(ctx, true);
|
|
543
|
-
await ctx.ui.custom<null>(
|
|
544
|
-
(tui, theme, _keybindings, done) =>
|
|
545
|
-
new UsageView(usage, {
|
|
546
|
-
theme,
|
|
547
|
-
now: () => deps.now(),
|
|
548
|
-
active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
|
|
549
|
-
onRefresh: () => refreshUsage(ctx, true),
|
|
550
|
-
onClose: () => done(null),
|
|
551
|
-
requestRender: () => tui.requestRender(),
|
|
552
|
-
}),
|
|
553
|
-
{ overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
|
|
554
|
-
);
|
|
555
|
-
},
|
|
852
|
+
handler: async (_args, ctx) => openUsage(ctx),
|
|
556
853
|
});
|
|
854
|
+
const usageShortcutKey = usageShortcut(env);
|
|
855
|
+
if (usageShortcutKey) {
|
|
856
|
+
pi.registerShortcut(usageShortcutKey as Parameters<ExtensionAPI["registerShortcut"]>[0], {
|
|
857
|
+
description: "Show subscription usage windows for the connected providers",
|
|
858
|
+
handler: async (ctx) => openUsage(ctx),
|
|
859
|
+
});
|
|
860
|
+
}
|
|
557
861
|
let prompt: GentlePromptEditor | undefined;
|
|
862
|
+
// Set by abortAndDispatchQueued via dispatchQueuedText when an Esc aborts
|
|
863
|
+
// a turn with a non-empty queue; sent exactly once, from agent_settled,
|
|
864
|
+
// once the aborted run has fully settled (issue #1218). Several aborts
|
|
865
|
+
// before that settle append in order, joined the way Pi joins its own
|
|
866
|
+
// queue, so nothing is overwritten. It belongs to the current session and
|
|
867
|
+
// is dropped on session_shutdown.
|
|
868
|
+
let pendingQueuedText: string | undefined;
|
|
869
|
+
// Resolved once at startup and cached in memory so the editor never
|
|
870
|
+
// re-reads the file per keypress. The /gentle:double-esc-cancel command
|
|
871
|
+
// below is the only place that touches the file, and every invocation
|
|
872
|
+
// re-syncs this cache from disk first, so status, the no-argument toggle
|
|
873
|
+
// direction, and the Esc gate always describe the same effective policy
|
|
874
|
+
// even when another session or a hand edit changed the file mid-session.
|
|
875
|
+
const doubleEscCancelConfigHome = gentlePiConfigHome(env);
|
|
876
|
+
const animationOptions = { gentlePiConfigHome: doubleEscCancelConfigHome };
|
|
877
|
+
let animationPolicy = resolveAnimationPolicy(animationOptions).policy;
|
|
878
|
+
let doubleEscCancelPolicy: DoubleEscCancelPolicy = resolveDoubleEscCancelPolicy({
|
|
879
|
+
env,
|
|
880
|
+
gentlePiConfigHome: doubleEscCancelConfigHome,
|
|
881
|
+
}).policy;
|
|
558
882
|
let changes: SessionChanges | undefined;
|
|
559
883
|
let registry: SessionWorktreeRegistry | undefined;
|
|
560
884
|
let currentContext: ExtensionContext | undefined;
|
|
@@ -616,14 +940,36 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
616
940
|
render: (width) => renderShellSidebarBar(footerModel(), theme, width),
|
|
617
941
|
invalidate() {},
|
|
618
942
|
});
|
|
943
|
+
// The header row carries everything that ticks every frame (model,
|
|
944
|
+
// effort, context, cost, usage) plus session identity; it never sees
|
|
945
|
+
// extension statuses or the working/thinking state.
|
|
946
|
+
const headerBar = (width: number) => renderShellHeaderBar(buildShellHeaderModel(footerModel()), theme, width, usageShortcutKey);
|
|
947
|
+
const disposeHeader = sidebarHeader(tui, {
|
|
948
|
+
digest: () => JSON.stringify(buildShellHeaderModel(footerModel())),
|
|
949
|
+
render: (width) => [headerBar(width).text, renderShellHeaderRule(theme, width)],
|
|
950
|
+
invalidate() {},
|
|
951
|
+
handleMouse(event) {
|
|
952
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
953
|
+
if (event.y !== 0) return undefined; // the rule row under the status line is decorative, never clickable
|
|
954
|
+
const { usageSpan } = headerBar(event.width);
|
|
955
|
+
if (!usageSpan || event.x < usageSpan.start || event.x >= usageSpan.end) return undefined;
|
|
956
|
+
void openUsage(ctx);
|
|
957
|
+
return { handled: true, render: true };
|
|
958
|
+
},
|
|
959
|
+
});
|
|
619
960
|
const uninstall = installSidebar(tui, theme);
|
|
620
|
-
return { ...part, dispose() { uninstall(); part.dispose(); } };
|
|
961
|
+
return { ...part, dispose() { disposeHeader(); uninstall(); part.dispose(); } };
|
|
621
962
|
});
|
|
622
963
|
void refreshUsage(ctx, true);
|
|
623
|
-
const ownsPrompt = installPrompt(
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
964
|
+
const ownsPrompt = installPrompt(
|
|
965
|
+
ctx,
|
|
966
|
+
(created) => {
|
|
967
|
+
prompt?.dispose();
|
|
968
|
+
prompt = created;
|
|
969
|
+
prompt.setAnimationPolicy(animationPolicy);
|
|
970
|
+
},
|
|
971
|
+
{ now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; } },
|
|
972
|
+
);
|
|
627
973
|
// Hide native feedback only when our petal replaces it. Native transcript
|
|
628
974
|
// thinking blocks remain Pi-owned; this changes only the supported loader UI.
|
|
629
975
|
if (ownsPrompt) ctx.ui.setWorkingVisible(false);
|
|
@@ -639,6 +985,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
639
985
|
applyChanges(ctx, tracker.model);
|
|
640
986
|
});
|
|
641
987
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
988
|
+
pendingQueuedText = undefined;
|
|
642
989
|
prompt?.dispose();
|
|
643
990
|
prompt = undefined;
|
|
644
991
|
if ((ctx.ui.getEditorComponent() as PromptFactory | undefined)?.[PROMPT_OWNER]) {
|
|
@@ -659,7 +1006,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
659
1006
|
ctx.ui.notify("No captured agent changes. Only successful write/edit operations from this session and its subagents are shown; shell changes are not attributed.", "info");
|
|
660
1007
|
return;
|
|
661
1008
|
}
|
|
662
|
-
await showChangesOverlay(ctx, { loadDiff: (root, file) => tracker.loadDiff(root, file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
|
|
1009
|
+
await showChangesOverlay(ctx, { loadDiff: (root, file) => tracker.loadDiff(root, file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env), gitForRoot: (root) => deps.gitRunner(root) });
|
|
663
1010
|
};
|
|
664
1011
|
pi.registerCommand(CHANGES_COMMAND_NAME, {
|
|
665
1012
|
description: "Browse captured write/edit changes from this agent session and its subagents, excluding preexisting and external edits. Shell changes are not attributed. Press o to open $EDITOR.",
|
|
@@ -683,13 +1030,103 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
683
1030
|
handler: async (ctx) => showCommandPalette(pi, ctx, env),
|
|
684
1031
|
});
|
|
685
1032
|
}
|
|
1033
|
+
pi.registerCommand("gentle:animations", {
|
|
1034
|
+
description: "Show or set global animations; no argument opens a selectable menu (quality|performance|potato, plus status).",
|
|
1035
|
+
// No argument opens a selectable menu when an interactive UI is present;
|
|
1036
|
+
// headless callers and fakes without ui.select keep the status fallback.
|
|
1037
|
+
handler: async (args, ctx) => {
|
|
1038
|
+
let action = args.trim() || "status";
|
|
1039
|
+
if (args.trim().length === 0 && ctx.hasUI && typeof ctx.ui.select === "function") {
|
|
1040
|
+
const selected = await ctx.ui.select(
|
|
1041
|
+
`Gentle animations (current: ${animationPolicy})`,
|
|
1042
|
+
["quality", "performance", "potato", "status"],
|
|
1043
|
+
);
|
|
1044
|
+
if (selected === undefined) return;
|
|
1045
|
+
action = selected;
|
|
1046
|
+
}
|
|
1047
|
+
if (action !== "status" && action !== "quality" && action !== "performance" && action !== "potato") {
|
|
1048
|
+
ctx.ui.notify("Use /gentle:animations status|quality|performance|potato.", "warning");
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
try {
|
|
1052
|
+
if (action !== "status") writeAnimationPolicy(action, animationOptions);
|
|
1053
|
+
const result = resolveAnimationPolicy(animationOptions);
|
|
1054
|
+
animationPolicy = result.policy;
|
|
1055
|
+
prompt?.setAnimationPolicy(animationPolicy);
|
|
1056
|
+
const source = result.source === "default" ? "built-in default" : `global file ${result.globalFile}`;
|
|
1057
|
+
ctx.ui.notify(`animations: ${result.policy} (decided by ${source})${result.malformed ? "; malformed or unreadable file, falling back to quality" : ""}. Prompt applies now; startup banner applies at next creation.`, result.malformed ? "warning" : "info");
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
1060
|
+
}
|
|
1061
|
+
},
|
|
1062
|
+
});
|
|
1063
|
+
// User-owned, like gentle:background-subagents and gentle:review-mode: the
|
|
1064
|
+
// only writer is this handler, reached only by explicit invocation. Unlike
|
|
1065
|
+
// those two, no argument toggles the effective policy instead of merely
|
|
1066
|
+
// reporting it (see odd/tasks/double-esc-cancel.md).
|
|
1067
|
+
pi.registerCommand(DOUBLE_ESC_CANCEL_COMMAND_NAME, {
|
|
1068
|
+
description: "Show or set the double-esc-cancel preference (status|enable|disable); no argument toggles it. User-initiated only.",
|
|
1069
|
+
handler: async (args, ctx) => {
|
|
1070
|
+
const trimmed = args.trim();
|
|
1071
|
+
if (trimmed !== "" && trimmed !== "status" && trimmed !== "enable" && trimmed !== "disable") {
|
|
1072
|
+
ctx.ui.notify(`Unknown /${DOUBLE_ESC_CANCEL_COMMAND_NAME} sub-action "${trimmed}". Use status, enable, or disable.`, "warning");
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
try {
|
|
1076
|
+
const before = resolveDoubleEscCancelPolicy({ env, gentlePiConfigHome: doubleEscCancelConfigHome });
|
|
1077
|
+
doubleEscCancelPolicy = before.policy;
|
|
1078
|
+
const subAction = trimmed === "" ? (before.policy === "on" ? "disable" : "enable") : trimmed;
|
|
1079
|
+
if (subAction === "status") {
|
|
1080
|
+
const report = renderDoubleEscCancelReport(before);
|
|
1081
|
+
ctx.ui.notify(report.message, report.type);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
const wrote: DoubleEscCancelPolicy = subAction === "enable" ? "on" : "off";
|
|
1085
|
+
writeDoubleEscCancelPolicy(wrote, { gentlePiConfigHome: doubleEscCancelConfigHome });
|
|
1086
|
+
const after = resolveDoubleEscCancelPolicy({ env, gentlePiConfigHome: doubleEscCancelConfigHome });
|
|
1087
|
+
// Cache what the file actually resolves to, not what was written: a
|
|
1088
|
+
// competing writer or a read failure would otherwise leave the gate
|
|
1089
|
+
// and the report disagreeing.
|
|
1090
|
+
doubleEscCancelPolicy = after.policy;
|
|
1091
|
+
const report = renderDoubleEscCancelReport(after, wrote);
|
|
1092
|
+
ctx.ui.notify(report.message, report.type);
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
});
|
|
686
1098
|
pi.on("agent_start", (_event, ctx) => {
|
|
1099
|
+
// A turn can start any other way (the user sending the draft, an
|
|
1100
|
+
// extension, a shortcut) before the aborted run's own agent_settled
|
|
1101
|
+
// below has delivered the pending text. Nothing is sent from here: Pi
|
|
1102
|
+
// is mid-turn, so the text simply waits and goes out, once, when that
|
|
1103
|
+
// turn settles. It is never dropped.
|
|
687
1104
|
prompt?.setWorking(true);
|
|
688
1105
|
// The dev-binary card is a startup notice: it leaves with the first prompt.
|
|
689
1106
|
if (ctx.hasUI) ctx.ui.setWidget(DEV_BINARY_WIDGET_KEY, undefined);
|
|
690
1107
|
});
|
|
691
|
-
pi.on("agent_settled", () => {
|
|
1108
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
1109
|
+
// Pi clears its own run-active flag before emitting agent_settled, so
|
|
1110
|
+
// this is normally idle; if a run is somehow still in flight the prompt
|
|
1111
|
+
// stays working and the pending text waits for the next settle.
|
|
1112
|
+
if (!ctx.isIdle()) return;
|
|
692
1113
|
prompt?.setWorking(false);
|
|
1114
|
+
if (pendingQueuedText === undefined) return;
|
|
1115
|
+
const queued = pendingQueuedText;
|
|
1116
|
+
pendingQueuedText = undefined;
|
|
1117
|
+
try {
|
|
1118
|
+
pi.sendUserMessage(queued);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
// Never drop the user's words: put them back in front of the draft,
|
|
1121
|
+
// exactly the shape Pi's own restore would have left, and say why.
|
|
1122
|
+
if (prompt) {
|
|
1123
|
+
const current = prompt.getText();
|
|
1124
|
+
prompt.setText([queued, current].filter((text) => text.trim() !== "").join("\n\n"));
|
|
1125
|
+
} else {
|
|
1126
|
+
pendingQueuedText = queued;
|
|
1127
|
+
}
|
|
1128
|
+
if (ctx.hasUI) ctx.ui.notify(`Could not send the queued message after cancel; it is back in the editor: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1129
|
+
}
|
|
693
1130
|
});
|
|
694
1131
|
pi.on("agent_end", async (_event, ctx) => {
|
|
695
1132
|
await refreshChanges(ctx);
|