gentle-pi 3.2.1 → 3.3.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/docs/gentle-shell.md +27 -14
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +22 -17
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +123 -21
- package/extensions/gentle-shell.ts +379 -31
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/native-review-cli.ts +14 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +246 -171
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +150 -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 +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +14 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/verify-package-files.mjs +3 -3
- 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/command-palette.test.ts +1 -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 +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +795 -23
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +19 -1
- 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 -6
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +277 -300
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +22 -24
- 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 +192 -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 +243 -5
- 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/lib/opaque-pi-reviewer-adapter.ts +0 -404
- 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, 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,18 @@ 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";
|
|
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 {
|
|
20
|
+
DOUBLE_ESC_CANCEL_WINDOW_MS,
|
|
21
|
+
resolveDoubleEscCancelPolicy,
|
|
22
|
+
writeDoubleEscCancelPolicy,
|
|
23
|
+
type DoubleEscCancelPolicy,
|
|
24
|
+
type DoubleEscCancelResolution,
|
|
25
|
+
} from "../lib/double-esc-cancel-policy.ts";
|
|
18
26
|
import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseUsageHeaders, UsageStore, type ProviderUsage } from "../lib/shell-usage.ts";
|
|
19
27
|
import { UsageView } from "../lib/shell-usage-view.ts";
|
|
20
|
-
import { sidebarPart } from "../lib/shell-sidebar.ts";
|
|
28
|
+
import { sidebarHeader, sidebarPart } from "../lib/shell-sidebar.ts";
|
|
21
29
|
import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
22
30
|
import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
|
|
23
31
|
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
@@ -181,25 +189,73 @@ interface PromptEditorDeps {
|
|
|
181
189
|
bold: (text: string) => string;
|
|
182
190
|
requestRender(): void;
|
|
183
191
|
pending(): boolean;
|
|
192
|
+
now(): number;
|
|
193
|
+
/** Read fresh on every keypress: the command handler updates this in-memory, the editor never re-reads the file. */
|
|
194
|
+
doubleEscCancelEnabled(): boolean;
|
|
195
|
+
/** Hand off text reconstructed from Pi's Esc-abort restore so it is sent as the next turn instead of sitting in the editor. */
|
|
196
|
+
dispatchQueuedText(text: string): void;
|
|
184
197
|
}
|
|
185
198
|
|
|
186
199
|
const PROMPT_FRAME_ROLE = "border";
|
|
200
|
+
// Matches Pi's own idle double-Esc window (empty editor -> /tree or /fork);
|
|
201
|
+
// this is the same muscle memory applied to clearing a non-empty draft.
|
|
202
|
+
const IDLE_ESC_CLEAR_WINDOW_MS = 500;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Pi's own Esc-abort handler (`restoreQueuedMessagesToEditor({ abort: true
|
|
206
|
+
* })`) rebuilds the editor text as
|
|
207
|
+
* `[queuedText, currentText].filter((t) => t.trim()).join("\n\n")`, where
|
|
208
|
+
* `currentText` is the draft captured just before the abort. Reverse that
|
|
209
|
+
* join to recover the queued text alone, so the draft can be restored by
|
|
210
|
+
* itself and the queued text dispatched as the next turn. `draft` is empty
|
|
211
|
+
* (including whitespace-only) whenever `.trim() === ""`, matching the
|
|
212
|
+
* `filter` predicate above exactly.
|
|
213
|
+
*
|
|
214
|
+
* Returns `""` only for the genuine no-queue case (`combined === draft`, or
|
|
215
|
+
* both empty). Returns `undefined` when `combined` does not match Pi's join
|
|
216
|
+
* shape at all — a future Pi change, or anything else that touched the
|
|
217
|
+
* editor during the abort. That distinction matters to the caller: an empty
|
|
218
|
+
* queue means "nothing to restore," while an unrecognized shape means "do
|
|
219
|
+
* not touch what Pi already wrote," so a mismatch is never silently treated
|
|
220
|
+
* as an empty queue.
|
|
221
|
+
*/
|
|
222
|
+
export function extractQueuedText(combined: string, draft: string): string | undefined {
|
|
223
|
+
if (combined === draft) return "";
|
|
224
|
+
if (draft.trim() === "") return combined;
|
|
225
|
+
const suffix = `\n\n${draft}`;
|
|
226
|
+
return combined.endsWith(suffix) ? combined.slice(0, combined.length - suffix.length) : undefined;
|
|
227
|
+
}
|
|
187
228
|
|
|
188
229
|
export class GentlePromptEditor extends CustomEditor {
|
|
189
230
|
private promptState: PromptState = PROMPT_STATE.IDLE;
|
|
190
231
|
private tick = 0;
|
|
191
232
|
private pulse: NodeJS.Timeout | undefined;
|
|
192
233
|
private readonly deps: PromptEditorDeps;
|
|
234
|
+
// CustomEditor keeps its own `keybindings` private, so this class holds
|
|
235
|
+
// its own reference to run the same app.interrupt match before deciding
|
|
236
|
+
// whether to swallow the keystroke.
|
|
237
|
+
private readonly keybindingsManager: KeybindingsManager;
|
|
238
|
+
private pendingEscapeCancelDeadline: number | undefined;
|
|
239
|
+
private pendingIdleClearDeadline: number | undefined;
|
|
240
|
+
// Snapshot of the draft at the first Esc; the second Esc only clears when
|
|
241
|
+
// the text is still exactly this, so an edit in between never gets
|
|
242
|
+
// silently discarded (issue #1218 review).
|
|
243
|
+
private pendingIdleClearText: string | undefined;
|
|
193
244
|
|
|
194
245
|
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
|
|
195
246
|
super(tui, theme, keybindings);
|
|
196
247
|
this.deps = deps;
|
|
248
|
+
this.keybindingsManager = keybindings;
|
|
197
249
|
}
|
|
198
250
|
|
|
199
251
|
setWorking(working: boolean): void {
|
|
200
252
|
this.promptState = working ? PROMPT_STATE.WORKING : PROMPT_STATE.IDLE;
|
|
201
253
|
this.stopPulse();
|
|
202
|
-
if (working) {
|
|
254
|
+
if (!working) {
|
|
255
|
+
this.pendingEscapeCancelDeadline = undefined;
|
|
256
|
+
} else {
|
|
257
|
+
this.pendingIdleClearDeadline = undefined;
|
|
258
|
+
this.pendingIdleClearText = undefined;
|
|
203
259
|
this.pulse = setInterval(() => {
|
|
204
260
|
this.tick += 1;
|
|
205
261
|
this.deps.requestRender();
|
|
@@ -209,6 +265,112 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
209
265
|
this.deps.requestRender();
|
|
210
266
|
}
|
|
211
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Swallow the first Esc while working (issue #1163), opt-in via
|
|
270
|
+
* doubleEscCancelEnabled(). `pi.registerShortcut("escape")` is not viable
|
|
271
|
+
* here: Pi reserves app.interrupt and skips colliding extension
|
|
272
|
+
* shortcuts, so this has to sit in front of CustomEditor's own
|
|
273
|
+
* handleInput instead. The Esc that actually aborts the turn (the single
|
|
274
|
+
* Esc when double-esc-cancel is off, or the confirming second Esc when
|
|
275
|
+
* it is on) always goes through abortAndDispatchQueued so the queued
|
|
276
|
+
* text Pi would otherwise dump back into the editor is sent as the next
|
|
277
|
+
* turn instead (issue #1218). Idle double-Esc (tree/fork), bash-mode
|
|
278
|
+
* Esc, and autocomplete cancel are all decided by CustomEditor/onEscape
|
|
279
|
+
* and never reach this branch.
|
|
280
|
+
*/
|
|
281
|
+
override handleInput(data: string): void {
|
|
282
|
+
// Any keystroke that is not the confirming Esc ends the pending idle
|
|
283
|
+
// clear, even one that leaves the text identical (type, then delete).
|
|
284
|
+
if (this.pendingIdleClearDeadline !== undefined && !this.keybindingsManager.matches(data, "app.interrupt")) {
|
|
285
|
+
this.pendingIdleClearDeadline = undefined;
|
|
286
|
+
this.pendingIdleClearText = undefined;
|
|
287
|
+
}
|
|
288
|
+
if (
|
|
289
|
+
this.promptState === PROMPT_STATE.WORKING &&
|
|
290
|
+
!this.isShowingAutocomplete() &&
|
|
291
|
+
this.keybindingsManager.matches(data, "app.interrupt")
|
|
292
|
+
) {
|
|
293
|
+
if (!this.deps.doubleEscCancelEnabled()) {
|
|
294
|
+
this.abortAndDispatchQueued(data);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (this.isPendingEscapeCancel()) {
|
|
298
|
+
this.pendingEscapeCancelDeadline = undefined;
|
|
299
|
+
this.abortAndDispatchQueued(data);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
// Pi's own idle double-Esc (empty editor -> /tree or /fork) uses a
|
|
303
|
+
// 500ms window; canceling a running turn is a heavier, harder-to-undo
|
|
304
|
+
// action, so this confirmation deliberately gets double that time.
|
|
305
|
+
this.pendingEscapeCancelDeadline = this.deps.now() + DOUBLE_ESC_CANCEL_WINDOW_MS;
|
|
306
|
+
this.deps.requestRender();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Idle with a non-empty draft: Pi's own idle double-Esc only acts on an
|
|
310
|
+
// empty editor (tree/fork), so a draft's first Esc would otherwise do
|
|
311
|
+
// nothing. Mirror the same swallow-then-confirm shape as the
|
|
312
|
+
// working-cancel gate above, on the same 500ms window as Pi's own idle
|
|
313
|
+
// double-Esc (issue #1218). Bash-mode drafts ("!...", the same rule
|
|
314
|
+
// Pi's own interactive-mode uses to detect bash mode) are Pi's own
|
|
315
|
+
// bash-mode Esc territory and must never reach this gate.
|
|
316
|
+
if (
|
|
317
|
+
this.promptState === PROMPT_STATE.IDLE &&
|
|
318
|
+
!this.isShowingAutocomplete() &&
|
|
319
|
+
this.keybindingsManager.matches(data, "app.interrupt")
|
|
320
|
+
) {
|
|
321
|
+
const text = this.getText();
|
|
322
|
+
if (text.trim() !== "" && !text.trimStart().startsWith("!")) {
|
|
323
|
+
// The second Esc only clears when the text is still exactly what
|
|
324
|
+
// it was at the first Esc; an edit in between starts a fresh
|
|
325
|
+
// first press on the new text instead of silently discarding it.
|
|
326
|
+
if (this.isPendingIdleClear() && this.pendingIdleClearText === text) {
|
|
327
|
+
this.pendingIdleClearDeadline = undefined;
|
|
328
|
+
this.pendingIdleClearText = undefined;
|
|
329
|
+
this.addToHistory(text);
|
|
330
|
+
this.setText("");
|
|
331
|
+
this.deps.requestRender();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
this.pendingIdleClearDeadline = this.deps.now() + IDLE_ESC_CLEAR_WINDOW_MS;
|
|
335
|
+
this.pendingIdleClearText = text;
|
|
336
|
+
this.deps.requestRender();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
super.handleInput(data);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Runs the Esc that actually aborts the turn. Pi's own onEscape (invoked
|
|
345
|
+
* synchronously by `super.handleInput`) restores `queuedText + draft`
|
|
346
|
+
* into the editor and aborts; snapshot the draft first, reconstruct the
|
|
347
|
+
* queued text from what comes back, restore the draft alone, and hand
|
|
348
|
+
* the queued text to the dispatcher so it is sent once the aborted run
|
|
349
|
+
* settles (see the `agent_settled` handler in `gentleShell`). Images
|
|
350
|
+
* inside queued messages are already dropped by Pi's own restore, before
|
|
351
|
+
* this code ever sees the text.
|
|
352
|
+
*
|
|
353
|
+
* `extractQueuedText` returning `undefined` means the restored text does
|
|
354
|
+
* not match Pi's own join shape; Pi's own text wins and is left exactly
|
|
355
|
+
* as it is, nothing is dispatched. An empty string means a genuine empty
|
|
356
|
+
* queue: there is nothing to restore, so `setText` is not called at all
|
|
357
|
+
* on the common no-queue path. Only a recognized, non-empty queue
|
|
358
|
+
* restores the draft and dispatches.
|
|
359
|
+
*/
|
|
360
|
+
private abortAndDispatchQueued(data: string): void {
|
|
361
|
+
const draft = this.getText();
|
|
362
|
+
super.handleInput(data);
|
|
363
|
+
const queued = extractQueuedText(this.getText(), draft);
|
|
364
|
+
// undefined: unrecognized shape, Pi's own text stays untouched.
|
|
365
|
+
if (queued === undefined) return;
|
|
366
|
+
// "": nothing was queued, and the editor already holds the draft, so no
|
|
367
|
+
// redundant write. Anything else was recognized: the draft comes back
|
|
368
|
+
// alone, and only real text (not whitespace) is worth a turn.
|
|
369
|
+
if (queued !== "") this.setText(draft);
|
|
370
|
+
if (queued.trim() === "") return;
|
|
371
|
+
this.deps.dispatchQueuedText(queued);
|
|
372
|
+
}
|
|
373
|
+
|
|
212
374
|
render(width: number): string[] {
|
|
213
375
|
const lines = super.render(Math.max(1, width - 2));
|
|
214
376
|
if (this.getText() === "" && lines.length === 3) lines[1] = withPromptHint(lines[1], PROMPT_HINT, this.deps.fg);
|
|
@@ -221,6 +383,11 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
221
383
|
borderColor: (text) => this.deps.fg(PROMPT_FRAME_ROLE, text),
|
|
222
384
|
fg: this.deps.fg,
|
|
223
385
|
bold: this.deps.bold,
|
|
386
|
+
escHint: this.promptState === PROMPT_STATE.WORKING && this.isPendingEscapeCancel()
|
|
387
|
+
? DOUBLE_ESC_CANCEL_HINT
|
|
388
|
+
: this.promptState === PROMPT_STATE.IDLE && this.isPendingIdleClear()
|
|
389
|
+
? IDLE_ESC_CLEAR_HINT
|
|
390
|
+
: undefined,
|
|
224
391
|
});
|
|
225
392
|
}
|
|
226
393
|
|
|
@@ -228,6 +395,18 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
228
395
|
this.stopPulse();
|
|
229
396
|
}
|
|
230
397
|
|
|
398
|
+
private isPendingEscapeCancel(): boolean {
|
|
399
|
+
return this.pendingEscapeCancelDeadline !== undefined && this.deps.now() < this.pendingEscapeCancelDeadline;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private isPendingIdleClear(): boolean {
|
|
403
|
+
return (
|
|
404
|
+
this.pendingIdleClearDeadline !== undefined &&
|
|
405
|
+
this.deps.now() < this.pendingIdleClearDeadline &&
|
|
406
|
+
this.pendingIdleClearText === this.getText()
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
231
410
|
private stopPulse(): void {
|
|
232
411
|
if (this.pulse) clearInterval(this.pulse);
|
|
233
412
|
this.pulse = undefined;
|
|
@@ -239,7 +418,11 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
239
418
|
const PROMPT_OWNER = Symbol.for("gentle-pi.prompt-owner");
|
|
240
419
|
type PromptFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>> & { [PROMPT_OWNER]?: boolean };
|
|
241
420
|
|
|
242
|
-
function installPrompt(
|
|
421
|
+
function installPrompt(
|
|
422
|
+
ctx: ExtensionContext,
|
|
423
|
+
onCreated: (prompt: GentlePromptEditor) => void,
|
|
424
|
+
promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void },
|
|
425
|
+
): boolean {
|
|
243
426
|
const previous = ctx.ui.getEditorComponent() as PromptFactory | undefined;
|
|
244
427
|
if (previous && !previous[PROMPT_OWNER]) return false;
|
|
245
428
|
const factory: PromptFactory = (tui, theme, keybindings) => {
|
|
@@ -248,6 +431,9 @@ function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEd
|
|
|
248
431
|
bold: (text) => ctx.ui.theme.bold(text),
|
|
249
432
|
requestRender: () => tui.requestRender(),
|
|
250
433
|
pending: () => ctx.hasPendingMessages(),
|
|
434
|
+
now: promptDeps.now,
|
|
435
|
+
doubleEscCancelEnabled: promptDeps.doubleEscCancelEnabled,
|
|
436
|
+
dispatchQueuedText: promptDeps.dispatchQueuedText,
|
|
251
437
|
});
|
|
252
438
|
onCreated(prompt);
|
|
253
439
|
return prompt;
|
|
@@ -257,6 +443,45 @@ function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEd
|
|
|
257
443
|
return true;
|
|
258
444
|
}
|
|
259
445
|
|
|
446
|
+
const DOUBLE_ESC_CANCEL_COMMAND_NAME = "gentle:double-esc-cancel";
|
|
447
|
+
|
|
448
|
+
function describeDoubleEscCancelSource(resolution: DoubleEscCancelResolution): string {
|
|
449
|
+
switch (resolution.source) {
|
|
450
|
+
case "global_file":
|
|
451
|
+
return `global file ${resolution.globalFile}`;
|
|
452
|
+
case "environment":
|
|
453
|
+
return "GENTLE_PI_DOUBLE_ESC_CANCEL";
|
|
454
|
+
default:
|
|
455
|
+
return "built-in default";
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Report the effective policy, the source that decided it, and (when this
|
|
461
|
+
* invocation just wrote one) the policy it wrote. Unlike background-subagents
|
|
462
|
+
* there is no project-file layer to outrank the write, so a write always
|
|
463
|
+
* takes effect immediately.
|
|
464
|
+
*/
|
|
465
|
+
function renderDoubleEscCancelReport(
|
|
466
|
+
resolution: DoubleEscCancelResolution,
|
|
467
|
+
wrote?: DoubleEscCancelPolicy,
|
|
468
|
+
): { message: string; type: "info" | "warning" } {
|
|
469
|
+
const lines = [`double-esc-cancel: ${resolution.policy} (decided by ${describeDoubleEscCancelSource(resolution)})`];
|
|
470
|
+
if (wrote !== undefined) lines.push(`Wrote ${wrote} to the global file ${resolution.globalFile}.`);
|
|
471
|
+
if (resolution.malformed) {
|
|
472
|
+
lines.push(`${resolution.globalFile} is present but malformed, so the policy fails closed to off and the environment variable is not consulted.`);
|
|
473
|
+
}
|
|
474
|
+
if (resolution.envValue !== undefined && resolution.source !== "environment") {
|
|
475
|
+
lines.push(
|
|
476
|
+
resolution.envValue === "on" || resolution.envValue === "off"
|
|
477
|
+
? `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.`
|
|
478
|
+
: `GENTLE_PI_DOUBLE_ESC_CANCEL="${resolution.envValue}" is not a recognized value ("on" or "off"), so it is ignored.`,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
lines.push("Resolution order (first hit wins): global file, GENTLE_PI_DOUBLE_ESC_CANCEL, built-in default off.");
|
|
482
|
+
return { message: lines.join("\n"), type: resolution.malformed ? "warning" : "info" };
|
|
483
|
+
}
|
|
484
|
+
|
|
260
485
|
const CHANGES_WIDGET_KEY = "gentle-shell-changes";
|
|
261
486
|
const CHANGES_COMMAND_NAME = "gentle:changes";
|
|
262
487
|
const CHANGES_SHORTCUT_DEFAULT = "alt+g";
|
|
@@ -318,6 +543,12 @@ export function changesShortcut(env: NodeJS.ProcessEnv = process.env): string |
|
|
|
318
543
|
return value === "" || value.toLowerCase() === "off" ? undefined : value;
|
|
319
544
|
}
|
|
320
545
|
|
|
546
|
+
export function usageShortcut(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
547
|
+
const value = env.GENTLE_PI_SHELL_USAGE_KEY?.trim();
|
|
548
|
+
if (value === undefined) return USAGE_SHORTCUT_DEFAULT;
|
|
549
|
+
return value === "" || value.toLowerCase() === "off" ? undefined : value;
|
|
550
|
+
}
|
|
551
|
+
|
|
321
552
|
function positiveMs(value: string | undefined, fallback: number): number {
|
|
322
553
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
323
554
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
@@ -337,15 +568,24 @@ interface OverlayDeps {
|
|
|
337
568
|
refresh(): Promise<ChangesModel>;
|
|
338
569
|
apply(ctx: ExtensionContext, model: ChangesModel): void;
|
|
339
570
|
pollMs: number;
|
|
571
|
+
gitForRoot(root: string): GitRunner;
|
|
340
572
|
}
|
|
341
573
|
|
|
342
|
-
// Refresh only the captured session model. Never read live files
|
|
574
|
+
// Refresh only the captured session model. Never read live files here; the
|
|
575
|
+
// only Git the overlay touches is each root's HEAD, to label its tree.
|
|
343
576
|
async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
|
|
344
577
|
let host: ExternalEditorHost | undefined;
|
|
345
578
|
let view: WorktreeChangesView | undefined;
|
|
579
|
+
// Session evidence knows roots, not branches; label them while the overlay
|
|
580
|
+
// is open and repaint when Git answers.
|
|
581
|
+
const labels = new RootBranchLabels(deps.gitForRoot, () => {
|
|
582
|
+
view?.update(labels.decorate(deps.worktrees()));
|
|
583
|
+
host?.requestRender();
|
|
584
|
+
});
|
|
585
|
+
const worktrees = () => labels.decorate(deps.worktrees());
|
|
346
586
|
const refresh = async () => {
|
|
347
587
|
const latest = await deps.refresh();
|
|
348
|
-
view?.update(
|
|
588
|
+
view?.update(worktrees());
|
|
349
589
|
deps.apply(ctx, latest);
|
|
350
590
|
};
|
|
351
591
|
const poll = setInterval(() => void refresh(), deps.pollMs);
|
|
@@ -354,7 +594,7 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
|
|
|
354
594
|
const chosen = await ctx.ui.custom<{ root: string; file: ChangedFile } | null>(
|
|
355
595
|
(tui, theme, _keybindings, done) => {
|
|
356
596
|
host = tui;
|
|
357
|
-
view = new WorktreeChangesView(
|
|
597
|
+
view = new WorktreeChangesView(worktrees(), {
|
|
358
598
|
theme,
|
|
359
599
|
rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
|
|
360
600
|
loadDiff: (root, file) => Promise.resolve(deps.loadDiff(root, file)),
|
|
@@ -417,6 +657,7 @@ function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
|
|
|
417
657
|
}
|
|
418
658
|
|
|
419
659
|
const USAGE_COMMAND_NAME = "gentle:usage";
|
|
660
|
+
const USAGE_SHORTCUT_DEFAULT = "alt+u";
|
|
420
661
|
const REVIEW_PREFLIGHT_TYPE = "gentle-pi.review-preflight";
|
|
421
662
|
const DEV_BINARY_WIDGET_KEY = "gentle-shell-dev-binary";
|
|
422
663
|
const SHA_PREFIX_LENGTH = 16;
|
|
@@ -536,25 +777,51 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
536
777
|
const hint = keyHint("app.tools.expand", options.expanded ? "collapse" : "expand");
|
|
537
778
|
return cardComponent({ title: "Gentle AI", subtitle: "review preflight", body, tone: CARD_TONE.INFO }, theme, { expanded: options.expanded, hint });
|
|
538
779
|
});
|
|
780
|
+
const openUsage = async (ctx: ExtensionContext) => {
|
|
781
|
+
await refreshUsage(ctx, true);
|
|
782
|
+
await ctx.ui.custom<null>(
|
|
783
|
+
(tui, theme, _keybindings, done) =>
|
|
784
|
+
new UsageView(usage, {
|
|
785
|
+
theme,
|
|
786
|
+
now: () => deps.now(),
|
|
787
|
+
active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
|
|
788
|
+
onRefresh: () => refreshUsage(ctx, true),
|
|
789
|
+
onClose: () => done(null),
|
|
790
|
+
requestRender: () => tui.requestRender(),
|
|
791
|
+
}),
|
|
792
|
+
{ overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
|
|
793
|
+
);
|
|
794
|
+
};
|
|
539
795
|
pi.registerCommand(USAGE_COMMAND_NAME, {
|
|
540
796
|
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
|
-
},
|
|
797
|
+
handler: async (_args, ctx) => openUsage(ctx),
|
|
556
798
|
});
|
|
799
|
+
const usageShortcutKey = usageShortcut(env);
|
|
800
|
+
if (usageShortcutKey) {
|
|
801
|
+
pi.registerShortcut(usageShortcutKey as Parameters<ExtensionAPI["registerShortcut"]>[0], {
|
|
802
|
+
description: "Show subscription usage windows for the connected providers",
|
|
803
|
+
handler: async (ctx) => openUsage(ctx),
|
|
804
|
+
});
|
|
805
|
+
}
|
|
557
806
|
let prompt: GentlePromptEditor | undefined;
|
|
807
|
+
// Set by abortAndDispatchQueued via dispatchQueuedText when an Esc aborts
|
|
808
|
+
// a turn with a non-empty queue; sent exactly once, from agent_settled,
|
|
809
|
+
// once the aborted run has fully settled (issue #1218). Several aborts
|
|
810
|
+
// before that settle append in order, joined the way Pi joins its own
|
|
811
|
+
// queue, so nothing is overwritten. It belongs to the current session and
|
|
812
|
+
// is dropped on session_shutdown.
|
|
813
|
+
let pendingQueuedText: string | undefined;
|
|
814
|
+
// Resolved once at startup and cached in memory so the editor never
|
|
815
|
+
// re-reads the file per keypress. The /gentle:double-esc-cancel command
|
|
816
|
+
// below is the only place that touches the file, and every invocation
|
|
817
|
+
// re-syncs this cache from disk first, so status, the no-argument toggle
|
|
818
|
+
// direction, and the Esc gate always describe the same effective policy
|
|
819
|
+
// even when another session or a hand edit changed the file mid-session.
|
|
820
|
+
const doubleEscCancelConfigHome = gentlePiConfigHome(env);
|
|
821
|
+
let doubleEscCancelPolicy: DoubleEscCancelPolicy = resolveDoubleEscCancelPolicy({
|
|
822
|
+
env,
|
|
823
|
+
gentlePiConfigHome: doubleEscCancelConfigHome,
|
|
824
|
+
}).policy;
|
|
558
825
|
let changes: SessionChanges | undefined;
|
|
559
826
|
let registry: SessionWorktreeRegistry | undefined;
|
|
560
827
|
let currentContext: ExtensionContext | undefined;
|
|
@@ -616,14 +883,34 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
616
883
|
render: (width) => renderShellSidebarBar(footerModel(), theme, width),
|
|
617
884
|
invalidate() {},
|
|
618
885
|
});
|
|
886
|
+
// The header row carries everything that ticks every frame (model,
|
|
887
|
+
// effort, context, cost, usage) plus session identity; it never sees
|
|
888
|
+
// extension statuses or the working/thinking state.
|
|
889
|
+
const headerBar = (width: number) => renderShellHeaderBar(buildShellHeaderModel(footerModel()), theme, width, usageShortcutKey);
|
|
890
|
+
const disposeHeader = sidebarHeader(tui, {
|
|
891
|
+
digest: () => JSON.stringify(buildShellHeaderModel(footerModel())),
|
|
892
|
+
render: (width) => [headerBar(width).text],
|
|
893
|
+
invalidate() {},
|
|
894
|
+
handleMouse(event) {
|
|
895
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
896
|
+
const { usageSpan } = headerBar(event.width);
|
|
897
|
+
if (!usageSpan || event.x < usageSpan.start || event.x >= usageSpan.end) return undefined;
|
|
898
|
+
void openUsage(ctx);
|
|
899
|
+
return { handled: true, render: true };
|
|
900
|
+
},
|
|
901
|
+
});
|
|
619
902
|
const uninstall = installSidebar(tui, theme);
|
|
620
|
-
return { ...part, dispose() { uninstall(); part.dispose(); } };
|
|
903
|
+
return { ...part, dispose() { disposeHeader(); uninstall(); part.dispose(); } };
|
|
621
904
|
});
|
|
622
905
|
void refreshUsage(ctx, true);
|
|
623
|
-
const ownsPrompt = installPrompt(
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
906
|
+
const ownsPrompt = installPrompt(
|
|
907
|
+
ctx,
|
|
908
|
+
(created) => {
|
|
909
|
+
prompt?.dispose();
|
|
910
|
+
prompt = created;
|
|
911
|
+
},
|
|
912
|
+
{ now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; } },
|
|
913
|
+
);
|
|
627
914
|
// Hide native feedback only when our petal replaces it. Native transcript
|
|
628
915
|
// thinking blocks remain Pi-owned; this changes only the supported loader UI.
|
|
629
916
|
if (ownsPrompt) ctx.ui.setWorkingVisible(false);
|
|
@@ -639,6 +926,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
639
926
|
applyChanges(ctx, tracker.model);
|
|
640
927
|
});
|
|
641
928
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
929
|
+
pendingQueuedText = undefined;
|
|
642
930
|
prompt?.dispose();
|
|
643
931
|
prompt = undefined;
|
|
644
932
|
if ((ctx.ui.getEditorComponent() as PromptFactory | undefined)?.[PROMPT_OWNER]) {
|
|
@@ -659,7 +947,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
659
947
|
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
948
|
return;
|
|
661
949
|
}
|
|
662
|
-
await showChangesOverlay(ctx, { loadDiff: (root, file) => tracker.loadDiff(root, file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
|
|
950
|
+
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
951
|
};
|
|
664
952
|
pi.registerCommand(CHANGES_COMMAND_NAME, {
|
|
665
953
|
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 +971,73 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
683
971
|
handler: async (ctx) => showCommandPalette(pi, ctx, env),
|
|
684
972
|
});
|
|
685
973
|
}
|
|
974
|
+
// User-owned, like gentle:background-subagents and gentle:review-mode: the
|
|
975
|
+
// only writer is this handler, reached only by explicit invocation. Unlike
|
|
976
|
+
// those two, no argument toggles the effective policy instead of merely
|
|
977
|
+
// reporting it (see odd/tasks/double-esc-cancel.md).
|
|
978
|
+
pi.registerCommand(DOUBLE_ESC_CANCEL_COMMAND_NAME, {
|
|
979
|
+
description: "Show or set the double-esc-cancel preference (status|enable|disable); no argument toggles it. User-initiated only.",
|
|
980
|
+
handler: async (args, ctx) => {
|
|
981
|
+
const trimmed = args.trim();
|
|
982
|
+
if (trimmed !== "" && trimmed !== "status" && trimmed !== "enable" && trimmed !== "disable") {
|
|
983
|
+
ctx.ui.notify(`Unknown /${DOUBLE_ESC_CANCEL_COMMAND_NAME} sub-action "${trimmed}". Use status, enable, or disable.`, "warning");
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
try {
|
|
987
|
+
const before = resolveDoubleEscCancelPolicy({ env, gentlePiConfigHome: doubleEscCancelConfigHome });
|
|
988
|
+
doubleEscCancelPolicy = before.policy;
|
|
989
|
+
const subAction = trimmed === "" ? (before.policy === "on" ? "disable" : "enable") : trimmed;
|
|
990
|
+
if (subAction === "status") {
|
|
991
|
+
const report = renderDoubleEscCancelReport(before);
|
|
992
|
+
ctx.ui.notify(report.message, report.type);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const wrote: DoubleEscCancelPolicy = subAction === "enable" ? "on" : "off";
|
|
996
|
+
writeDoubleEscCancelPolicy(wrote, { gentlePiConfigHome: doubleEscCancelConfigHome });
|
|
997
|
+
const after = resolveDoubleEscCancelPolicy({ env, gentlePiConfigHome: doubleEscCancelConfigHome });
|
|
998
|
+
// Cache what the file actually resolves to, not what was written: a
|
|
999
|
+
// competing writer or a read failure would otherwise leave the gate
|
|
1000
|
+
// and the report disagreeing.
|
|
1001
|
+
doubleEscCancelPolicy = after.policy;
|
|
1002
|
+
const report = renderDoubleEscCancelReport(after, wrote);
|
|
1003
|
+
ctx.ui.notify(report.message, report.type);
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
1008
|
+
});
|
|
686
1009
|
pi.on("agent_start", (_event, ctx) => {
|
|
1010
|
+
// A turn can start any other way (the user sending the draft, an
|
|
1011
|
+
// extension, a shortcut) before the aborted run's own agent_settled
|
|
1012
|
+
// below has delivered the pending text. Nothing is sent from here: Pi
|
|
1013
|
+
// is mid-turn, so the text simply waits and goes out, once, when that
|
|
1014
|
+
// turn settles. It is never dropped.
|
|
687
1015
|
prompt?.setWorking(true);
|
|
688
1016
|
// The dev-binary card is a startup notice: it leaves with the first prompt.
|
|
689
1017
|
if (ctx.hasUI) ctx.ui.setWidget(DEV_BINARY_WIDGET_KEY, undefined);
|
|
690
1018
|
});
|
|
691
|
-
pi.on("agent_settled", () => {
|
|
1019
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
1020
|
+
// Pi clears its own run-active flag before emitting agent_settled, so
|
|
1021
|
+
// this is normally idle; if a run is somehow still in flight the prompt
|
|
1022
|
+
// stays working and the pending text waits for the next settle.
|
|
1023
|
+
if (!ctx.isIdle()) return;
|
|
692
1024
|
prompt?.setWorking(false);
|
|
1025
|
+
if (pendingQueuedText === undefined) return;
|
|
1026
|
+
const queued = pendingQueuedText;
|
|
1027
|
+
pendingQueuedText = undefined;
|
|
1028
|
+
try {
|
|
1029
|
+
pi.sendUserMessage(queued);
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
// Never drop the user's words: put them back in front of the draft,
|
|
1032
|
+
// exactly the shape Pi's own restore would have left, and say why.
|
|
1033
|
+
if (prompt) {
|
|
1034
|
+
const current = prompt.getText();
|
|
1035
|
+
prompt.setText([queued, current].filter((text) => text.trim() !== "").join("\n\n"));
|
|
1036
|
+
} else {
|
|
1037
|
+
pendingQueuedText = queued;
|
|
1038
|
+
}
|
|
1039
|
+
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");
|
|
1040
|
+
}
|
|
693
1041
|
});
|
|
694
1042
|
pi.on("agent_end", async (_event, ctx) => {
|
|
695
1043
|
await refreshChanges(ctx);
|
|
@@ -104,19 +104,37 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
|
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
const todoCard = (current: TodoSession, theme: Parameters<typeof renderTodoCard>[1], scrollable: boolean, spacer: boolean): Component & { dispose(): void } => {
|
|
107
|
+
let hovered = false;
|
|
107
108
|
const card: Component = {
|
|
108
109
|
render(width: number) {
|
|
109
110
|
const lines = renderTodoCard(current.state, theme, width, {
|
|
110
111
|
collapsed: current.collapsed,
|
|
111
112
|
staleTurns: staleTurns(current.state, current.turn),
|
|
112
113
|
collapseKey,
|
|
114
|
+
hovered,
|
|
113
115
|
...(scrollable ? { scrollable: true } : {}),
|
|
114
116
|
});
|
|
115
117
|
return spacer && lines.length > 0 ? [...lines, ""] : lines;
|
|
116
118
|
},
|
|
117
|
-
invalidate() {
|
|
119
|
+
invalidate() {
|
|
120
|
+
hovered = false;
|
|
121
|
+
},
|
|
118
122
|
};
|
|
119
123
|
const region = new NativePointerRegion(card, {
|
|
124
|
+
onHover(event) {
|
|
125
|
+
// The region spans the whole card, but only the header row (y===0)
|
|
126
|
+
// is the clickable control, so a move elsewhere in the card clears
|
|
127
|
+
// hover exactly like leaving the region entirely would.
|
|
128
|
+
const next = event.y === 0;
|
|
129
|
+
if (next === hovered) return { handled: true };
|
|
130
|
+
hovered = next;
|
|
131
|
+
return { handled: true, render: true };
|
|
132
|
+
},
|
|
133
|
+
onLeave() {
|
|
134
|
+
if (!hovered) return;
|
|
135
|
+
hovered = false;
|
|
136
|
+
current.host?.requestRender();
|
|
137
|
+
},
|
|
120
138
|
onClick(event) {
|
|
121
139
|
if (event.button !== "left" || event.y !== 0) return undefined;
|
|
122
140
|
toggle(current);
|