@timurproko/a1 0.1.8-dev.368 → 0.1.8-dev.402

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 (87) hide show
  1. package/dist/composition/owned-ui.d.ts +2 -0
  2. package/dist/composition/owned-ui.js +28 -11
  3. package/dist/contracts/owned-ui/image-attachments.d.ts +7 -0
  4. package/dist/contracts/owned-ui/image-attachments.js +7 -0
  5. package/dist/foundation/release/dependency-certification.js +65 -7
  6. package/dist/integrations/pi/components/owned-editor-ux.d.ts +8 -6
  7. package/dist/integrations/pi/components/owned-editor-ux.js +115 -26
  8. package/dist/integrations/pi/components/shell-editor-autocomplete.js +4 -0
  9. package/dist/integrations/pi/components/shell-presenters-transcript.js +15 -5
  10. package/dist/integrations/pi/components/shell-shared-facade.d.ts +14 -5
  11. package/dist/integrations/pi/components/tool-image-presentation.d.ts +21 -0
  12. package/dist/integrations/pi/components/tool-image-presentation.js +86 -0
  13. package/dist/integrations/pi/components/tool-result-adapter.d.ts +2 -2
  14. package/dist/integrations/pi/components/transcript-image-resolver.d.ts +7 -0
  15. package/dist/integrations/pi/components/transcript-image-resolver.js +19 -0
  16. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +1 -1
  17. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.js +3 -1
  18. package/dist/integrations/pi/components/upstream/components/tool-execution.d.ts +61 -0
  19. package/dist/integrations/pi/components/upstream/components/tool-execution.js +344 -0
  20. package/dist/integrations/pi/engine/adapter.d.ts +4 -1
  21. package/dist/integrations/pi/engine/adapter.js +66 -28
  22. package/dist/integrations/pi/engine/pending-delivery.d.ts +4 -0
  23. package/dist/integrations/pi/engine/pending-delivery.js +15 -6
  24. package/dist/integrations/pi/engine/tool-rendering.js +26 -3
  25. package/dist/integrations/pi/engine/transcript-image-assets.d.ts +11 -0
  26. package/dist/integrations/pi/engine/transcript-image-assets.js +86 -0
  27. package/dist/integrations/pi/session-ui/clipboard-diagnostics.d.ts +16 -0
  28. package/dist/integrations/pi/session-ui/clipboard-diagnostics.js +65 -0
  29. package/dist/integrations/pi/session-ui/editor-hyperlink-budget.d.ts +9 -0
  30. package/dist/integrations/pi/session-ui/editor-hyperlink-budget.js +23 -0
  31. package/dist/integrations/pi/session-ui/index.d.ts +1 -0
  32. package/dist/integrations/pi/session-ui/index.js +1 -0
  33. package/dist/integrations/pi/session-ui/paste-executor.d.ts +10 -0
  34. package/dist/integrations/pi/session-ui/paste-executor.js +232 -0
  35. package/dist/integrations/pi/session-ui/paste-helper.d.ts +1 -0
  36. package/dist/integrations/pi/session-ui/paste-helper.js +173 -0
  37. package/dist/integrations/pi/session-ui/paste-preparation-client.d.ts +16 -0
  38. package/dist/integrations/pi/session-ui/paste-preparation-client.js +159 -0
  39. package/dist/integrations/pi/session-ui/paste-protocol.d.ts +78 -0
  40. package/dist/integrations/pi/session-ui/paste-protocol.js +18 -0
  41. package/dist/integrations/pi/session-ui/paste-text-preparation.d.ts +20 -0
  42. package/dist/integrations/pi/session-ui/paste-text-preparation.js +134 -0
  43. package/dist/integrations/pi/session-ui/paste-text-worker.d.ts +1 -0
  44. package/dist/integrations/pi/session-ui/paste-text-worker.js +12 -0
  45. package/dist/integrations/pi/session-ui/path-chip-presentation.d.ts +7 -0
  46. package/dist/integrations/pi/session-ui/path-chip-presentation.js +24 -0
  47. package/dist/integrations/pi/session-ui/prompt-chips.d.ts +8 -1
  48. package/dist/integrations/pi/session-ui/prompt-chips.js +129 -149
  49. package/dist/integrations/pi/session-ui/response-copy-coordinator.d.ts +27 -0
  50. package/dist/integrations/pi/session-ui/response-copy-coordinator.js +189 -0
  51. package/dist/integrations/pi/session-ui/response-copy-helper.d.ts +1 -0
  52. package/dist/integrations/pi/session-ui/response-copy-helper.js +122 -0
  53. package/dist/integrations/pi/session-ui/response-copy-protocol.d.ts +56 -0
  54. package/dist/integrations/pi/session-ui/response-copy-protocol.js +5 -0
  55. package/dist/integrations/pi/session-ui/response-copy-transport.d.ts +31 -0
  56. package/dist/integrations/pi/session-ui/response-copy-transport.js +187 -0
  57. package/dist/integrations/pi/session-ui/session-shell-root.d.ts +15 -6
  58. package/dist/integrations/pi/session-ui/session-shell-root.js +25 -6
  59. package/dist/integrations/pi/session-ui/session-shell.js +100 -28
  60. package/dist/integrations/pi/session-ui/session-viewport-controller.d.ts +2 -2
  61. package/dist/integrations/pi/session-ui/session-viewport-controller.js +4 -3
  62. package/dist/integrations/pi/session-ui/system-clipboard.d.ts +2 -2
  63. package/dist/integrations/pi/session-ui/system-clipboard.js +26 -11
  64. package/dist/integrations/pi/tui-runtime/adapter.js +3 -1
  65. package/dist/native/darwin-arm64/manifest.json +1 -1
  66. package/dist/native/linux-x64/manifest.json +1 -1
  67. package/dist/native/win32-x64/manifest.json +2 -2
  68. package/dist/native/win32-x64/process-guardian.exe +0 -0
  69. package/dist/product-identity.d.ts +1 -1
  70. package/dist/product-identity.js +1 -1
  71. package/dist/product-identity.json +1 -0
  72. package/dist/ui/components/index.d.ts +1 -0
  73. package/dist/ui/components/index.js +1 -0
  74. package/dist/ui/components/selection-copy.d.ts +24 -0
  75. package/dist/ui/components/selection-copy.js +39 -0
  76. package/dist/ui/components/spans.d.ts +5 -2
  77. package/dist/ui/components/spans.js +12 -3
  78. package/dist/ui/components/text-selection.js +2 -0
  79. package/dist/ui/components/text.js +1 -1
  80. package/dist/ui/components/transcript-viewport.d.ts +5 -0
  81. package/dist/ui/components/transcript-viewport.js +100 -23
  82. package/dist/ui/components/visible-hyperlinks.js +40 -8
  83. package/docs/architecture/response-copy-delivery.md +83 -0
  84. package/docs/architecture/tool-image-presentation.md +62 -0
  85. package/docs/ci-release-runbook.md +19 -7
  86. package/docs/openspec-archive-automation.md +124 -0
  87. package/package.json +2 -1
@@ -23,6 +23,8 @@ export interface OwnedUiCompositionOptions {
23
23
  readonly projectTrustPrompt?: PiProjectTrustPreflightPrompt;
24
24
  /** Explicit local diagnostic destination; ignored by comparison/settings-free compositions. */
25
25
  readonly suggestionDiagnosticsPath?: string;
26
+ /** Optional local metadata-only clipboard diagnostics; never enabled in comparison profiles. */
27
+ readonly clipboardDiagnosticsPath?: string;
26
28
  }
27
29
  export interface OwnedUiComposition {
28
30
  readonly application: OwnedUiApplicationPort;
@@ -5,7 +5,7 @@ import { resolvePromptHistoryDataDir } from "../features/launch/index.js";
5
5
  import { resolveProductPaths } from "../foundation/lifecycle/index.js";
6
6
  import { applyConfiguredPiTheme, getAvailablePiThemes, loadHistoryEditor } from "../integrations/pi/components/index.js";
7
7
  import { createPiEngineAdapter, } from "../integrations/pi/engine/index.js";
8
- import { OwnedUiSessionShell } from "../integrations/pi/session-ui/index.js";
8
+ import { ClipboardDiagnosticCapture, OwnedUiSessionShell } from "../integrations/pi/session-ui/index.js";
9
9
  import { OwnedUiSettingsSession, OwnedUiSettingsStore } from "../ui/settings/index.js";
10
10
  import { createPiTerminalBridge } from "../integrations/pi/tui-runtime/index.js";
11
11
  import { createOwnedRouteHost } from "./settings-route-host.js";
@@ -63,16 +63,32 @@ export async function composeOwnedUi(options = {}) {
63
63
  }),
64
64
  imageSidecar: new PromptImageSidecar(historyProfileLocation.imagesDir),
65
65
  };
66
- const shell = new OwnedUiSessionShell({
67
- backend: adapter,
68
- cwd: adapter.cwd,
69
- ...(options.terminal === undefined ? {} : { terminal: createPiTerminalBridge(options.terminal) }),
70
- ...(routeHost === null ? {} : { routeHost }),
71
- ...(ownedSurfaces ? { sessionLayout: "custom-viewport" } : {}),
72
- ...(viewportSettings === null ? {} : { viewportSettings }),
73
- ...(promptSuggestions === null ? {} : { promptSuggestions }),
74
- ...(promptHistory === null ? {} : { promptHistory: { ...promptHistory, editor: await loadHistoryEditor() } }),
75
- });
66
+ const clipboardDestination = options.clipboardDiagnosticsPath ?? process.env[PRODUCT_IDENTITY.environment.clipboardDiagnostics];
67
+ const clipboardDiagnostics = settings !== null && ownedSurfaces && clipboardDestination?.trim()
68
+ ? new ClipboardDiagnosticCapture(clipboardDestination) : null;
69
+ let shell;
70
+ try {
71
+ shell = new OwnedUiSessionShell({
72
+ backend: adapter,
73
+ cwd: adapter.cwd,
74
+ ...(options.terminal === undefined ? {} : { terminal: createPiTerminalBridge(options.terminal) }),
75
+ ...(routeHost === null ? {} : { routeHost }),
76
+ ...(ownedSurfaces ? { sessionLayout: "custom-viewport" } : {}),
77
+ ...(clipboardDiagnostics === null ? {} : {
78
+ responseCopy: { onEvent: event => clipboardDiagnostics.copy(event) },
79
+ pasteDiagnostics: event => clipboardDiagnostics.paste(event),
80
+ inputPresentation: { onEvent: event => clipboardDiagnostics.runtime(event) },
81
+ }),
82
+ ...(viewportSettings === null ? {} : { viewportSettings }),
83
+ ...(promptSuggestions === null ? {} : { promptSuggestions }),
84
+ ...(promptHistory === null ? {} : { promptHistory: { ...promptHistory, editor: await loadHistoryEditor() } }),
85
+ });
86
+ }
87
+ catch (error) {
88
+ clipboardDiagnostics?.dispose();
89
+ suggestionDiagnostics?.dispose();
90
+ throw error;
91
+ }
76
92
  const application = {
77
93
  get disposed() { return adapter.disposed; },
78
94
  start: () => shell.start(),
@@ -84,6 +100,7 @@ export async function composeOwnedUi(options = {}) {
84
100
  }
85
101
  finally {
86
102
  suggestionDiagnostics?.dispose();
103
+ clipboardDiagnostics?.dispose();
87
104
  }
88
105
  },
89
106
  };
@@ -1,8 +1,15 @@
1
1
  import type { OwnedUiImageAttachment } from "./model.js";
2
2
  export declare const MAX_PROMPT_IMAGES = 8;
3
+ /** Owned clipboard text is bounded before transfer, normalization, or editor insertion. */
4
+ export declare const MAX_CLIPBOARD_TEXT_BYTES: number;
3
5
  /** Limit applies to canonical ASCII base64, not decoded image bytes. */
4
6
  export declare const MAX_IMAGE_DATA_BYTES: number;
5
7
  declare const MESSAGES: {
8
+ readonly "paste-timeout": "Clipboard paste timed out. The editor is still available.";
9
+ readonly "paste-unavailable": "Could not paste; the clipboard is unavailable.";
10
+ readonly "paste-size": "Pasted text exceeds the 16 MiB limit.";
11
+ readonly "paste-busy": "Clipboard preparation is full (8 pending pastes).";
12
+ readonly "paste-write-failed": "Paste skipped because the preceding copy failed.";
6
13
  readonly "image-size": "Image exceeds the 8 MiB encoded base64 limit. Reduce the image size and paste it again.";
7
14
  readonly "image-count": "A prompt supports at most 8 images. Remove an attachment before adding another.";
8
15
  readonly "image-data": "Image data is invalid. Paste a valid image again.";
@@ -1,7 +1,14 @@
1
1
  export const MAX_PROMPT_IMAGES = 8;
2
+ /** Owned clipboard text is bounded before transfer, normalization, or editor insertion. */
3
+ export const MAX_CLIPBOARD_TEXT_BYTES = 16 * 1024 * 1024;
2
4
  /** Limit applies to canonical ASCII base64, not decoded image bytes. */
3
5
  export const MAX_IMAGE_DATA_BYTES = 8 * 1024 * 1024;
4
6
  const MESSAGES = {
7
+ "paste-timeout": "Clipboard paste timed out. The editor is still available.",
8
+ "paste-unavailable": "Could not paste; the clipboard is unavailable.",
9
+ "paste-size": "Pasted text exceeds the 16 MiB limit.",
10
+ "paste-busy": "Clipboard preparation is full (8 pending pastes).",
11
+ "paste-write-failed": "Paste skipped because the preceding copy failed.",
5
12
  "image-size": "Image exceeds the 8 MiB encoded base64 limit. Reduce the image size and paste it again.",
6
13
  "image-count": "A prompt supports at most 8 images. Remove an attachment before adding another.",
7
14
  "image-data": "Image data is invalid. Paste a valid image again.",
@@ -82,9 +82,16 @@ async function publishCertification(dataDir, identity, document, replaceInvalid)
82
82
  try {
83
83
  await publishLockedCertification(dataDir, identity, document, replaceInvalid, path);
84
84
  }
85
- finally {
86
- await releaseLock();
85
+ catch (publicationError) {
86
+ try {
87
+ await releaseLock();
88
+ }
89
+ catch (releaseError) {
90
+ throw new AggregateError([publicationError, releaseError], "dependency certification publication and lease release failed");
91
+ }
92
+ throw publicationError;
87
93
  }
94
+ await releaseLock();
88
95
  }
89
96
  async function publishLockedCertification(dataDir, identity, document, replaceInvalid, path) {
90
97
  const existing = await managedDependencyCertificationPath(dataDir, identity.layerId);
@@ -130,11 +137,7 @@ async function acquirePublicationLock(path) {
130
137
  // Concurrency: a nonempty directory cannot replace another nonempty directory on either
131
138
  // platform. Publishing the complete owner atomically also avoids partially written leases.
132
139
  await rename(candidate, path);
133
- return async () => {
134
- const released = `${path}.released-${token}`;
135
- await rename(path, released);
136
- await rm(released, { recursive: true, force: true });
137
- };
140
+ return () => releasePublicationLock(path, token);
138
141
  }
139
142
  catch (error) {
140
143
  if (!["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].some(code => hasCode(error, code)))
@@ -182,6 +185,61 @@ async function acquirePublicationLock(path) {
182
185
  await rm(candidate, { recursive: true, force: true });
183
186
  }
184
187
  }
188
+ /** Retire this generation once; retries after retirement can only touch its private cleanup path. */
189
+ async function releasePublicationLock(path, token) {
190
+ const released = `${path}.released-${token}`;
191
+ const deadline = Date.now() + 1_000;
192
+ let retired = false;
193
+ let delay = 20;
194
+ while (true) {
195
+ try {
196
+ if (!retired) {
197
+ await assertPublicationLockOwner(path, token);
198
+ await rename(path, released);
199
+ retired = true;
200
+ }
201
+ // Concurrency: a successor can own `path` now. Never inspect or delete it during cleanup.
202
+ await rm(released, { recursive: true, force: true });
203
+ return;
204
+ }
205
+ catch (error) {
206
+ const phase = retired ? "cleanup" : "retirement";
207
+ if (!isReleaseContention(error)) {
208
+ throw new Error(`dependency certification lease release ${phase} failed: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error });
209
+ }
210
+ const remaining = deadline - Date.now();
211
+ if (remaining <= 0) {
212
+ throw new Error(`dependency certification lease release ${phase} deadline exceeded (${error.code})`, { cause: error });
213
+ }
214
+ await new Promise(resolvePromise => setTimeout(resolvePromise, Math.min(delay, remaining)));
215
+ delay = Math.min(delay * 2, 100);
216
+ }
217
+ }
218
+ }
219
+ /** Ownership uncertainty is never authority to retire a missing, linked, or replaced generation. */
220
+ async function assertPublicationLockOwner(path, token) {
221
+ try {
222
+ const metadata = await lstat(path);
223
+ if (!metadata.isDirectory() || metadata.isSymbolicLink() || await realpath(path) !== path)
224
+ throw new Error("invalid lock directory");
225
+ const ownerPath = resolve(path, "owner.json");
226
+ const ownerMetadata = await lstat(ownerPath);
227
+ if (!ownerMetadata.isFile() || ownerMetadata.isSymbolicLink() || await realpath(ownerPath) !== ownerPath)
228
+ throw new Error("invalid owner file");
229
+ const owner = JSON.parse(await readFile(ownerPath, "utf8"));
230
+ if (!owner || typeof owner !== "object" || !("pid" in owner) || !("token" in owner)
231
+ || owner.pid !== process.pid || owner.token !== token)
232
+ throw new Error("owner generation differs");
233
+ }
234
+ catch (error) {
235
+ if (isReleaseContention(error))
236
+ throw error;
237
+ throw new Error("publication lease release ownership could not be verified", { cause: error });
238
+ }
239
+ }
240
+ function isReleaseContention(error) {
241
+ return ["EPERM", "EACCES", "EBUSY"].some(code => hasCode(error, code));
242
+ }
185
243
  function validateCertification(value, identity, allowLegacyPlatform = false) {
186
244
  if (!value || typeof value !== "object" || Array.isArray(value))
187
245
  throw new Error(`dependency layer certification differs from manifest: ${identity.layerId}`);
@@ -1,6 +1,6 @@
1
1
  import type { SelectionEditor as Editor } from "./editor-interaction.js";
2
2
  import type { KeybindingsManager } from "./upstream/adjacent/core/keybindings.js";
3
- import type { PiShellClipboardContent, PiShellEditorTextRange } from "./shell-shared-facade.js";
3
+ import type { PiShellClipboardContent, PiShellEditorTextRange, PiShellPasteReservation } from "./shell-shared-facade.js";
4
4
  /**
5
5
  * Middleware boundary around Pi's editor. Interceptors may claim an input or
6
6
  * delegate to the next interceptor/vanilla editor, and may decorate the rows
@@ -46,16 +46,18 @@ export declare class OwnedEditorUxInterception {
46
46
  export interface PromptSelectionUxOptions {
47
47
  readonly copyText: (text: string) => void;
48
48
  readonly readClipboardContent: () => Promise<PiShellClipboardContent | null>;
49
- readonly beginClipboardPaste?: () => {
50
- readonly marker: string;
51
- readonly result: Promise<string>;
52
- };
49
+ readonly beginClipboardPaste?: () => PiShellPasteReservation;
50
+ readonly beginTextPaste?: (text: string) => PiShellPasteReservation;
51
+ readonly deferTextPaste?: (text: string) => boolean;
52
+ readonly onPasteRejected?: (error: unknown) => void;
53
+ readonly onPasteInput?: (bytes: number) => void;
53
54
  readonly transformPastedContent: (content: PiShellClipboardContent) => string;
54
55
  readonly atomicRanges: (line: string) => readonly PiShellEditorTextRange[];
55
56
  readonly hiddenRanges?: (line: string) => readonly PiShellEditorTextRange[];
56
57
  readonly expandCopiedText: (text: string) => string;
57
58
  readonly paintSelection: (line: string, from: number, to: number, atomic: boolean) => string;
58
- readonly decorateRow: (row: string, width: number) => string;
59
+ /** Row indices restart at zero for each complete decoration pass. */
60
+ readonly decorateRow: (row: string, width: number, rowIndex: number) => string;
59
61
  readonly requestRender: () => void;
60
62
  readonly getRows: () => number;
61
63
  /** Presentation-only columns reserved before semantic editor text. */
@@ -1,3 +1,4 @@
1
+ import { ImageAttachmentError, MAX_CLIPBOARD_TEXT_BYTES } from "../../../contracts/owned-ui/index.js";
1
2
  import { CURSOR_MARKER, decodeKittyPrintable, visibleWidth, } from "#pi-tui";
2
3
  import { promptPathWordRanges } from "./path-word-ranges.js";
3
4
  export class OwnedEditorUxInterception {
@@ -267,7 +268,7 @@ class PromptSelectionInterceptor {
267
268
  }
268
269
  }
269
270
  }
270
- return rows.map(row => this.options.decorateRow(row, width));
271
+ return rows.map((row, index) => this.options.decorateRow(row, width, index));
271
272
  }
272
273
  reset() {
273
274
  this.#selection = undefined;
@@ -447,20 +448,57 @@ class PromptSelectionInterceptor {
447
448
  const before = data.slice(0, start);
448
449
  this.handleInput(before, (replacement = before) => next(replacement));
449
450
  }
450
- this.#terminalPaste = "";
451
+ this.#terminalPaste = { parts: [], piece: "", tail: "", bytes: 0, oversized: false };
451
452
  data = data.slice(start + 6);
452
453
  }
453
- // Compatibility: the terminal's StdinBuffer assembles split opening delimiters;
454
- // retain the body here too so editor-level chunks cannot allocate a native paste ID.
455
- this.#terminalPaste += data;
456
- const end = this.#terminalPaste.indexOf("\x1b[201~");
457
- if (end < 0)
454
+ // Protocol: inspect only the new fragment plus a delimiter-sized suffix, not the whole growing body.
455
+ const pending = this.#terminalPaste;
456
+ const fragment = pending.tail + data;
457
+ const end = fragment.indexOf("\x1b[201~");
458
+ const append = (part) => {
459
+ if (pending.oversized || part.length === 0)
460
+ return;
461
+ if (this.options.beginTextPaste !== undefined) {
462
+ pending.bytes += Buffer.byteLength(part);
463
+ if (pending.bytes > MAX_CLIPBOARD_TEXT_BYTES) {
464
+ pending.oversized = true;
465
+ pending.parts = [];
466
+ pending.piece = "";
467
+ return;
468
+ }
469
+ }
470
+ // Performance: bound fragment metadata even if a terminal supplies one character per input chunk.
471
+ pending.piece += part;
472
+ if (pending.piece.length >= 16_384) {
473
+ pending.parts.push(pending.piece);
474
+ pending.piece = "";
475
+ }
476
+ };
477
+ if (end < 0) {
478
+ let cut = Math.max(0, fragment.length - 5);
479
+ if (cut > 0 && /[\uD800-\uDBFF]/u.test(fragment[cut - 1]) && /[\uDC00-\uDFFF]/u.test(fragment[cut]))
480
+ cut--;
481
+ append(fragment.slice(0, cut));
482
+ pending.tail = fragment.slice(cut);
458
483
  return true;
459
- const text = this.#terminalPaste.slice(0, end);
460
- const remaining = this.#terminalPaste.slice(end + 6);
484
+ }
485
+ append(fragment.slice(0, end));
486
+ const remaining = fragment.slice(end + 6);
461
487
  this.#terminalPaste = undefined;
488
+ this.options.onPasteInput?.(pending.bytes);
489
+ if (pending.oversized) {
490
+ this.options.onPasteRejected?.(new ImageAttachmentError("paste-size"));
491
+ if (remaining.length > 0)
492
+ this.handleInput(remaining, (replacement = remaining) => next(replacement));
493
+ return true;
494
+ }
495
+ const text = pending.parts.join("") + pending.piece;
496
+ if (text.length > 0)
497
+ this.#redoStack = [];
462
498
  if (text.length === 0)
463
499
  this.pasteClipboard();
500
+ else if (this.options.beginTextPaste !== undefined && (this.options.deferTextPaste?.(text) ?? true))
501
+ this.#pasteFromClipboard(text);
464
502
  else {
465
503
  const transformed = this.options.transformPastedContent({ kind: "text", text });
466
504
  if (transformed === text && this.#orderedSelection() === undefined)
@@ -478,18 +516,24 @@ class PromptSelectionInterceptor {
478
516
  this.handleInput(remaining, (replacement = remaining) => next(replacement));
479
517
  return true;
480
518
  }
481
- #pasteFromClipboard() {
482
- if (this.options.beginClipboardPaste !== undefined) {
519
+ #pasteFromClipboard(source) {
520
+ const begin = source === undefined ? this.options.beginClipboardPaste
521
+ : this.options.beginTextPaste === undefined ? undefined : () => this.options.beginTextPaste(source);
522
+ if (begin !== undefined) {
483
523
  const generation = this.#pasteGeneration;
484
524
  const restore = this.#orderedSelection() === undefined ? "" : this.#selectedText();
485
- const paste = this.options.beginClipboardPaste();
525
+ const paste = begin();
526
+ this.#redoStack = [];
486
527
  if (this.#orderedSelection() !== undefined)
487
528
  this.#replaceSelection(paste.marker);
488
529
  else
489
530
  this.editor.insertTextAtCursor(paste.marker);
490
531
  this.#requestRender();
491
- const replace = (text) => { if (generation === this.#pasteGeneration)
492
- this.#replacePasteMarker(paste.marker, text || restore); };
532
+ const replace = (text) => {
533
+ if (generation === this.#pasteGeneration)
534
+ this.#replacePasteMarker(paste.marker, paste.isCurrent?.() === false ? restore : text || restore);
535
+ paste.complete?.();
536
+ };
493
537
  void paste.result.then(replace).catch(() => replace(restore));
494
538
  return;
495
539
  }
@@ -520,10 +564,12 @@ class PromptSelectionInterceptor {
520
564
  #replacePasteMarker(marker, replacement) {
521
565
  const current = this.editor.getText();
522
566
  const from = current.indexOf(marker);
523
- if (from < 0)
567
+ const text = normalizeInsertedText(replacement);
568
+ if (from < 0) {
569
+ this.#rewritePasteSnapshots(marker, text);
524
570
  return;
571
+ }
525
572
  const to = from + marker.length;
526
- const text = normalizeInsertedText(replacement);
527
573
  const lines = editorState(this.editor).lines;
528
574
  const remap = (position) => {
529
575
  const offset = positionOffset(lines, position);
@@ -533,12 +579,23 @@ class PromptSelectionInterceptor {
533
579
  const selection = this.#selection === undefined ? undefined : { anchor: remap(this.#selection.anchor), head: remap(this.#selection.head) };
534
580
  const next = current.slice(0, from) + text + current.slice(to);
535
581
  // Compatibility: completing a paste updates its provisional undo snapshots, not the user's undo history.
536
- if (this.editor.interaction !== undefined) {
582
+ if (this.editor.interaction !== undefined)
537
583
  this.editor.interaction.replaceLines(next.split("\n"));
584
+ else
585
+ editorState(this.editor).lines = next.split("\n");
586
+ this.#rewritePasteSnapshots(marker, text);
587
+ this.#setCursor(positionAtOffset(next, cursor));
588
+ this.editor.onChange?.(this.editor.getText());
589
+ this.editor.invalidate();
590
+ if (selection !== undefined)
591
+ this.#selection = { anchor: positionAtOffset(next, selection.anchor), head: positionAtOffset(next, selection.head) };
592
+ this.#requestRender();
593
+ }
594
+ #rewritePasteSnapshots(marker, text) {
595
+ // Concurrency: even a removed reservation must be retired from redo/undo snapshots, or a canceled paste can reappear.
596
+ if (this.editor.interaction !== undefined)
538
597
  this.editor.interaction.updateUndoStates(state => replaceSnapshotMarker(state, marker, text));
539
- }
540
598
  else {
541
- editorState(this.editor).lines = next.split("\n");
542
599
  const undo = Reflect.get(this.editor, "undoStack");
543
600
  const snapshots = typeof undo === "object" && undo !== null ? Reflect.get(undo, "stack") : undefined;
544
601
  if (Array.isArray(snapshots))
@@ -554,12 +611,6 @@ class PromptSelectionInterceptor {
554
611
  snapshot.text = state.lines.join("\n");
555
612
  snapshot.cursor = { line: state.cursorLine, col: state.cursorCol };
556
613
  }
557
- this.#setCursor(positionAtOffset(next, cursor));
558
- this.editor.onChange?.(this.editor.getText());
559
- this.editor.invalidate();
560
- if (selection !== undefined)
561
- this.#selection = { anchor: positionAtOffset(next, selection.anchor), head: positionAtOffset(next, selection.head) };
562
- this.#requestRender();
563
614
  }
564
615
  #replaceSelection(text) {
565
616
  const selection = this.#orderedSelection();
@@ -757,7 +808,6 @@ class PromptSelectionInterceptor {
757
808
  }
758
809
  function installAtomicSegmentation(editor, rangesForText, wordDirection) {
759
810
  const transform = (text, mode, values) => {
760
- const segments = [...values].filter(isEditorSegment);
761
811
  const ranges = rangesForText(text).map(range => ({ ...range, wordLike: false }));
762
812
  if (mode === "word") {
763
813
  for (const range of contextualPathRanges(editor, text, wordDirection())) {
@@ -766,6 +816,12 @@ function installAtomicSegmentation(editor, rangesForText, wordDirection) {
766
816
  }
767
817
  }
768
818
  ranges.sort((left, right) => left.start - right.start);
819
+ if (mode === "grapheme" && ranges.length > 0 && hasSegmentLookup(values)) {
820
+ const indexed = atomicSegmentsByBoundary(text, ranges, offset => values.containing(offset));
821
+ if (indexed !== undefined)
822
+ return indexed;
823
+ }
824
+ const segments = [...values].filter(isEditorSegment);
769
825
  if (ranges.length === 0)
770
826
  return segments;
771
827
  const merged = [];
@@ -803,6 +859,39 @@ function installAtomicSegmentation(editor, rangesForText, wordDirection) {
803
859
  Reflect.set(editor, "segment", (text, mode) => transform(text, mode, original(text, mode)));
804
860
  Reflect.set(editor, ATOMIC_SEGMENTATION, true);
805
861
  }
862
+ /** Narrow the optional indexed segment capability without a dynamic callback invocation. */
863
+ function hasSegmentLookup(values) {
864
+ return typeof values === "object" && values !== null && "containing" in values && typeof values.containing === "function";
865
+ }
866
+ /** Skip atomic interiors using the supplied segmenter's boundaries; preserve the iterable fallback exactly. */
867
+ function atomicSegmentsByBoundary(text, ranges, containing) {
868
+ const merged = [];
869
+ let offset = 0, rangeIndex = 0;
870
+ while (offset < text.length) {
871
+ const segment = containing(offset);
872
+ if (!isEditorSegment(segment) || segment.index !== offset || segment.segment.length === 0)
873
+ return undefined;
874
+ while ((ranges[rangeIndex]?.end ?? Number.POSITIVE_INFINITY) <= offset)
875
+ rangeIndex++;
876
+ const range = ranges[rangeIndex];
877
+ if (range !== undefined && offset >= range.start && offset < range.end) {
878
+ if (range.end > text.length)
879
+ return undefined;
880
+ const last = containing(range.end - 1);
881
+ if (!isEditorSegment(last) || last.index + last.segment.length < range.end)
882
+ return undefined;
883
+ if (offset === range.start)
884
+ merged.push({ segment: text.slice(range.start, range.end).replaceAll(" ", ATOMIC_SPACE_SENTINEL), index: range.start, input: text });
885
+ // Compatibility: the old iterable consumes the entire final grapheme, even if it crosses the chip end.
886
+ offset = last.index + last.segment.length;
887
+ }
888
+ else {
889
+ merged.push(segment);
890
+ offset += segment.segment.length;
891
+ }
892
+ }
893
+ return merged;
894
+ }
806
895
  function contextualPathRanges(editor, text, direction) {
807
896
  if (direction === undefined)
808
897
  return promptPathWordRanges(text);
@@ -76,6 +76,10 @@ export function createPiShellEditor(options) {
76
76
  copyText: options.onCopyText ?? (() => { }),
77
77
  readClipboardContent: options.readClipboardContent ?? (async () => null),
78
78
  ...(options.beginClipboardPaste === undefined ? {} : { beginClipboardPaste: options.beginClipboardPaste }),
79
+ ...(options.beginTextPaste === undefined ? {} : { beginTextPaste: options.beginTextPaste }),
80
+ ...(options.deferTextPaste === undefined ? {} : { deferTextPaste: options.deferTextPaste }),
81
+ ...(options.onPasteRejected === undefined ? {} : { onPasteRejected: options.onPasteRejected }),
82
+ ...(options.onPasteInput === undefined ? {} : { onPasteInput: options.onPasteInput }),
79
83
  transformPastedContent: options.transformPastedContent ?? (content => content.kind === "text" ? content.text : ""),
80
84
  atomicRanges: options.editorAtomicRanges ?? (() => []),
81
85
  hiddenRanges: options.editorHiddenRanges ?? (() => []),
@@ -1,6 +1,8 @@
1
- import { AssistantMessageComponent, BashExecutionComponent, CompactionSummaryMessageComponent, CustomMessageComponent, DynamicBorder, getMarkdownTheme, parseSkillBlock, ToolExecutionComponent, UserMessageComponent, } from "@earendil-works/pi-coding-agent";
1
+ import { AssistantMessageComponent, BashExecutionComponent, CompactionSummaryMessageComponent, CustomMessageComponent, DynamicBorder, getMarkdownTheme, parseSkillBlock, UserMessageComponent, } from "@earendil-works/pi-coding-agent";
2
+ import { ToolExecutionComponent } from "./upstream/components/tool-execution.js";
2
3
  import { SkillInvocationMessageComponent } from "./upstream/components/skill-invocation-message.js";
3
4
  import { piToolArguments as toolArguments, updatePiToolResult as updateToolResult } from "./tool-result-adapter.js";
5
+ import { createTranscriptImageResolver } from "./transcript-image-resolver.js";
4
6
  import { createMermaidMarkdownTransformer } from "./upstream/components/mermaid.js";
5
7
  import { Container, Image, Spacer, Text, } from "#pi-tui";
6
8
  import { PRODUCT_TEXT } from "../../../product-identity.js";
@@ -11,6 +13,7 @@ import { componentPort, createTuiFacade, ensureTheme, isRecord, } from "./shell-
11
13
  export function createPiShellTranscriptComponent(initial, cwd, extensions, submittedPrompt, initialOutputPad = PINNED_PI_LAYOUT.outputPad, initialHideThinkingBlock = false, initialMermaidRenderingMode = "off", initialShowImages = true, initialImageWidthCells = 80, imageAssets, presentation) {
12
14
  ensureTheme();
13
15
  let block = initial;
16
+ const retainedImages = createTranscriptImageResolver(imageAssets, initial);
14
17
  let expanded = false;
15
18
  let outputPad = initialOutputPad;
16
19
  let hideThinkingBlock = initialHideThinkingBlock;
@@ -22,6 +25,7 @@ export function createPiShellTranscriptComponent(initial, cwd, extensions, submi
22
25
  let updating = false;
23
26
  let dirty = false;
24
27
  let presentationRevision = 0;
28
+ let mountedTool;
25
29
  const mutate = (action) => {
26
30
  const previous = updating;
27
31
  updating = true;
@@ -33,6 +37,7 @@ export function createPiShellTranscriptComponent(initial, cwd, extensions, submi
33
37
  }
34
38
  };
35
39
  const rebuild = () => {
40
+ mountedTool?.dispose();
36
41
  const token = ++mount;
37
42
  const requestRender = () => {
38
43
  if (disposed || updating || token !== mount || dirty)
@@ -41,8 +46,12 @@ export function createPiShellTranscriptComponent(initial, cwd, extensions, submi
41
46
  presentationRevision++;
42
47
  presentation?.changed();
43
48
  };
44
- return mutate(() => withTranscriptImages(transcriptComponent(block, cwd, expanded, extensions, submittedPrompt, outputPad, hideThinkingBlock, mermaidRenderingMode, showImages, imageWidthCells, imageAssets, createTuiFacade({ getColumns: presentation?.getColumns ?? (() => 80),
45
- getRows: presentation?.getRows ?? (() => 24), requestRender })), block, imageAssets, showImages, imageWidthCells));
49
+ return mutate(() => {
50
+ const next = transcriptComponent(block, cwd, expanded, extensions, submittedPrompt, outputPad, hideThinkingBlock, mermaidRenderingMode, showImages, imageWidthCells, retainedImages, createTuiFacade({ getColumns: presentation?.getColumns ?? (() => 80),
51
+ getRows: presentation?.getRows ?? (() => 24), requestRender }));
52
+ mountedTool = next instanceof ToolExecutionComponent ? next : undefined;
53
+ return withTranscriptImages(next, block, retainedImages, showImages, imageWidthCells);
54
+ });
46
55
  };
47
56
  let component = rebuild();
48
57
  return {
@@ -56,13 +65,14 @@ export function createPiShellTranscriptComponent(initial, cwd, extensions, submi
56
65
  return warning === undefined ? rows : [...rows, ...new Text(piTheme().fg("warning", warning), outputPad, 0).render(width)];
57
66
  },
58
67
  invalidate: () => mutate(() => component.invalidate()),
59
- dispose: () => { disposed = true; mount++; },
68
+ dispose: () => { disposed = true; mount++; mountedTool?.dispose(); retainedImages.dispose(); },
60
69
  update(next) {
61
70
  if (next.id !== block.id)
62
71
  throw new TypeError("Pi transcript component identity cannot change");
63
72
  const previous = block;
64
73
  block = next;
65
- if (!mutate(() => updateTranscriptComponent(component, previous, next, expanded, imageAssets)))
74
+ retainedImages.update(next);
75
+ if (!mutate(() => updateTranscriptComponent(component, previous, next, expanded, retainedImages)))
66
76
  component = rebuild();
67
77
  },
68
78
  setExpanded(next) {
@@ -134,6 +134,13 @@ export interface PiShellHeaderOptions {
134
134
  readonly notices?: readonly PiShellStartupNotice[];
135
135
  readonly resources?: readonly PiShellResourceEntry[];
136
136
  }
137
+ /** An owned paste reserves its edit immediately and acknowledges completion only after insertion. */
138
+ export interface PiShellPasteReservation {
139
+ readonly marker: string;
140
+ readonly result: Promise<string>;
141
+ complete?(): void;
142
+ isCurrent?(): boolean;
143
+ }
137
144
  export interface PiShellEditorOptions {
138
145
  readonly persistentHistory?: boolean;
139
146
  readonly historyEditor?: HistoryEditorConstructor;
@@ -162,16 +169,18 @@ export interface PiShellEditorOptions {
162
169
  readonly onPromptSuggestionAccepted?: (text: string) => void;
163
170
  readonly onCopyText?: (text: string) => void;
164
171
  readonly readClipboardContent?: (signal?: AbortSignal) => Promise<PiShellClipboardContent | null>;
165
- readonly beginClipboardPaste?: () => {
166
- readonly marker: string;
167
- readonly result: Promise<string>;
168
- };
172
+ readonly beginClipboardPaste?: () => PiShellPasteReservation;
173
+ readonly beginTextPaste?: (text: string) => PiShellPasteReservation;
174
+ readonly deferTextPaste?: (text: string) => boolean;
175
+ readonly onPasteRejected?: (error: unknown) => void;
176
+ readonly onPasteInput?: (bytes: number) => void;
169
177
  readonly editorHiddenRanges?: (line: string) => readonly PiShellEditorTextRange[];
170
178
  readonly transformPastedContent?: (content: PiShellClipboardContent) => string;
171
179
  readonly editorAtomicRanges?: (line: string) => readonly PiShellEditorTextRange[];
172
180
  readonly expandCopiedEditorText?: (text: string) => string;
173
181
  readonly paintEditorSelection?: (line: string, from: number, to: number, atomic: boolean) => string;
174
- readonly decorateEditorRow?: (row: string, width: number) => string;
182
+ /** Row indices restart at zero for each complete owned editor decoration pass. */
183
+ readonly decorateEditorRow?: (row: string, width: number, rowIndex: number) => string;
175
184
  readonly cwd?: string;
176
185
  readonly agentDir?: string;
177
186
  readonly autocompleteCommands?: readonly PiShellAutocompleteCommand[];
@@ -0,0 +1,21 @@
1
+ import { convertToPng } from "@earendil-works/pi-coding-agent";
2
+ import { type Component } from "#pi-tui";
3
+ /**
4
+ * Own only current attachment presentation; tool renderers still receive original result content.
5
+ * Unlike pinned 0.84.2's index cache, entries follow immutable source bytes and stale completions
6
+ * cannot republish. At most one conversion runs per mount; queued work is only the current set.
7
+ */
8
+ export declare class ToolImagePresentation {
9
+ #private;
10
+ private readonly changed;
11
+ private readonly convert;
12
+ constructor(changed: () => void, convert?: typeof convertToPng);
13
+ update(content: readonly {
14
+ type: string;
15
+ data?: string;
16
+ mimeType?: string;
17
+ }[], show: boolean): void;
18
+ /** Public Image remains responsible for Kitty/iTerm protocol and dimension-aware fallback. */
19
+ components(width: number): Component[];
20
+ dispose(): void;
21
+ }