@timurproko/a1 0.1.8-dev.136 → 0.1.8-dev.139

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 (81) hide show
  1. package/README.md +2 -4
  2. package/dist/cli/capabilities.d.ts +2 -11
  3. package/dist/cli/capabilities.js +1 -1
  4. package/dist/cli/dispatch.js +3 -6
  5. package/dist/composition/owned-ui.js +19 -1
  6. package/dist/contracts/owned-ui/model.d.ts +21 -0
  7. package/dist/contracts/owned-ui/validation.js +3 -0
  8. package/dist/features/launch/development-launch.js +11 -3
  9. package/dist/features/launch/index.d.ts +0 -1
  10. package/dist/features/launch/index.js +0 -1
  11. package/dist/features/launch/intent.d.ts +3 -2
  12. package/dist/features/launch/intent.js +1 -2
  13. package/dist/features/launch/prepare-launch.d.ts +0 -2
  14. package/dist/features/launch/prepare-launch.js +2 -9
  15. package/dist/features/launch/profile-paths.d.ts +1 -2
  16. package/dist/features/launch/profile-paths.js +2 -8
  17. package/dist/features/launch/runtime-selection.d.ts +2 -5
  18. package/dist/features/owned-ui/settings-app.js +36 -13
  19. package/dist/foundation/lifecycle/model.d.ts +1 -1
  20. package/dist/foundation/lifecycle/model.js +1 -1
  21. package/dist/foundation/release/bootstrap.d.ts +1 -3
  22. package/dist/foundation/release/bootstrap.js +12 -4
  23. package/dist/foundation/storage/control-store.js +37 -3
  24. package/dist/integrations/pi/components/shell-footer-status.d.ts +1 -1
  25. package/dist/integrations/pi/components/shell-footer-status.js +14 -5
  26. package/dist/integrations/pi/components/shell-presenters-transcript.d.ts +3 -2
  27. package/dist/integrations/pi/components/shell-presenters-transcript.js +9 -8
  28. package/dist/integrations/pi/components/submitted-prompt-adapter.d.ts +13 -0
  29. package/dist/integrations/pi/components/submitted-prompt-adapter.js +27 -0
  30. package/dist/integrations/pi/engine/adapter.js +16 -2
  31. package/dist/integrations/pi/engine/session-integration.js +5 -2
  32. package/dist/integrations/pi/owned-ui/session-shell-root.d.ts +17 -2
  33. package/dist/integrations/pi/owned-ui/session-shell-root.js +451 -42
  34. package/dist/integrations/pi/owned-ui/session-shell.js +116 -13
  35. package/dist/integrations/pi/tui-runtime/adapter.d.ts +2 -1
  36. package/dist/integrations/pi/tui-runtime/adapter.js +51 -2
  37. package/dist/integrations/pi/tui-runtime/contracts.d.ts +2 -0
  38. package/dist/native/darwin-arm64/manifest.json +1 -1
  39. package/dist/native/linux-x64/manifest.json +1 -1
  40. package/dist/native/win32-x64/manifest.json +2 -2
  41. package/dist/native/win32-x64/process-guardian.exe +0 -0
  42. package/dist/product-identity.d.ts +2 -2
  43. package/dist/product-identity.js +3 -4
  44. package/dist/product-identity.json +3 -5
  45. package/dist/ui/components/index.d.ts +3 -0
  46. package/dist/ui/components/index.js +3 -0
  47. package/dist/ui/components/mouse.d.ts +10 -0
  48. package/dist/ui/components/mouse.js +23 -0
  49. package/dist/ui/components/prompt-row.d.ts +14 -0
  50. package/dist/ui/components/prompt-row.js +20 -0
  51. package/dist/ui/components/scrollbar.d.ts +26 -0
  52. package/dist/ui/components/scrollbar.js +35 -0
  53. package/dist/ui/components/spans.d.ts +6 -0
  54. package/dist/ui/components/spans.js +36 -0
  55. package/dist/ui/components/submitted-prompt.d.ts +23 -0
  56. package/dist/ui/components/submitted-prompt.js +50 -0
  57. package/dist/ui/components/text-selection.d.ts +53 -0
  58. package/dist/ui/components/text-selection.js +168 -0
  59. package/dist/ui/components/text.d.ts +14 -0
  60. package/dist/ui/components/text.js +40 -0
  61. package/dist/ui/components/transcript-viewport.d.ts +98 -0
  62. package/dist/ui/components/transcript-viewport.js +353 -0
  63. package/dist/ui/settings/declarations.d.ts +8 -1
  64. package/dist/ui/settings/declarations.js +33 -5
  65. package/dist/ui/settings/migrations.d.ts +1 -1
  66. package/dist/ui/settings/migrations.js +12 -2
  67. package/dist/ui/settings/sections.js +15 -8
  68. package/dist/ui/settings/session.d.ts +2 -0
  69. package/dist/ui/settings/session.js +7 -5
  70. package/docs/architecture/boundaries.md +2 -2
  71. package/docs/architecture/project-structure.md +1 -1
  72. package/docs/architecture/resource-and-data-policy.md +1 -1
  73. package/docs/architecture/toolchain.md +5 -5
  74. package/docs/architecture/ui-reference-provenance.md +11 -2
  75. package/docs/features/launch-profiles.md +7 -14
  76. package/docs/manual-launch-instance-acceptance.md +12 -35
  77. package/docs/manual-owned-ui-checkpoint.md +2 -3
  78. package/docs/manual-pi-boundary-candidate.md +4 -5
  79. package/package.json +1 -2
  80. package/dist/features/launch/profiles.d.ts +0 -12
  81. package/dist/features/launch/profiles.js +0 -26
@@ -1,4 +1,4 @@
1
- import { MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseMouseInput } from "../../../ui/components/index.js";
1
+ import { TranscriptViewport, backgroundSgrSpan, composeSubmittedPromptRows, displayWidth, formatSubmittedPromptTime, overlaySpan, submittedPromptLayout, routeMouseInput, scrollForTrackPage, scrollForThumbRow, scrollbarSelectionRows, scrollbarWheelRows, } from "../../../ui/components/index.js";
2
2
  import { PINNED_PI_HIDDEN_COMMAND_NAMES, PINNED_PI_WORKFLOW_COMMAND_NAMES, } from "../engine/index.js";
3
3
  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";
4
4
  import { PiTuiRuntimeAdapter, } from "../tui-runtime/index.js";
@@ -10,6 +10,8 @@ export class OwnedUiSessionShellRoot {
10
10
  #transcript = new Map();
11
11
  #blocksById = new Map();
12
12
  #renderedRows = new Map();
13
+ /** Flattened transcript rows reused by wheel, rail, jump, and selection frames. */
14
+ #documentLayouts = new Map();
13
15
  #themeUnsubscribe;
14
16
  #transcriptOrder = [];
15
17
  #view;
@@ -17,6 +19,20 @@ export class OwnedUiSessionShellRoot {
17
19
  #footer;
18
20
  #queued;
19
21
  #extensionRenderers;
22
+ #customViewport;
23
+ #submittedPromptComposer;
24
+ #viewport = new TranscriptViewport();
25
+ #viewportConfig = {
26
+ scrollbarAppearance: "hover",
27
+ scrollbarStyle: "thin",
28
+ scrollbarSpeed: "normal",
29
+ };
30
+ #dragGrabOffset = null;
31
+ /** A left-button sequence begun in status/editor/footer chrome is swallowed. */
32
+ #dockPointerSuppressed = false;
33
+ #selectionAutoScrollTimer;
34
+ #selectionAutoScrollPointer;
35
+ #viewportActivityTimer;
20
36
  #componentRuntime;
21
37
  #toolsExpanded = false;
22
38
  #thinkingVisible = true;
@@ -34,8 +50,12 @@ export class OwnedUiSessionShellRoot {
34
50
  constructor(view, cwd, handlers, startup = {}, agentDir, extensionRenderers = {
35
51
  getMessageRenderer: () => undefined,
36
52
  getToolDefinition: () => undefined,
37
- }) {
53
+ }, sessionLayout = "pinned") {
38
54
  this.#view = view;
55
+ this.#customViewport = sessionLayout === "custom-viewport";
56
+ this.#submittedPromptComposer = this.#customViewport
57
+ ? { layout: submittedPromptLayout, compose: composeSubmittedPromptRows }
58
+ : undefined;
39
59
  this.#cwd = cwd;
40
60
  this.#extensionRenderers = extensionRenderers;
41
61
  this.#componentRuntime = handlers;
@@ -43,7 +63,7 @@ export class OwnedUiSessionShellRoot {
43
63
  this.resources = createPiShellLoadedResources(startup.resources ?? [], startup.expanded ?? false);
44
64
  this.#status = createPiShellStatus(view, handlers);
45
65
  this.#footer = createPiShellFooter(this.#viewWithExtensionStatuses(view), cwd);
46
- this.#queued = createPiQueuedInputStatus(view.editor.queuedSubmissions);
66
+ this.#queued = createPiQueuedInputStatus(view.editor.queuedSubmissions, this.#customViewport ? "custom-viewport" : "pinned");
47
67
  this.editor = createPiShellEditor({
48
68
  ...handlers,
49
69
  cwd,
@@ -59,9 +79,16 @@ export class OwnedUiSessionShellRoot {
59
79
  this.#syncTranscript(view.transcript);
60
80
  // Colours come from the active theme, so rendered rows outlive their revision only
61
81
  // until the theme under them changes.
62
- this.#themeUnsubscribe = onPiThemeChange(() => this.#renderedRows.clear());
82
+ this.#themeUnsubscribe = onPiThemeChange(() => {
83
+ this.#renderedRows.clear();
84
+ this.#documentLayouts.clear();
85
+ });
63
86
  }
64
87
  update(view) {
88
+ if (view.diagnostics.length !== this.#view.diagnostics.length
89
+ || view.diagnostics.some((diagnostic, index) => diagnostic.sequence !== this.#view.diagnostics[index]?.sequence)) {
90
+ this.#documentLayouts.clear();
91
+ }
65
92
  this.#view = view;
66
93
  this.#status.update(view);
67
94
  this.#footer.update(this.#viewWithExtensionStatuses(view));
@@ -80,7 +107,7 @@ export class OwnedUiSessionShellRoot {
80
107
  this.#blocksById.set(block.id, block);
81
108
  const component = this.#transcript.get(block.id);
82
109
  if (component === undefined) {
83
- const created = createPiShellTranscriptComponent(block, this.#cwd, this.#extensionRenderers);
110
+ const created = createPiShellTranscriptComponent(block, this.#cwd, this.#extensionRenderers, this.#submittedPromptComposer);
84
111
  created.setExpanded(this.#toolsExpanded);
85
112
  this.#transcript.set(block.id, created);
86
113
  this.#transcriptOrder.push(block.id);
@@ -91,18 +118,307 @@ export class OwnedUiSessionShellRoot {
91
118
  // One chunk touches one block, and the updated component tracks its own dirtiness.
92
119
  // Invalidating the whole shell here would re-wrap the entire transcript per chunk.
93
120
  this.#renderedRows.delete(block.id);
121
+ this.#documentLayouts.clear();
94
122
  }
95
123
  render(width) {
124
+ if (!this.#customViewport)
125
+ return [...this.#renderDocument(width), ...this.#renderDock(width)];
126
+ const height = Math.max(0, this.#componentRuntime.getRows());
127
+ const dock = this.#renderDockLayout(width);
128
+ const availableWithoutStatus = Math.max(0, height - Math.min(height, dock.rowsWithoutStatus.length));
129
+ // Ordinary transcript content uses the full terminal width. The rail is an
130
+ // overlay, not a lost wrapping cell. Submitted prompts alone retain the
131
+ // intentional final gutter after their right-aligned timestamp.
132
+ const documentWidth = width;
133
+ let document = this.#renderDocumentLayout(documentWidth);
134
+ const statusRows = dock.statusRows;
135
+ // v2 pins Working only while the complete transcript still fits. Once the
136
+ // transcript overflows, status joins the scrollable document tail so it
137
+ // naturally leaves the screen when the user scrolls up.
138
+ // Pi presents pending messages before Working. Keep status in the dock while
139
+ // a queue exists so moving overflowing status into the document cannot
140
+ // reverse that semantic order; it returns to v2 scrolling once the queue clears.
141
+ const pinStatus = this.#view.editor.queuedSubmissions.length > 0
142
+ || document.rows.length + statusRows.length <= availableWithoutStatus;
143
+ const dockRows = pinStatus ? dock.rows : dock.rowsWithoutStatus;
144
+ if (!pinStatus)
145
+ document = { ...document, rows: [...document.rows, ...statusRows] };
146
+ this.#viewport.setConfig(this.#viewportConfig);
147
+ return this.#viewport.compose({
148
+ documentRows: document.rows,
149
+ dockRows,
150
+ promptAnchors: document.promptAnchors,
151
+ width,
152
+ height,
153
+ // Anchor above the stable editor/widget/footer group. Queued input,
154
+ // working states, and transient notifications may grow above it without
155
+ // moving the floating control up and down the terminal.
156
+ bottomControlRow: Math.max(0, height - Math.min(height, dock.stableBottomRows) - 1),
157
+ theme: {
158
+ // Match the v2 rail: a continuously dim track and an accent thumb at
159
+ // rest, thickening on hover/drag through the presentation glyph. Reset
160
+ // inherited text decorations so dim transcript rows cannot dull the
161
+ // thumb; the row background deliberately remains intact.
162
+ track: text => `${SCROLLBAR_CELL_RESET}${piTheme().fg("dim", text)}`,
163
+ thumb: text => `${SCROLLBAR_CELL_RESET}${piTheme().fg("accent", text)}`,
164
+ sticky: (text, hovered) => piTheme().bg(hovered ? "selectedBg" : "toolPendingBg", piTheme().fg("text", withoutTerminalBackground(text))),
165
+ quietSticky: text => `\u001b[2m${text.replace(/\u001b\[(?:0|22)m/g, "$&\u001b[2m")}\u001b[22m`,
166
+ bottomControl: (text, hovered) => piTheme().bg(hovered ? "selectedBg" : "toolPendingBg", piTheme().fg("text", text)),
167
+ // Selection paints only the familiar dark-blue background. Source
168
+ // foreground roles (including links, warnings, muted text, and bold
169
+ // intensity) remain exactly as rendered instead of being inverted.
170
+ selection: (line, from, to) => backgroundSgrSpan(line, from, to, "\u001b[48;2;38;79;120m", "\u001b[49m"),
171
+ },
172
+ }).rows;
173
+ }
174
+ setViewportConfig(config) {
175
+ if (config.scrollbarAppearance !== this.#viewportConfig.scrollbarAppearance)
176
+ this.#documentLayouts.clear();
177
+ this.#viewportConfig = config;
178
+ this.#viewport.setConfig(config);
179
+ this.#componentRuntime.requestRender();
180
+ }
181
+ resumeViewportFollowing() {
182
+ if (!this.#customViewport)
183
+ return;
184
+ this.#stopSelectionAutoScroll();
185
+ const selectionChanged = this.#viewport.clearSelection();
186
+ const scrolled = this.#viewport.scrollToEnd();
187
+ if (scrolled)
188
+ this.#scheduleViewportActivityExpiry();
189
+ if (scrolled || selectionChanged)
190
+ this.#componentRuntime.requestRender();
191
+ }
192
+ noteCompletedAssistantMessage() {
193
+ if (!this.#customViewport || !this.#viewport.noteNewMessage())
194
+ return;
195
+ this.#componentRuntime.requestRender();
196
+ }
197
+ resetViewport() {
198
+ if (this.#viewportActivityTimer !== undefined)
199
+ clearTimeout(this.#viewportActivityTimer);
200
+ this.#viewportActivityTimer = undefined;
201
+ this.#stopSelectionAutoScroll();
202
+ this.#viewport.reset();
203
+ }
204
+ clearViewportPointerState() {
205
+ if (this.#viewportActivityTimer !== undefined)
206
+ clearTimeout(this.#viewportActivityTimer);
207
+ this.#viewportActivityTimer = undefined;
208
+ this.#dragGrabOffset = null;
209
+ this.#dockPointerSuppressed = false;
210
+ this.#stopSelectionAutoScroll();
211
+ this.#viewport.clearSelection();
212
+ this.#viewport.clearTransient();
213
+ }
214
+ handleViewportPreInput(data, allowWheel = true, now = Date.now()) {
215
+ if (!this.#customViewport || this.#viewport.frame === null)
216
+ return { data, consumed: false };
217
+ if (data === "\u0003") {
218
+ const copyText = this.#viewport.selectedText();
219
+ if (copyText !== null && copyText.length > 0) {
220
+ this.#viewport.clearSelection();
221
+ this.#componentRuntime.requestRender();
222
+ return { data: "", consumed: true, copyText };
223
+ }
224
+ }
225
+ if (allowWheel && ALT_END_INPUTS.has(data) && !this.#viewport.followingEnd) {
226
+ this.#viewport.scrollToEnd(now);
227
+ this.#scheduleViewportActivityExpiry();
228
+ this.#componentRuntime.requestRender();
229
+ return { data: "", consumed: true };
230
+ }
231
+ if (allowWheel && ALT_HOME_INPUTS.has(data)) {
232
+ if (this.#viewport.scrollToPreviousPrompt(now)) {
233
+ this.#scheduleViewportActivityExpiry();
234
+ this.#componentRuntime.requestRender();
235
+ }
236
+ return { data: "", consumed: true };
237
+ }
238
+ const frame = this.#viewport.frame;
239
+ let repaint = false;
240
+ let activity = false;
241
+ const routed = routeMouseInput(data, event => {
242
+ const hits = frame.hits;
243
+ const overRail = hits.rail !== null
244
+ && event.column === hits.rail.column
245
+ && event.row >= hits.rail.rowStart
246
+ && event.row < hits.rail.rowStart + hits.rail.trackHeight;
247
+ const overSticky = hits.sticky !== null && event.row === hits.sticky.row && event.column <= hits.sticky.width;
248
+ const overBottom = hits.bottom !== null && event.row === hits.bottom.row
249
+ && event.column >= hits.bottom.columnStart && event.column <= hits.bottom.columnEnd;
250
+ if (event.kind === "motion") {
251
+ this.#viewport.setRailHovered(overRail);
252
+ this.#viewport.setStickyHovered(overSticky);
253
+ this.#viewport.setBottomHovered(overBottom);
254
+ repaint = true;
255
+ if (this.#dockPointerSuppressed)
256
+ return true;
257
+ // Once a left-button selection starts, its release—not the button bits
258
+ // on an intermediate motion report—ends ownership. Some terminals emit
259
+ // no-button motion while busy frames are being coalesced; accepting it
260
+ // keeps the drag continuous instead of apparently freezing.
261
+ if (this.#viewport.selectionActive) {
262
+ this.#viewport.extendSelection(event.column, event.row, now, false);
263
+ this.#updateSelectionAutoScroll(event.column, event.row, hits.viewportHeight);
264
+ activity = true;
265
+ return true;
266
+ }
267
+ if (this.#dragGrabOffset !== null && hits.rail !== null) {
268
+ const target = scrollForThumbRow(hits.rail.geometry, event.row - hits.rail.rowStart - this.#dragGrabOffset);
269
+ this.#viewport.scrollTo(target, now);
270
+ activity = true;
271
+ return true;
272
+ }
273
+ return overRail || overSticky || overBottom;
274
+ }
275
+ if (event.kind === "wheel-up" || event.kind === "wheel-down") {
276
+ if (!allowWheel || event.row < 1 || event.row > hits.viewportHeight)
277
+ return false;
278
+ const distance = scrollbarWheelRows(this.#viewportConfig.scrollbarSpeed);
279
+ this.#viewport.scrollBy(event.kind === "wheel-up" ? -distance : distance, now);
280
+ activity = true;
281
+ repaint = true;
282
+ return true;
283
+ }
284
+ if (event.kind === "press") {
285
+ if (event.button !== 0)
286
+ return false;
287
+ this.#stopSelectionAutoScroll();
288
+ if (this.#viewport.clearSelection())
289
+ repaint = true;
290
+ if (overBottom) {
291
+ this.#viewport.scrollToEnd(now);
292
+ activity = true;
293
+ repaint = true;
294
+ return true;
295
+ }
296
+ if (overSticky && hits.sticky !== null) {
297
+ this.#viewport.scrollTo(hits.sticky.target, now);
298
+ activity = true;
299
+ repaint = true;
300
+ return true;
301
+ }
302
+ if (overRail && hits.rail !== null) {
303
+ const trackRow = event.row - hits.rail.rowStart;
304
+ const geometry = hits.rail.geometry;
305
+ if (trackRow >= geometry.thumbTop && trackRow < geometry.thumbTop + geometry.thumbHeight) {
306
+ this.#dragGrabOffset = trackRow - geometry.thumbTop;
307
+ this.#viewport.setRailDragging(true);
308
+ }
309
+ else {
310
+ this.#viewport.scrollTo(scrollForTrackPage(geometry, trackRow, frame.scrollTop, hits.viewportHeight), now);
311
+ }
312
+ activity = true;
313
+ repaint = true;
314
+ return true;
315
+ }
316
+ if (event.row > hits.viewportHeight) {
317
+ // Status, editor, widgets, and footer are controls rather than
318
+ // transcript. Consume the complete drag so Pi's fullscreen fallback
319
+ // selection cannot paint those rows.
320
+ this.#dockPointerSuppressed = true;
321
+ return true;
322
+ }
323
+ if (event.row >= 1 && event.row <= hits.viewportHeight && event.column <= frame.contentWidth) {
324
+ this.#viewport.pressSelection(event.column, event.row, now);
325
+ repaint = true;
326
+ return true;
327
+ }
328
+ return false;
329
+ }
330
+ if (event.kind === "release") {
331
+ if (this.#viewport.releaseSelection()) {
332
+ this.#stopSelectionAutoScroll();
333
+ repaint = true;
334
+ return true;
335
+ }
336
+ if (this.#dragGrabOffset !== null) {
337
+ this.#dragGrabOffset = null;
338
+ this.#viewport.setRailDragging(false);
339
+ repaint = true;
340
+ return true;
341
+ }
342
+ if (this.#dockPointerSuppressed) {
343
+ this.#dockPointerSuppressed = false;
344
+ return true;
345
+ }
346
+ }
347
+ return false;
348
+ });
349
+ if (activity)
350
+ this.#scheduleViewportActivityExpiry();
351
+ if (repaint)
352
+ this.#componentRuntime.requestRender();
353
+ return routed;
354
+ }
355
+ #updateSelectionAutoScroll(column, row, viewportHeight) {
356
+ const beyondEdge = row <= 1 || row > viewportHeight;
357
+ if (!beyondEdge) {
358
+ this.#stopSelectionAutoScroll();
359
+ return;
360
+ }
361
+ this.#selectionAutoScrollPointer = { column, row };
362
+ if (this.#selectionAutoScrollTimer !== undefined)
363
+ return;
364
+ this.#selectionAutoScrollTimer = setTimeout(() => this.#selectionAutoScrollTick(), SELECTION_AUTO_SCROLL_INTERVAL_MS);
365
+ this.#selectionAutoScrollTimer.unref?.();
366
+ }
367
+ #selectionAutoScrollTick() {
368
+ this.#selectionAutoScrollTimer = undefined;
369
+ const pointer = this.#selectionAutoScrollPointer;
370
+ if (pointer === undefined || !this.#viewport.selectionActive) {
371
+ this.#stopSelectionAutoScroll();
372
+ return;
373
+ }
374
+ const before = this.#viewport.scrollTop;
375
+ const rowsPerTick = scrollbarSelectionRows(this.#viewportConfig.scrollbarSpeed);
376
+ for (let row = 0; row < rowsPerTick; row += 1) {
377
+ const previous = this.#viewport.scrollTop;
378
+ this.#viewport.extendSelection(pointer.column, pointer.row);
379
+ if (this.#viewport.scrollTop === previous)
380
+ break;
381
+ }
382
+ if (this.#viewport.scrollTop === before) {
383
+ this.#stopSelectionAutoScroll();
384
+ return;
385
+ }
386
+ this.#componentRuntime.requestRender();
387
+ this.#scheduleViewportActivityExpiry();
388
+ this.#selectionAutoScrollTimer = setTimeout(() => this.#selectionAutoScrollTick(), SELECTION_AUTO_SCROLL_INTERVAL_MS);
389
+ this.#selectionAutoScrollTimer.unref?.();
390
+ }
391
+ #stopSelectionAutoScroll() {
392
+ if (this.#selectionAutoScrollTimer !== undefined)
393
+ clearTimeout(this.#selectionAutoScrollTimer);
394
+ this.#selectionAutoScrollTimer = undefined;
395
+ this.#selectionAutoScrollPointer = undefined;
396
+ }
397
+ #scheduleViewportActivityExpiry() {
398
+ if (this.#viewportActivityTimer !== undefined)
399
+ clearTimeout(this.#viewportActivityTimer);
400
+ this.#viewportActivityTimer = setTimeout(() => {
401
+ this.#viewportActivityTimer = undefined;
402
+ this.#componentRuntime.requestRender();
403
+ }, 925);
404
+ this.#viewportActivityTimer.unref?.();
405
+ }
406
+ #renderDock(width) {
407
+ return this.#renderDockLayout(width).rows;
408
+ }
409
+ #renderDockLayout(width) {
96
410
  const queued = this.#view.editor.queuedSubmissions.length === 0 ? [] : this.#queued.render(width);
97
- return [
98
- ...this.#renderDocument(width),
99
- ...queued,
100
- ...this.#renderStatus(width),
101
- ...this.#renderWidgets("aboveEditor", width),
102
- ...this.#inputSurface.render(width),
103
- ...this.#renderWidgets("belowEditor", width),
104
- ...this.#renderFooter(width),
105
- ];
411
+ const statusRows = this.#renderStatus(width);
412
+ const aboveWidgets = this.#renderWidgets("aboveEditor", width);
413
+ const input = this.#inputSurface.render(width);
414
+ const belowWidgets = this.#renderWidgets("belowEditor", width);
415
+ const footer = this.#renderFooter(width);
416
+ return {
417
+ rows: [...queued, ...statusRows, ...aboveWidgets, ...input, ...belowWidgets, ...footer],
418
+ rowsWithoutStatus: [...queued, ...aboveWidgets, ...input, ...belowWidgets, ...footer],
419
+ statusRows,
420
+ stableBottomRows: aboveWidgets.length + input.length + belowWidgets.length + footer.length,
421
+ };
106
422
  }
107
423
  layoutRoot() {
108
424
  const document = layoutPort(width => this.#renderDocument(width), () => this.invalidate());
@@ -154,23 +470,66 @@ export class OwnedUiSessionShellRoot {
154
470
  };
155
471
  }
156
472
  #renderDocument(width) {
157
- const transcript = this.#transcriptOrder.flatMap((id, index) => {
158
- const block = this.#blocksById.get(id);
159
- if (!this.#thinkingVisible && block?.kind === "thinking")
160
- return [];
161
- const rows = this.#blockRows(id, block, width);
162
- if (index > 0 && block?.kind === "user")
163
- return ["", ...rows];
164
- return rows;
165
- });
473
+ return this.#renderDocumentLayout(width).rows;
474
+ }
475
+ #renderDocumentLayout(width) {
476
+ // Extension headers may animate independently of transcript revisions, so
477
+ // only the stable built-in document participates in this frame cache.
478
+ const cached = this.#extensionHeader === null ? this.#documentLayouts.get(width) : undefined;
479
+ if (cached !== undefined)
480
+ return cached;
166
481
  const diagnostics = this.#view.diagnostics;
167
482
  const startupRows = diagnostics
168
483
  .filter(diagnostic => diagnostic.code === "engine-startup")
169
484
  .flatMap(diagnostic => renderPiShellStartupDiagnostic(diagnostic, width));
170
- const packageUpdateRows = diagnostics
485
+ const resourceRows = this.#customViewport ? [] : [...this.resources.render(width)];
486
+ if (resourceRows.at(-1) === "")
487
+ resourceRows.pop();
488
+ const headerRows = this.#extensionHeader !== null
489
+ ? this.#extensionHeader.render(width)
490
+ : this.#customViewport ? [] : this.header.render(width);
491
+ const rows = [
492
+ ...startupRows,
493
+ ...headerRows,
494
+ ...resourceRows,
495
+ ];
496
+ const promptAnchors = [];
497
+ for (let index = 0; index < this.#transcriptOrder.length; index += 1) {
498
+ const id = this.#transcriptOrder[index];
499
+ const block = this.#blocksById.get(id);
500
+ if (!this.#thinkingVisible && block?.kind === "thinking")
501
+ continue;
502
+ const blockWidth = this.#customViewport
503
+ && block?.kind === "user"
504
+ && this.#viewportConfig.scrollbarAppearance !== "hidden"
505
+ && width > 1
506
+ ? width - 1
507
+ : width;
508
+ const blockRows = this.#blockRows(id, block, blockWidth);
509
+ if (block?.kind === "user") {
510
+ // The first natural prompt gets one breathing row at the document top.
511
+ // Once scrolling advances, the prompt itself reaches row zero and then
512
+ // becomes sticky there, so the spacer is never pinned with it.
513
+ if (this.#customViewport && index === 0)
514
+ rows.push("");
515
+ else if (index > 0)
516
+ rows.push("");
517
+ }
518
+ const firstRow = rows.length;
519
+ rows.push(...blockRows);
520
+ if (block?.kind === "user" && blockRows[0] !== undefined) {
521
+ promptAnchors.push({
522
+ id: block.id,
523
+ firstRow,
524
+ lastRow: Math.max(firstRow, rows.length - 1),
525
+ sourceRow: pinnedPromptSourceRow(block, blockRows[0], blockWidth),
526
+ });
527
+ }
528
+ }
529
+ rows.push(...diagnostics
171
530
  .filter(diagnostic => diagnostic.code === "package-updates")
172
- .flatMap(diagnostic => renderPiShellPackageUpdateNotice(diagnostic.message.split("\n").filter(line => line.startsWith("- ")).map(line => line.slice(2)), width));
173
- const diagnosticRows = diagnostics
531
+ .flatMap(diagnostic => renderPiShellPackageUpdateNotice(diagnostic.message.split("\n").filter(line => line.startsWith("- ")).map(line => line.slice(2)), width)));
532
+ rows.push(...diagnostics
174
533
  .filter(diagnostic => diagnostic.code !== "engine-startup" && diagnostic.code !== "package-updates")
175
534
  .slice(-3)
176
535
  .flatMap(diagnostic => renderPiShellTranscriptBlock({
@@ -181,18 +540,15 @@ export class OwnedUiSessionShellRoot {
181
540
  title: diagnostic.code,
182
541
  text: diagnostic.message,
183
542
  payload: {},
184
- }, width, this.#cwd));
185
- const resourceRows = [...this.resources.render(width)];
186
- if (resourceRows.at(-1) === "")
187
- resourceRows.pop();
188
- return [
189
- ...startupRows,
190
- ...(this.#extensionHeader ?? this.header).render(width),
191
- ...resourceRows,
192
- ...transcript,
193
- ...packageUpdateRows,
194
- ...diagnosticRows,
195
- ];
543
+ }, width, this.#cwd)));
544
+ const layout = { rows: Object.freeze(rows), promptAnchors: Object.freeze(promptAnchors) };
545
+ if (this.#extensionHeader === null) {
546
+ this.#documentLayouts.set(width, layout);
547
+ // The custom viewport commonly probes full width and reserved-rail width.
548
+ while (this.#documentLayouts.size > 2)
549
+ this.#documentLayouts.delete(this.#documentLayouts.keys().next().value);
550
+ }
551
+ return layout;
196
552
  }
197
553
  /**
198
554
  * Rows for one transcript block. A finalized block renders once for a given revision
@@ -206,11 +562,20 @@ export class OwnedUiSessionShellRoot {
206
562
  return [];
207
563
  if (block === undefined || block.status !== "finalized")
208
564
  return component.render(width);
209
- const cached = this.#renderedRows.get(id);
210
- if (cached && cached.width === width && cached.revision === block.revision)
565
+ let byWidth = this.#renderedRows.get(id);
566
+ const cached = byWidth?.get(width);
567
+ if (cached?.revision === block.revision)
211
568
  return cached.rows;
212
569
  const rows = component.render(width);
213
- this.#renderedRows.set(id, { width, revision: block.revision, rows });
570
+ if (byWidth === undefined) {
571
+ byWidth = new Map();
572
+ this.#renderedRows.set(id, byWidth);
573
+ }
574
+ byWidth.set(width, { revision: block.revision, rows });
575
+ // Bare A1 probes full width before reserving the overflowing rail column;
576
+ // retain both widths without turning resize history into an unbounded cache.
577
+ while (byWidth.size > 2)
578
+ byWidth.delete(byWidth.keys().next().value);
214
579
  return rows;
215
580
  }
216
581
  transcriptComponent(id) {
@@ -222,6 +587,7 @@ export class OwnedUiSessionShellRoot {
222
587
  && this.#transcriptOrder.at(-1) === previousId
223
588
  && this.#workflowStatusAnchors.get(previousId) === this.#view.transcript.length) {
224
589
  this.#workflowStatusMessages.set(previousId, message);
590
+ this.#documentLayouts.clear();
225
591
  this.invalidate();
226
592
  return;
227
593
  }
@@ -313,6 +679,7 @@ export class OwnedUiSessionShellRoot {
313
679
  }
314
680
  toggleThinkingVisibility() {
315
681
  this.#thinkingVisible = !this.#thinkingVisible;
682
+ this.#documentLayouts.clear();
316
683
  this.invalidate();
317
684
  }
318
685
  get toolsExpanded() {
@@ -335,6 +702,7 @@ export class OwnedUiSessionShellRoot {
335
702
  if (this.#extensionHeader !== component)
336
703
  this.#extensionHeader?.dispose?.();
337
704
  this.#extensionHeader = component;
705
+ this.#documentLayouts.clear();
338
706
  this.invalidate();
339
707
  }
340
708
  setExtensionFooter(component) {
@@ -366,6 +734,7 @@ export class OwnedUiSessionShellRoot {
366
734
  this.#workflowStatusMessages.clear();
367
735
  this.#transcriptOrder = this.#transcriptOrder.filter(id => !id.startsWith("workflow-status-"));
368
736
  this.#lastWorkflowStatusId = undefined;
737
+ this.#documentLayouts.clear();
369
738
  this.invalidate();
370
739
  }
371
740
  resetExtensionUi() {
@@ -382,6 +751,7 @@ export class OwnedUiSessionShellRoot {
382
751
  this.#footer.update(this.#viewWithExtensionStatuses(this.#view));
383
752
  // An extension renderer may have drawn transcript blocks that are now unrendered by it.
384
753
  this.#renderedRows.clear();
754
+ this.#documentLayouts.clear();
385
755
  this.invalidate();
386
756
  }
387
757
  addExtensionNotification(message, type) {
@@ -401,6 +771,9 @@ export class OwnedUiSessionShellRoot {
401
771
  onBranchChange: () => () => { },
402
772
  };
403
773
  }
774
+ usesDefaultInputSurface() {
775
+ return this.#inputSurface === this.editor;
776
+ }
404
777
  setInputSurface(component, disposePrevious = true) {
405
778
  const next = component ?? this.editor;
406
779
  if (next === this.#inputSurface)
@@ -432,6 +805,7 @@ export class OwnedUiSessionShellRoot {
432
805
  this.#inputSurface.setFocused?.(focused);
433
806
  }
434
807
  dispose() {
808
+ this.clearViewportPointerState();
435
809
  this.#themeUnsubscribe();
436
810
  this.header.dispose?.();
437
811
  this.resources.dispose?.();
@@ -439,6 +813,7 @@ export class OwnedUiSessionShellRoot {
439
813
  component.dispose?.();
440
814
  this.#transcript.clear();
441
815
  this.#renderedRows.clear();
816
+ this.#documentLayouts.clear();
442
817
  if (this.#inputSurface !== this.editor)
443
818
  this.#inputSurface.dispose?.();
444
819
  this.#extensionHeader?.dispose?.();
@@ -452,6 +827,13 @@ export class OwnedUiSessionShellRoot {
452
827
  this.#queued.dispose?.();
453
828
  }
454
829
  #syncTranscript(blocks) {
830
+ const previousIds = this.#transcriptOrder.filter(id => !id.startsWith("workflow-status-"));
831
+ const transcriptChanged = previousIds.length !== blocks.length || blocks.some((block, index) => {
832
+ const previous = this.#blocksById.get(block.id);
833
+ return previousIds[index] !== block.id || previous?.revision !== block.revision;
834
+ });
835
+ if (transcriptChanged)
836
+ this.#documentLayouts.clear();
455
837
  this.#blocksById.clear();
456
838
  for (const block of blocks)
457
839
  this.#blocksById.set(block.id, block);
@@ -466,7 +848,7 @@ export class OwnedUiSessionShellRoot {
466
848
  for (const block of blocks) {
467
849
  const component = this.#transcript.get(block.id);
468
850
  if (component === undefined) {
469
- const created = createPiShellTranscriptComponent(block, this.#cwd, this.#extensionRenderers);
851
+ const created = createPiShellTranscriptComponent(block, this.#cwd, this.#extensionRenderers, this.#submittedPromptComposer);
470
852
  created.setExpanded(this.#toolsExpanded);
471
853
  this.#transcript.set(block.id, created);
472
854
  }
@@ -508,6 +890,7 @@ export class OwnedUiSessionShellRoot {
508
890
  this.#transcript.set(id, component);
509
891
  this.#workflowStatusAnchors.set(id, this.#view.transcript.length);
510
892
  this.#transcriptOrder.push(id);
893
+ this.#documentLayouts.clear();
511
894
  this.invalidate();
512
895
  return id;
513
896
  }
@@ -555,9 +938,35 @@ export class OwnedUiSessionShellRoot {
555
938
  component.setExpanded(expanded);
556
939
  // Expansion changes what a block draws without changing its revision.
557
940
  this.#renderedRows.clear();
941
+ this.#documentLayouts.clear();
558
942
  this.invalidate();
559
943
  }
560
944
  }
945
+ const SELECTION_AUTO_SCROLL_INTERVAL_MS = 30;
946
+ const ALT_END_INPUTS = new Set(["\u001b[1;3F", "\u001b[4;3~", "\u001b[8;3~"]);
947
+ const ALT_HOME_INPUTS = new Set(["\u001b[1;3H", "\u001b[1;3~", "\u001b[7;3~"]);
948
+ const SCROLLBAR_CELL_RESET = "\u001b[22;23;24;25;27;28;29;39;54;55m";
949
+ const TERMINAL_BACKGROUND = /\u001b\[(?:4[0-9]|10[0-7]|48(?:[;:][0-9;:]*)?)m/g;
950
+ /** Repaint a source prompt with viewport chrome while preserving text, links, and foreground roles. */
951
+ function withoutTerminalBackground(text) {
952
+ return text.replace(TERMINAL_BACKGROUND, "");
953
+ }
954
+ /** A pinned timestamp is content now, not secondary transcript metadata. */
955
+ function pinnedPromptSourceRow(block, sourceRow, width) {
956
+ if (typeof block.payload !== "object" || block.payload === null)
957
+ return sourceRow;
958
+ const value = block.payload.timestamp;
959
+ if (typeof value !== "number" || !Number.isFinite(value))
960
+ return sourceRow;
961
+ const timestamp = formatSubmittedPromptTime(value);
962
+ if (timestamp === null || submittedPromptLayout(width, value).timestamp === null)
963
+ return sourceRow;
964
+ const rowWidth = displayWidth(sourceRow);
965
+ const timestampWidth = displayWidth(timestamp);
966
+ if (rowWidth < timestampWidth)
967
+ return sourceRow;
968
+ return overlaySpan(sourceRow, rowWidth - timestampWidth, rowWidth, piTheme().fg("userMessageText", timestamp));
969
+ }
561
970
  function layoutPort(render, invalidate, handleInput) {
562
971
  return {
563
972
  render,