gentle-pi 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- 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 +101 -151
- 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 +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- 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 +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- 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 +2 -0
- 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 +123 -49
- package/lib/gentle-shell-launcher.ts +9 -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/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-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- 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 +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -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/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- 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 +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- 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 +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -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 +183 -8
- 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-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/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- 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 +99 -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/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-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- 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 +50 -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/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 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,7 +13,7 @@ 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";
|
|
@@ -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";
|
|
@@ -171,6 +174,8 @@ export interface ShellDeps {
|
|
|
171
174
|
/** A root Changes may scan: removed worktrees and leftover directories are skipped. */
|
|
172
175
|
rootExists(root: string): boolean;
|
|
173
176
|
vimRuntimeVersion?(): string | undefined;
|
|
177
|
+
/** Clipboard image source for the prompt's attachment chips; tests inject a fake. */
|
|
178
|
+
readClipboardImage?(): Promise<ClipboardImage | null>;
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
export type ActiveProfileReader = (() => string | undefined) & {
|
|
@@ -274,33 +279,38 @@ function sessionCost(ctx: ExtensionContext): number {
|
|
|
274
279
|
}
|
|
275
280
|
|
|
276
281
|
type ShellSessionStats = { costTotal: number; usage: ReturnType<ExtensionContext["getContextUsage"]> };
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
282
|
+
// The exact identity sessionStats' memo keys on: when any element differs the
|
|
283
|
+
// stats are recomputed. Undefined means the host lacks the leaf identity and
|
|
284
|
+
// the stats must be computed fresh on every call. Callers that digest live
|
|
285
|
+
// state per frame (issue #1681) reuse this key so their own memos can never
|
|
286
|
+
// disagree with sessionStats about what is still current.
|
|
287
|
+
type SessionStatsKey = readonly [
|
|
288
|
+
sessionId: string | undefined,
|
|
289
|
+
leafId: string | null,
|
|
290
|
+
entryCount: number | undefined,
|
|
291
|
+
model: ExtensionContext["model"],
|
|
292
|
+
contextWindow: number | undefined,
|
|
293
|
+
];
|
|
294
|
+
const shellSessionStats = new WeakMap<object, { key: SessionStatsKey; stats: ShellSessionStats }>();
|
|
295
|
+
|
|
296
|
+
function sessionStatsKey(ctx: ExtensionContext): SessionStatsKey | undefined {
|
|
287
297
|
const manager = ctx.sessionManager as ExtensionContext["sessionManager"] & { getEntryCount?(): number };
|
|
288
|
-
const compute = (): ShellSessionStats => ({ costTotal: sessionCost(ctx), usage: ctx.getContextUsage() });
|
|
289
298
|
// Append-only session entries move the leaf, including compaction and branch
|
|
290
299
|
// switches. A virtual model's routed physical model comes from the latest
|
|
291
300
|
// response, which is appended as an entry, so the leaf covers it as well.
|
|
292
301
|
// 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
|
-
|
|
302
|
+
if (typeof manager.getLeafId !== "function") return undefined;
|
|
303
|
+
return [manager.getSessionId(), manager.getLeafId(), manager.getEntryCount?.(), ctx.model, ctx.model?.contextWindow] as const;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function sessionStats(ctx: ExtensionContext): ShellSessionStats {
|
|
307
|
+
const compute = (): ShellSessionStats => ({ costTotal: sessionCost(ctx), usage: ctx.getContextUsage() });
|
|
308
|
+
const key = sessionStatsKey(ctx);
|
|
309
|
+
if (!key) return compute();
|
|
310
|
+
const cached = shellSessionStats.get(ctx.sessionManager);
|
|
311
|
+
if (cached && cached.key.every((value, index) => value === key[index])) return cached.stats;
|
|
302
312
|
const stats = compute();
|
|
303
|
-
shellSessionStats.set(
|
|
313
|
+
shellSessionStats.set(ctx.sessionManager, { key, stats });
|
|
304
314
|
return stats;
|
|
305
315
|
}
|
|
306
316
|
|
|
@@ -336,6 +346,75 @@ export function buildShellBarModel(
|
|
|
336
346
|
};
|
|
337
347
|
}
|
|
338
348
|
|
|
349
|
+
export interface FooterStatusMemoDeps {
|
|
350
|
+
pi: ExtensionAPI;
|
|
351
|
+
ctx: ExtensionContext;
|
|
352
|
+
footerData: ShellFooterData;
|
|
353
|
+
usage: () => ProviderUsage | undefined;
|
|
354
|
+
profile: () => string | undefined;
|
|
355
|
+
jobs: () => RunningJobDisplay[] | undefined;
|
|
356
|
+
review: () => ReviewSidebarSnapshot | undefined;
|
|
357
|
+
tracker: () => Pick<WorktreeChangesTracker, "model" | "unbornRoots">;
|
|
358
|
+
visualSettings: () => unknown;
|
|
359
|
+
cardStyle: () => unknown;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Issue #1681: railDigest evaluates every rail's digest before the fullscreen
|
|
363
|
+
// layout's prepared cache can answer, so both Status rails digest per
|
|
364
|
+
// keystroke and a cache hit still paid for building and serializing the whole
|
|
365
|
+
// bar model. The memo keys on the cheap live inputs the model reads instead:
|
|
366
|
+
// the digest serializes only this key, and the model is rebuilt just when one
|
|
367
|
+
// of those inputs moved. On hosts without leaf identity the session stats
|
|
368
|
+
// stay uncached and ride the key by value, so those hosts keep exactly the
|
|
369
|
+
// pre-memo digest honesty.
|
|
370
|
+
export function createFooterStatusMemo(deps: FooterStatusMemoDeps): { key(): string; model(): ShellBarModel } {
|
|
371
|
+
const { pi, ctx, footerData } = deps;
|
|
372
|
+
let cache: { key: string; model: ShellBarModel } | undefined;
|
|
373
|
+
const key = (): string => {
|
|
374
|
+
const statsKey = sessionStatsKey(ctx);
|
|
375
|
+
const model = ctx.model;
|
|
376
|
+
return JSON.stringify([
|
|
377
|
+
// Session statistics identity by value: without leaf identity the
|
|
378
|
+
// computed stats ride the key, so those hosts keep exactly the pre-memo
|
|
379
|
+
// digest honesty. The model rides its value pieces (id, contextWindow,
|
|
380
|
+
// effort lookup, OAuth state) because that is all buildShellBarModel
|
|
381
|
+
// reads — serializing the model object itself would bloat every digest.
|
|
382
|
+
statsKey ? [statsKey[0], statsKey[1], statsKey[2]] : sessionStats(ctx),
|
|
383
|
+
model?.id ?? null,
|
|
384
|
+
model?.contextWindow ?? null,
|
|
385
|
+
model?.reasoning ? (isNanAdaptiveReasoningModel(model) ? "auto" : pi.getThinkingLevel()) : undefined,
|
|
386
|
+
model ? ctx.modelRegistry.isUsingOAuth(model) : false,
|
|
387
|
+
footerData.getGitBranch(),
|
|
388
|
+
deps.profile(),
|
|
389
|
+
ctx.sessionManager.getSessionName(),
|
|
390
|
+
ctx.sessionManager.getCwd(),
|
|
391
|
+
[...footerData.getExtensionStatuses()].sort(([a], [b]) => a.localeCompare(b)),
|
|
392
|
+
deps.usage(),
|
|
393
|
+
deps.jobs(),
|
|
394
|
+
deps.review(),
|
|
395
|
+
(() => {
|
|
396
|
+
const { model: changes, unbornRoots } = deps.tracker();
|
|
397
|
+
return { fingerprint: changesFingerprint(changes), files: changes.files.length, added: changes.added, deleted: changes.deleted, unbornRoots: unbornRoots.length };
|
|
398
|
+
})(),
|
|
399
|
+
deps.visualSettings(),
|
|
400
|
+
deps.cardStyle(),
|
|
401
|
+
]);
|
|
402
|
+
};
|
|
403
|
+
const model = (): ShellBarModel => {
|
|
404
|
+
const k = key();
|
|
405
|
+
if (cache?.key === k) return cache.model;
|
|
406
|
+
const tracker = deps.tracker();
|
|
407
|
+
const built: ShellBarModel = {
|
|
408
|
+
...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: deps.usage(), profile: deps.profile(), jobs: deps.jobs() }),
|
|
409
|
+
changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted },
|
|
410
|
+
review: deps.review(),
|
|
411
|
+
};
|
|
412
|
+
cache = { key: k, model: built };
|
|
413
|
+
return built;
|
|
414
|
+
};
|
|
415
|
+
return { key, model };
|
|
416
|
+
}
|
|
417
|
+
|
|
339
418
|
// Gauge-specific theme roles (issue #2222): resolved from the installed theme
|
|
340
419
|
// source and memoized by gauge-theme.ts, so the render hot path only stats the
|
|
341
420
|
// theme file. Undefined — and therefore today's shared roles — whenever the theme
|
|
@@ -392,6 +471,11 @@ interface PromptEditorDeps {
|
|
|
392
471
|
* generic "working…" label. Never applied to IDLE or QUEUED.
|
|
393
472
|
*/
|
|
394
473
|
workingLabel?: () => string | undefined;
|
|
474
|
+
/** Pasted images become atomic `[Image#N]` chips; absent, Pi's paste handler runs unchanged. */
|
|
475
|
+
attachments?: ImageAttachments;
|
|
476
|
+
readClipboardImage?: () => Promise<ClipboardImage | null>;
|
|
477
|
+
/** Injectable silence-watchdog window; production and tests default to SHELL_PULSE_WATCHDOG_MS. */
|
|
478
|
+
watchdogMs?: number;
|
|
395
479
|
}
|
|
396
480
|
|
|
397
481
|
const PROMPT_FRAME_ROLE = "border";
|
|
@@ -446,7 +530,10 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
446
530
|
private visualReplacePending = false;
|
|
447
531
|
private visualObjectPending: "i" | "a" | undefined;
|
|
448
532
|
private visualAnchor: { line: number; col: number } | undefined;
|
|
449
|
-
private
|
|
533
|
+
private watchdog: NodeJS.Timeout | undefined;
|
|
534
|
+
// Last time a frame painted (or the watchdog forced one) while working; the
|
|
535
|
+
// quality watchdog repaints only when now() drifts past its window.
|
|
536
|
+
private lastRenderAt = 0;
|
|
450
537
|
private readonly deps: PromptEditorDeps;
|
|
451
538
|
private floatMouseLayout?: ReturnType<typeof resolvePromptLayout>;
|
|
452
539
|
// Native selection engine (shift+home/end, alt+a, replace-on-key): ported
|
|
@@ -464,19 +551,97 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
464
551
|
// the text is still exactly this, so an edit in between never gets
|
|
465
552
|
// silently discarded (issue #1218 review).
|
|
466
553
|
private pendingIdleClearText: string | undefined;
|
|
554
|
+
// Pi's own paste handler (files, text, unsupported formats). Pi assigns
|
|
555
|
+
// `onPasteImage` after construction; the accessor below keeps it as the
|
|
556
|
+
// fallback and routes every paste key, Vim NORMAL included, to pasteImage.
|
|
557
|
+
private piPasteImage: (() => void) | undefined;
|
|
558
|
+
private pastingImage = false;
|
|
559
|
+
private disposed = false;
|
|
560
|
+
// Counts submitted drafts, so a clipboard read that resolves after its
|
|
561
|
+
// draft was sent never lands in (and rides along with) the next message.
|
|
562
|
+
private submittedDrafts = 0;
|
|
467
563
|
|
|
468
564
|
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
|
|
469
565
|
super(tui, theme, keybindings);
|
|
470
566
|
this.selectionEngine = new SelectionEngine(this);
|
|
471
567
|
this.deps = deps;
|
|
472
568
|
this.keybindingsManager = keybindings;
|
|
569
|
+
const attachments = deps.attachments;
|
|
570
|
+
if (!attachments || !deps.readClipboardImage) return;
|
|
571
|
+
// SAFETY: Pi's Editor routes cursor movement, wrapping and deletion through
|
|
572
|
+
// its private segment(). Wrapping it only merges registered chips into one
|
|
573
|
+
// segment; if a future Pi drops the method, chips degrade to plain text.
|
|
574
|
+
const host = this as unknown as {
|
|
575
|
+
segment?: (text: string, mode?: string) => Iterable<{ segment: string; index: number; input: string }>;
|
|
576
|
+
submitValue?: () => void;
|
|
577
|
+
};
|
|
578
|
+
const segment = host.segment;
|
|
579
|
+
if (typeof segment === "function") host.segment = (text, mode) => attachments.segment(text, segment.call(this, text, mode));
|
|
580
|
+
// SAFETY: Pi empties the draft in its private submitValue() before onSubmit.
|
|
581
|
+
// Counting here only marks the draft boundary; if a future Pi drops the
|
|
582
|
+
// method, a late read falls back to landing in the next draft.
|
|
583
|
+
const submitValue = host.submitValue;
|
|
584
|
+
if (typeof submitValue === "function") host.submitValue = () => { this.submittedDrafts += 1; submitValue.call(this); };
|
|
585
|
+
// Undefined until Pi installs its handler, so Pi's `if (!onPasteImage)`
|
|
586
|
+
// wiring still runs; afterwards every read returns the chip handler.
|
|
587
|
+
Object.defineProperty(this, "onPasteImage", {
|
|
588
|
+
configurable: true,
|
|
589
|
+
enumerable: true,
|
|
590
|
+
get: () => this.piPasteImage ? () => this.pasteImage() : undefined,
|
|
591
|
+
set: (handler: (() => void) | undefined) => { this.piPasteImage = handler; },
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Registered image chips on `line`; the Vim adapter keeps NORMAL motions out of them. */
|
|
596
|
+
atomicSpans(line: string): Array<{ start: number; end: number }> {
|
|
597
|
+
return this.deps.attachments?.spans(line) ?? [];
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Insert the clipboard image as a chip, or hand the paste to Pi. */
|
|
601
|
+
private pasteImage(): void {
|
|
602
|
+
const attachments = this.deps.attachments;
|
|
603
|
+
const read = this.deps.readClipboardImage;
|
|
604
|
+
if (!attachments || !read) { this.piPasteImage?.(); return; }
|
|
605
|
+
// The Windows clipboard read on WSL takes about a second; a repeated key
|
|
606
|
+
// press during it must not insert a second chip or a stray path.
|
|
607
|
+
if (this.pastingImage) return;
|
|
608
|
+
this.pastingImage = true;
|
|
609
|
+
void this.insertClipboardImage(attachments, read).finally(() => { this.pastingImage = false; });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private async insertClipboardImage(attachments: ImageAttachments, read: () => Promise<ClipboardImage | null>): Promise<void> {
|
|
613
|
+
const draft = this.submittedDrafts;
|
|
614
|
+
let image: ClipboardImage | null;
|
|
615
|
+
try { image = await read(); } catch { image = null; }
|
|
616
|
+
// A read that outlives this editor (session switch, quit) must not
|
|
617
|
+
// register bytes in the next session or write into a disposed prompt.
|
|
618
|
+
if (this.disposed) return;
|
|
619
|
+
// The draft this paste belonged to was already sent: neither attach the
|
|
620
|
+
// image to the next message nor run Pi's fallback into the new draft.
|
|
621
|
+
if (this.submittedDrafts !== draft) {
|
|
622
|
+
if (image) this.deps.notifyCompatibility?.("Image paste discarded: the message was sent before the clipboard finished reading. Paste again to attach it.");
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
if (!image) { this.piPasteImage?.(); return; }
|
|
627
|
+
const { token } = attachments.add(image);
|
|
628
|
+
const cursor = this.getCursor();
|
|
629
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
630
|
+
const before = cursor.col > 0 && !/\s/.test(line[cursor.col - 1] ?? "") ? " " : "";
|
|
631
|
+
const after = cursor.col < line.length && !/\s/.test(line[cursor.col] ?? "") ? " " : "";
|
|
632
|
+
this.insertTextAtCursor(`${before}${token}${after}`);
|
|
633
|
+
this.deps.requestRender();
|
|
634
|
+
} catch (error) {
|
|
635
|
+
// Never let a paste reject unhandled: Node 24 ends the process on it.
|
|
636
|
+
this.deps.notifyCompatibility?.(`Image paste failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
637
|
+
}
|
|
473
638
|
}
|
|
474
639
|
|
|
475
640
|
setAnimationPolicy(policy: AnimationPolicy): void {
|
|
476
641
|
if (this.animationPolicy === policy) return;
|
|
477
642
|
this.animationPolicy = policy;
|
|
478
|
-
this.
|
|
479
|
-
if (this.promptState === PROMPT_STATE.WORKING) this.
|
|
643
|
+
this.stopWatchdog();
|
|
644
|
+
if (this.promptState === PROMPT_STATE.WORKING) this.startWatchdog();
|
|
480
645
|
this.deps.requestRender();
|
|
481
646
|
}
|
|
482
647
|
|
|
@@ -561,24 +726,32 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
561
726
|
this.deps.requestRender();
|
|
562
727
|
}
|
|
563
728
|
|
|
564
|
-
private
|
|
729
|
+
private startWatchdog(): void {
|
|
565
730
|
if (this.animationPolicy === "potato") return;
|
|
566
|
-
|
|
731
|
+
const windowMs = this.deps.watchdogMs ?? SHELL_PULSE_WATCHDOG_MS;
|
|
732
|
+
this.lastRenderAt = this.deps.now();
|
|
733
|
+
this.watchdog = setInterval(() => {
|
|
734
|
+
// Render-driven petal (gentle-shell#994): streaming frames already
|
|
735
|
+
// advance the animation in render(), so under quality the watchdog
|
|
736
|
+
// stays silent unless no frame painted within its window. performance
|
|
737
|
+
// keeps its coarse cadence regardless of render traffic.
|
|
738
|
+
if (this.animationPolicy === "quality" && this.deps.now() - this.lastRenderAt < windowMs) return;
|
|
567
739
|
this.tick += 1;
|
|
740
|
+
this.lastRenderAt = this.deps.now();
|
|
568
741
|
this.deps.requestRender();
|
|
569
|
-
},
|
|
570
|
-
this.
|
|
742
|
+
}, windowMs);
|
|
743
|
+
this.watchdog.unref();
|
|
571
744
|
}
|
|
572
745
|
|
|
573
746
|
setWorking(working: boolean): void {
|
|
574
747
|
this.promptState = working ? PROMPT_STATE.WORKING : PROMPT_STATE.IDLE;
|
|
575
|
-
this.
|
|
748
|
+
this.stopWatchdog();
|
|
576
749
|
if (!working) {
|
|
577
750
|
this.pendingEscapeCancelDeadline = undefined;
|
|
578
751
|
} else {
|
|
579
752
|
this.pendingIdleClearDeadline = undefined;
|
|
580
753
|
this.pendingIdleClearText = undefined;
|
|
581
|
-
this.
|
|
754
|
+
this.startWatchdog();
|
|
582
755
|
}
|
|
583
756
|
this.deps.requestRender();
|
|
584
757
|
}
|
|
@@ -1065,6 +1238,13 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
1065
1238
|
}
|
|
1066
1239
|
|
|
1067
1240
|
render(width: number): string[] {
|
|
1241
|
+
// Render-driven petal (gentle-shell#994): every frame the host already
|
|
1242
|
+
// paints while working advances the animation for free, so no fast
|
|
1243
|
+
// timer forces renders during streaming.
|
|
1244
|
+
if (this.promptState === PROMPT_STATE.WORKING && this.animationPolicy === "quality") {
|
|
1245
|
+
this.tick += 1;
|
|
1246
|
+
this.lastRenderAt = this.deps.now();
|
|
1247
|
+
}
|
|
1068
1248
|
const layout = resolvePromptLayout(width, cardStyle(), this.deps.bg);
|
|
1069
1249
|
this.floatMouseLayout = layout.background ? layout : undefined;
|
|
1070
1250
|
const inner = layout.nativeWidth;
|
|
@@ -1128,7 +1308,8 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
1128
1308
|
}
|
|
1129
1309
|
|
|
1130
1310
|
dispose(): void {
|
|
1131
|
-
this.
|
|
1311
|
+
this.disposed = true;
|
|
1312
|
+
this.stopWatchdog();
|
|
1132
1313
|
}
|
|
1133
1314
|
|
|
1134
1315
|
private isPendingEscapeCancel(): boolean {
|
|
@@ -1143,13 +1324,62 @@ export class GentlePromptEditor extends CustomEditor {
|
|
|
1143
1324
|
);
|
|
1144
1325
|
}
|
|
1145
1326
|
|
|
1146
|
-
private
|
|
1147
|
-
if (this.
|
|
1148
|
-
this.
|
|
1327
|
+
private stopWatchdog(): void {
|
|
1328
|
+
if (this.watchdog) clearInterval(this.watchdog);
|
|
1329
|
+
this.watchdog = undefined;
|
|
1149
1330
|
this.tick = 0;
|
|
1150
1331
|
}
|
|
1151
1332
|
}
|
|
1152
1333
|
|
|
1334
|
+
const IMAGE_ATTACHMENTS_WIDGET_KEY = "gentle-shell-image-attachments";
|
|
1335
|
+
const IMAGE_PREVIEW_MAX_ROWS = 6;
|
|
1336
|
+
const IMAGE_PREVIEW_MAX_COLUMNS = 40;
|
|
1337
|
+
|
|
1338
|
+
/** Unknown capabilities count as image-capable; Pi decides for its own models. */
|
|
1339
|
+
export function modelAcceptsImages(model: { input?: readonly string[] } | undefined): boolean {
|
|
1340
|
+
return !model?.input || model.input.includes("image");
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Lists the chips still in the draft above the prompt. Terminals with an image
|
|
1345
|
+
* protocol (Kitty, iTerm2) get a thumbnail under each label; every other
|
|
1346
|
+
* terminal, or a multiplexer that hides the protocol, gets the label alone.
|
|
1347
|
+
*/
|
|
1348
|
+
export function imageAttachmentsWidget(
|
|
1349
|
+
attachments: ImageAttachments,
|
|
1350
|
+
draft: () => string,
|
|
1351
|
+
theme: { fg(color: string, text: string): string },
|
|
1352
|
+
acceptsImages: () => boolean,
|
|
1353
|
+
canDrawImages: () => boolean = () => getCapabilities().images !== null,
|
|
1354
|
+
) {
|
|
1355
|
+
const previews = new Map<number, Image>();
|
|
1356
|
+
return {
|
|
1357
|
+
render(width: number): string[] {
|
|
1358
|
+
const shown = attachments.referenced(draft());
|
|
1359
|
+
for (const id of previews.keys()) if (!shown.some((attachment) => attachment.id === id)) previews.delete(id);
|
|
1360
|
+
if (shown.length === 0) return [];
|
|
1361
|
+
const draw = canDrawImages();
|
|
1362
|
+
const warning = acceptsImages() ? "" : theme.fg("warning", " · not sent: the current model does not accept images");
|
|
1363
|
+
const lines: string[] = [];
|
|
1364
|
+
for (const attachment of shown) {
|
|
1365
|
+
lines.push(truncateToWidth(`${theme.fg("accent", attachment.token)} ${theme.fg("dim", describeAttachment(attachment))}${warning}`, width));
|
|
1366
|
+
if (!draw) continue;
|
|
1367
|
+
let preview = previews.get(attachment.id);
|
|
1368
|
+
if (!preview) {
|
|
1369
|
+
preview = new Image(attachment.base64, attachment.mimeType, { fallbackColor: (text) => theme.fg("dim", text) },
|
|
1370
|
+
{ maxWidthCells: IMAGE_PREVIEW_MAX_COLUMNS, maxHeightCells: IMAGE_PREVIEW_MAX_ROWS }, attachment.dimensions);
|
|
1371
|
+
previews.set(attachment.id, preview);
|
|
1372
|
+
}
|
|
1373
|
+
lines.push(...preview.render(width));
|
|
1374
|
+
}
|
|
1375
|
+
return lines;
|
|
1376
|
+
},
|
|
1377
|
+
invalidate(): void {
|
|
1378
|
+
for (const preview of previews.values()) preview.invalidate();
|
|
1379
|
+
},
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1153
1383
|
// Stable across extension module reloads; never infer ownership from a name.
|
|
1154
1384
|
const PROMPT_OWNER = Symbol.for("gentle-pi.prompt-owner");
|
|
1155
1385
|
type PromptFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>> & { [PROMPT_OWNER]?: boolean };
|
|
@@ -1157,7 +1387,7 @@ type PromptFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorCom
|
|
|
1157
1387
|
function installPrompt(
|
|
1158
1388
|
ctx: ExtensionContext,
|
|
1159
1389
|
onCreated: (prompt: GentlePromptEditor) => void,
|
|
1160
|
-
promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void; vimRuntimeVersion?: () => string | undefined },
|
|
1390
|
+
promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void; vimRuntimeVersion?: () => string | undefined; attachments?: ImageAttachments; readClipboardImage?: () => Promise<ClipboardImage | null> },
|
|
1161
1391
|
): boolean {
|
|
1162
1392
|
const previous = ctx.ui.getEditorComponent() as PromptFactory | undefined;
|
|
1163
1393
|
if (previous && !previous[PROMPT_OWNER]) return false;
|
|
@@ -1174,10 +1404,12 @@ function installPrompt(
|
|
|
1174
1404
|
notifyCompatibility: (message) => ctx.ui.notify(message, "warning"),
|
|
1175
1405
|
tuiVersion: promptDeps.vimRuntimeVersion,
|
|
1176
1406
|
workingLabel: () => oddPhaseRegistry.label(ctx.sessionManager.getSessionId()),
|
|
1407
|
+
attachments: promptDeps.attachments,
|
|
1408
|
+
readClipboardImage: promptDeps.readClipboardImage,
|
|
1177
1409
|
});
|
|
1178
1410
|
// Pi's own docs require an explicit requestRender() after a state change
|
|
1179
1411
|
// (docs/tui.md: "Call tui.requestRender() after state changes"); no host
|
|
1180
|
-
// re-render is implicitly guaranteed, and the WORKING
|
|
1412
|
+
// re-render is implicitly guaranteed, and the WORKING watchdog does not
|
|
1181
1413
|
// run at all under the "potato" animation policy. Register this session's
|
|
1182
1414
|
// redraw so a phase reported by the tool is visible immediately.
|
|
1183
1415
|
oddPhaseRegistry.setRenderRequest(ctx.sessionManager.getSessionId(), () => tui.requestRender());
|
|
@@ -1279,26 +1511,7 @@ const fileRevision = (root: string): FileRevision => async (path) => {
|
|
|
1279
1511
|
return `${info.size}:${info.mtimeMs}`;
|
|
1280
1512
|
};
|
|
1281
1513
|
|
|
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
|
-
|
|
1514
|
+
export { openInExternalEditor, type ExternalEditorHost } from "../lib/external-editor.ts";
|
|
1302
1515
|
export function changesShortcut(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
1303
1516
|
const value = env.GENTLE_PI_SHELL_CHANGES_KEY?.trim();
|
|
1304
1517
|
if (value === undefined) return CHANGES_SHORTCUT_DEFAULT;
|
|
@@ -1326,8 +1539,11 @@ function changesPollMs(env: NodeJS.ProcessEnv): number {
|
|
|
1326
1539
|
const USAGE_FETCH_TIMEOUT_DEFAULT_MS = 10_000;
|
|
1327
1540
|
const usageFetchTimeoutMs = (env: NodeJS.ProcessEnv): number => positiveMs(env.GENTLE_PI_SHELL_USAGE_TIMEOUT_MS, USAGE_FETCH_TIMEOUT_DEFAULT_MS);
|
|
1328
1541
|
|
|
1542
|
+
// The footer memo reads this every keystroke: tracker models store the
|
|
1543
|
+
// digest once per scan (lib/shell-changes.ts), so this is O(1); hand-built
|
|
1544
|
+
// models without a stored digest fall back to computing it here.
|
|
1329
1545
|
function changesFingerprint(model: ChangesModel): string {
|
|
1330
|
-
return model.
|
|
1546
|
+
return model.digest ?? changesDigest(model);
|
|
1331
1547
|
}
|
|
1332
1548
|
|
|
1333
1549
|
interface OverlayDeps {
|
|
@@ -1372,7 +1588,11 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
|
|
|
1372
1588
|
{ overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } },
|
|
1373
1589
|
);
|
|
1374
1590
|
if (!chosen || !host) return;
|
|
1375
|
-
|
|
1591
|
+
try {
|
|
1592
|
+
if (!openInExternalEditor(host, chosen.file.path, process.env, spawnSync, chosen.root)) ctx.ui.notify("No editor configured. Set $VISUAL or $EDITOR.", "warning");
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
1595
|
+
}
|
|
1376
1596
|
} finally {
|
|
1377
1597
|
clearInterval(poll);
|
|
1378
1598
|
view?.dispose();
|
|
@@ -1774,6 +1994,8 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1774
1994
|
});
|
|
1775
1995
|
}
|
|
1776
1996
|
let prompt: GentlePromptEditor | undefined;
|
|
1997
|
+
// Images pasted into the current draft; emptied on every submit.
|
|
1998
|
+
const imageAttachments = new ImageAttachments();
|
|
1777
1999
|
// Set by abortAndDispatchQueued via dispatchQueuedText when an Esc aborts
|
|
1778
2000
|
// a turn with a non-empty queue; sent exactly once, from agent_settled,
|
|
1779
2001
|
// once the aborted run has fully settled (issue #1218). Several aborts
|
|
@@ -1926,13 +2148,21 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1926
2148
|
const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""), () => visualSettings);
|
|
1927
2149
|
// The Status card paints live session state that no event re-registers a
|
|
1928
2150
|
// 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
|
-
|
|
2151
|
+
// statuses. The digest is what keeps the fullscreen memo honest. Issue
|
|
2152
|
+
// #1681: railDigest evaluates every rail's digest per keystroke, so both
|
|
2153
|
+
// Status rails digest a cheap key and reuse the memoized model
|
|
2154
|
+
// (createFooterStatusMemo).
|
|
2155
|
+
const footerStatus = createFooterStatusMemo({
|
|
2156
|
+
pi, ctx, footerData,
|
|
2157
|
+
usage: () => usage.get(ctx.model?.provider ?? ""),
|
|
2158
|
+
profile: () => deps.activeProfile(),
|
|
2159
|
+
jobs: () => runningJobs,
|
|
2160
|
+
review: () => review,
|
|
2161
|
+
tracker: () => tracker,
|
|
2162
|
+
visualSettings: () => visualSettings,
|
|
2163
|
+
cardStyle: () => cardStyle(),
|
|
1935
2164
|
});
|
|
2165
|
+
const footerModel = footerStatus.model;
|
|
1936
2166
|
// At narrow fullscreen widths only one status row paints: a top header
|
|
1937
2167
|
// suppresses the bottom bar in the layout, and otherwise the bottom bar
|
|
1938
2168
|
// takes over the header's data while the below-input header steps aside.
|
|
@@ -1960,7 +2190,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1960
2190
|
},
|
|
1961
2191
|
};
|
|
1962
2192
|
const part = sidebarPart(tui, "footer", bottomBar, {
|
|
1963
|
-
digest: () =>
|
|
2193
|
+
digest: () => footerStatus.key(),
|
|
1964
2194
|
render: (width) => renderShellSidebarBar(footerModel(), theme, width, visualSettings),
|
|
1965
2195
|
invalidate() {},
|
|
1966
2196
|
});
|
|
@@ -1969,7 +2199,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1969
2199
|
// when Status is absent. Opaque statuses and working state stay excluded.
|
|
1970
2200
|
const headerBar = (width: number) => renderShellHeaderChrome(buildShellHeaderModel(footerModel(), !statusCardVisible() && (statusOwner() === STATUS_OWNER.HEADER || visualSettings.statusPlacement === "hidden")), theme, width, usageShortcutKey, visualSettings, gaugePaint(ctx));
|
|
1971
2201
|
const disposeHeader = sidebarHeader(tui, {
|
|
1972
|
-
digest: () =>
|
|
2202
|
+
digest: () => footerStatus.key(),
|
|
1973
2203
|
render: (width) => headerBar(width).rows,
|
|
1974
2204
|
invalidate() {},
|
|
1975
2205
|
handleMouse(event) {
|
|
@@ -2018,8 +2248,15 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2018
2248
|
prompt.setAnimationPolicy(animationPolicy);
|
|
2019
2249
|
prompt.setVimPolicy(vimPolicy);
|
|
2020
2250
|
},
|
|
2021
|
-
{ now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; }, vimRuntimeVersion: deps.vimRuntimeVersion },
|
|
2251
|
+
{ 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
2252
|
);
|
|
2253
|
+
imageAttachments.clear();
|
|
2254
|
+
ctx.ui.setWidget(IMAGE_ATTACHMENTS_WIDGET_KEY, ownsPrompt
|
|
2255
|
+
// Kitty re-sends a full image block whenever content above it moves, so
|
|
2256
|
+
// thumbnails show only while idle; while the agent streams, labels only.
|
|
2257
|
+
? (_tui, theme) => imageAttachmentsWidget(imageAttachments, () => prompt?.getText() ?? "", theme, () => modelAcceptsImages(ctx.model),
|
|
2258
|
+
() => getCapabilities().images !== null && ctx.isIdle())
|
|
2259
|
+
: undefined);
|
|
2023
2260
|
// Hide native feedback only when our petal replaces it. Native transcript
|
|
2024
2261
|
// thinking blocks remain Pi-owned; this changes only the supported loader UI.
|
|
2025
2262
|
// Inside Herdr the row stays: its native Pi detection reads it.
|
|
@@ -2035,6 +2272,20 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2035
2272
|
shown = "";
|
|
2036
2273
|
applyChanges(ctx, tracker.model);
|
|
2037
2274
|
});
|
|
2275
|
+
// Chips still in the submitted draft travel as real image attachments, so
|
|
2276
|
+
// the model sees the image without calling `read` on a temp-file path.
|
|
2277
|
+
pi.on("input", (event, ctx) => {
|
|
2278
|
+
if (event.source !== "interactive" || imageAttachments.size === 0) return { action: "continue" };
|
|
2279
|
+
// The editor may already hold the next draft (a prompt Pi queued during
|
|
2280
|
+
// compaction): its chips stay registered for their own submit.
|
|
2281
|
+
const submitted = imageAttachments.submit(event.text, modelAcceptsImages(ctx.model), prompt?.getText() ?? "");
|
|
2282
|
+
if (!submitted) return { action: "continue" };
|
|
2283
|
+
if (submitted.omitted > 0 && ctx.hasUI) {
|
|
2284
|
+
const count = submitted.omitted === 1 ? "1 pasted image was" : `${submitted.omitted} pasted images were`;
|
|
2285
|
+
ctx.ui.notify(`${count} not sent: the current model does not accept images. Switch to a vision model and paste again.`, "warning");
|
|
2286
|
+
}
|
|
2287
|
+
return { action: "transform", text: submitted.text, images: [...(event.images ?? []), ...submitted.images] };
|
|
2288
|
+
});
|
|
2038
2289
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
2039
2290
|
closeCustomize?.();
|
|
2040
2291
|
if (review) {
|
|
@@ -2050,6 +2301,8 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2050
2301
|
oddPhaseRegistry.clear(ctx.sessionManager.getSessionId());
|
|
2051
2302
|
oddPhaseRegistry.clearRenderRequest(ctx.sessionManager.getSessionId());
|
|
2052
2303
|
pendingQueuedText = undefined;
|
|
2304
|
+
imageAttachments.clear();
|
|
2305
|
+
ctx.ui.setWidget(IMAGE_ATTACHMENTS_WIDGET_KEY, undefined);
|
|
2053
2306
|
prompt?.dispose();
|
|
2054
2307
|
prompt = undefined;
|
|
2055
2308
|
if ((ctx.ui.getEditorComponent() as PromptFactory | undefined)?.[PROMPT_OWNER]) {
|
|
@@ -2069,10 +2322,14 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2069
2322
|
applyChanges(ctx, model);
|
|
2070
2323
|
// A failed root scan is unknown, never clean: say so whenever one failed.
|
|
2071
2324
|
const failed = tracker.failedRoots.length;
|
|
2325
|
+
const unborn = tracker.unbornRoots.length;
|
|
2072
2326
|
const roots = tracker.scannedRootCount;
|
|
2073
2327
|
if (model.files.length === 0) {
|
|
2074
2328
|
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
2329
|
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");
|
|
2330
|
+
else if (unborn > 0) ctx.ui.notify(unborn === roots
|
|
2331
|
+
? `No commits yet in ${unborn === 1 ? "this session's repository" : "any root"} — make a commit to start tracking changes.`
|
|
2332
|
+
: `No uncommitted changes; ${unborn} of ${roots} roots have no commits yet, so they are not tracked.`, "info");
|
|
2076
2333
|
else ctx.ui.notify("No uncommitted changes in this session's repository or its subagents' worktrees.", "info");
|
|
2077
2334
|
return;
|
|
2078
2335
|
}
|
|
@@ -2269,19 +2526,21 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
2269
2526
|
await refreshYolo();
|
|
2270
2527
|
},
|
|
2271
2528
|
});
|
|
2272
|
-
for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
|
|
2529
|
+
for (const mode of ["normal", "auto", "yolo", "super-yolo"] as const) rows.push({
|
|
2273
2530
|
category,
|
|
2274
2531
|
label: () => `Session permissions: ${mode.toUpperCase()}${mode === "auto" ? " (beta)" : ""}${permissionDisplay === mode ? " (current)" : ""}`,
|
|
2275
2532
|
preview: () => ({ title: "Permissions · current session", sample: mode === "auto"
|
|
2276
2533
|
? "independent session-model action checks · extra inference cost · hard safety and native consent remain"
|
|
2277
|
-
: mode === "yolo" ? "ordinary scoped delivery permission · destructive confirmations remain"
|
|
2534
|
+
: mode === "yolo" ? "ordinary scoped delivery permission · destructive confirmations remain"
|
|
2535
|
+
: mode === "super-yolo" ? "no permission prompts · only private-path protection (.env, keys, secrets) remains"
|
|
2536
|
+
: "normal checks · overrides an elevated global default" }),
|
|
2278
2537
|
action: async () => {
|
|
2279
2538
|
if (closed) return;
|
|
2280
2539
|
if (!adapter?.setMode || !await adapter.setMode(mode)) ctx.ui.notify("Session mode unchanged — live primary UI unavailable or changed.", "warning");
|
|
2281
2540
|
await refreshYolo();
|
|
2282
2541
|
},
|
|
2283
2542
|
});
|
|
2284
|
-
for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
|
|
2543
|
+
for (const mode of ["normal", "auto", "yolo", "super-yolo"] as const) rows.push({
|
|
2285
2544
|
category,
|
|
2286
2545
|
label: () => `Default permissions: ${mode.toUpperCase()}${mode === "auto" ? " (beta)" : ""}${permissionDefault === mode ? " (saved)" : ""}`,
|
|
2287
2546
|
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."}` }),
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerVoiceCommand, type VoiceCommandDeps } from "../lib/voice-command.ts";
|
|
3
|
+
|
|
4
|
+
// Gentle Voice: /gentle:voice owns optional local dictation (docs/voice.md).
|
|
5
|
+
// Loading this extension registers the command and, when the push-to-talk
|
|
6
|
+
// toggle is on, a hold-Space listener at session start. Nothing is probed,
|
|
7
|
+
// downloaded, loaded or recorded until the user runs the command or holds Space.
|
|
8
|
+
|
|
9
|
+
export default function gentleVoice(pi: ExtensionAPI, deps: VoiceCommandDeps = {}): void {
|
|
10
|
+
registerVoiceCommand(pi, deps);
|
|
11
|
+
}
|