gentle-pi 4.3.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +138 -155
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +141 -57
- package/lib/gentle-shell-launcher.ts +11 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +227 -27
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +104 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +67 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/vim-editor-adapter.test.ts +2 -2
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { isHomeOrBroadUserDirectory } from "../lib/bounded-writer-admission.ts";
|
|
3
|
-
import { Editor, decodeKittyPrintable, isKeyRelease, matchesKey, parseKey, truncateToWidth, visibleWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Editor, Image, decodeKittyPrintable, getCapabilities, isKeyRelease, matchesKey, parseKey, truncateToWidth, visibleWidth, type Component, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
import { readClipboardImage, type ClipboardImage } from "../lib/clipboard-image.ts";
|
|
5
|
+
import { describeAttachment, ImageAttachments } from "../lib/image-attachments.ts";
|
|
4
6
|
import { execFile, spawnSync } from "node:child_process";
|
|
7
|
+
import { openInExternalEditor, type ExternalEditorHost } from "../lib/external-editor.ts";
|
|
5
8
|
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
6
9
|
import { lstat, readFile } from "node:fs/promises";
|
|
7
10
|
import { fileURLToPath } from "node:url";
|
|
@@ -10,11 +13,11 @@ import { resolveSessionProfile, sessionProfileLabel, sessionProfileModelProfiles
|
|
|
10
13
|
import * as os from "node:os";
|
|
11
14
|
import { dirname, join, resolve } from "node:path";
|
|
12
15
|
import { buildShellHeaderModel, renderShellBar, renderShellBelowInputFloat, renderShellBottomOnlyBar, renderShellHeaderChrome, renderShellSidebarBar, keepNativeWorkingRow, shellEnabled, shellHeaderUsageHit, shellJobsCount, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
|
|
13
|
-
import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE, WorktreeChangesTracker, renderChangesWidget, type ChangedFile, type ChangesModel, type FileRevision, type GitRunner, type LineCounter, type WorktreeChanges } from "../lib/shell-changes.ts";
|
|
16
|
+
import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE, WorktreeChangesTracker, changesDigest, renderChangesWidget, type ChangedFile, type ChangesModel, type FileRevision, type GitRunner, type LineCounter, type WorktreeChanges } from "../lib/shell-changes.ts";
|
|
14
17
|
import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
|
|
15
18
|
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
16
19
|
import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver, type WorktreeIdentity } from "../lib/session-worktree-registry.ts";
|
|
17
|
-
import { CARD_STYLE, CARD_TONE, cardStyle, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts";
|
|
20
|
+
import { CARD_STYLE, CARD_TONE, cardStyle, panelHeaderRow, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts";
|
|
18
21
|
import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts";
|
|
19
22
|
import { CARD_CONTENT, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts";
|
|
20
23
|
import { CommandPalette, commandsKey, type CommandPaletteResult } from "../lib/command-palette.ts";
|
|
@@ -29,7 +32,7 @@ import { DEFAULT_VISUAL_SETTINGS, DENSITY, HEADER_PLACEMENT, STATUS_PLACEMENT, V
|
|
|
29
32
|
import { BANNER_COLORS, DEFAULT_BANNER_CONFIG, readBannerConfig, readBannerConfigForEdit, writeBannerConfig } from "./startup-banner.ts";
|
|
30
33
|
import { agentsViewKey } from "../lib/agents-keys.ts";
|
|
31
34
|
import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
|
|
32
|
-
import { DOUBLE_ESC_CANCEL_HINT, floatPromptRow, framePromptLines, resolvePromptLayout, IDLE_ESC_CLEAR_HINT, PROMPT_HINT, PROMPT_STATE,
|
|
35
|
+
import { DOUBLE_ESC_CANCEL_HINT, floatPromptRow, framePromptLines, resolvePromptLayout, IDLE_ESC_CLEAR_HINT, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_WATCHDOG_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
|
|
33
36
|
import { oddPhaseRegistry } from "../lib/odd-phase.ts";
|
|
34
37
|
import { inferOddPhase } from "../lib/odd-phase-inference.ts";
|
|
35
38
|
import { isInteractiveMode } from "../lib/rpc-host.ts";
|
|
@@ -122,7 +125,9 @@ import { sidebarHeader, sidebarPart, sidebarState, VISUAL_SETTINGS_CHANGED, type
|
|
|
122
125
|
import { installSidebar, invalidateSidebar, narrowStatusOwner, SIDEBAR_BREAKPOINT, STATUS_OWNER } from "../lib/shell-sidebar-layout.ts";
|
|
123
126
|
import { sessionChangeRoots } from "../lib/session-changes.ts";
|
|
124
127
|
import { REVIEW_SIDEBAR_EVENT, isReviewSidebarSnapshot, type ReviewSidebarSnapshot } from "../lib/review-sidebar-state.ts";
|
|
125
|
-
import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY, isJobsSidebarSnapshot, type RunningJobDisplay } from "../lib/jobs-sidebar-state.ts";
|
|
128
|
+
import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY, isJobsSidebarSnapshot, jobsCollapseKey, type RunningJobDisplay } from "../lib/jobs-sidebar-state.ts";
|
|
129
|
+
import { JOBS_FINISHED_TTL_MS, jobsCardTone, renderJobsCard } from "../lib/jobs-widget.ts";
|
|
130
|
+
import { NativePointerRegion } from "../lib/native-pointer-region.ts";
|
|
126
131
|
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
127
132
|
import { SelectionEngine } from "../lib/selection-engine.ts";
|
|
128
133
|
import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
|
|
@@ -171,6 +176,8 @@ export interface ShellDeps {
|
|
|
171
176
|
/** A root Changes may scan: removed worktrees and leftover directories are skipped. */
|
|
172
177
|
rootExists(root: string): boolean;
|
|
173
178
|
vimRuntimeVersion?(): string | undefined;
|
|
179
|
+
/** Clipboard image source for the prompt's attachment chips; tests inject a fake. */
|
|
180
|
+
readClipboardImage?(): Promise<ClipboardImage | null>;
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
export type ActiveProfileReader = (() => string | undefined) & {
|
|
@@ -274,33 +281,38 @@ function sessionCost(ctx: ExtensionContext): number {
|
|
|
274
281
|
}
|
|
275
282
|
|
|
276
283
|
type ShellSessionStats = { costTotal: number; usage: ReturnType<ExtensionContext["getContextUsage"]> };
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
284
|
+
// The exact identity sessionStats' memo keys on: when any element differs the
|
|
285
|
+
// stats are recomputed. Undefined means the host lacks the leaf identity and
|
|
286
|
+
// the stats must be computed fresh on every call. Callers that digest live
|
|
287
|
+
// state per frame (issue #1681) reuse this key so their own memos can never
|
|
288
|
+
// disagree with sessionStats about what is still current.
|
|
289
|
+
type SessionStatsKey = readonly [
|
|
290
|
+
sessionId: string | undefined,
|
|
291
|
+
leafId: string | null,
|
|
292
|
+
entryCount: number | undefined,
|
|
293
|
+
model: ExtensionContext["model"],
|
|
294
|
+
contextWindow: number | undefined,
|
|
295
|
+
];
|
|
296
|
+
const shellSessionStats = new WeakMap<object, { key: SessionStatsKey; stats: ShellSessionStats }>();
|
|
297
|
+
|
|
298
|
+
function sessionStatsKey(ctx: ExtensionContext): SessionStatsKey | undefined {
|
|
287
299
|
const manager = ctx.sessionManager as ExtensionContext["sessionManager"] & { getEntryCount?(): number };
|
|
288
|
-
const compute = (): ShellSessionStats => ({ costTotal: sessionCost(ctx), usage: ctx.getContextUsage() });
|
|
289
300
|
// Append-only session entries move the leaf, including compaction and branch
|
|
290
301
|
// switches. A virtual model's routed physical model comes from the latest
|
|
291
302
|
// response, which is appended as an entry, so the leaf covers it as well.
|
|
292
303
|
// Older/test hosts without that identity must stay uncached.
|
|
293
|
-
if (typeof manager.getLeafId !== "function") return
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
if (
|
|
301
|
-
|
|
304
|
+
if (typeof manager.getLeafId !== "function") return undefined;
|
|
305
|
+
return [manager.getSessionId(), manager.getLeafId(), manager.getEntryCount?.(), ctx.model, ctx.model?.contextWindow] as const;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function sessionStats(ctx: ExtensionContext): ShellSessionStats {
|
|
309
|
+
const compute = (): ShellSessionStats => ({ costTotal: sessionCost(ctx), usage: ctx.getContextUsage() });
|
|
310
|
+
const key = sessionStatsKey(ctx);
|
|
311
|
+
if (!key) return compute();
|
|
312
|
+
const cached = shellSessionStats.get(ctx.sessionManager);
|
|
313
|
+
if (cached && cached.key.every((value, index) => value === key[index])) return cached.stats;
|
|
302
314
|
const stats = compute();
|
|
303
|
-
shellSessionStats.set(
|
|
315
|
+
shellSessionStats.set(ctx.sessionManager, { key, stats });
|
|
304
316
|
return stats;
|
|
305
317
|
}
|
|
306
318
|
|
|
@@ -336,6 +348,75 @@ export function buildShellBarModel(
|
|
|
336
348
|
};
|
|
337
349
|
}
|
|
338
350
|
|
|
351
|
+
export interface FooterStatusMemoDeps {
|
|
352
|
+
pi: ExtensionAPI;
|
|
353
|
+
ctx: ExtensionContext;
|
|
354
|
+
footerData: ShellFooterData;
|
|
355
|
+
usage: () => ProviderUsage | undefined;
|
|
356
|
+
profile: () => string | undefined;
|
|
357
|
+
jobs: () => RunningJobDisplay[] | undefined;
|
|
358
|
+
review: () => ReviewSidebarSnapshot | undefined;
|
|
359
|
+
tracker: () => Pick<WorktreeChangesTracker, "model" | "unbornRoots">;
|
|
360
|
+
visualSettings: () => unknown;
|
|
361
|
+
cardStyle: () => unknown;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Issue #1681: railDigest evaluates every rail's digest before the fullscreen
|
|
365
|
+
// layout's prepared cache can answer, so both Status rails digest per
|
|
366
|
+
// keystroke and a cache hit still paid for building and serializing the whole
|
|
367
|
+
// bar model. The memo keys on the cheap live inputs the model reads instead:
|
|
368
|
+
// the digest serializes only this key, and the model is rebuilt just when one
|
|
369
|
+
// of those inputs moved. On hosts without leaf identity the session stats
|
|
370
|
+
// stay uncached and ride the key by value, so those hosts keep exactly the
|
|
371
|
+
// pre-memo digest honesty.
|
|
372
|
+
export function createFooterStatusMemo(deps: FooterStatusMemoDeps): { key(): string; model(): ShellBarModel } {
|
|
373
|
+
const { pi, ctx, footerData } = deps;
|
|
374
|
+
let cache: { key: string; model: ShellBarModel } | undefined;
|
|
375
|
+
const key = (): string => {
|
|
376
|
+
const statsKey = sessionStatsKey(ctx);
|
|
377
|
+
const model = ctx.model;
|
|
378
|
+
return JSON.stringify([
|
|
379
|
+
// Session statistics identity by value: without leaf identity the
|
|
380
|
+
// computed stats ride the key, so those hosts keep exactly the pre-memo
|
|
381
|
+
// digest honesty. The model rides its value pieces (id, contextWindow,
|
|
382
|
+
// effort lookup, OAuth state) because that is all buildShellBarModel
|
|
383
|
+
// reads — serializing the model object itself would bloat every digest.
|
|
384
|
+
statsKey ? [statsKey[0], statsKey[1], statsKey[2]] : sessionStats(ctx),
|
|
385
|
+
model?.id ?? null,
|
|
386
|
+
model?.contextWindow ?? null,
|
|
387
|
+
model?.reasoning ? (isNanAdaptiveReasoningModel(model) ? "auto" : pi.getThinkingLevel()) : undefined,
|
|
388
|
+
model ? ctx.modelRegistry.isUsingOAuth(model) : false,
|
|
389
|
+
footerData.getGitBranch(),
|
|
390
|
+
deps.profile(),
|
|
391
|
+
ctx.sessionManager.getSessionName(),
|
|
392
|
+
ctx.sessionManager.getCwd(),
|
|
393
|
+
[...footerData.getExtensionStatuses()].sort(([a], [b]) => a.localeCompare(b)),
|
|
394
|
+
deps.usage(),
|
|
395
|
+
deps.jobs(),
|
|
396
|
+
deps.review(),
|
|
397
|
+
(() => {
|
|
398
|
+
const { model: changes, unbornRoots } = deps.tracker();
|
|
399
|
+
return { fingerprint: changesFingerprint(changes), files: changes.files.length, added: changes.added, deleted: changes.deleted, unbornRoots: unbornRoots.length };
|
|
400
|
+
})(),
|
|
401
|
+
deps.visualSettings(),
|
|
402
|
+
deps.cardStyle(),
|
|
403
|
+
]);
|
|
404
|
+
};
|
|
405
|
+
const model = (): ShellBarModel => {
|
|
406
|
+
const k = key();
|
|
407
|
+
if (cache?.key === k) return cache.model;
|
|
408
|
+
const tracker = deps.tracker();
|
|
409
|
+
const built: ShellBarModel = {
|
|
410
|
+
...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: deps.usage(), profile: deps.profile(), jobs: deps.jobs() }),
|
|
411
|
+
changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted },
|
|
412
|
+
review: deps.review(),
|
|
413
|
+
};
|
|
414
|
+
cache = { key: k, model: built };
|
|
415
|
+
return built;
|
|
416
|
+
};
|
|
417
|
+
return { key, model };
|
|
418
|
+
}
|
|
419
|
+
|
|
339
420
|
// Gauge-specific theme roles (issue #2222): resolved from the installed theme
|
|
340
421
|
// source and memoized by gauge-theme.ts, so the render hot path only stats the
|
|
341
422
|
// theme file. Undefined — and therefore today's shared roles — whenever the theme
|
|
@@ -392,6 +473,11 @@ interface PromptEditorDeps {
|
|
|
392
473
|
* generic "working…" label. Never applied to IDLE or QUEUED.
|
|
393
474
|
*/
|
|
394
475
|
workingLabel?: () => string | undefined;
|
|
476
|
+
/** Pasted images become atomic `[Image#N]` chips; absent, Pi's paste handler runs unchanged. */
|
|
477
|
+
attachments?: ImageAttachments;
|
|
478
|
+
readClipboardImage?: () => Promise<ClipboardImage | null>;
|
|
479
|
+
/** Injectable silence-watchdog window; production and tests default to SHELL_PULSE_WATCHDOG_MS. */
|
|
480
|
+
watchdogMs?: number;
|
|
395
481
|
}
|
|
396
482
|
|
|
397
483
|
const PROMPT_FRAME_ROLE = "border";
|
|
@@ -446,7 +532,10 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
446
532
|
private visualReplacePending = false;
|
|
447
533
|
private visualObjectPending: "i" | "a" | undefined;
|
|
448
534
|
private visualAnchor: { line: number; col: number } | undefined;
|
|
449
|
-
private
|
|
535
|
+
private watchdog: NodeJS.Timeout | undefined;
|
|
536
|
+
// Last time a frame painted (or the watchdog forced one) while working; the
|
|
537
|
+
// quality watchdog repaints only when now() drifts past its window.
|
|
538
|
+
private lastRenderAt = 0;
|
|
450
539
|
private readonly deps: PromptEditorDeps;
|
|
451
540
|
private floatMouseLayout?: ReturnType<typeof resolvePromptLayout>;
|
|
452
541
|
// Native selection engine (shift+home/end, alt+a, replace-on-key): ported
|
|
@@ -464,19 +553,97 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
464
553
|
// the text is still exactly this, so an edit in between never gets
|
|
465
554
|
// silently discarded (issue #1218 review).
|
|
466
555
|
private pendingIdleClearText: string | undefined;
|
|
556
|
+
// Pi's own paste handler (files, text, unsupported formats). Pi assigns
|
|
557
|
+
// `onPasteImage` after construction; the accessor below keeps it as the
|
|
558
|
+
// fallback and routes every paste key, Vim NORMAL included, to pasteImage.
|
|
559
|
+
private piPasteImage: (() => void) | undefined;
|
|
560
|
+
private pastingImage = false;
|
|
561
|
+
private disposed = false;
|
|
562
|
+
// Counts submitted drafts, so a clipboard read that resolves after its
|
|
563
|
+
// draft was sent never lands in (and rides along with) the next message.
|
|
564
|
+
private submittedDrafts = 0;
|
|
467
565
|
|
|
468
566
|
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
|
|
469
567
|
super(tui, theme, keybindings);
|
|
470
568
|
this.selectionEngine = new SelectionEngine(this);
|
|
471
569
|
this.deps = deps;
|
|
472
570
|
this.keybindingsManager = keybindings;
|
|
571
|
+
const attachments = deps.attachments;
|
|
572
|
+
if (!attachments || !deps.readClipboardImage) return;
|
|
573
|
+
// SAFETY: Pi's Editor routes cursor movement, wrapping and deletion through
|
|
574
|
+
// its private segment(). Wrapping it only merges registered chips into one
|
|
575
|
+
// segment; if a future Pi drops the method, chips degrade to plain text.
|
|
576
|
+
const host = this as unknown as {
|
|
577
|
+
segment?: (text: string, mode?: string) => Iterable<{ segment: string; index: number; input: string }>;
|
|
578
|
+
submitValue?: () => void;
|
|
579
|
+
};
|
|
580
|
+
const segment = host.segment;
|
|
581
|
+
if (typeof segment === "function") host.segment = (text, mode) => attachments.segment(text, segment.call(this, text, mode));
|
|
582
|
+
// SAFETY: Pi empties the draft in its private submitValue() before onSubmit.
|
|
583
|
+
// Counting here only marks the draft boundary; if a future Pi drops the
|
|
584
|
+
// method, a late read falls back to landing in the next draft.
|
|
585
|
+
const submitValue = host.submitValue;
|
|
586
|
+
if (typeof submitValue === "function") host.submitValue = () => { this.submittedDrafts += 1; submitValue.call(this); };
|
|
587
|
+
// Undefined until Pi installs its handler, so Pi's `if (!onPasteImage)`
|
|
588
|
+
// wiring still runs; afterwards every read returns the chip handler.
|
|
589
|
+
Object.defineProperty(this, "onPasteImage", {
|
|
590
|
+
configurable: true,
|
|
591
|
+
enumerable: true,
|
|
592
|
+
get: () => this.piPasteImage ? () => this.pasteImage() : undefined,
|
|
593
|
+
set: (handler: (() => void) | undefined) => { this.piPasteImage = handler; },
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Registered image chips on `line`; the Vim adapter keeps NORMAL motions out of them. */
|
|
598
|
+
atomicSpans(line: string): Array<{ start: number; end: number }> {
|
|
599
|
+
return this.deps.attachments?.spans(line) ?? [];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Insert the clipboard image as a chip, or hand the paste to Pi. */
|
|
603
|
+
private pasteImage(): void {
|
|
604
|
+
const attachments = this.deps.attachments;
|
|
605
|
+
const read = this.deps.readClipboardImage;
|
|
606
|
+
if (!attachments || !read) { this.piPasteImage?.(); return; }
|
|
607
|
+
// The Windows clipboard read on WSL takes about a second; a repeated key
|
|
608
|
+
// press during it must not insert a second chip or a stray path.
|
|
609
|
+
if (this.pastingImage) return;
|
|
610
|
+
this.pastingImage = true;
|
|
611
|
+
void this.insertClipboardImage(attachments, read).finally(() => { this.pastingImage = false; });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private async insertClipboardImage(attachments: ImageAttachments, read: () => Promise<ClipboardImage | null>): Promise<void> {
|
|
615
|
+
const draft = this.submittedDrafts;
|
|
616
|
+
let image: ClipboardImage | null;
|
|
617
|
+
try { image = await read(); } catch { image = null; }
|
|
618
|
+
// A read that outlives this editor (session switch, quit) must not
|
|
619
|
+
// register bytes in the next session or write into a disposed prompt.
|
|
620
|
+
if (this.disposed) return;
|
|
621
|
+
// The draft this paste belonged to was already sent: neither attach the
|
|
622
|
+
// image to the next message nor run Pi's fallback into the new draft.
|
|
623
|
+
if (this.submittedDrafts !== draft) {
|
|
624
|
+
if (image) this.deps.notifyCompatibility?.("Image paste discarded: the message was sent before the clipboard finished reading. Paste again to attach it.");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
if (!image) { this.piPasteImage?.(); return; }
|
|
629
|
+
const { token } = attachments.add(image);
|
|
630
|
+
const cursor = this.getCursor();
|
|
631
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
632
|
+
const before = cursor.col > 0 && !/\s/.test(line[cursor.col - 1] ?? "") ? " " : "";
|
|
633
|
+
const after = cursor.col < line.length && !/\s/.test(line[cursor.col] ?? "") ? " " : "";
|
|
634
|
+
this.insertTextAtCursor(`${before}${token}${after}`);
|
|
635
|
+
this.deps.requestRender();
|
|
636
|
+
} catch (error) {
|
|
637
|
+
// Never let a paste reject unhandled: Node 24 ends the process on it.
|
|
638
|
+
this.deps.notifyCompatibility?.(`Image paste failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
639
|
+
}
|
|
473
640
|
}
|
|
474
641
|
|
|
475
642
|
setAnimationPolicy(policy: AnimationPolicy): void {
|
|
476
643
|
if (this.animationPolicy === policy) return;
|
|
477
644
|
this.animationPolicy = policy;
|
|
478
|
-
this.
|
|
479
|
-
if (this.promptState === PROMPT_STATE.WORKING) this.
|
|
645
|
+
this.stopWatchdog();
|
|
646
|
+
if (this.promptState === PROMPT_STATE.WORKING) this.startWatchdog();
|
|
480
647
|
this.deps.requestRender();
|
|
481
648
|
}
|
|
482
649
|
|
|
@@ -561,24 +728,32 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
561
728
|
this.deps.requestRender();
|
|
562
729
|
}
|
|
563
730
|
|
|
564
|
-
private
|
|
731
|
+
private startWatchdog(): void {
|
|
565
732
|
if (this.animationPolicy === "potato") return;
|
|
566
|
-
|
|
733
|
+
const windowMs = this.deps.watchdogMs ?? SHELL_PULSE_WATCHDOG_MS;
|
|
734
|
+
this.lastRenderAt = this.deps.now();
|
|
735
|
+
this.watchdog = setInterval(() => {
|
|
736
|
+
// Render-driven petal (gentle-shell#994): streaming frames already
|
|
737
|
+
// advance the animation in render(), so under quality the watchdog
|
|
738
|
+
// stays silent unless no frame painted within its window. performance
|
|
739
|
+
// keeps its coarse cadence regardless of render traffic.
|
|
740
|
+
if (this.animationPolicy === "quality" && this.deps.now() - this.lastRenderAt < windowMs) return;
|
|
567
741
|
this.tick += 1;
|
|
742
|
+
this.lastRenderAt = this.deps.now();
|
|
568
743
|
this.deps.requestRender();
|
|
569
|
-
},
|
|
570
|
-
this.
|
|
744
|
+
}, windowMs);
|
|
745
|
+
this.watchdog.unref();
|
|
571
746
|
}
|
|
572
747
|
|
|
573
748
|
setWorking(working: boolean): void {
|
|
574
749
|
this.promptState = working ? PROMPT_STATE.WORKING : PROMPT_STATE.IDLE;
|
|
575
|
-
this.
|
|
750
|
+
this.stopWatchdog();
|
|
576
751
|
if (!working) {
|
|
577
752
|
this.pendingEscapeCancelDeadline = undefined;
|
|
578
753
|
} else {
|
|
579
754
|
this.pendingIdleClearDeadline = undefined;
|
|
580
755
|
this.pendingIdleClearText = undefined;
|
|
581
|
-
this.
|
|
756
|
+
this.startWatchdog();
|
|
582
757
|
}
|
|
583
758
|
this.deps.requestRender();
|
|
584
759
|
}
|
|
@@ -1065,6 +1240,13 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
1065
1240
|
}
|
|
1066
1241
|
|
|
1067
1242
|
render(width: number): string[] {
|
|
1243
|
+
// Render-driven petal (gentle-shell#994): every frame the host already
|
|
1244
|
+
// paints while working advances the animation for free, so no fast
|
|
1245
|
+
// timer forces renders during streaming.
|
|
1246
|
+
if (this.promptState === PROMPT_STATE.WORKING && this.animationPolicy === "quality") {
|
|
1247
|
+
this.tick += 1;
|
|
1248
|
+
this.lastRenderAt = this.deps.now();
|
|
1249
|
+
}
|
|
1068
1250
|
const layout = resolvePromptLayout(width, cardStyle(), this.deps.bg);
|
|
1069
1251
|
this.floatMouseLayout = layout.background ? layout : undefined;
|
|
1070
1252
|
const inner = layout.nativeWidth;
|
|
@@ -1128,7 +1310,8 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
1128
1310
|
}
|
|
1129
1311
|
|
|
1130
1312
|
dispose(): void {
|
|
1131
|
-
this.
|
|
1313
|
+
this.disposed = true;
|
|
1314
|
+
this.stopWatchdog();
|
|
1132
1315
|
}
|
|
1133
1316
|
|
|
1134
1317
|
private isPendingEscapeCancel(): boolean {
|
|
@@ -1143,13 +1326,62 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
1143
1326
|
);
|
|
1144
1327
|
}
|
|
1145
1328
|
|
|
1146
|
-
private
|
|
1147
|
-
if (this.
|
|
1148
|
-
this.
|
|
1329
|
+
private stopWatchdog(): void {
|
|
1330
|
+
if (this.watchdog) clearInterval(this.watchdog);
|
|
1331
|
+
this.watchdog = undefined;
|
|
1149
1332
|
this.tick = 0;
|
|
1150
1333
|
}
|
|
1151
1334
|
}
|
|
1152
1335
|
|
|
1336
|
+
const IMAGE_ATTACHMENTS_WIDGET_KEY = "gentle-shell-image-attachments";
|
|
1337
|
+
const IMAGE_PREVIEW_MAX_ROWS = 6;
|
|
1338
|
+
const IMAGE_PREVIEW_MAX_COLUMNS = 40;
|
|
1339
|
+
|
|
1340
|
+
/** Unknown capabilities count as image-capable; Pi decides for its own models. */
|
|
1341
|
+
export function modelAcceptsImages(model: { input?: readonly string[] } | undefined): boolean {
|
|
1342
|
+
return !model?.input || model.input.includes("image");
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Lists the chips still in the draft above the prompt. Terminals with an image
|
|
1347
|
+
* protocol (Kitty, iTerm2) get a thumbnail under each label; every other
|
|
1348
|
+
* terminal, or a multiplexer that hides the protocol, gets the label alone.
|
|
1349
|
+
*/
|
|
1350
|
+
export function imageAttachmentsWidget(
|
|
1351
|
+
attachments: ImageAttachments,
|
|
1352
|
+
draft: () => string,
|
|
1353
|
+
theme: { fg(color: string, text: string): string },
|
|
1354
|
+
acceptsImages: () => boolean,
|
|
1355
|
+
canDrawImages: () => boolean = () => getCapabilities().images !== null,
|
|
1356
|
+
) {
|
|
1357
|
+
const previews = new Map<number, Image>();
|
|
1358
|
+
return {
|
|
1359
|
+
render(width: number): string[] {
|
|
1360
|
+
const shown = attachments.referenced(draft());
|
|
1361
|
+
for (const id of previews.keys()) if (!shown.some((attachment) => attachment.id === id)) previews.delete(id);
|
|
1362
|
+
if (shown.length === 0) return [];
|
|
1363
|
+
const draw = canDrawImages();
|
|
1364
|
+
const warning = acceptsImages() ? "" : theme.fg("warning", " · not sent: the current model does not accept images");
|
|
1365
|
+
const lines: string[] = [];
|
|
1366
|
+
for (const attachment of shown) {
|
|
1367
|
+
lines.push(truncateToWidth(`${theme.fg("accent", attachment.token)} ${theme.fg("dim", describeAttachment(attachment))}${warning}`, width));
|
|
1368
|
+
if (!draw) continue;
|
|
1369
|
+
let preview = previews.get(attachment.id);
|
|
1370
|
+
if (!preview) {
|
|
1371
|
+
preview = new Image(attachment.base64, attachment.mimeType, { fallbackColor: (text) => theme.fg("dim", text) },
|
|
1372
|
+
{ maxWidthCells: IMAGE_PREVIEW_MAX_COLUMNS, maxHeightCells: IMAGE_PREVIEW_MAX_ROWS }, attachment.dimensions);
|
|
1373
|
+
previews.set(attachment.id, preview);
|
|
1374
|
+
}
|
|
1375
|
+
lines.push(...preview.render(width));
|
|
1376
|
+
}
|
|
1377
|
+
return lines;
|
|
1378
|
+
},
|
|
1379
|
+
invalidate(): void {
|
|
1380
|
+
for (const preview of previews.values()) preview.invalidate();
|
|
1381
|
+
},
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1153
1385
|
// Stable across extension module reloads; never infer ownership from a name.
|
|
1154
1386
|
const PROMPT_OWNER = Symbol.for("gentle-pi.prompt-owner");
|
|
1155
1387
|
type PromptFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>> & { [PROMPT_OWNER]?: boolean };
|
|
@@ -1157,7 +1389,7 @@ type PromptFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorCom
|
|
|
1157
1389
|
function installPrompt(
|
|
1158
1390
|
ctx: ExtensionContext,
|
|
1159
1391
|
onCreated: (prompt: GentlePromptEditor) => void,
|
|
1160
|
-
promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void; vimRuntimeVersion?: () => string | undefined },
|
|
1392
|
+
promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void; vimRuntimeVersion?: () => string | undefined; attachments?: ImageAttachments; readClipboardImage?: () => Promise<ClipboardImage | null> },
|
|
1161
1393
|
): boolean {
|
|
1162
1394
|
const previous = ctx.ui.getEditorComponent() as PromptFactory | undefined;
|
|
1163
1395
|
if (previous && !previous[PROMPT_OWNER]) return false;
|
|
@@ -1174,10 +1406,12 @@ function installPrompt(
|
|
|
1174
1406
|
notifyCompatibility: (message) => ctx.ui.notify(message, "warning"),
|
|
1175
1407
|
tuiVersion: promptDeps.vimRuntimeVersion,
|
|
1176
1408
|
workingLabel: () => oddPhaseRegistry.label(ctx.sessionManager.getSessionId()),
|
|
1409
|
+
attachments: promptDeps.attachments,
|
|
1410
|
+
readClipboardImage: promptDeps.readClipboardImage,
|
|
1177
1411
|
});
|
|
1178
1412
|
// Pi's own docs require an explicit requestRender() after a state change
|
|
1179
1413
|
// (docs/tui.md: "Call tui.requestRender() after state changes"); no host
|
|
1180
|
-
// re-render is implicitly guaranteed, and the WORKING
|
|
1414
|
+
// re-render is implicitly guaranteed, and the WORKING watchdog does not
|
|
1181
1415
|
// run at all under the "potato" animation policy. Register this session's
|
|
1182
1416
|
// redraw so a phase reported by the tool is visible immediately.
|
|
1183
1417
|
oddPhaseRegistry.setRenderRequest(ctx.sessionManager.getSessionId(), () => tui.requestRender());
|
|
@@ -1279,26 +1513,7 @@ const fileRevision = (root: string): FileRevision => async (path) => {
|
|
|
1279
1513
|
return `${info.size}:${info.mtimeMs}`;
|
|
1280
1514
|
};
|
|
1281
1515
|
|
|
1282
|
-
export
|
|
1283
|
-
stop(): void;
|
|
1284
|
-
start(): void;
|
|
1285
|
-
requestRender(force?: boolean): void;
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
export function openInExternalEditor(host: ExternalEditorHost, path: string, env: NodeJS.ProcessEnv = process.env, spawn: typeof spawnSync = spawnSync, cwd?: string): boolean {
|
|
1289
|
-
const command = env.VISUAL || env.EDITOR;
|
|
1290
|
-
if (!command) return false;
|
|
1291
|
-
const [editor, ...editorArgs] = command.split(" ");
|
|
1292
|
-
host.stop();
|
|
1293
|
-
try {
|
|
1294
|
-
spawn(editor, [...editorArgs, path], { cwd, stdio: "inherit", shell: process.platform === "win32" });
|
|
1295
|
-
} finally {
|
|
1296
|
-
host.start();
|
|
1297
|
-
host.requestRender(true);
|
|
1298
|
-
}
|
|
1299
|
-
return true;
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1516
|
+
export { openInExternalEditor, type ExternalEditorHost } from "../lib/external-editor.ts";
|
|
1302
1517
|
export function changesShortcut(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
1303
1518
|
const value = env.GENTLE_PI_SHELL_CHANGES_KEY?.trim();
|
|
1304
1519
|
if (value === undefined) return CHANGES_SHORTCUT_DEFAULT;
|
|
@@ -1326,8 +1541,11 @@ function changesPollMs(env: NodeJS.ProcessEnv): number {
|
|
|
1326
1541
|
const USAGE_FETCH_TIMEOUT_DEFAULT_MS = 10_000;
|
|
1327
1542
|
const usageFetchTimeoutMs = (env: NodeJS.ProcessEnv): number => positiveMs(env.GENTLE_PI_SHELL_USAGE_TIMEOUT_MS, USAGE_FETCH_TIMEOUT_DEFAULT_MS);
|
|
1328
1543
|
|
|
1544
|
+
// The footer memo reads this every keystroke: tracker models store the
|
|
1545
|
+
// digest once per scan (lib/shell-changes.ts), so this is O(1); hand-built
|
|
1546
|
+
// models without a stored digest fall back to computing it here.
|
|
1329
1547
|
function changesFingerprint(model: ChangesModel): string {
|
|
1330
|
-
return model.
|
|
1548
|
+
return model.digest ?? changesDigest(model);
|
|
1331
1549
|
}
|
|
1332
1550
|
|
|
1333
1551
|
interface OverlayDeps {
|
|
@@ -1372,7 +1590,11 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
|
|
|
1372
1590
|
{ overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } },
|
|
1373
1591
|
);
|
|
1374
1592
|
if (!chosen || !host) return;
|
|
1375
|
-
|
|
1593
|
+
try {
|
|
1594
|
+
if (!openInExternalEditor(host, chosen.file.path, process.env, spawnSync, chosen.root)) ctx.ui.notify("No editor configured. Set $VISUAL or $EDITOR.", "warning");
|
|
1595
|
+
} catch (error) {
|
|
1596
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
1597
|
+
}
|
|
1376
1598
|
} finally {
|
|
1377
1599
|
clearInterval(poll);
|
|
1378
1600
|
view?.dispose();
|
|
@@ -1774,6 +1996,8 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1774
1996
|
});
|
|
1775
1997
|
}
|
|
1776
1998
|
let prompt: GentlePromptEditor | undefined;
|
|
1999
|
+
// Images pasted into the current draft; emptied on every submit.
|
|
2000
|
+
const imageAttachments = new ImageAttachments();
|
|
1777
2001
|
// Set by abortAndDispatchQueued via dispatchQueuedText when an Esc aborts
|
|
1778
2002
|
// a turn with a non-empty queue; sent exactly once, from agent_settled,
|
|
1779
2003
|
// once the aborted run has fully settled (issue #1218). Several aborts
|
|
@@ -1826,6 +2050,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1826
2050
|
let currentContext: ExtensionContext | undefined;
|
|
1827
2051
|
let review: ReviewSidebarSnapshot | undefined;
|
|
1828
2052
|
let runningJobs: RunningJobDisplay[] | undefined;
|
|
2053
|
+
let jobsCollapsed = false;
|
|
1829
2054
|
const redrawReview = () => {
|
|
1830
2055
|
renderHost?.invalidateSidebar?.();
|
|
1831
2056
|
renderHost?.requestRender();
|
|
@@ -1841,7 +2066,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1841
2066
|
const event = value as { sessionId?: unknown; snapshot?: unknown } | undefined;
|
|
1842
2067
|
if (!currentContext || event?.sessionId !== currentContext.sessionManager.getSessionId()) return;
|
|
1843
2068
|
if (!isJobsSidebarSnapshot(event.snapshot)) return;
|
|
1844
|
-
runningJobs = event.snapshot.jobs.map(({ id, label, startedAt }) => ({ id, label, startedAt }));
|
|
2069
|
+
runningJobs = event.snapshot.jobs.map(({ id, label, startedAt, status, endedAt, exitCode }) => ({ id, label, startedAt, status, endedAt, exitCode }));
|
|
1845
2070
|
redrawReview();
|
|
1846
2071
|
});
|
|
1847
2072
|
pi.on("session_tree", () => {
|
|
@@ -1926,13 +2151,23 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1926
2151
|
const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""), () => visualSettings);
|
|
1927
2152
|
// The Status card paints live session state that no event re-registers a
|
|
1928
2153
|
// part for: model, effort, context, cost, session name and extension
|
|
1929
|
-
// statuses. The digest is what keeps the fullscreen memo honest
|
|
1930
|
-
//
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
2154
|
+
// statuses. The digest is what keeps the fullscreen memo honest. Issue
|
|
2155
|
+
// #1681: railDigest evaluates every rail's digest per keystroke, so both
|
|
2156
|
+
// Status rails digest a cheap key and reuse the memoized model
|
|
2157
|
+
// (createFooterStatusMemo).
|
|
2158
|
+
const footerStatus = createFooterStatusMemo({
|
|
2159
|
+
pi, ctx, footerData,
|
|
2160
|
+
usage: () => usage.get(ctx.model?.provider ?? ""),
|
|
2161
|
+
profile: () => deps.activeProfile(),
|
|
2162
|
+
// The compact bar and header derive ⧗ N jobs from model.jobs.length,
|
|
2163
|
+
// so this input is running-only: the snapshot carries the finished tail.
|
|
2164
|
+
jobs: () => runningJobs?.filter((job) => job.status === undefined),
|
|
2165
|
+
review: () => review,
|
|
2166
|
+
tracker: () => tracker,
|
|
2167
|
+
visualSettings: () => visualSettings,
|
|
2168
|
+
cardStyle: () => cardStyle(),
|
|
1935
2169
|
});
|
|
2170
|
+
const footerModel = footerStatus.model;
|
|
1936
2171
|
// At narrow fullscreen widths only one status row paints: a top header
|
|
1937
2172
|
// suppresses the bottom bar in the layout, and otherwise the bottom bar
|
|
1938
2173
|
// takes over the header's data while the below-input header steps aside.
|
|
@@ -1960,16 +2195,78 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1960
2195
|
},
|
|
1961
2196
|
};
|
|
1962
2197
|
const part = sidebarPart(tui, "footer", bottomBar, {
|
|
1963
|
-
digest: () =>
|
|
2198
|
+
digest: () => footerStatus.key(),
|
|
1964
2199
|
render: (width) => renderShellSidebarBar(footerModel(), theme, width, visualSettings),
|
|
1965
2200
|
invalidate() {},
|
|
1966
2201
|
});
|
|
2202
|
+
// The Jobs card: its own rail section, like Agents and Todos, fed by the
|
|
2203
|
+
// same running-job display snapshot the Status card used to print. The
|
|
2204
|
+
// rail is natively scrollable, so it lists every running job; there is
|
|
2205
|
+
// no above-editor copy — regular mode keeps the compact bar count.
|
|
2206
|
+
let jobsHovered = false;
|
|
2207
|
+
let jobsPainted = false;
|
|
2208
|
+
let jobsHeaderRow = 0;
|
|
2209
|
+
const jobsRailCard: Component = {
|
|
2210
|
+
render(width: number) {
|
|
2211
|
+
const jobs = runningJobs ?? [];
|
|
2212
|
+
if (jobs.length === 0) { jobsPainted = false; return []; }
|
|
2213
|
+
const lines = renderJobsCard(jobs, theme, width, deps.now(), { collapsed: jobsCollapsed, collapseKey: jobsKey, collapseControl: true, hovered: jobsHovered });
|
|
2214
|
+
jobsPainted = lines.length > 0;
|
|
2215
|
+
jobsHeaderRow = jobsPainted ? panelHeaderRow(theme, width, jobsCardTone(jobs)) : 0;
|
|
2216
|
+
return lines;
|
|
2217
|
+
},
|
|
2218
|
+
invalidate() {
|
|
2219
|
+
jobsHovered = false;
|
|
2220
|
+
},
|
|
2221
|
+
};
|
|
2222
|
+
const jobsRegion = new NativePointerRegion(jobsRailCard, {
|
|
2223
|
+
onHover(event) {
|
|
2224
|
+
const next = event.y === jobsHeaderRow;
|
|
2225
|
+
if (next === jobsHovered) return { handled: true };
|
|
2226
|
+
jobsHovered = next;
|
|
2227
|
+
return { handled: true, render: true };
|
|
2228
|
+
},
|
|
2229
|
+
onLeave() {
|
|
2230
|
+
if (!jobsHovered) return;
|
|
2231
|
+
jobsHovered = false;
|
|
2232
|
+
tui.requestRender();
|
|
2233
|
+
},
|
|
2234
|
+
onClick(event) {
|
|
2235
|
+
if (event.button !== "left" || event.y !== jobsHeaderRow) return undefined;
|
|
2236
|
+
jobsCollapsed = !jobsCollapsed;
|
|
2237
|
+
invalidateSidebar(tui);
|
|
2238
|
+
tui.requestRender();
|
|
2239
|
+
return { handled: true, render: true };
|
|
2240
|
+
},
|
|
2241
|
+
});
|
|
2242
|
+
const jobsRail: Component & { digest(): string; dispose(): void } = {
|
|
2243
|
+
digest: () => JSON.stringify([jobsCollapsed, jobsHovered, runningJobs, Math.floor(deps.now() / 1000)]),
|
|
2244
|
+
render: (width) => jobsRegion.render(width),
|
|
2245
|
+
handleMouse: (event) => jobsRegion.handleMouse(event),
|
|
2246
|
+
invalidate() {
|
|
2247
|
+
jobsRegion.invalidate();
|
|
2248
|
+
},
|
|
2249
|
+
dispose() {
|
|
2250
|
+
jobsRegion.dispose();
|
|
2251
|
+
},
|
|
2252
|
+
};
|
|
2253
|
+
const jobsPart = sidebarPart(tui, "jobs", { render: () => [], invalidate() {}, dispose() {} }, jobsRail);
|
|
2254
|
+
// Elapsed time moves while a job runs, and a finished tail must clear
|
|
2255
|
+
// itself once its TTL expires with nothing running behind it; one frame
|
|
2256
|
+
// a second is enough, and the digest's second bucket revalidates the
|
|
2257
|
+
// cached section.
|
|
2258
|
+
const jobsTimer = setInterval(() => {
|
|
2259
|
+
const now = deps.now();
|
|
2260
|
+
const fresh = (runningJobs ?? []).some((job) => job.status === undefined || now - (job.endedAt ?? 0) < JOBS_FINISHED_TTL_MS);
|
|
2261
|
+
if (jobsPainted || fresh) tui.requestRender();
|
|
2262
|
+
}, 1_000);
|
|
2263
|
+
jobsTimer.unref?.();
|
|
1967
2264
|
// The header row carries everything that ticks every frame (model,
|
|
1968
2265
|
// effort, context, cost, usage) plus session identity and the jobs count
|
|
1969
2266
|
// when Status is absent. Opaque statuses and working state stay excluded.
|
|
1970
2267
|
const headerBar = (width: number) => renderShellHeaderChrome(buildShellHeaderModel(footerModel(), !statusCardVisible() && (statusOwner() === STATUS_OWNER.HEADER || visualSettings.statusPlacement === "hidden")), theme, width, usageShortcutKey, visualSettings, gaugePaint(ctx));
|
|
1971
2268
|
const disposeHeader = sidebarHeader(tui, {
|
|
1972
|
-
digest: () =>
|
|
2269
|
+
digest: () => footerStatus.key(),
|
|
1973
2270
|
render: (width) => headerBar(width).rows,
|
|
1974
2271
|
invalidate() {},
|
|
1975
2272
|
handleMouse(event) {
|
|
@@ -1999,15 +2296,17 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1999
2296
|
}), { placement: "belowEditor" });
|
|
2000
2297
|
return { ...part,
|
|
2001
2298
|
render(width: number) {
|
|
2002
|
-
|
|
2003
|
-
|
|
2299
|
+
// Running-only: a finished tail must never paint a ⧗ 0 jobs row.
|
|
2300
|
+
const compactRunningJobs = runningJobs?.filter((job) => job.status === undefined);
|
|
2301
|
+
if (visualSettings.statusPlacement === "hidden" && (tui as TUI & { mode?: string }).mode !== "fullscreen" && compactRunningJobs?.length) {
|
|
2302
|
+
return [truncateToWidth(theme.fg("muted", shellJobsCount(compactRunningJobs.length)), width, "…")];
|
|
2004
2303
|
}
|
|
2005
2304
|
const rows = part.render(width);
|
|
2006
2305
|
// Preserve a shrinkable exterior dock row, outside prompt/completion geometry.
|
|
2007
2306
|
return rows.length === 0 && (tui as TUI & { mode?: string }).mode === "fullscreen"
|
|
2008
2307
|
&& resolvePromptLayout(width, cardStyle(), theme.bg?.bind(theme)).background ? [""] : rows;
|
|
2009
2308
|
},
|
|
2010
|
-
dispose() { ctx.ui.setWidget(HEADER_WIDGET_KEY, undefined); disposeHeader(); uninstall(); part.dispose(); if (sidebarTui === tui) { sidebarTui = undefined; groupedChangesOwner = () => false; } } };
|
|
2309
|
+
dispose() { clearInterval(jobsTimer); jobsPart.dispose(); ctx.ui.setWidget(HEADER_WIDGET_KEY, undefined); disposeHeader(); uninstall(); part.dispose(); if (sidebarTui === tui) { sidebarTui = undefined; groupedChangesOwner = () => false; } } };
|
|
2011
2310
|
});
|
|
2012
2311
|
void refreshUsage(ctx, true);
|
|
2013
2312
|
const ownsPrompt = installPrompt(
|
|
@@ -2018,8 +2317,15 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2018
2317
|
prompt.setAnimationPolicy(animationPolicy);
|
|
2019
2318
|
prompt.setVimPolicy(vimPolicy);
|
|
2020
2319
|
},
|
|
2021
|
-
{ now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; }, vimRuntimeVersion: deps.vimRuntimeVersion },
|
|
2320
|
+
{ now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; }, vimRuntimeVersion: deps.vimRuntimeVersion, attachments: imageAttachments, readClipboardImage: () => (deps.readClipboardImage ?? readClipboardImage)() },
|
|
2022
2321
|
);
|
|
2322
|
+
imageAttachments.clear();
|
|
2323
|
+
ctx.ui.setWidget(IMAGE_ATTACHMENTS_WIDGET_KEY, ownsPrompt
|
|
2324
|
+
// Kitty re-sends a full image block whenever content above it moves, so
|
|
2325
|
+
// thumbnails show only while idle; while the agent streams, labels only.
|
|
2326
|
+
? (_tui, theme) => imageAttachmentsWidget(imageAttachments, () => prompt?.getText() ?? "", theme, () => modelAcceptsImages(ctx.model),
|
|
2327
|
+
() => getCapabilities().images !== null && ctx.isIdle())
|
|
2328
|
+
: undefined);
|
|
2023
2329
|
// Hide native feedback only when our petal replaces it. Native transcript
|
|
2024
2330
|
// thinking blocks remain Pi-owned; this changes only the supported loader UI.
|
|
2025
2331
|
// Inside Herdr the row stays: its native Pi detection reads it.
|
|
@@ -2035,6 +2341,20 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2035
2341
|
shown = "";
|
|
2036
2342
|
applyChanges(ctx, tracker.model);
|
|
2037
2343
|
});
|
|
2344
|
+
// Chips still in the submitted draft travel as real image attachments, so
|
|
2345
|
+
// the model sees the image without calling `read` on a temp-file path.
|
|
2346
|
+
pi.on("input", (event, ctx) => {
|
|
2347
|
+
if (event.source !== "interactive" || imageAttachments.size === 0) return { action: "continue" };
|
|
2348
|
+
// The editor may already hold the next draft (a prompt Pi queued during
|
|
2349
|
+
// compaction): its chips stay registered for their own submit.
|
|
2350
|
+
const submitted = imageAttachments.submit(event.text, modelAcceptsImages(ctx.model), prompt?.getText() ?? "");
|
|
2351
|
+
if (!submitted) return { action: "continue" };
|
|
2352
|
+
if (submitted.omitted > 0 && ctx.hasUI) {
|
|
2353
|
+
const count = submitted.omitted === 1 ? "1 pasted image was" : `${submitted.omitted} pasted images were`;
|
|
2354
|
+
ctx.ui.notify(`${count} not sent: the current model does not accept images. Switch to a vision model and paste again.`, "warning");
|
|
2355
|
+
}
|
|
2356
|
+
return { action: "transform", text: submitted.text, images: [...(event.images ?? []), ...submitted.images] };
|
|
2357
|
+
});
|
|
2038
2358
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
2039
2359
|
closeCustomize?.();
|
|
2040
2360
|
if (review) {
|
|
@@ -2050,6 +2370,8 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2050
2370
|
oddPhaseRegistry.clear(ctx.sessionManager.getSessionId());
|
|
2051
2371
|
oddPhaseRegistry.clearRenderRequest(ctx.sessionManager.getSessionId());
|
|
2052
2372
|
pendingQueuedText = undefined;
|
|
2373
|
+
imageAttachments.clear();
|
|
2374
|
+
ctx.ui.setWidget(IMAGE_ATTACHMENTS_WIDGET_KEY, undefined);
|
|
2053
2375
|
prompt?.dispose();
|
|
2054
2376
|
prompt = undefined;
|
|
2055
2377
|
if ((ctx.ui.getEditorComponent() as PromptFactory | undefined)?.[PROMPT_OWNER]) {
|
|
@@ -2069,10 +2391,14 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2069
2391
|
applyChanges(ctx, model);
|
|
2070
2392
|
// A failed root scan is unknown, never clean: say so whenever one failed.
|
|
2071
2393
|
const failed = tracker.failedRoots.length;
|
|
2394
|
+
const unborn = tracker.unbornRoots.length;
|
|
2072
2395
|
const roots = tracker.scannedRootCount;
|
|
2073
2396
|
if (model.files.length === 0) {
|
|
2074
2397
|
if (failed > 0 && failed === roots) ctx.ui.notify(`Could not read Git status for ${failed} ${failed === 1 ? "root" : "roots"}; uncommitted changes are unknown.`, "error");
|
|
2075
2398
|
else if (failed > 0) ctx.ui.notify(`No uncommitted changes in the roots Git could read; Git status failed for ${failed} of ${roots} roots.`, "warning");
|
|
2399
|
+
else if (unborn > 0) ctx.ui.notify(unborn === roots
|
|
2400
|
+
? `No commits yet in ${unborn === 1 ? "this session's repository" : "any root"} — make a commit to start tracking changes.`
|
|
2401
|
+
: `No uncommitted changes; ${unborn} of ${roots} roots have no commits yet, so they are not tracked.`, "info");
|
|
2076
2402
|
else ctx.ui.notify("No uncommitted changes in this session's repository or its subagents' worktrees.", "info");
|
|
2077
2403
|
return;
|
|
2078
2404
|
}
|
|
@@ -2101,6 +2427,17 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2101
2427
|
handler: async (ctx) => showCommandPalette(pi, ctx, env),
|
|
2102
2428
|
});
|
|
2103
2429
|
}
|
|
2430
|
+
const jobsKey = jobsCollapseKey(env);
|
|
2431
|
+
if (jobsKey) {
|
|
2432
|
+
pi.registerShortcut(jobsKey as Parameters<ExtensionAPI["registerShortcut"]>[0], {
|
|
2433
|
+
description: "Collapse or expand the jobs card",
|
|
2434
|
+
handler: async () => {
|
|
2435
|
+
jobsCollapsed = !jobsCollapsed;
|
|
2436
|
+
if (sidebarTui) invalidateSidebar(sidebarTui);
|
|
2437
|
+
renderHost?.requestRender();
|
|
2438
|
+
},
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2104
2441
|
pi.registerCommand("gentle:customize", {
|
|
2105
2442
|
description: "Configure appearance, audio notifications, Vim editing, session permissions and global Auto/YOLO defaults, and prompt history capture.",
|
|
2106
2443
|
handler: async (_args, ctx) => {
|
|
@@ -2269,19 +2606,21 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2269
2606
|
await refreshYolo();
|
|
2270
2607
|
},
|
|
2271
2608
|
});
|
|
2272
|
-
for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
|
|
2609
|
+
for (const mode of ["normal", "auto", "yolo", "super-yolo"] as const) rows.push({
|
|
2273
2610
|
category,
|
|
2274
2611
|
label: () => `Session permissions: ${mode.toUpperCase()}${mode === "auto" ? " (beta)" : ""}${permissionDisplay === mode ? " (current)" : ""}`,
|
|
2275
2612
|
preview: () => ({ title: "Permissions · current session", sample: mode === "auto"
|
|
2276
2613
|
? "independent session-model action checks · extra inference cost · hard safety and native consent remain"
|
|
2277
|
-
: mode === "yolo" ? "ordinary scoped delivery permission · destructive confirmations remain"
|
|
2614
|
+
: mode === "yolo" ? "ordinary scoped delivery permission · destructive confirmations remain"
|
|
2615
|
+
: mode === "super-yolo" ? "no permission prompts · only private-path protection (.env, keys, secrets) remains"
|
|
2616
|
+
: "normal checks · overrides an elevated global default" }),
|
|
2278
2617
|
action: async () => {
|
|
2279
2618
|
if (closed) return;
|
|
2280
2619
|
if (!adapter?.setMode || !await adapter.setMode(mode)) ctx.ui.notify("Session mode unchanged — live primary UI unavailable or changed.", "warning");
|
|
2281
2620
|
await refreshYolo();
|
|
2282
2621
|
},
|
|
2283
2622
|
});
|
|
2284
|
-
for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
|
|
2623
|
+
for (const mode of ["normal", "auto", "yolo", "super-yolo"] as const) rows.push({
|
|
2285
2624
|
category,
|
|
2286
2625
|
label: () => `Default permissions: ${mode.toUpperCase()}${mode === "auto" ? " (beta)" : ""}${permissionDefault === mode ? " (saved)" : ""}`,
|
|
2287
2626
|
preview: () => ({ title: "Permissions · user-global default", sample: `New primary sessions start in ${mode.toUpperCase()}; current session stays unchanged. ${mode === "auto" ? "Additional classifier inference uses the session model; usage is separate." : "Independent safety and consent remain."}` }),
|