@timurproko/a1 0.1.8-dev.226 → 0.1.8-dev.259

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 (94) hide show
  1. package/bin/guardian.js +4 -2
  2. package/bin/supervisor.js +1 -1
  3. package/bin/ui.js +44 -36
  4. package/bin/warmup.js +18 -9
  5. package/dist/cli/dispatch.d.ts +9 -1
  6. package/dist/cli/dispatch.js +73 -14
  7. package/dist/cli/package-messages.d.ts +26 -0
  8. package/dist/cli/package-messages.js +71 -0
  9. package/dist/cli/packages.d.ts +2 -1
  10. package/dist/cli/packages.js +12 -19
  11. package/dist/composition/owned-ui.d.ts +5 -2
  12. package/dist/composition/owned-ui.js +3 -1
  13. package/dist/contracts/agent-engine/package-ports.d.ts +6 -0
  14. package/dist/features/launch/intent.d.ts +5 -3
  15. package/dist/features/launch/intent.js +7 -2
  16. package/dist/features/owned-ui/index.d.ts +1 -0
  17. package/dist/features/owned-ui/index.js +1 -0
  18. package/dist/features/owned-ui/session-fork-prompt.d.ts +8 -0
  19. package/dist/features/owned-ui/session-fork-prompt.js +34 -0
  20. package/dist/foundation/launch-guardian/main.d.ts +2 -0
  21. package/dist/foundation/launch-guardian/main.js +8 -3
  22. package/dist/foundation/lifecycle/index.d.ts +2 -0
  23. package/dist/foundation/lifecycle/index.js +2 -0
  24. package/dist/foundation/lifecycle/paths.d.ts +2 -0
  25. package/dist/foundation/lifecycle/paths.js +9 -2
  26. package/dist/foundation/lifecycle/session-selection.d.ts +9 -0
  27. package/dist/foundation/lifecycle/session-selection.js +39 -0
  28. package/dist/foundation/lifecycle/supervisor-startup.d.ts +30 -0
  29. package/dist/foundation/lifecycle/supervisor-startup.js +106 -0
  30. package/dist/foundation/process-containment/darwin-process-inspector.d.ts +5 -0
  31. package/dist/foundation/process-containment/darwin-process-inspector.js +7 -0
  32. package/dist/foundation/process-containment/index.d.ts +1 -0
  33. package/dist/foundation/process-containment/index.js +1 -0
  34. package/dist/foundation/process-containment/windows-process-inspector.d.ts +2 -1
  35. package/dist/foundation/process-containment/windows-process-inspector.js +4 -2
  36. package/dist/foundation/release/bootstrap.d.ts +10 -3
  37. package/dist/foundation/release/bootstrap.js +58 -25
  38. package/dist/foundation/release/dependency-layer.js +11 -10
  39. package/dist/foundation/release/immutable-platform.d.ts +6 -0
  40. package/dist/foundation/release/immutable-platform.js +13 -0
  41. package/dist/foundation/release/index.d.ts +1 -0
  42. package/dist/foundation/release/index.js +1 -0
  43. package/dist/foundation/release/release-store.d.ts +1 -1
  44. package/dist/foundation/release/release-store.js +4 -2
  45. package/dist/foundation/release/restart-certification.d.ts +67 -0
  46. package/dist/foundation/release/restart-certification.js +211 -0
  47. package/dist/foundation/release/update.js +4 -4
  48. package/dist/foundation/release/warmup.js +1 -1
  49. package/dist/foundation/startup/startup-runtime.d.ts +2 -2
  50. package/dist/foundation/startup/startup-runtime.js +1 -1
  51. package/dist/foundation/supervision/main.d.ts +1 -1
  52. package/dist/foundation/supervision/main.js +60 -30
  53. package/dist/foundation/supervision/server.js +14 -1
  54. package/dist/integrations/pi/components/shell-footer-status.js +28 -12
  55. package/dist/integrations/pi/components/shell-selectors-dialogs.d.ts +4 -1
  56. package/dist/integrations/pi/components/shell-selectors-dialogs.js +2 -1
  57. package/dist/integrations/pi/components/shell-shared-facade.d.ts +5 -0
  58. package/dist/integrations/pi/engine/adapter.d.ts +6 -0
  59. package/dist/integrations/pi/engine/adapter.js +354 -53
  60. package/dist/integrations/pi/engine/index.d.ts +1 -0
  61. package/dist/integrations/pi/engine/index.js +1 -0
  62. package/dist/integrations/pi/engine/package-integration.js +31 -25
  63. package/dist/integrations/pi/engine/runtime-integration.d.ts +3 -0
  64. package/dist/integrations/pi/engine/runtime-integration.js +11 -4
  65. package/dist/integrations/pi/engine/session-selection.d.ts +28 -0
  66. package/dist/integrations/pi/engine/session-selection.js +90 -0
  67. package/dist/integrations/pi/engine/workflows.d.ts +18 -1
  68. package/dist/integrations/pi/session-ui/session-shell-root.d.ts +5 -1
  69. package/dist/integrations/pi/session-ui/session-shell-root.js +76 -24
  70. package/dist/integrations/pi/session-ui/session-shell.d.ts +1 -1
  71. package/dist/integrations/pi/session-ui/session-shell.js +46 -14
  72. package/dist/integrations/pi/session-ui/session-viewport-controller.d.ts +2 -1
  73. package/dist/integrations/pi/session-ui/session-viewport-controller.js +47 -18
  74. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +12 -1
  75. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +166 -26
  76. package/dist/native/darwin-arm64/manifest.json +4 -4
  77. package/dist/native/darwin-arm64/process-guardian +0 -0
  78. package/dist/native/linux-x64/manifest.json +1 -1
  79. package/dist/native/linux-x64/process-guardian +0 -0
  80. package/dist/native/win32-x64/manifest.json +2 -2
  81. package/dist/native/win32-x64/process-guardian.exe +0 -0
  82. package/dist/ui/components/index.d.ts +1 -0
  83. package/dist/ui/components/index.js +1 -0
  84. package/dist/ui/components/text-selection.js +8 -35
  85. package/dist/ui/components/transcript-viewport.d.ts +8 -2
  86. package/dist/ui/components/transcript-viewport.js +8 -6
  87. package/dist/ui/components/visible-hyperlinks.d.ts +19 -0
  88. package/dist/ui/components/visible-hyperlinks.js +64 -0
  89. package/docs/architecture/process-guardian-provenance.md +3 -1
  90. package/docs/architecture/ui-reference-provenance.md +2 -0
  91. package/docs/ci-release-runbook.md +1 -1
  92. package/docs/features/launch-profiles.md +17 -1
  93. package/docs/manual-ghost-link-baseline.md +89 -0
  94. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { PRODUCT_TEXT } from "../../../product-identity.js";
2
- import { MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseMouseInput } from "../../../ui/components/index.js";
2
+ import { MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseMouseInput, readVisibleHyperlinks } from "../../../ui/components/index.js";
3
3
  import { PINNED_PI_HIDDEN_COMMAND_NAMES, PINNED_PI_WORKFLOW_COMMAND_NAMES, } from "../engine/index.js";
4
4
  import { createPiExtensionUiBridge, createPiQueuedInputStatus, createPiShellArmin, createPiShellAuthProviderSelector, createPiShellChangelog, createPiShellDaxnuts, createPiShellDialog, createPiShellEarendilAnnouncement, createPiShellEditor, createPiShellExtensionSelector, createPiShellFooter, createPiShellHeader, createPiShellHotkeys, createPiShellLoadedResources, createPiShellLoginDialog, createPiShellModelSelector, createPiShellOperationLoader, createPiShellReloadBox, createPiShellScopedModelsSelector, createPiShellSelector, createPiShellSessionInfo, createPiShellSessionSelector, createPiShellSettingsSelector, createPiShellStatus, createPiShellTranscriptComponent, createPiShellTreeSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, onPiThemeChange, piTheme, renderPiShellPackageUpdateNotice, renderPiShellStartupDiagnostic, renderPiShellStatusText, renderPiShellTranscriptBlock, } from "../components/index.js";
5
5
  import { DamageAwareTerminalAdapter, PiTuiRuntimeAdapter, classifyPiTuiInput, } from "../tui-runtime/index.js";
@@ -63,6 +63,7 @@ export class OwnedUiSessionShell {
63
63
  getColumns: () => runtime?.viewport().columns ?? options.terminal?.columns ?? 80,
64
64
  getRows: () => runtime?.viewport().rows ?? options.terminal?.rows ?? 24,
65
65
  requestRender: force => runtime?.requestRender(force),
66
+ requestHyperlinkCleanup: () => damageTerminal?.requestHyperlinkCleanup(),
66
67
  onViewportFrame: frame => damageTerminal?.arm(frame.descriptor, {
67
68
  overlayActive: runtime?.hasOverlay() ?? false,
68
69
  selectionActive: this.root.hasActiveSelection(),
@@ -129,7 +130,9 @@ export class OwnedUiSessionShell {
129
130
  decorateTerminal: (terminal) => {
130
131
  damageTerminal = new DamageAwareTerminalAdapter(terminal, {
131
132
  regionalScroll: process.env.TERM !== "dumb",
133
+ inspectHyperlinks: readVisibleHyperlinks,
132
134
  onResize: () => streamPresentation?.noteImmediatePresentation(),
135
+ onHyperlinkCleanupRequired: () => runtime?.requestRender(true),
133
136
  });
134
137
  return damageTerminal;
135
138
  },
@@ -294,6 +297,10 @@ export class OwnedUiSessionShell {
294
297
  startLogin: request => this.#startWorkflowLogin(request),
295
298
  prompt: request => this.#requestWorkflowInput(request),
296
299
  notify: event => this.#notifyWorkflowLogin(event),
300
+ publish: message => {
301
+ this.root.appendWorkflowMessage(message);
302
+ this.runtime.requestRender();
303
+ },
297
304
  finishLogin: () => this.#finishWorkflowLogin(),
298
305
  });
299
306
  this.root.editor.setAutocompleteCommands(this.backend.workflowAutocompleteCommands());
@@ -541,7 +548,7 @@ export class OwnedUiSessionShell {
541
548
  this.#dialogHandle = handle;
542
549
  return handle;
543
550
  }
544
- showModelSelector() {
551
+ showModelSelector(initialSearchInput) {
545
552
  const context = this.backend.pinnedModelSelectorContext();
546
553
  const close = () => {
547
554
  this.root.setInputSurface(null);
@@ -554,6 +561,7 @@ export class OwnedUiSessionShell {
554
561
  getRows: () => this.runtime.viewport().rows,
555
562
  requestRender: () => this.runtime.requestRender(),
556
563
  },
564
+ ...(initialSearchInput === undefined ? {} : { initialSearchInput }),
557
565
  onSelect: model => {
558
566
  close();
559
567
  void this.runWorkflow({ command: "model", argument: "", selection: modelReference(model) });
@@ -566,7 +574,7 @@ export class OwnedUiSessionShell {
566
574
  showForkSelector() {
567
575
  const options = this.backend.pinnedForkOptions();
568
576
  if (options.length === 0) {
569
- this.root.appendWorkflowResult({ command: "fork", outcome: "failed", message: "No user messages available to fork from" });
577
+ this.root.appendWorkflowResult({ command: "fork", outcome: "completed", message: "No messages to fork from", messageKind: "status" });
570
578
  this.runtime.requestRender();
571
579
  return;
572
580
  }
@@ -582,9 +590,21 @@ export class OwnedUiSessionShell {
582
590
  this.runtime.requestRender();
583
591
  }
584
592
  async showLogoutSelector() {
585
- const options = await this.backend.pinnedLogoutOptions();
593
+ let options;
594
+ try {
595
+ options = await this.backend.pinnedLogoutOptions();
596
+ }
597
+ catch (error) {
598
+ this.root.appendWorkflowResult({
599
+ command: "logout",
600
+ outcome: "failed",
601
+ message: `Could not read stored credentials: ${error instanceof Error ? error.message : String(error)}`,
602
+ });
603
+ this.runtime.requestRender();
604
+ return;
605
+ }
586
606
  if (options.length === 0) {
587
- this.root.appendWorkflowStatus("No authenticated providers available.");
607
+ this.root.appendWorkflowStatus("No stored credentials to remove. /logout only removes credentials saved by /login; environment variables and models.json config are unchanged.");
588
608
  this.runtime.requestRender();
589
609
  return;
590
610
  }
@@ -805,10 +825,6 @@ export class OwnedUiSessionShell {
805
825
  await this.showLogoutSelector();
806
826
  return { outcome: "completed", diagnostic: null };
807
827
  }
808
- if (request.command === "import" && request.confirmed === undefined && request.argument.trim().length > 0) {
809
- const confirmed = await this.#extensionBridge.context.confirm("Import session", `Replace current session with ${request.argument.trim()}?`);
810
- return this.runWorkflow({ ...request, confirmed: confirmed === true });
811
- }
812
828
  if (request.command === "tree" && request.selection === undefined && request.confirmed === undefined && request.argument.trim().length === 0) {
813
829
  this.showTreeSelector();
814
830
  return { outcome: "completed", diagnostic: null };
@@ -817,22 +833,21 @@ export class OwnedUiSessionShell {
817
833
  this.root.resetExtensionUi();
818
834
  this.root.resetWorkflowPresentation();
819
835
  }
820
- const operationSurface = request.command === "share"
836
+ const shareSurface = request.command === "share"
821
837
  ? createPiShellOperationLoader({
822
838
  getColumns: () => this.runtime.viewport().columns,
823
839
  getRows: () => this.runtime.viewport().rows,
824
840
  requestRender: () => this.runtime.requestRender(),
825
841
  }, "Creating gist...")
826
- : request.command === "reload"
827
- ? createPiShellReloadBox()
828
- : undefined;
842
+ : undefined;
843
+ const operationSurface = shareSurface ?? (request.command === "reload" ? createPiShellReloadBox() : undefined);
829
844
  if (operationSurface) {
830
845
  this.root.setInputSurface(operationSurface);
831
846
  this.runtime.requestRender();
832
847
  }
833
848
  let result;
834
849
  try {
835
- result = await this.backend.executeWorkflow(request);
850
+ result = await this.backend.executeWorkflow(shareSurface === undefined ? request : { ...request, signal: shareSurface.signal });
836
851
  }
837
852
  finally {
838
853
  if (operationSurface) {
@@ -840,10 +855,27 @@ export class OwnedUiSessionShell {
840
855
  this.runtime.requestRender();
841
856
  }
842
857
  }
858
+ if (result.outcome === "requires-selection" && request.command === "model") {
859
+ if (result.messages !== undefined) {
860
+ for (const message of result.messages)
861
+ this.root.appendWorkflowMessage(message);
862
+ }
863
+ this.showModelSelector((result.detail ?? request.argument.trim()) || undefined);
864
+ return { outcome: "completed", diagnostic: null };
865
+ }
843
866
  if (result.outcome === "requires-confirmation" && request.command === "resume") {
844
867
  const confirmed = await this.#extensionBridge.context.confirm("Session cwd not found", result.message);
845
868
  return this.runWorkflow({ ...request, confirmed: confirmed === true });
846
869
  }
870
+ if (result.outcome === "requires-confirmation" && request.command === "import") {
871
+ const recoveringCwd = result.detail !== undefined;
872
+ const confirmed = await this.#extensionBridge.context.confirm(recoveringCwd ? "Session cwd not found" : "Import session", result.message);
873
+ return this.runWorkflow({
874
+ ...request,
875
+ confirmed: confirmed === true,
876
+ ...(confirmed === true && result.detail ? { cwdOverride: result.detail } : {}),
877
+ });
878
+ }
847
879
  if (result.outcome === "requires-selection" || result.outcome === "requires-confirmation") {
848
880
  this.root.appendWorkflowResult({ command: request.command, outcome: "failed", message: `Owned controller missing for ${request.command}` });
849
881
  this.runtime.requestRender();
@@ -5,6 +5,7 @@ export interface SessionViewportControllerOptions {
5
5
  readonly enabled: boolean;
6
6
  readonly editor: PiShellEditorPort;
7
7
  readonly requestRender: (force?: boolean) => void;
8
+ readonly requestHyperlinkCleanup?: () => void;
8
9
  /** Whether mutable editor URL chips need an immediate forced repaint on deletion. */
9
10
  readonly hasEditorLinks?: () => boolean;
10
11
  }
@@ -32,7 +33,7 @@ export declare class SessionViewportController {
32
33
  readonly rowStart: number;
33
34
  readonly rowEnd: number;
34
35
  } | undefined): void;
35
- compose(input: TranscriptViewportFrameInput): TranscriptViewportFrame;
36
+ compose(input: Omit<TranscriptViewportFrameInput, "pointerPosition">): TranscriptViewportFrame;
36
37
  composeDockOnly(dockRows: readonly string[], width: number, height: number): TranscriptViewportFrame | null;
37
38
  /** Returns true when document wrapping may have changed. */
38
39
  setConfig(config: OwnedUiViewportSettings): boolean;
@@ -1,4 +1,4 @@
1
- import { TranscriptViewport, hyperlinkTargetAtColumn, routeMouseInput, scrollForTrackPage, scrollForThumbRow, scrollbarSelectionRows, scrollbarWheelRows, } from "../../../ui/components/index.js";
1
+ import { TranscriptViewport, readVisibleHyperlinks, routeMouseInput, scrollForTrackPage, scrollForThumbRow, scrollbarSelectionRows, scrollbarWheelRows, } from "../../../ui/components/index.js";
2
2
  /**
3
3
  * Owns the stateful interaction policy of the bare-A1 session viewport. The
4
4
  * shell root supplies semantic rows and dock geometry; this controller owns
@@ -9,6 +9,7 @@ export class SessionViewportController {
9
9
  #editor;
10
10
  #requestRender;
11
11
  #hasEditorLinks;
12
+ #requestHyperlinkCleanup;
12
13
  #viewport = new TranscriptViewport();
13
14
  #config = {
14
15
  scrollbarAppearance: "auto",
@@ -18,6 +19,8 @@ export class SessionViewportController {
18
19
  #dragGrabOffset = null;
19
20
  // Invariant: a left-button sequence begun in dock chrome remains owned by the dock.
20
21
  #dockPointerSuppressed = false;
22
+ // Invariant: a left-button sequence begun in non-selectable transcript tail chrome is held there.
23
+ #tailPointerSuppressed = false;
21
24
  // Invariant: editor selection remains true only while its left button is held.
22
25
  #editorPointerSelecting = false;
23
26
  // Invariant: pointer routing uses the most recently composed editor rows.
@@ -37,6 +40,7 @@ export class SessionViewportController {
37
40
  options.requestRender(force);
38
41
  };
39
42
  this.#hasEditorLinks = options.hasEditorLinks ?? (() => false);
43
+ this.#requestHyperlinkCleanup = options.requestHyperlinkCleanup ?? (() => { });
40
44
  }
41
45
  get config() {
42
46
  return this.#config;
@@ -65,7 +69,10 @@ export class SessionViewportController {
65
69
  compose(input) {
66
70
  this.#viewport.setConfig(this.#config);
67
71
  const selectionRevision = this.#viewport.selectionRevision;
68
- const frame = this.#viewport.compose(input);
72
+ const frame = this.#viewport.compose({
73
+ ...input,
74
+ ...(this.#pointerPosition === undefined ? {} : { pointerPosition: this.#pointerPosition }),
75
+ });
69
76
  // Concurrency: if selection changed during composition, this frame truthfully carries
70
77
  // the older revision and exactly one ordinary follow-up render publishes the latest.
71
78
  if (this.#viewport.selectionRevision !== selectionRevision
@@ -78,6 +85,7 @@ export class SessionViewportController {
78
85
  if (this.#hoveredHyperlinkKey !== undefined && next !== this.#hoveredHyperlinkKey) {
79
86
  // Platform: Windows Terminal can leave its solid native-hover underline behind
80
87
  // when a following viewport moves the link away from a stationary pointer.
88
+ this.#requestHyperlinkCleanup();
81
89
  this.#requestRender(true);
82
90
  }
83
91
  this.#hoveredHyperlinkKey = next;
@@ -126,6 +134,7 @@ export class SessionViewportController {
126
134
  this.#clearActivityTimer();
127
135
  this.#dragGrabOffset = null;
128
136
  this.#dockPointerSuppressed = false;
137
+ this.#tailPointerSuppressed = false;
129
138
  this.#editorPointerSelecting = false;
130
139
  this.#editorPointerFrame = undefined;
131
140
  this.#pointerPosition = undefined;
@@ -146,8 +155,10 @@ export class SessionViewportController {
146
155
  return { data: "", consumed: true };
147
156
  }
148
157
  // Platform: URL chip deletion must overwrite terminal link cells in the same frame.
149
- if (EDITOR_LINK_DELETION_INPUTS.has(data) && this.#hasEditorLinks())
158
+ if (EDITOR_LINK_DELETION_INPUTS.has(data) && this.#hasEditorLinks()) {
159
+ this.#requestHyperlinkCleanup();
150
160
  this.#requestRender(true);
161
+ }
151
162
  if (this.#viewport.frame === null)
152
163
  return { data, consumed: false };
153
164
  if (data === "\u0003") {
@@ -205,20 +216,27 @@ export class SessionViewportController {
205
216
  const overSticky = hits.sticky !== null && event.row === hits.sticky.row && event.column <= hits.sticky.width;
206
217
  const overBottom = hits.bottom !== null && event.row === hits.bottom.row
207
218
  && event.column >= hits.bottom.columnStart && event.column <= hits.bottom.columnEnd;
208
- if (event.kind === "motion") {
209
- this.#pointerPosition = { column: event.column, row: event.row };
210
- if (!this.#viewport.selectionActive && !this.#editorPointerSelecting) {
211
- const nextHyperlink = this.#hyperlinkKeyAt(frame, event.column, event.row);
212
- if (this.#hoveredHyperlinkKey !== undefined && nextHyperlink !== this.#hoveredHyperlinkKey) {
213
- // Platform: a forced redraw overwrites Windows Terminal's cached solid hover
214
- // underline exactly once when the pointer leaves or changes links.
215
- forceRepaint = true;
216
- }
217
- this.#hoveredHyperlinkKey = nextHyperlink;
219
+ const previousPointer = this.#pointerPosition;
220
+ const wasOverBottom = hits.bottom !== null && previousPointer !== undefined
221
+ && previousPointer.row === hits.bottom.row
222
+ && previousPointer.column >= hits.bottom.columnStart && previousPointer.column <= hits.bottom.columnEnd;
223
+ // Invariant: every report locates the pointer, independently of which surface owns the action.
224
+ // Keep this location while the control is hidden; composition resolves its next hit region.
225
+ this.#pointerPosition = { column: event.column, row: event.row };
226
+ repaint ||= wasOverBottom !== overBottom;
227
+ if (!this.#viewport.selectionActive && !this.#editorPointerSelecting) {
228
+ const nextHyperlink = this.#hyperlinkKeyAt(frame, event.column, event.row);
229
+ if (this.#hoveredHyperlinkKey !== undefined && nextHyperlink !== this.#hoveredHyperlinkKey) {
230
+ // Platform: overwrite Windows Terminal's cached native-hover underline exactly once.
231
+ this.#requestHyperlinkCleanup();
232
+ forceRepaint = true;
233
+ repaint = true;
218
234
  }
235
+ this.#hoveredHyperlinkKey = nextHyperlink;
236
+ }
237
+ if (event.kind === "motion") {
219
238
  this.#viewport.setRailHovered(overRail);
220
239
  this.#viewport.setStickyHovered(overSticky);
221
- this.#viewport.setBottomHovered(overBottom);
222
240
  repaint = true;
223
241
  if (this.#editor.ownsPointer() && this.#editorPointerFrame !== undefined) {
224
242
  this.#editor.handlePointer({
@@ -229,7 +247,7 @@ export class SessionViewportController {
229
247
  });
230
248
  return true;
231
249
  }
232
- if (this.#dockPointerSuppressed)
250
+ if (this.#dockPointerSuppressed || this.#tailPointerSuppressed)
233
251
  return true;
234
252
  if (this.#viewport.selectionActive) {
235
253
  this.#viewport.extendSelection(event.column, event.row, now, false);
@@ -316,7 +334,13 @@ export class SessionViewportController {
316
334
  return true;
317
335
  }
318
336
  if (event.row >= 1 && event.row <= hits.viewportHeight && event.column <= frame.contentWidth) {
319
- this.#viewport.pressSelection(event.column, event.row, now);
337
+ if (!this.#viewport.pressSelection(event.column, event.row, now)) {
338
+ // Invariant: a full drag begun on transient tail rows cannot become transcript selection.
339
+ if (hits.transientTail.includes(event.row))
340
+ this.#tailPointerSuppressed = true;
341
+ repaint = true;
342
+ return this.#tailPointerSuppressed;
343
+ }
320
344
  repaint = true;
321
345
  return true;
322
346
  }
@@ -354,6 +378,10 @@ export class SessionViewportController {
354
378
  this.#dockPointerSuppressed = false;
355
379
  return true;
356
380
  }
381
+ if (this.#tailPointerSuppressed) {
382
+ this.#tailPointerSuppressed = false;
383
+ return true;
384
+ }
357
385
  }
358
386
  return false;
359
387
  });
@@ -368,8 +396,9 @@ export class SessionViewportController {
368
396
  #hyperlinkKeyAt(frame, column, row) {
369
397
  if (row < 1 || row > frame.rows.length || column < 1)
370
398
  return undefined;
371
- const target = hyperlinkTargetAtColumn(frame.rows[row - 1] ?? "", column - 1);
372
- return target === undefined ? undefined : `${row}\u0000${target}`;
399
+ const range = readVisibleHyperlinks(frame.rows[row - 1] ?? "").ranges
400
+ .find(candidate => column - 1 >= candidate.from && column - 1 < candidate.to);
401
+ return range === undefined ? undefined : JSON.stringify([row, range]);
373
402
  }
374
403
  #updateSelectionAutoScroll(column, row, viewportHeight) {
375
404
  const beyondEdge = row <= 1 || row > viewportHeight;
@@ -21,7 +21,7 @@ export interface PiTuiDamageFrameSafety {
21
21
  readonly selectionActive: boolean;
22
22
  readonly replacementSurfaceActive: boolean;
23
23
  }
24
- export type PiTuiDamageDecisionReason = "transformed" | "suppressed-redundant-clear" | "not-armed" | "unsafe-frame" | "stale-frame" | "unsupported-region-scroll" | "geometry-mismatch" | "grammar-mismatch" | "unsafe-terminal-content" | "incomplete-prior-frame" | "excessive-real-damage";
24
+ export type PiTuiDamageDecisionReason = "transformed" | "suppressed-redundant-clear" | "hyperlink-cleanup" | "pending-hyperlink-cleanup" | "not-armed" | "unsafe-frame" | "stale-frame" | "unsupported-region-scroll" | "geometry-mismatch" | "grammar-mismatch" | "unsafe-terminal-content" | "incomplete-prior-frame" | "excessive-real-damage";
25
25
  export interface PiTuiDamageDecision {
26
26
  readonly frameId: number | null;
27
27
  readonly transformed: boolean;
@@ -31,7 +31,15 @@ export interface PiTuiDamageDecision {
31
31
  }
32
32
  export interface DamageAwareTerminalOptions {
33
33
  readonly regionalScroll: boolean;
34
+ /** The shell supplies visible-row analysis; the terminal layer owns no UI parser. */
35
+ readonly inspectHyperlinks: (content: string) => {
36
+ readonly ranges: readonly unknown[];
37
+ readonly signature: string;
38
+ readonly replaySafe: boolean;
39
+ readonly width: number;
40
+ };
34
41
  readonly onResize?: () => void;
42
+ readonly onHyperlinkCleanupRequired?: () => void;
35
43
  }
36
44
  /**
37
45
  * A fail-closed adapter for one pinned Pi fullscreen write grammar. Semantic
@@ -47,6 +55,9 @@ export declare class DamageAwareTerminalAdapter implements PiTuiTerminalPort {
47
55
  get rows(): number;
48
56
  get kittyProtocolActive(): boolean;
49
57
  get lastDecision(): PiTuiDamageDecision;
58
+ get hyperlinkCleanupPending(): boolean;
59
+ /** Latches cleanup through composition/coalescing until a complete write covers it. */
60
+ requestHyperlinkCleanup(): void;
50
61
  arm(descriptor: PiTuiDamageFrameDescriptor, safety: PiTuiDamageFrameSafety): void;
51
62
  start(onInput: (data: string) => void, onResize: () => void): void;
52
63
  stop(): void;
@@ -2,8 +2,8 @@ export const PINNED_PI_TUI_DAMAGE_GRAMMAR = "@earendil-works/pi-tui@0.84.2:tui-a
2
2
  const BEGIN_SYNCHRONIZED_OUTPUT = "\u001b[?2026h";
3
3
  const END_SYNCHRONIZED_OUTPUT = "\u001b[?2026l";
4
4
  const ROW_MARKER = /\u001b\[(\d+);1H\u001b\[2K/gu;
5
- const CURSOR_SUFFIX = /\u001b\[(\d+);(\d+)H\u001b\[\?25[hl]\u001b\[\?2026l$/u;
6
- const OSC8 = /\u001b\]8;[^;\u0007\u001b]*;([^\u0007\u001b]*)(?:\u0007|\u001b\\)/gu;
5
+ const CURSOR_SUFFIX = /(?:\u001b\[(\d+);(\d+)H)?\u001b\[\?25[hl]\u001b\[\?2026l$/u;
6
+ const OUT_OF_BAND = /^(?:\u001b\](?:0|2|9|52);[^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[\?25[hl])+$/u;
7
7
  /**
8
8
  * A fail-closed adapter for one pinned Pi fullscreen write grammar. Semantic
9
9
  * viewport metadata authorizes a shift; bytes are inspected only to validate
@@ -13,6 +13,11 @@ export class DamageAwareTerminalAdapter {
13
13
  inner;
14
14
  options;
15
15
  #rows = new Map();
16
+ #links = new Map();
17
+ #cleanupRevision = 0;
18
+ #cleanedRevision = 0;
19
+ #recoveryRevision = 0;
20
+ #epoch = 0;
16
21
  #armed;
17
22
  #lastConsumedFrameId = 0;
18
23
  #cacheWidth = 0;
@@ -32,18 +37,26 @@ export class DamageAwareTerminalAdapter {
32
37
  get rows() { return this.inner.rows; }
33
38
  get kittyProtocolActive() { return this.inner.kittyProtocolActive; }
34
39
  get lastDecision() { return this.#decision; }
40
+ get hyperlinkCleanupPending() { return this.#cleanupRevision > this.#cleanedRevision; }
41
+ /** Latches cleanup through composition/coalescing until a complete write covers it. */
42
+ requestHyperlinkCleanup() { this.#cleanupRevision += 1; }
35
43
  arm(descriptor, safety) {
36
44
  this.#armed = { descriptor, safety };
37
45
  }
38
46
  start(onInput, onResize) {
39
47
  this.inner.start(onInput, () => {
40
- this.#invalidateRows();
48
+ this.#epoch += 1;
49
+ this.#recoveryRevision = 0;
50
+ this.#invalidatePresentation();
41
51
  this.options.onResize?.();
42
52
  onResize();
43
53
  });
44
54
  }
45
55
  stop() {
56
+ this.#epoch += 1;
46
57
  this.#armed = undefined;
58
+ this.#cleanupRevision = this.#cleanedRevision = this.#recoveryRevision = 0;
59
+ this.#lastConsumedFrameId = 0;
47
60
  this.#invalidateRows();
48
61
  this.inner.stop();
49
62
  }
@@ -52,25 +65,71 @@ export class DamageAwareTerminalAdapter {
52
65
  const armed = this.#armed;
53
66
  this.#armed = undefined;
54
67
  const parsed = parsePinnedFullscreenWrite(data);
55
- const decision = this.#decide(armed, parsed);
56
- this.#decision = decision;
57
- if (decision.reason === "suppressed-redundant-clear" && parsed !== null) {
58
- this.inner.write(buildClearlessWrite(parsed));
68
+ if (armed !== undefined && armed.descriptor.frameId <= this.#lastConsumedFrameId) {
69
+ // Concurrency: a recognized obsolete frame must never repaint newer cells,
70
+ // acknowledge cleanup, or become the reference for a subsequent differential.
71
+ this.#decision = this.#decide(armed, parsed);
72
+ return;
73
+ }
74
+ const sameGeometry = this.#cacheWidth === this.columns && this.#cacheHeight === this.rows;
75
+ const unknownPaint = parsed === null && !OUT_OF_BAND.test(data);
76
+ if (!sameGeometry || unknownPaint)
77
+ this.#invalidatePresentation();
78
+ if (unknownPaint && !this.hyperlinkCleanupPending && this.options.inspectHyperlinks(data).ranges.length > 0) {
79
+ this.requestHyperlinkCleanup();
59
80
  }
60
- else if (decision.transformed && armed !== undefined && parsed !== null) {
61
- this.inner.write(buildDamageWrite(parsed, armed.descriptor, new Set(decision.paintedRows), this.#rows));
81
+ if (parsed !== null && !this.hyperlinkCleanupPending) {
82
+ for (const row of parsed.rows) {
83
+ const previous = this.#links.get(row.row);
84
+ if (previous === undefined || this.#rows.get(row.row) === row.content
85
+ || (previous.replaySafe && previous.ranges.length === 0))
86
+ continue;
87
+ const next = this.options.inspectHyperlinks(row.content);
88
+ if (previous.ranges.length > 0 && previous.signature !== next.signature) {
89
+ this.requestHyperlinkCleanup();
90
+ break;
91
+ }
92
+ }
93
+ }
94
+ const decision = this.#decide(armed, parsed);
95
+ const cleanup = this.hyperlinkCleanupPending && parsed !== null
96
+ && (armed === undefined || (armed.descriptor.width === this.columns && armed.descriptor.height === this.rows))
97
+ ? this.#completeCleanupFrame(parsed) : null;
98
+ const coveredRevision = this.#cleanupRevision;
99
+ const epoch = this.#epoch;
100
+ let output = data;
101
+ if (cleanup !== null) {
102
+ output = buildCompleteWrite(cleanup);
103
+ this.#decision = {
104
+ frameId: armed?.descriptor.frameId ?? null, transformed: output !== data,
105
+ reason: "hyperlink-cleanup", shiftRows: 0, paintedRows: cleanup.rows.map(row => row.row),
106
+ };
62
107
  }
63
108
  else {
64
- this.inner.write(data);
109
+ this.#decision = decision;
110
+ if (decision.reason === "suppressed-redundant-clear" && parsed !== null)
111
+ output = buildClearlessWrite(parsed);
112
+ else if (decision.transformed && armed !== undefined && parsed !== null) {
113
+ output = buildDamageWrite(parsed, armed.descriptor, new Set(decision.paintedRows), this.#rows);
114
+ }
65
115
  }
66
- this.#rememberFrame(armed?.descriptor, parsed, data);
116
+ this.inner.write(output);
117
+ if (epoch !== this.#epoch
118
+ || (armed !== undefined && this.#lastConsumedFrameId !== armed.descriptor.frameId))
119
+ return;
120
+ // Invariant: cache invalidation follows forwarded bytes, not a clear which
121
+ // was stripped from the input. A synthesized cleanup publishes every row.
122
+ this.#rememberFrame(armed?.descriptor, cleanup ?? parsed, output);
123
+ if (cleanup !== null)
124
+ this.#cleanedRevision = Math.max(this.#cleanedRevision, coveredRevision);
125
+ this.#scheduleCleanupRecovery();
67
126
  }
68
127
  moveBy(lines) { this.inner.moveBy(lines); }
69
128
  hideCursor() { this.inner.hideCursor(); }
70
129
  showCursor() { this.inner.showCursor(); }
71
- clearLine() { this.#invalidateRows(); this.inner.clearLine(); }
72
- clearFromCursor() { this.#invalidateRows(); this.inner.clearFromCursor(); }
73
- clearScreen() { this.#invalidateRows(); this.inner.clearScreen(); }
130
+ clearLine() { this.#invalidatePresentation(); this.inner.clearLine(); }
131
+ clearFromCursor() { this.#invalidatePresentation(); this.inner.clearFromCursor(); }
132
+ clearScreen() { this.#invalidatePresentation(); this.inner.clearScreen(); }
74
133
  setTitle(title) { this.inner.setTitle(title); }
75
134
  setProgress(active) { this.inner.setProgress(active); }
76
135
  #decide(armed, parsed) {
@@ -87,9 +146,12 @@ export class DamageAwareTerminalAdapter {
87
146
  if (descriptor.frameId <= this.#lastConsumedFrameId)
88
147
  return { ...base, reason: "stale-frame" };
89
148
  this.#lastConsumedFrameId = descriptor.frameId;
149
+ if (this.hyperlinkCleanupPending)
150
+ return { ...base, reason: "pending-hyperlink-cleanup" };
90
151
  if (parsed !== null && parsed.structuralPrefix === "\u001b[2J"
91
152
  && this.#cacheWidth === descriptor.width && this.#cacheHeight === descriptor.height
92
- && hasCompleteScreenRows(parsed.rows, descriptor.height) && !hasUnsafeTerminalContent(parsed)) {
153
+ && descriptor.width === this.columns && descriptor.height === this.rows
154
+ && hasCompleteScreenRows(parsed.rows, descriptor.height) && this.#hasSameHyperlinkState(parsed.rows)) {
93
155
  return {
94
156
  frameId: descriptor.frameId,
95
157
  transformed: true,
@@ -111,7 +173,9 @@ export class DamageAwareTerminalAdapter {
111
173
  }
112
174
  if (parsed === null)
113
175
  return { ...base, reason: "grammar-mismatch" };
114
- if (parsed.structuralPrefix.length > 0 || hasUnsafeTerminalContent(parsed)) {
176
+ if (parsed.structuralPrefix.length > 0
177
+ || hasUnsafeTerminalContent(parsed, this.options.inspectHyperlinks, descriptor.transcript)
178
+ || this.#hasLinkRisk(descriptor.transcript) || this.#hasUnsafeRows(descriptor.transcript)) {
115
179
  return { ...base, reason: "unsafe-terminal-content" };
116
180
  }
117
181
  const { rowStart, rowEnd } = descriptor.transcript;
@@ -160,16 +224,85 @@ export class DamageAwareTerminalAdapter {
160
224
  }
161
225
  if (parsed === null)
162
226
  return;
163
- if (descriptor !== undefined && (descriptor.width !== this.#cacheWidth || descriptor.height !== this.#cacheHeight)) {
227
+ const width = descriptor?.width ?? this.columns;
228
+ const height = descriptor?.height ?? this.rows;
229
+ if (width !== this.#cacheWidth || height !== this.#cacheHeight) {
164
230
  this.#invalidateRows();
165
- this.#cacheWidth = descriptor.width;
166
- this.#cacheHeight = descriptor.height;
231
+ this.#cacheWidth = width;
232
+ this.#cacheHeight = height;
167
233
  }
168
- for (const row of parsed.rows)
234
+ if (parsed.rows.some(row => row.row > this.rows)) {
235
+ this.#invalidatePresentation();
236
+ return;
237
+ }
238
+ for (const row of parsed.rows) {
239
+ if (this.#rows.get(row.row) !== row.content)
240
+ this.#links.set(row.row, this.options.inspectHyperlinks(row.content));
169
241
  this.#rows.set(row.row, row.content);
242
+ }
243
+ }
244
+ #hasSameHyperlinkState(rows) {
245
+ return rows.every(row => {
246
+ const previous = this.#links.get(row.row);
247
+ const next = row.content === this.#rows.get(row.row) ? previous : this.options.inspectHyperlinks(row.content);
248
+ return next?.replaySafe === true && (previous?.signature ?? "") === next.signature;
249
+ });
250
+ }
251
+ #hasLinkRisk(region) {
252
+ for (const [row, state] of this.#links) {
253
+ if (region !== undefined && (row < region.rowStart || row > region.rowEnd))
254
+ continue;
255
+ if (state.ranges.length > 0)
256
+ return true;
257
+ }
258
+ return false;
259
+ }
260
+ #hasUnsafeRows(region) {
261
+ for (const [row, state] of this.#links) {
262
+ if (row >= region.rowStart && row <= region.rowEnd && !state.replaySafe)
263
+ return true;
264
+ }
265
+ return false;
266
+ }
267
+ #completeCleanupFrame(parsed) {
268
+ const desired = parsed.structuralPrefix.length === 0 ? new Map(this.#rows) : new Map();
269
+ for (const row of parsed.rows)
270
+ desired.set(row.row, row.content);
271
+ if (desired.size !== this.rows)
272
+ return null;
273
+ const rows = [];
274
+ for (let row = 1; row <= this.rows; row += 1) {
275
+ const content = desired.get(row);
276
+ if (content === undefined)
277
+ return null;
278
+ const state = content === this.#rows.get(row) ? this.#links.get(row) : this.options.inspectHyperlinks(content);
279
+ if (state?.replaySafe !== true || state.width > this.columns)
280
+ return null;
281
+ rows.push({ row, content, segment: `\u001b[${row};1H\u001b[2K${content}` });
282
+ }
283
+ return { structuralPrefix: "\u001b[2J", rows, cursorSuffix: parsed.cursorSuffix };
284
+ }
285
+ #scheduleCleanupRecovery() {
286
+ if (!this.hyperlinkCleanupPending || this.#recoveryRevision === this.#cleanupRevision
287
+ || this.options.onHyperlinkCleanupRequired === undefined)
288
+ return;
289
+ this.#recoveryRevision = this.#cleanupRevision;
290
+ const epoch = this.#epoch;
291
+ // Concurrency: force only after Pi finishes updating its previous-screen
292
+ // state. One recovery per revision avoids loops on unsupported image frames.
293
+ queueMicrotask(() => {
294
+ if (epoch === this.#epoch && this.hyperlinkCleanupPending)
295
+ this.options.onHyperlinkCleanupRequired?.();
296
+ });
297
+ }
298
+ #invalidatePresentation() {
299
+ if (this.#hasLinkRisk() && !this.hyperlinkCleanupPending)
300
+ this.requestHyperlinkCleanup();
301
+ this.#invalidateRows();
170
302
  }
171
303
  #invalidateRows() {
172
304
  this.#rows.clear();
305
+ this.#links.clear();
173
306
  this.#cacheWidth = 0;
174
307
  this.#cacheHeight = 0;
175
308
  }
@@ -183,7 +316,8 @@ function parsePinnedFullscreenWrite(data) {
183
316
  const body = data.slice(BEGIN_SYNCHRONIZED_OUTPUT.length, suffix.index);
184
317
  const matches = [...body.matchAll(ROW_MARKER)];
185
318
  if (matches.length === 0)
186
- return null;
319
+ return body.length === 0
320
+ ? { structuralPrefix: "", rows: [], cursorSuffix: data.slice(suffix.index) } : null;
187
321
  const structuralPrefix = body.slice(0, matches[0].index);
188
322
  if (structuralPrefix !== "" && structuralPrefix !== "\u001b[2J")
189
323
  return null;
@@ -203,14 +337,16 @@ function parsePinnedFullscreenWrite(data) {
203
337
  }
204
338
  return { structuralPrefix, rows, cursorSuffix: data.slice(suffix.index) };
205
339
  }
206
- function hasUnsafeTerminalContent(parsed) {
340
+ function hasUnsafeTerminalContent(parsed, inspect, linkRegion) {
207
341
  const data = parsed.rows.map(row => row.content).join("");
208
342
  if (data.includes("\u001b_G") || data.includes("\u001bPq") || data.includes("\u001b[2J"))
209
343
  return true;
210
- for (const match of data.matchAll(OSC8))
211
- if ((match[1] ?? "").length > 0)
212
- return true;
213
- return false;
344
+ return parsed.rows.some(row => {
345
+ if (linkRegion !== undefined && (row.row < linkRegion.rowStart || row.row > linkRegion.rowEnd))
346
+ return false;
347
+ const state = inspect(row.content);
348
+ return !state.replaySafe || state.ranges.length > 0;
349
+ });
214
350
  }
215
351
  function buildDamageWrite(parsed, descriptor, paintedRows, previousRows) {
216
352
  const transcript = descriptor.transcript;
@@ -227,6 +363,10 @@ function buildDamageWrite(parsed, descriptor, paintedRows, previousRows) {
227
363
  }).join("");
228
364
  return `${BEGIN_SYNCHRONIZED_OUTPUT}${regionShift}${rowPaint}${parsed.cursorSuffix}`;
229
365
  }
366
+ /** Keeps the clear and all current rows in the same synchronized transaction. */
367
+ function buildCompleteWrite(parsed) {
368
+ return `${BEGIN_SYNCHRONIZED_OUTPUT}${parsed.structuralPrefix}${parsed.rows.map(row => row.segment).join("")}${parsed.cursorSuffix}`;
369
+ }
230
370
  function buildClearlessWrite(parsed) {
231
371
  return `${BEGIN_SYNCHRONIZED_OUTPUT}${parsed.rows.map(row => row.segment).join("")}${parsed.cursorSuffix}`;
232
372
  }