gentle-pi 3.2.0 → 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/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +40 -17
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +25 -11
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +179 -12
- package/extensions/gentle-shell.ts +408 -38
- 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/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +23 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +262 -94
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +158 -29
- 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/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +23 -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/mirror-odd-routing.mjs +242 -0
- 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 +903 -25
- 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 +32 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -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 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +285 -239
- 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 +83 -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 +224 -6
- 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 +123 -3
- package/tests/shell-usage.test.ts +254 -6
- package/lib/opaque-pi-reviewer-adapter.ts +0 -284
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
|
@@ -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";
|
|
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 {
|
|
20
|
+
DOUBLE_ESC_CANCEL_WINDOW_MS,
|
|
21
|
+
resolveDoubleEscCancelPolicy,
|
|
22
|
+
writeDoubleEscCancelPolicy,
|
|
23
|
+
type DoubleEscCancelPolicy,
|
|
24
|
+
type DoubleEscCancelResolution,
|
|
25
|
+
} from "../lib/double-esc-cancel-policy.ts";
|
|
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;
|
|
@@ -482,21 +723,43 @@ export async function fetchCodexUsage(token: string | undefined, fetchFn: typeof
|
|
|
482
723
|
}
|
|
483
724
|
}
|
|
484
725
|
|
|
726
|
+
// The NaN Cloud quota endpoint is the one the official dashboard reads with the
|
|
727
|
+
// same API key pi already holds. The key travels in the header only: the request
|
|
728
|
+
// refuses redirects so it cannot be replayed to another origin, asks for no
|
|
729
|
+
// stored copy, and nothing here logs, renders, or persists it.
|
|
730
|
+
export async function fetchNanUsage(apiKey: string | undefined, fetchFn: typeof fetch, now: number): Promise<ProviderUsage | undefined> {
|
|
731
|
+
if (!apiKey) return undefined;
|
|
732
|
+
try {
|
|
733
|
+
const response = await fetchFn(NAN_QUOTA_URL, {
|
|
734
|
+
redirect: "error",
|
|
735
|
+
cache: "no-store",
|
|
736
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", "User-Agent": "gentle-pi" },
|
|
737
|
+
});
|
|
738
|
+
if (!response.ok) return undefined;
|
|
739
|
+
const parsed = parseNanQuota(await response.json(), now);
|
|
740
|
+
return parsed.limits.length > 0 ? parsed : undefined;
|
|
741
|
+
} catch {
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
485
746
|
export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
|
|
486
747
|
installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
|
|
487
748
|
if (!shellEnabled(env)) return;
|
|
488
749
|
const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides };
|
|
489
750
|
const usage = new UsageStore();
|
|
490
751
|
let renderHost: ShellRenderHost | undefined;
|
|
491
|
-
|
|
752
|
+
// The 5-minute rule is per provider: one provider's fetch cannot leave the
|
|
753
|
+
// next one waiting for an interval it never used.
|
|
754
|
+
const usageFetchedAt = new Map<string, number>();
|
|
492
755
|
const refreshUsage = async (ctx: ExtensionContext, force: boolean) => {
|
|
493
756
|
const provider = ctx.model?.provider;
|
|
494
|
-
if (provider !== CODEX_PROVIDER) return;
|
|
757
|
+
if (provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return;
|
|
495
758
|
const now = deps.now();
|
|
496
|
-
if (!force && now - usageFetchedAt < USAGE_REFRESH_MS) return;
|
|
497
|
-
usageFetchedAt
|
|
498
|
-
const
|
|
499
|
-
const fetched = await fetchCodexUsage(
|
|
759
|
+
if (!force && now - (usageFetchedAt.get(provider) ?? 0) < USAGE_REFRESH_MS) return;
|
|
760
|
+
usageFetchedAt.set(provider, now);
|
|
761
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider).catch(() => undefined);
|
|
762
|
+
const fetched = provider === NAN_PROVIDER ? await fetchNanUsage(apiKey, deps.fetch, deps.now()) : await fetchCodexUsage(apiKey, deps.fetch, deps.now());
|
|
500
763
|
if (!fetched) return;
|
|
501
764
|
usage.record(fetched);
|
|
502
765
|
renderHost?.invalidateSidebar?.();
|
|
@@ -514,25 +777,51 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
514
777
|
const hint = keyHint("app.tools.expand", options.expanded ? "collapse" : "expand");
|
|
515
778
|
return cardComponent({ title: "Gentle AI", subtitle: "review preflight", body, tone: CARD_TONE.INFO }, theme, { expanded: options.expanded, hint });
|
|
516
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
|
+
};
|
|
517
795
|
pi.registerCommand(USAGE_COMMAND_NAME, {
|
|
518
796
|
description: "Show subscription usage windows for the connected providers. Press r to refetch.",
|
|
519
|
-
handler: async (_args, ctx) =>
|
|
520
|
-
await refreshUsage(ctx, true);
|
|
521
|
-
await ctx.ui.custom<null>(
|
|
522
|
-
(tui, theme, _keybindings, done) =>
|
|
523
|
-
new UsageView(usage, {
|
|
524
|
-
theme,
|
|
525
|
-
now: () => deps.now(),
|
|
526
|
-
active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
|
|
527
|
-
onRefresh: () => refreshUsage(ctx, true),
|
|
528
|
-
onClose: () => done(null),
|
|
529
|
-
requestRender: () => tui.requestRender(),
|
|
530
|
-
}),
|
|
531
|
-
{ overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
|
|
532
|
-
);
|
|
533
|
-
},
|
|
797
|
+
handler: async (_args, ctx) => openUsage(ctx),
|
|
534
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
|
+
}
|
|
535
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;
|
|
536
825
|
let changes: SessionChanges | undefined;
|
|
537
826
|
let registry: SessionWorktreeRegistry | undefined;
|
|
538
827
|
let currentContext: ExtensionContext | undefined;
|
|
@@ -594,14 +883,34 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
594
883
|
render: (width) => renderShellSidebarBar(footerModel(), theme, width),
|
|
595
884
|
invalidate() {},
|
|
596
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
|
+
});
|
|
597
902
|
const uninstall = installSidebar(tui, theme);
|
|
598
|
-
return { ...part, dispose() { uninstall(); part.dispose(); } };
|
|
903
|
+
return { ...part, dispose() { disposeHeader(); uninstall(); part.dispose(); } };
|
|
599
904
|
});
|
|
600
905
|
void refreshUsage(ctx, true);
|
|
601
|
-
const ownsPrompt = installPrompt(
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
+
);
|
|
605
914
|
// Hide native feedback only when our petal replaces it. Native transcript
|
|
606
915
|
// thinking blocks remain Pi-owned; this changes only the supported loader UI.
|
|
607
916
|
if (ownsPrompt) ctx.ui.setWorkingVisible(false);
|
|
@@ -617,6 +926,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
617
926
|
applyChanges(ctx, tracker.model);
|
|
618
927
|
});
|
|
619
928
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
929
|
+
pendingQueuedText = undefined;
|
|
620
930
|
prompt?.dispose();
|
|
621
931
|
prompt = undefined;
|
|
622
932
|
if ((ctx.ui.getEditorComponent() as PromptFactory | undefined)?.[PROMPT_OWNER]) {
|
|
@@ -637,7 +947,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
637
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");
|
|
638
948
|
return;
|
|
639
949
|
}
|
|
640
|
-
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) });
|
|
641
951
|
};
|
|
642
952
|
pi.registerCommand(CHANGES_COMMAND_NAME, {
|
|
643
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.",
|
|
@@ -661,13 +971,73 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
661
971
|
handler: async (ctx) => showCommandPalette(pi, ctx, env),
|
|
662
972
|
});
|
|
663
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
|
+
});
|
|
664
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.
|
|
665
1015
|
prompt?.setWorking(true);
|
|
666
1016
|
// The dev-binary card is a startup notice: it leaves with the first prompt.
|
|
667
1017
|
if (ctx.hasUI) ctx.ui.setWidget(DEV_BINARY_WIDGET_KEY, undefined);
|
|
668
1018
|
});
|
|
669
|
-
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;
|
|
670
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
|
+
}
|
|
671
1041
|
});
|
|
672
1042
|
pi.on("agent_end", async (_event, ctx) => {
|
|
673
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);
|