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.
Files changed (208) hide show
  1. package/README.md +8 -5
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +101 -151
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +2 -0
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. 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, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
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
- const shellSessionStats = new WeakMap<object, {
278
- sessionId: string | undefined;
279
- leafId: string | null;
280
- entryCount: number | undefined;
281
- model: ExtensionContext["model"];
282
- contextWindow: number | undefined;
283
- stats: ShellSessionStats;
284
- }>();
285
-
286
- function sessionStats(ctx: ExtensionContext): ShellSessionStats {
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 compute();
294
- const sessionId = manager.getSessionId();
295
- const leafId = manager.getLeafId();
296
- const entryCount = manager.getEntryCount?.();
297
- const model = ctx.model;
298
- const contextWindow = model?.contextWindow;
299
- const cached = shellSessionStats.get(manager);
300
- if (cached && cached.sessionId === sessionId && cached.leafId === leafId &&
301
- cached.entryCount === entryCount && cached.model === model && cached.contextWindow === contextWindow) return cached.stats;
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(manager, { sessionId, leafId, entryCount, model, contextWindow, stats });
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 pulse: NodeJS.Timeout | undefined;
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.stopPulse();
479
- if (this.promptState === PROMPT_STATE.WORKING) this.startPulse();
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 startPulse(): void {
729
+ private startWatchdog(): void {
565
730
  if (this.animationPolicy === "potato") return;
566
- this.pulse = setInterval(() => {
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
- }, this.animationPolicy === "performance" ? 1000 : SHELL_PULSE_MS);
570
- this.pulse.unref();
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.stopPulse();
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.startPulse();
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.stopPulse();
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 stopPulse(): void {
1147
- if (this.pulse) clearInterval(this.pulse);
1148
- this.pulse = undefined;
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 pulse loop does not
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 interface ExternalEditorHost {
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.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}:${file.diffRevision ?? ""}:${file.countsUnavailable ?? ""}`).join("|");
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
- if (!openInExternalEditor(host, chosen.file.path, process.env, spawnSync, chosen.root)) ctx.ui.notify("No editor configured. Set $VISUAL or $EDITOR.", "warning");
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, and it
1930
- // rebuilds the model exactly as the narrow bottom bar does every frame.
1931
- const footerModel = (): ShellBarModel => ({
1932
- ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile(), jobs: runningJobs }),
1933
- changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted },
1934
- review,
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: () => JSON.stringify([footerModel(), tracker.model, visualSettings, cardStyle()]),
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: () => JSON.stringify([footerModel(), tracker.model, visualSettings, cardStyle()]),
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" : "normal checks · overrides an elevated global default" }),
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
+ }