@timurproko/a1 0.2.6-dev.734 → 0.2.6-dev.736

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 (28) hide show
  1. package/dist/app/session-shell/index.d.ts +2 -2
  2. package/dist/app/session-shell/index.js +1 -1
  3. package/dist/app/session-shell/session-shell-root.d.ts +9 -1
  4. package/dist/app/session-shell/session-shell-root.js +56 -47
  5. package/dist/app/session-shell/session-shell.d.ts +37 -9
  6. package/dist/app/session-shell/session-shell.js +266 -446
  7. package/dist/app/session-shell/shell-host.d.ts +105 -0
  8. package/dist/app/session-shell/shell-host.js +523 -0
  9. package/dist/cli/session-context.js +6 -3
  10. package/dist/composition/owned-ui.js +45 -25
  11. package/dist/contracts/owned-ui/index.d.ts +1 -1
  12. package/dist/contracts/owned-ui/session-presenters.d.ts +35 -8
  13. package/dist/features/launch/index.d.ts +1 -1
  14. package/dist/features/launch/index.js +1 -1
  15. package/dist/features/launch/session-context.d.ts +1 -0
  16. package/dist/features/launch/session-context.js +1 -0
  17. package/dist/foundation/lifecycle/index.d.ts +1 -1
  18. package/dist/foundation/lifecycle/index.js +1 -1
  19. package/dist/foundation/lifecycle/session-repository-context.d.ts +8 -2
  20. package/dist/foundation/lifecycle/session-repository-context.js +200 -65
  21. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +11 -0
  22. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +18 -2
  23. package/dist/native/darwin-arm64/manifest.json +1 -1
  24. package/dist/native/linux-x64/manifest.json +1 -1
  25. package/dist/native/win32-x64/manifest.json +2 -2
  26. package/dist/native/win32-x64/process-guardian.exe +0 -0
  27. package/docs/architecture/ui-reference-provenance.md +1 -1
  28. package/package.json +1 -1
@@ -7,18 +7,13 @@ import { boundedCleanup } from "../../foundation/terminal-cleanup/terminal-reset
7
7
  import { assertOwnedUiCommand, OWNED_UI_HIDDEN_COMMAND_NAMES } from "../../contracts/owned-ui/index.js";
8
8
  import { assertPromptImages, ImageAttachmentError } from "../../contracts/owned-ui/index.js";
9
9
  import { ContextualPromptSuggestionController } from "./prompt-suggestion-controller.js";
10
- import { MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseMouseInput } from "../../ui/components/mouse.js";
11
- import { readVisibleHyperlinks } from "../../ui/components/visible-hyperlinks.js";
12
10
  import { workflowCommandNames } from "../../integrations/pi/engine/workflows.js";
13
11
  import { createPiExtensionUiBridge } from "../../integrations/pi/components/shell-extension-ui.js";
14
12
  import { ProgramStatusReporter } from "../../integrations/pi/components/upstream/program-status-reporter.js";
15
13
  import { SKILLS_COMMAND_NAME, findSkillByArgument, rewriteSkillsTunnelSubmission, skillPrompt, skillsFromCommands, } from "../../integrations/pi/components/skills-command.js";
16
14
  import { createPiShellAuthProviderSelector, createPiShellDialog, createPiShellExtensionSelector, createPiShellLoginDialog, createPiShellReloadBox, createPiShellSelector, createPiShellSkillsSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, } from "../../integrations/pi/components/shell-selectors-dialogs.js";
17
15
  import { renderPiShellStatusText, } from "../../integrations/pi/components/shell-presenters-info.js";
18
- import { piCanvasBackgroundAnsi } from "../../integrations/pi/components/theme.js";
19
- import { DamageAwareTerminalAdapter } from "../../integrations/pi/tui-runtime/damage-aware-terminal.js";
20
- import { PiTuiRuntimeAdapter } from "../../integrations/pi/tui-runtime/adapter.js";
21
- import { classifyPiTuiInput } from "../../integrations/pi/tui-runtime/input-presentation-coordinator.js";
16
+ import { OwnedUiTerminalHost, sessionTerminalHostOptions, } from "./shell-host.js";
22
17
  import { runImageWorker } from "./image-preparation-client.js";
23
18
  import { STREAM_PRESENTATION_INTERVAL_MS, StreamPresentationCoalescer, } from "./stream-presentation-coalescer.js";
24
19
  import { writeSystemClipboardText, } from "./system-clipboard.js";
@@ -26,13 +21,18 @@ import { OwnedUiSessionShellRoot, shellResourceEntries, } from "./session-shell-
26
21
  // Invariant: a disposed shell leaves the engine a host that holds no reference back to it.
27
22
  const DETACHED_WORKFLOW_INTERACTION = { prompt: async () => null, notify() { } };
28
23
  export { OwnedUiSessionShellRoot, } from "./session-shell-root.js";
29
- /** Coordinates backend, owned presentation, and Pi TUI lifecycles for one interactive session. */
30
- export class OwnedUiSessionShell {
24
+ export { OwnedUiTerminalHost, sessionTerminalHostOptions } from "./shell-host.js";
25
+ /**
26
+ * Presents one engine session: its transcript root, editor, controllers, submission, workflows, and dialogs.
27
+ * It reaches the terminal only through its terminal host's handle, and paints only while it is attached.
28
+ */
29
+ export class OwnedUiSessionPresenter {
31
30
  backend;
32
31
  #presenters;
33
32
  #dialogHost;
34
33
  root;
35
- runtime;
34
+ #host;
35
+ #terminal;
36
36
  #cwd;
37
37
  #listeners = new Set();
38
38
  #unsubscribe;
@@ -58,9 +58,11 @@ export class OwnedUiSessionShell {
58
58
  #editorRevision = 0;
59
59
  #started = false;
60
60
  #disposed = false;
61
+ #released;
61
62
  #shutdownPromise;
62
63
  #disposePromise;
63
- #pointerReporting = false;
64
+ #layoutParts;
65
+ #pendingClipboardWrite = Promise.resolve();
64
66
  // Invariant: translated startup diagnostics create at most one dock notice per kind per shell.
65
67
  #startupTrustHandled = false;
66
68
  #customViewport;
@@ -71,13 +73,10 @@ export class OwnedUiSessionShell {
71
73
  #copyExecutor;
72
74
  #copyIntentSequence = 0;
73
75
  #unbindClipboardWriter;
74
- #damageTerminal;
75
- #quitOutro;
76
76
  #reloadPresentation;
77
77
  #streamPresentation;
78
- #removeViewportPreInput;
78
+ #inputCoordination;
79
79
  #unsubscribeSettings;
80
- #unsubscribeBackgroundSettings;
81
80
  #unbindPiSettings;
82
81
  #unbindTerminalSettings;
83
82
  #unbindShutdownSettings;
@@ -93,9 +92,9 @@ export class OwnedUiSessionShell {
93
92
  #sessionGeneration;
94
93
  #sessionBindingGeneration;
95
94
  #suggestionModelKey;
96
- constructor(options) {
95
+ constructor(host, options) {
97
96
  const { backend, cwd, routeHost, sessionLayout } = options.engine;
98
- const { terminal, startup, viewportSettings, backgroundSettings, quitOutro, reload: reloadPresentation, stream: streamPresentationOptions, input: inputPresentation, } = options.presentation ?? {};
97
+ const { terminal, startup, viewportSettings, reload: reloadPresentation, stream: streamPresentationOptions, input: inputPresentation, } = options.presentation ?? {};
99
98
  const { clipboard, responseCopy, paste: pasteDiagnostics, pastePreparation } = options.diagnostics ?? {};
100
99
  const promptHistory = options.history;
101
100
  const { clipboard: sharedClipboard, keybindings: sharedKeybindings } = options.shared ?? {};
@@ -108,23 +107,22 @@ export class OwnedUiSessionShell {
108
107
  this.#routeHost = routeHost ?? null;
109
108
  this.#startupRoute = options.engine.startupRoute;
110
109
  this.#customViewport = sessionLayout === "custom-viewport";
110
+ this.#inputCoordination = inputPresentation?.coordination !== false;
111
+ this.#host = host;
112
+ this.#terminal = host.connect(this);
111
113
  // Invariant: the host's optional members follow the layout, so it is built only once the layout is known.
112
114
  this.#dialogHost = this.#createDialogHost();
113
115
  this.#lazySelectors = options.diagnostics?.lazySelectors;
114
116
  this.#stopped = new Promise(resolve => {
115
117
  this.#resolveStopped = resolve;
116
118
  });
117
- let runtime;
118
- let damageTerminal;
119
- let streamPresentation;
120
- let pendingClipboardWrite = Promise.resolve();
121
119
  let promptSuggestionController = null;
122
120
  // Invariant: owned prompt limits and collapsed skills are bare-A1 replacements; comparison profiles keep pinned behavior.
123
121
  this.#promptImages = this.#customViewport ? options.promptImages ?? null : null;
124
122
  this.#skills = this.#customViewport ? options.skills ?? null : null;
125
123
  // Invariant: construction is transactional. Each binding registers its release as it is made, and a
126
124
  // throw releases them in reverse, so a shell that never finished constructing holds nothing.
127
- const constructed = [];
125
+ const constructed = [() => host.disconnect(this)];
128
126
  try {
129
127
  const terminalCopy = terminal !== undefined || hasAsyncClipboardOutput();
130
128
  this.#responseCopy = this.#customViewport ? new ResponseCopyCoordinator({
@@ -133,23 +131,23 @@ export class OwnedUiSessionShell {
133
131
  ...(terminal === undefined && clipboard === undefined ? {} : { destination: "terminal" }),
134
132
  ...(clipboard?.writeText === undefined ? {} : { writeText: (text, signal) => clipboard.writeText(text, signal) }),
135
133
  ...(terminalCopy ? { terminal: { submit: async (control, signal) => {
136
- if (signal.aborted || this.#disposed || !runtime?.active)
134
+ if (signal.aborted || this.#disposed || !this.#terminal.running)
137
135
  throw new Error("Copy canceled");
138
136
  // Performance: never grow the real terminal's pending buffer with another clipboard payload.
139
137
  if (terminal === undefined && process.stdout.writableLength + control.length > MAX_COPY_CONTROL_BYTES) {
140
138
  throw new Error("Clipboard terminal is busy");
141
139
  }
142
- runtime.writeControl(control);
140
+ this.#terminal.writeControl(control);
143
141
  } } } : {}),
144
142
  })),
145
143
  ...(responseCopy?.onEvent === undefined ? {} : { onEvent: responseCopy.onEvent }),
146
144
  onFailure: result => {
147
- if (this.#disposed || !runtime?.active)
145
+ if (this.#disposed || !this.#terminal.running)
148
146
  return;
149
147
  this.root.appendWorkflowStatus(result.outcome === "timed-out" ? "Copy timed out; the clipboard did not respond."
150
148
  : result.failure === "size" ? "Selection exceeds this clipboard route's size limit."
151
149
  : "Could not copy the selection; the clipboard is unavailable.");
152
- runtime.requestRender();
150
+ this.#terminal.requestRender();
153
151
  },
154
152
  }) : null;
155
153
  constructed.push(() => { this.#responseCopy?.dispose(); this.#copyExecutor?.dispose(); });
@@ -175,12 +173,12 @@ export class OwnedUiSessionShell {
175
173
  return text === null ? null : { kind: "text", text };
176
174
  };
177
175
  this.root = new OwnedUiSessionShellRoot(this.backend.session.view(), cwd, {
178
- getColumns: () => runtime?.viewport().columns ?? terminal?.columns ?? 80,
179
- getRows: () => runtime?.viewport().rows ?? terminal?.rows ?? 24,
180
- requestRender: force => runtime?.requestRender(force),
181
- requestHyperlinkCleanup: rows => damageTerminal?.requestHyperlinkCleanup(rows),
182
- onViewportFrame: frame => damageTerminal?.arm(frame.descriptor, {
183
- overlayActive: runtime?.hasOverlay() ?? false,
176
+ getColumns: () => this.#terminal.viewport().columns,
177
+ getRows: () => this.#terminal.viewport().rows,
178
+ requestRender: force => this.#terminal.requestRender(force),
179
+ requestHyperlinkCleanup: rows => this.#terminal.requestHyperlinkCleanup(rows),
180
+ onViewportFrame: frame => this.#terminal.armFrame(frame.descriptor, {
181
+ overlayActive: this.#terminal.hasOverlay(),
184
182
  selectionActive: this.root.hasActiveSelection(),
185
183
  replacementSurfaceActive: !this.root.usesDefaultInputSurface(),
186
184
  }),
@@ -197,7 +195,7 @@ export class OwnedUiSessionShell {
197
195
  onThinkingCycle: () => { void this.cycleThinkingLevel(); },
198
196
  onThinkingToggle: () => {
199
197
  this.root.toggleThinkingVisibility();
200
- this.runtime.requestRender();
198
+ this.#terminal.requestRender();
201
199
  },
202
200
  onMessageCopy: () => { void this.runWorkflow({ command: "copy", argument: "" }); },
203
201
  onFollowUp: () => { void this.queueFollowUp().catch(() => this.#reportSubmissionError()); },
@@ -228,15 +226,15 @@ export class OwnedUiSessionShell {
228
226
  const write = () => {
229
227
  if (this.#disposed)
230
228
  return Promise.resolve();
231
- runtime?.writeControl(`\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
229
+ this.#terminal.writeControl(`\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
232
230
  return clipboard === undefined ? writeSystemClipboardText(text)
233
231
  : clipboard.writeText?.(text) ?? Promise.resolve();
234
232
  };
235
233
  // Compatibility: comparison profiles retain their existing clipboard path.
236
- pendingClipboardWrite = write().catch(() => { });
234
+ this.#pendingClipboardWrite = write().catch(() => { });
237
235
  },
238
236
  readClipboardContent: async (signal = new AbortController().signal) => {
239
- await pendingClipboardWrite;
237
+ await this.#pendingClipboardWrite;
240
238
  return readClipboard(signal);
241
239
  },
242
240
  captureClipboardPaste: () => {
@@ -262,73 +260,16 @@ export class OwnedUiSessionShell {
262
260
  }, this.backend.identity.agentDir, this.#presenters.transcriptRenderers(), sessionLayout, {
263
261
  resolve: assetId => this.backend.extensions.resolveTranscriptImage(assetId),
264
262
  });
265
- // Invariant: the runtime owns the root (its theme subscription and helper clients) once constructed;
266
- // stopping an idle runtime disposes it synchronously.
267
- constructed.push(() => {
268
- if (runtime === undefined)
269
- this.root.dispose();
270
- else
271
- void runtime.dispose().catch(() => { });
272
- });
263
+ // Invariant: the presenter owns its root (theme subscription and helper clients) and disposes it once the
264
+ // terminal no longer renders it.
265
+ constructed.push(() => this.root.dispose());
273
266
  const initialPiSettings = this.backend.settings.snapshot();
274
- // Invariant: bare A1 owns a bounded viewport and therefore always runs on the alternate
275
- // fullscreen surface. The pinned comparison profiles still honor Pi's mode.
276
- const tuiMode = this.#customViewport
277
- ? "fullscreen"
278
- : this.backend.identity.disposed ? "regular" : initialPiSettings.tuiMode;
279
- const runtimeOptions = {
280
- root: this.root,
281
- mode: tuiMode,
282
- ...(this.#customViewport ? {
283
- // Invariant: the owned shell enables and routes mouse reports itself. Pi's
284
- // enclosing fullscreen renderer must never establish a competing white selection.
285
- mouse: false,
286
- consumeUnhandledMouse: true,
287
- onOverlayGeometry: surfaces => this.root.setViewportOverlaySurfaces(surfaces),
288
- decorateTerminal: (terminal) => {
289
- damageTerminal = new DamageAwareTerminalAdapter(terminal, {
290
- regionalScroll: process.env.TERM !== "dumb",
291
- inspectHyperlinks: readVisibleHyperlinks,
292
- onResize: () => streamPresentation?.noteImmediatePresentation(),
293
- onHyperlinkCleanupRequired: () => runtime?.requestRender(true),
294
- });
295
- return damageTerminal;
296
- },
297
- ...(inputPresentation?.coordination === false ? {} : {
298
- inputCoordination: {
299
- classify: (data, focusedOverlay) => classifyPiTuiInput(data, focusedOverlay ?? this.root.inputCoordinationSurface()),
300
- onReceipt: () => streamPresentation?.noteImmediatePresentation(),
301
- ...(inputPresentation?.scheduler === undefined
302
- ? {}
303
- : { scheduler: inputPresentation.scheduler }),
304
- },
305
- }),
306
- } : { layoutRoot: this.root.layoutRoot() }),
307
- ...(inputPresentation?.onEvent === undefined ? {} : {
308
- inputDiagnostics: {
309
- onEvent: inputPresentation.onEvent,
310
- ...(inputPresentation.now === undefined ? {} : { now: inputPresentation.now }),
311
- },
312
- }),
313
- ...(terminal === undefined ? {} : { terminal: terminal }),
314
- hardwareCursor: this.backend.session.view().terminal.hardwareCursor,
315
- ...(this.#customViewport ? {} : { wheelScrollLines: initialPiSettings.fullscreenWheelScrollLines }),
316
- };
317
- runtime = new PiTuiRuntimeAdapter(runtimeOptions);
318
- this.runtime = runtime;
319
- this.#programStatus = new ProgramStatusReporter(status => { if (this.runtime.active)
320
- this.runtime.setProgramStatus(status); }, () => this.view().status.footer?.sessionName ?? undefined);
321
- this.#damageTerminal = damageTerminal ?? null;
322
- if (this.#customViewport && backgroundSettings !== undefined && damageTerminal !== undefined) {
323
- damageTerminal.setCanvasBackground(piCanvasBackgroundAnsi(backgroundSettings.snapshot()));
324
- }
325
- this.#quitOutro = quitOutro;
267
+ this.#programStatus = new ProgramStatusReporter(status => this.#terminal.setProgramStatus(status), () => this.view().status.footer?.sessionName ?? undefined);
326
268
  this.#reloadPresentation = reloadPresentation;
327
269
  const presentationInterval = streamPresentationOptions?.intervalMs ?? STREAM_PRESENTATION_INTERVAL_MS;
328
- streamPresentation = streamPresentationOptions?.scheduler === undefined
329
- ? new StreamPresentationCoalescer(() => this.runtime.requestRender(), presentationInterval)
330
- : new StreamPresentationCoalescer(() => this.runtime.requestRender(), presentationInterval, streamPresentationOptions.scheduler);
331
- this.#streamPresentation = streamPresentation;
270
+ this.#streamPresentation = streamPresentationOptions?.scheduler === undefined
271
+ ? new StreamPresentationCoalescer(() => this.#terminal.requestRender(), presentationInterval)
272
+ : new StreamPresentationCoalescer(() => this.#terminal.requestRender(), presentationInterval, streamPresentationOptions.scheduler);
332
273
  constructed.push(() => this.#streamPresentation.dispose());
333
274
  const promptSuggestionOptions = this.#customViewport ? options.suggestions : undefined;
334
275
  promptSuggestionController = promptSuggestionOptions === undefined ? null : new ContextualPromptSuggestionController({
@@ -345,7 +286,7 @@ export class OwnedUiSessionShell {
345
286
  return true;
346
287
  },
347
288
  clear: () => this.root.setPromptSuggestion(null),
348
- requestRender: () => this.runtime.requestRender(),
289
+ requestRender: () => this.#terminal.requestRender(),
349
290
  },
350
291
  });
351
292
  this.#promptSuggestions = promptSuggestionController;
@@ -356,7 +297,7 @@ export class OwnedUiSessionShell {
356
297
  constructed.push(this.#unsubscribePromptSuggestions);
357
298
  this.#unsubscribePromptImages = this.#promptImages === null ? () => { } : this.#promptImages.onChange(() => {
358
299
  if (!this.#disposed && this.root.reconcilePromptImageLimitNotice())
359
- this.runtime.requestRender();
300
+ this.#terminal.requestRender();
360
301
  });
361
302
  constructed.push(this.#unsubscribePromptImages);
362
303
  // Rationale: the same listener refreshes the menu for the A1 presentation choice and for the engine's
@@ -366,11 +307,11 @@ export class OwnedUiSessionShell {
366
307
  if (this.#disposed || this.#commandListSignature() === this.#installedCommandSignature)
367
308
  return;
368
309
  this.#installAutocompleteCommands();
369
- this.runtime.requestRender();
310
+ this.#terminal.requestRender();
370
311
  });
371
312
  constructed.push(this.#unsubscribeSkills);
372
- this.runtime.setHardwareCursor(initialPiSettings.showHardwareCursor);
373
- this.runtime.setClearOnShrink(initialPiSettings.clearOnShrink);
313
+ this.#terminal.setHardwareCursor(initialPiSettings.showHardwareCursor);
314
+ this.#terminal.setClearOnShrink(initialPiSettings.clearOnShrink);
374
315
  this.#terminalProgressEnabled = initialPiSettings.showTerminalProgress;
375
316
  this.#fullscreenExitOutput = initialPiSettings.fullscreenExitOutput;
376
317
  // Invariant: bare A1 prints only the resume hint at exit, so the pinned exit-output
@@ -383,12 +324,12 @@ export class OwnedUiSessionShell {
383
324
  showHardwareCursor: { apply: value => {
384
325
  if (typeof value !== "boolean")
385
326
  throw new TypeError("Hardware cursor setting is invalid");
386
- this.runtime.setHardwareCursor(value);
327
+ this.#terminal.setHardwareCursor(value);
387
328
  } },
388
329
  clearOnShrink: { apply: value => {
389
330
  if (typeof value !== "boolean")
390
331
  throw new TypeError("Clear-on-shrink setting is invalid");
391
- this.runtime.setClearOnShrink(value);
332
+ this.#terminal.setClearOnShrink(value);
392
333
  } },
393
334
  showTerminalProgress: { apply: value => {
394
335
  if (typeof value !== "boolean")
@@ -398,55 +339,6 @@ export class OwnedUiSessionShell {
398
339
  } },
399
340
  });
400
341
  constructed.push(this.#unbindTerminalSettings);
401
- this.#removeViewportPreInput = this.#customViewport
402
- ? this.runtime.addPreInputListener(data => {
403
- if (inputPresentation?.coordination === false)
404
- this.#streamPresentation.noteImmediatePresentation();
405
- // Compatibility: route modal paging and boundary keys before Pi's fullscreen handlers.
406
- const isDefault = this.root.usesDefaultInputSurface();
407
- if (!this.runtime.hasOverlay() && !isDefault
408
- && (this.root.editor.matchesTerminalKey(data, "pageUp")
409
- || this.root.editor.matchesTerminalKey(data, "pageDown"))) {
410
- this.root.handleInput(data);
411
- this.runtime.requestRender();
412
- return { consume: true };
413
- }
414
- if (!this.runtime.hasOverlay() && (this.root.editor.matchesTerminalKey(data, "home")
415
- || this.root.editor.matchesTerminalKey(data, "end")
416
- || !isDefault && (this.root.editor.matchesTerminalKey(data, "ctrl+home")
417
- || this.root.editor.matchesTerminalKey(data, "ctrl+end")))) {
418
- if (isDefault)
419
- this.root.handleViewportPreInput(data, true);
420
- this.root.handleInput(data);
421
- this.runtime.requestRender();
422
- return { consume: true };
423
- }
424
- // Invariant: geometry must belong to the painted frame, including newly opened/nested
425
- // surfaces. Steady pointer input does not trigger a synchronous composition.
426
- const viewport = this.runtime.viewport();
427
- if (data.includes("\u001b[<") && !this.root.viewportInputGeometryReady(viewport.columns, viewport.rows)) {
428
- this.runtime.renderNow();
429
- }
430
- const routed = this.root.handleViewportPreInput(data, true, Date.now(), this.root.usesDefaultInputSurface() && !this.runtime.hasFocusedOverlay());
431
- if (routed.copySelection !== undefined) {
432
- const snapshot = routed.copySelection;
433
- const acknowledgement = copyAcknowledgement(snapshot);
434
- const intent = ++this.#copyIntentSequence;
435
- void this.#responseCopy?.submit(snapshot, pendingClipboardWrite).then(result => {
436
- if (this.#disposed || !this.runtime.active || intent !== this.#copyIntentSequence
437
- || result.outcome !== "delivered" && result.outcome !== "submitted-unverified")
438
- return;
439
- if (acknowledgement.hasNonWhitespace) {
440
- this.root.showCopyAcknowledgement(`copied ${acknowledgement.characters} chars to clipboard`);
441
- }
442
- });
443
- }
444
- if (!routed.consumed)
445
- return routed.data === data ? undefined : { data: routed.data };
446
- return routed.data.length === 0 ? { consume: true } : { data: routed.data };
447
- })
448
- : () => { };
449
- constructed.push(this.#removeViewportPreInput);
450
342
  const applyViewportSettings = () => {
451
343
  const snapshot = viewportSettings?.snapshot();
452
344
  this.root.setViewportConfig(snapshot ?? {
@@ -460,14 +352,6 @@ export class OwnedUiSessionShell {
460
352
  ? viewportSettings.onChange(settings => this.root.setViewportConfig(settings))
461
353
  : () => { };
462
354
  constructed.push(this.#unsubscribeSettings);
463
- this.#unsubscribeBackgroundSettings = this.#customViewport && backgroundSettings
464
- ? backgroundSettings.onChange(style => {
465
- if (this.#damageTerminal?.setCanvasBackground(piCanvasBackgroundAnsi(style))) {
466
- this.runtime.requestRender(true);
467
- }
468
- })
469
- : () => { };
470
- constructed.push(this.#unsubscribeBackgroundSettings);
471
355
  this.root.setEditorPaddingX(initialPiSettings.editorPaddingX);
472
356
  this.root.setAutocompleteMaxVisible(initialPiSettings.autocompleteMaxVisible);
473
357
  this.root.setOutputPad(initialPiSettings.outputPad);
@@ -524,7 +408,7 @@ export class OwnedUiSessionShell {
524
408
  if (value !== "auto" && (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 100)) {
525
409
  throw new TypeError("Fullscreen wheel-scroll distance is invalid");
526
410
  }
527
- this.runtime.setWheelScrollLines(value);
411
+ this.#terminal.setWheelScrollLines(value);
528
412
  } },
529
413
  }),
530
414
  });
@@ -538,29 +422,29 @@ export class OwnedUiSessionShell {
538
422
  limit: promptHistory.limit,
539
423
  fallback: this.view().transcript.flatMap(block => block.kind === "user" ? [block.text] : []),
540
424
  active: () => this.root.usesDefaultInputSurface(),
541
- render: () => this.runtime.requestRender(),
425
+ render: () => this.#terminal.requestRender(),
542
426
  rehydrate: value => this.root.rehydrateHistoryText(value, id => sidecar?.readAttachment(id) ?? null),
543
427
  });
544
428
  constructed.push(() => { void this.#promptHistory?.close().catch(() => false); });
545
429
  }
546
430
  this.#extensionBridge = createPiExtensionUiBridge({
547
431
  runtime: {
548
- getColumns: () => this.runtime.viewport().columns,
549
- getRows: () => this.runtime.viewport().rows,
550
- requestRender: () => this.runtime.requestRender(),
432
+ getColumns: () => this.#terminal.viewport().columns,
433
+ getRows: () => this.#terminal.viewport().rows,
434
+ requestRender: () => this.#terminal.requestRender(),
551
435
  },
552
436
  agentDir: this.backend.identity.agentDir,
553
437
  setInputSurface: component => this.root.setInputSurface(component, true, "opaque"),
554
438
  setProgramStatusBlocked: status => this.#programStatus.setBlocked("extension-dialog", status),
555
- showOverlay: (component, overlayOptions) => this.runtime.showOverlay(component, overlayOptions),
556
- listenInput: handler => this.runtime.addInputListener(handler),
439
+ showOverlay: (component, overlayOptions) => this.#terminal.showOverlay(component, overlayOptions),
440
+ listenInput: handler => this.#terminal.addInputListener(handler),
557
441
  replaceWidget: (key, component, placement) => this.root.setExtensionWidget(key, component, placement),
558
442
  replaceHeader: component => this.root.setExtensionHeader(component),
559
443
  replaceFooter: component => this.root.setExtensionFooter(component),
560
444
  setStatus: (key, text) => this.root.setExtensionStatus(key, text),
561
445
  setWorking: (message, visible) => this.root.setExtensionWorking(message, visible),
562
446
  notify: (message, type) => this.root.addExtensionNotification(message, type),
563
- setTitle: title => this.runtime.setTitle(title),
447
+ setTitle: title => this.#terminal.setTitle(title),
564
448
  getEditorText: () => this.root.editor.getText(),
565
449
  setEditorText: text => this.root.editor.setText(text),
566
450
  pasteToEditor: text => this.root.editor.insertText(text),
@@ -578,7 +462,7 @@ export class OwnedUiSessionShell {
578
462
  notify: event => this.#notifyWorkflowLogin(event),
579
463
  publish: message => {
580
464
  this.root.appendWorkflowMessage(message);
581
- this.runtime.requestRender();
465
+ this.#terminal.requestRender();
582
466
  },
583
467
  finishLogin: () => this.#finishWorkflowLogin(),
584
468
  });
@@ -680,6 +564,83 @@ export class OwnedUiSessionShell {
680
564
  view() {
681
565
  return this.backend.session.view();
682
566
  }
567
+ get terminalHost() {
568
+ return this.#host;
569
+ }
570
+ render(width) {
571
+ return this.root.render(width);
572
+ }
573
+ handleInput(data) {
574
+ this.root.handleInput(data);
575
+ }
576
+ invalidate() {
577
+ this.root.invalidate();
578
+ }
579
+ setFocused(focused) {
580
+ this.root.setFocused(focused);
581
+ }
582
+ inputCoordinationSurface() {
583
+ return this.root.inputCoordinationSurface();
584
+ }
585
+ setOverlaySurfaces(surfaces) {
586
+ this.root.setViewportOverlaySurfaces(surfaces);
587
+ }
588
+ noteImmediatePresentation() {
589
+ this.#streamPresentation.noteImmediatePresentation();
590
+ }
591
+ layoutPart(part) {
592
+ this.#layoutParts ??= this.root.layoutParts();
593
+ return this.#layoutParts[part];
594
+ }
595
+ activated() {
596
+ this.#syncTerminalProgress(this.view());
597
+ }
598
+ handleViewportInput(data) {
599
+ if (!this.#inputCoordination)
600
+ this.#streamPresentation.noteImmediatePresentation();
601
+ // Compatibility: route modal paging and boundary keys before Pi's fullscreen handlers.
602
+ const isDefault = this.root.usesDefaultInputSurface();
603
+ if (!this.#terminal.hasOverlay() && !isDefault
604
+ && (this.root.editor.matchesTerminalKey(data, "pageUp")
605
+ || this.root.editor.matchesTerminalKey(data, "pageDown"))) {
606
+ this.root.handleInput(data);
607
+ this.#terminal.requestRender();
608
+ return { consume: true };
609
+ }
610
+ if (!this.#terminal.hasOverlay() && (this.root.editor.matchesTerminalKey(data, "home")
611
+ || this.root.editor.matchesTerminalKey(data, "end")
612
+ || !isDefault && (this.root.editor.matchesTerminalKey(data, "ctrl+home")
613
+ || this.root.editor.matchesTerminalKey(data, "ctrl+end")))) {
614
+ if (isDefault)
615
+ this.root.handleViewportPreInput(data, true);
616
+ this.root.handleInput(data);
617
+ this.#terminal.requestRender();
618
+ return { consume: true };
619
+ }
620
+ // Invariant: geometry must belong to the painted frame, including newly opened/nested
621
+ // surfaces. Steady pointer input does not trigger a synchronous composition.
622
+ const viewport = this.#terminal.viewport();
623
+ if (data.includes("\u001b[<") && !this.root.viewportInputGeometryReady(viewport.columns, viewport.rows)) {
624
+ this.#terminal.renderNow();
625
+ }
626
+ const routed = this.root.handleViewportPreInput(data, true, Date.now(), this.root.usesDefaultInputSurface() && !this.#terminal.hasFocusedOverlay());
627
+ if (routed.copySelection !== undefined) {
628
+ const snapshot = routed.copySelection;
629
+ const acknowledgement = copyAcknowledgement(snapshot);
630
+ const intent = ++this.#copyIntentSequence;
631
+ void this.#responseCopy?.submit(snapshot, this.#pendingClipboardWrite).then(result => {
632
+ if (this.#disposed || !this.#terminal.running || intent !== this.#copyIntentSequence
633
+ || result.outcome !== "delivered" && result.outcome !== "submitted-unverified")
634
+ return;
635
+ if (acknowledgement.hasNonWhitespace) {
636
+ this.root.showCopyAcknowledgement(`copied ${acknowledgement.characters} chars to clipboard`);
637
+ }
638
+ });
639
+ }
640
+ if (!routed.consumed)
641
+ return routed.data === data ? undefined : { data: routed.data };
642
+ return routed.data.length === 0 ? { consume: true } : { data: routed.data };
643
+ }
683
644
  #promptSuggestionPresentationBlockReason(identity) {
684
645
  const view = this.view();
685
646
  if (this.#disposed)
@@ -689,18 +650,13 @@ export class OwnedUiSessionShell {
689
650
  return "stale-identity";
690
651
  return this.root.promptSuggestionPresentationBlockReason();
691
652
  }
692
- damagePresentationDecision() {
693
- return this.#damageTerminal?.lastDecision ?? null;
694
- }
695
653
  start() {
696
654
  if (this.#started)
697
655
  return;
698
656
  this.#started = true;
699
- this.runtime.start();
657
+ this.#terminal.start();
700
658
  this.#promptHistory?.start();
701
659
  this.#syncTerminalProgress(this.view());
702
- if (this.#customViewport)
703
- this.#setPointerReporting(true);
704
660
  void this.backend.extensions.bindExtensionUi(this.#extensionBridge.context, () => { void this.shutdown(); });
705
661
  this.#syncView();
706
662
  // Performance: the spare clipboard helpers fork after the first frame is out, so startup never waits on them.
@@ -754,7 +710,7 @@ export class OwnedUiSessionShell {
754
710
  render: width => [...renderPiShellStatusText(`Waiting for images (${count} submission${count === 1 ? "" : "s"}) — Esc cancels; dequeue restores`, width)],
755
711
  invalidate: () => { },
756
712
  }, "aboveEditor");
757
- this.runtime.requestRender();
713
+ this.#terminal.requestRender();
758
714
  }
759
715
  #cancelWaitingImages() {
760
716
  for (const item of this.#waitingImages.values())
@@ -807,13 +763,13 @@ export class OwnedUiSessionShell {
807
763
  ...(result.output ? { detail: result.output } : {}),
808
764
  };
809
765
  this.root.appendWorkflowResult(workflow);
810
- this.runtime.requestRender();
766
+ this.#terminal.requestRender();
811
767
  return workflow.outcome === "failed" ? rejected(workflow.message) : { outcome: "completed", diagnostic: null };
812
768
  }
813
769
  catch (error) {
814
770
  const message = `Bash command failed: ${error instanceof Error ? error.message : String(error)}`;
815
771
  this.root.appendWorkflowResult({ command: "debug", outcome: "failed", message });
816
- this.runtime.requestRender();
772
+ this.#terminal.requestRender();
817
773
  return rejected(message);
818
774
  }
819
775
  }
@@ -843,7 +799,7 @@ export class OwnedUiSessionShell {
843
799
  return this.shutdown();
844
800
  this.root.editor.setText("");
845
801
  this.#lastClearTime = now;
846
- this.runtime.requestRender();
802
+ this.#terminal.requestRender();
847
803
  return { outcome: "completed", diagnostic: null };
848
804
  }
849
805
  async interrupt(now = Date.now()) {
@@ -924,7 +880,7 @@ export class OwnedUiSessionShell {
924
880
  async cycleModel(direction) {
925
881
  const result = await this.backend.workflows.cycleModelWorkflow(direction);
926
882
  this.root.appendWorkflowResult(result);
927
- this.runtime.requestRender();
883
+ this.#terminal.requestRender();
928
884
  return workflowAdapterResult(result);
929
885
  }
930
886
  async queueFollowUp() {
@@ -956,7 +912,7 @@ export class OwnedUiSessionShell {
956
912
  if (queued.length === 0)
957
913
  return;
958
914
  this.root.editor.setText(queued.join("\n"));
959
- this.runtime.requestRender();
915
+ this.#terminal.requestRender();
960
916
  }
961
917
  showSelector(title, options, onSelect, onCancel) {
962
918
  this.#dialogHandle?.hide();
@@ -978,7 +934,7 @@ export class OwnedUiSessionShell {
978
934
  onCancel?.();
979
935
  },
980
936
  });
981
- const handle = this.runtime.showOverlay(component, {
937
+ const handle = this.#terminal.showOverlay(component, {
982
938
  width: "70%",
983
939
  maxHeight: "80%",
984
940
  anchor: "center",
@@ -993,16 +949,16 @@ export class OwnedUiSessionShell {
993
949
  return {
994
950
  get disposed() { return isDisposed(); },
995
951
  setInputSurface: surface => this.root.setInputSurface(surface),
996
- requestRender: () => this.runtime.requestRender(),
997
- viewport: () => this.runtime.viewport(),
952
+ requestRender: () => this.#terminal.requestRender(),
953
+ viewport: () => this.#terminal.viewport(),
998
954
  appendWorkflowStatus: text => this.root.appendWorkflowStatus(text),
999
955
  appendWorkflowResult: result => this.root.appendWorkflowResult(result),
1000
956
  runWorkflow: request => this.runWorkflow(request),
1001
957
  // Invariant: the custom bare-A1 surface holds frames while optional selectors load and is permanently
1002
958
  // fullscreen; the pinned comparison profiles render immediately and keep Pi's TUI mode switch.
1003
959
  ...(this.#customViewport
1004
- ? { beginPresentationHold: () => this.runtime.beginPresentationHold() }
1005
- : { switchTuiMode: mode => this.runtime.switchMode(mode) }),
960
+ ? { beginPresentationHold: () => this.#terminal.beginPresentationHold() }
961
+ : { switchTuiMode: mode => this.#terminal.switchMode(mode) }),
1006
962
  };
1007
963
  }
1008
964
  #loadLazySelectors() {
@@ -1018,13 +974,13 @@ export class OwnedUiSessionShell {
1018
974
  const close = () => {
1019
975
  this.root.setFooterLevel(true);
1020
976
  this.root.setInputSurface(null);
1021
- this.runtime.requestRender();
977
+ this.#terminal.requestRender();
1022
978
  };
1023
979
  const select = (level) => {
1024
980
  close();
1025
981
  void this.runWorkflow({ command: "thinking", argument: "", selection: level });
1026
982
  };
1027
- const releasePresentation = this.#customViewport ? this.runtime.beginPresentationHold() : undefined;
983
+ const releasePresentation = this.#customViewport ? this.#terminal.beginPresentationHold() : undefined;
1028
984
  try {
1029
985
  const selectors = await this.#loadLazySelectors();
1030
986
  const component = await selectors.createThinking({
@@ -1045,7 +1001,7 @@ export class OwnedUiSessionShell {
1045
1001
  return;
1046
1002
  this.root.setFooterLevel(false);
1047
1003
  this.root.setInputSurface(component);
1048
- this.runtime.requestRender();
1004
+ this.#terminal.requestRender();
1049
1005
  }
1050
1006
  finally {
1051
1007
  releasePresentation?.();
@@ -1055,7 +1011,7 @@ export class OwnedUiSessionShell {
1055
1011
  showSkillsSelector(skills = this.#skillSummaries()) {
1056
1012
  const close = () => {
1057
1013
  this.root.setInputSurface(null);
1058
- this.runtime.requestRender();
1014
+ this.#terminal.requestRender();
1059
1015
  };
1060
1016
  const component = createPiShellSkillsSelector({
1061
1017
  skills,
@@ -1066,7 +1022,7 @@ export class OwnedUiSessionShell {
1066
1022
  onCancel: close,
1067
1023
  });
1068
1024
  this.root.setInputSurface(component);
1069
- this.runtime.requestRender();
1025
+ this.#terminal.requestRender();
1070
1026
  }
1071
1027
  showModelSelector(initialSearchInput) {
1072
1028
  this.#presenters.openModelSelector(this.#dialogHost, initialSearchInput);
@@ -1075,19 +1031,19 @@ export class OwnedUiSessionShell {
1075
1031
  const options = this.backend.catalog.pinnedForkOptions();
1076
1032
  if (options.length === 0) {
1077
1033
  this.root.appendWorkflowResult({ command: "fork", outcome: "completed", message: "No messages to fork from", messageKind: "status" });
1078
- this.runtime.requestRender();
1034
+ this.#terminal.requestRender();
1079
1035
  return;
1080
1036
  }
1081
1037
  const close = () => {
1082
1038
  this.root.setInputSurface(null);
1083
- this.runtime.requestRender();
1039
+ this.#terminal.requestRender();
1084
1040
  };
1085
1041
  const component = createPiShellUserMessageSelector(options, selection => {
1086
1042
  close();
1087
1043
  void this.runWorkflow({ command: "fork", argument: "", selection });
1088
1044
  }, close);
1089
1045
  this.root.setInputSurface(component);
1090
- this.runtime.requestRender();
1046
+ this.#terminal.requestRender();
1091
1047
  }
1092
1048
  async showLogoutSelector() {
1093
1049
  let options;
@@ -1100,24 +1056,24 @@ export class OwnedUiSessionShell {
1100
1056
  outcome: "failed",
1101
1057
  message: `Could not read stored credentials: ${error instanceof Error ? error.message : String(error)}`,
1102
1058
  });
1103
- this.runtime.requestRender();
1059
+ this.#terminal.requestRender();
1104
1060
  return;
1105
1061
  }
1106
1062
  if (options.length === 0) {
1107
1063
  this.root.appendWorkflowStatus("No stored credentials to remove. /logout only removes credentials saved by /login; environment variables and models.json config are unchanged.");
1108
- this.runtime.requestRender();
1064
+ this.#terminal.requestRender();
1109
1065
  return;
1110
1066
  }
1111
1067
  const close = () => {
1112
1068
  this.root.setInputSurface(null);
1113
- this.runtime.requestRender();
1069
+ this.#terminal.requestRender();
1114
1070
  };
1115
1071
  const component = createPiShellAuthProviderSelector("logout", options, selection => {
1116
1072
  close();
1117
1073
  void this.runWorkflow({ command: "logout", argument: "", selection });
1118
1074
  }, close);
1119
1075
  this.root.setInputSurface(component);
1120
- this.runtime.requestRender();
1076
+ this.#terminal.requestRender();
1121
1077
  }
1122
1078
  showLoginMethodSelector(providerReference) {
1123
1079
  const method = this.backend.catalog.pinnedLoginMethodOptions(providerReference);
@@ -1133,7 +1089,7 @@ export class OwnedUiSessionShell {
1133
1089
  }
1134
1090
  const close = () => {
1135
1091
  this.root.setInputSurface(null);
1136
- this.runtime.requestRender();
1092
+ this.#terminal.requestRender();
1137
1093
  };
1138
1094
  const labels = method.options.map(option => option.label);
1139
1095
  const component = createPiShellExtensionSelector(method.title, labels, label => {
@@ -1144,7 +1100,7 @@ export class OwnedUiSessionShell {
1144
1100
  void this.runWorkflow({ command: "login", argument: "", selection });
1145
1101
  }, close);
1146
1102
  this.root.setInputSurface(component);
1147
- this.runtime.requestRender();
1103
+ this.#terminal.requestRender();
1148
1104
  }
1149
1105
  showTreeSelector(initialSelectedId) {
1150
1106
  return this.#presenters.openTreeSelector(this.#dialogHost, (entryId, skipSummaryPrompt) => {
@@ -1154,7 +1110,7 @@ export class OwnedUiSessionShell {
1154
1110
  showLoginAuthTypeSelector() {
1155
1111
  const close = () => {
1156
1112
  this.root.setInputSurface(null);
1157
- this.runtime.requestRender();
1113
+ this.#terminal.requestRender();
1158
1114
  };
1159
1115
  const labels = ["Sign in with an account", "Sign in with an API key"];
1160
1116
  const component = createPiShellExtensionSelector("Select authentication method:", labels, label => {
@@ -1162,18 +1118,18 @@ export class OwnedUiSessionShell {
1162
1118
  this.showLoginProviderSelector(label === labels[0] ? "oauth" : "api_key");
1163
1119
  }, close);
1164
1120
  this.root.setInputSurface(component);
1165
- this.runtime.requestRender();
1121
+ this.#terminal.requestRender();
1166
1122
  }
1167
1123
  showLoginProviderSelector(authType, initialSearchInput) {
1168
1124
  const options = this.backend.catalog.pinnedLoginOptions(authType);
1169
1125
  if (options.length === 0) {
1170
1126
  this.root.appendWorkflowStatus(authType === "oauth" ? "No account providers available." : authType === "api_key" ? "No API key providers available." : "No login providers available.");
1171
- this.runtime.requestRender();
1127
+ this.#terminal.requestRender();
1172
1128
  return;
1173
1129
  }
1174
1130
  const close = () => {
1175
1131
  this.root.setInputSurface(null);
1176
- this.runtime.requestRender();
1132
+ this.#terminal.requestRender();
1177
1133
  };
1178
1134
  const component = createPiShellAuthProviderSelector("login", options, id => {
1179
1135
  close();
@@ -1184,7 +1140,7 @@ export class OwnedUiSessionShell {
1184
1140
  this.showLoginAuthTypeSelector();
1185
1141
  }, initialSearchInput);
1186
1142
  this.root.setInputSurface(component);
1187
- this.runtime.requestRender();
1143
+ this.#terminal.requestRender();
1188
1144
  }
1189
1145
  showSessionSelector() {
1190
1146
  return this.#presenters.openSessionSelector(this.#dialogHost, () => { void this.shutdown(); });
@@ -1224,7 +1180,7 @@ export class OwnedUiSessionShell {
1224
1180
  if (this.#disposed)
1225
1181
  return;
1226
1182
  this.root.appendWorkflowResult({ command: "name", outcome: "failed", message: error instanceof Error ? error.message : String(error) });
1227
- this.runtime.requestRender();
1183
+ this.#terminal.requestRender();
1228
1184
  });
1229
1185
  return { outcome: "completed", diagnostic: null };
1230
1186
  }
@@ -1234,16 +1190,16 @@ export class OwnedUiSessionShell {
1234
1190
  if (setup) {
1235
1191
  const close = () => {
1236
1192
  this.root.setInputSurface(null);
1237
- this.runtime.requestRender();
1193
+ this.#terminal.requestRender();
1238
1194
  };
1239
1195
  const dialog = createPiShellLoginDialog({
1240
- getColumns: () => this.runtime.viewport().columns,
1241
- getRows: () => this.runtime.viewport().rows,
1242
- requestRender: () => this.runtime.requestRender(),
1196
+ getColumns: () => this.#terminal.viewport().columns,
1197
+ getRows: () => this.#terminal.viewport().rows,
1198
+ requestRender: () => this.#terminal.requestRender(),
1243
1199
  }, setup.providerId, close, setup.providerName, setup.title);
1244
1200
  dialog.showInfo(setup.message, [], true);
1245
1201
  this.root.setInputSurface(dialog);
1246
- this.runtime.requestRender();
1202
+ this.#terminal.requestRender();
1247
1203
  return { outcome: "completed", diagnostic: null };
1248
1204
  }
1249
1205
  }
@@ -1298,7 +1254,7 @@ export class OwnedUiSessionShell {
1298
1254
  const blocked = this.backend.workflows.reloadBlockedResult();
1299
1255
  if (blocked) {
1300
1256
  this.root.appendWorkflowResult(blocked);
1301
- this.runtime.requestRender();
1257
+ this.#terminal.requestRender();
1302
1258
  return workflowAdapterResult(blocked);
1303
1259
  }
1304
1260
  this.root.resetExtensionUi();
@@ -1308,16 +1264,16 @@ export class OwnedUiSessionShell {
1308
1264
  : undefined;
1309
1265
  const shareSurface = shareDialog === undefined ? undefined
1310
1266
  : (this.#customViewport ? shareDialog.createPiShellShareOperationDialog : shareDialog.createPiShellOperationLoader)({
1311
- getColumns: () => this.runtime.viewport().columns,
1312
- getRows: () => this.runtime.viewport().rows,
1313
- requestRender: () => this.runtime.requestRender(),
1267
+ getColumns: () => this.#terminal.viewport().columns,
1268
+ getRows: () => this.#terminal.viewport().rows,
1269
+ requestRender: () => this.#terminal.requestRender(),
1314
1270
  }, "Creating gist…");
1315
1271
  const operationSurface = shareSurface ?? (request.command === "reload" ? createPiShellReloadBox() : undefined);
1316
1272
  const now = this.#reloadPresentation?.now ?? Date.now;
1317
1273
  const shownAt = now();
1318
1274
  if (operationSurface) {
1319
1275
  this.root.setInputSurface(operationSurface);
1320
- this.runtime.requestRender();
1276
+ this.#terminal.requestRender();
1321
1277
  }
1322
1278
  let result;
1323
1279
  try {
@@ -1330,7 +1286,7 @@ export class OwnedUiSessionShell {
1330
1286
  if (request.command === "reload")
1331
1287
  await this.#holdReloadSurface(now() - shownAt);
1332
1288
  this.root.setInputSurface(null);
1333
- this.runtime.requestRender();
1289
+ this.#terminal.requestRender();
1334
1290
  }
1335
1291
  }
1336
1292
  if (copyGeneration !== undefined && (this.#disposed || copyGeneration !== this.backend.identity.sessionBindingGeneration)) {
@@ -1359,7 +1315,7 @@ export class OwnedUiSessionShell {
1359
1315
  }
1360
1316
  if (result.outcome === "requires-selection" || result.outcome === "requires-confirmation") {
1361
1317
  this.root.appendWorkflowResult({ command: request.command, outcome: "failed", message: `Owned controller missing for ${request.command}` });
1362
- this.runtime.requestRender();
1318
+ this.#terminal.requestRender();
1363
1319
  return { outcome: "failed", diagnostic: `Owned controller missing for ${request.command}` };
1364
1320
  }
1365
1321
  if (request.command === "reload" && result.outcome === "completed") {
@@ -1378,7 +1334,7 @@ export class OwnedUiSessionShell {
1378
1334
  else {
1379
1335
  this.root.appendWorkflowResult(result);
1380
1336
  }
1381
- this.runtime.requestRender();
1337
+ this.#terminal.requestRender();
1382
1338
  return workflowAdapterResult(result);
1383
1339
  }
1384
1340
  async #holdReloadSurface(visibleMs) {
@@ -1393,7 +1349,7 @@ export class OwnedUiSessionShell {
1393
1349
  const context = this.backend.catalog.pinnedProjectTrustContext();
1394
1350
  const close = () => {
1395
1351
  this.root.setInputSurface(null);
1396
- this.runtime.requestRender();
1352
+ this.#terminal.requestRender();
1397
1353
  };
1398
1354
  const component = createPiShellTrustSelector({
1399
1355
  ...context,
@@ -1407,12 +1363,12 @@ export class OwnedUiSessionShell {
1407
1363
  close();
1408
1364
  this.root.appendWorkflowResult({ command: "trust", outcome: "failed", message: error instanceof Error ? error.message : String(error) });
1409
1365
  }
1410
- this.runtime.requestRender();
1366
+ this.#terminal.requestRender();
1411
1367
  },
1412
1368
  onCancel: close,
1413
1369
  });
1414
1370
  this.root.setInputSurface(component);
1415
- this.runtime.requestRender();
1371
+ this.#terminal.requestRender();
1416
1372
  }
1417
1373
  showScopedModelsSelector() {
1418
1374
  this.#presenters.openScopedModelsSelector(this.#dialogHost);
@@ -1426,32 +1382,26 @@ export class OwnedUiSessionShell {
1426
1382
  showModelsDialog(initialQuery) {
1427
1383
  this.#presenters.openModelsDialog(this.#dialogHost, initialQuery);
1428
1384
  }
1429
- // Invariant: pointer reporting is disabled on every path that ends the owning screen.
1430
- #setPointerReporting(enabled, forceOff = false) {
1431
- const effective = forceOff ? false : this.#customViewport || enabled;
1432
- if (this.#pointerReporting === effective)
1433
- return;
1434
- this.#pointerReporting = effective;
1435
- if (!this.runtime.active)
1436
- return;
1437
- this.runtime.writeControl(effective ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
1438
- }
1439
- // Invariant: one teardown per shell, starting on a microtask so never inside the event listener that
1440
- // requested it. Callers during the teardown share its outcome; a later call finds it done.
1385
+ // Invariant: one teardown per presenter, starting on a microtask so never inside the event listener that
1386
+ // requested it. Callers during the teardown share its outcome; a later call finds it done. Closing the
1387
+ // terminal host's last presenter ends the terminal.
1441
1388
  dispose() {
1442
1389
  if (this.#disposePromise !== undefined)
1443
1390
  return this.#disposePromise;
1444
1391
  if (this.#disposed)
1445
1392
  return Promise.resolve();
1446
1393
  this.#disposed = true;
1447
- const pending = Promise.resolve().then(() => this.#dispose());
1394
+ const pending = Promise.resolve().then(() => this.#host.close(this));
1448
1395
  this.#disposePromise = pending;
1449
1396
  const clear = () => { if (this.#disposePromise === pending)
1450
1397
  this.#disposePromise = undefined; };
1451
1398
  void pending.then(clear, clear);
1452
1399
  return pending;
1453
1400
  }
1454
- async #dispose() {
1401
+ release(terminal) {
1402
+ if (this.#released !== undefined)
1403
+ return { exitText: "", failures: [], settle: async () => [] };
1404
+ this.#disposed = true;
1455
1405
  const failures = [];
1456
1406
  const attempt = (action) => { try {
1457
1407
  action();
@@ -1462,8 +1412,6 @@ export class OwnedUiSessionShell {
1462
1412
  // Invariant: events stop first, so nothing re-enters the view during history close, outro, or restoration.
1463
1413
  attempt(() => this.#unsubscribe());
1464
1414
  this.#copyIntentSequence += 1;
1465
- // Invariant: the outro frame is what the terminal shows now, before any cleanup writes.
1466
- const outroFrame = this.#captureQuitOutroFrame();
1467
1415
  this.#responseCopy?.dispose();
1468
1416
  this.#copyExecutor?.dispose();
1469
1417
  this.#cancelWaitingImages();
@@ -1472,8 +1420,6 @@ export class OwnedUiSessionShell {
1472
1420
  attempt(() => { historyCleanup = this.#promptHistory?.close() ?? Promise.resolve(true); });
1473
1421
  attempt(() => { pasteCleanup = this.root.disposePendingPastes(); });
1474
1422
  attempt(() => this.root.clearViewportPointerState());
1475
- attempt(() => this.#setPointerReporting(false, true));
1476
- attempt(() => this.#removeViewportPreInput());
1477
1423
  attempt(() => this.#streamPresentation.dispose());
1478
1424
  attempt(() => {
1479
1425
  if (this.#startupRouteTimer !== undefined)
@@ -1485,17 +1431,16 @@ export class OwnedUiSessionShell {
1485
1431
  attempt(() => this.#unsubscribePromptImages());
1486
1432
  attempt(() => this.#unsubscribeSkills());
1487
1433
  attempt(() => this.#unsubscribeSettings());
1488
- attempt(() => this.#unsubscribeBackgroundSettings());
1489
- let fullscreenExitText = "";
1434
+ let exitText = "";
1490
1435
  attempt(() => {
1491
1436
  const exitMode = this.backend.identity.disposed ? this.#fullscreenExitOutput : this.backend.settings.snapshot().fullscreenExitOutput;
1492
1437
  const resume = this.backend.identity.currentSessionResumeMetadata();
1493
1438
  const resumeHint = resume === null ? "" : `${dim("To resume this session:")} ${formatSessionResumeCommand(resume)}`;
1494
1439
  // Invariant: bare A1 leaves only the hint behind; the pinned comparison profile still
1495
1440
  // honors fullscreenExitOutput, including the styled transcript.
1496
- fullscreenExitText = this.runtime.mode !== "fullscreen" ? ""
1441
+ exitText = terminal.mode !== "fullscreen" ? ""
1497
1442
  : this.#customViewport || exitMode === "resume-hint" ? resumeHint
1498
- : [this.root.exitTranscript(this.runtime.viewport().columns), resumeHint].filter(Boolean).join("\n\n");
1443
+ : [this.root.exitTranscript(terminal.columns), resumeHint].filter(Boolean).join("\n\n");
1499
1444
  });
1500
1445
  attempt(() => this.#unbindClipboardWriter());
1501
1446
  attempt(() => this.#unbindPiSettings());
@@ -1505,76 +1450,22 @@ export class OwnedUiSessionShell {
1505
1450
  attempt(() => this.#dialogHandle?.hide());
1506
1451
  attempt(() => this.#extensionBridge.dispose());
1507
1452
  attempt(() => this.#programStatus.clear());
1508
- // Invariant: from here to the leave nothing but the outro paints. A throttled frame the
1509
- // renderer still has queued would otherwise land during the stop-time input drain and
1510
- // flash the prompt and footer, whether or not an effect plays.
1511
- attempt(() => this.#freezeQuitPresentation());
1512
- await this.#playQuitOutro(outroFrame);
1513
- // Invariant: terminal restoration precedes any potentially stalled backend teardown. The
1514
- // fullscreen leave preserves the screen: the pinned runtime never dumps its final document
1515
- // into the parent terminal, so only the configured exit text follows the leave.
1516
- await this.runtime.dispose({ preserveScreen: this.runtime.mode === "fullscreen" }).catch(error => failures.push(error));
1517
- await historyCleanup.catch(() => false); // Security: background durability outcomes never enter terminal output.
1518
- await boundedCleanup(() => pasteCleanup).catch(error => failures.push(error));
1519
- await boundedCleanup(() => this.backend.extensions.unbindExtensionUi()).catch(error => failures.push(error));
1520
- if (failures.length > 0)
1521
- throw new AggregateError(failures, "Owned UI disposal failed");
1522
- if (fullscreenExitText.length > 0)
1523
- this.runtime.writeAfterStop(`${fullscreenExitText}\n`);
1524
- }
1525
- // Invariant: the snapshot is synchronous; the outro module itself loads only at quit.
1526
- #captureQuitOutroFrame() {
1527
- const outro = this.#quitOutro;
1528
- if (outro === undefined || !outro.interactive || !this.#customViewport || this.#damageTerminal === null)
1529
- return null;
1530
- if (!this.runtime.active || this.runtime.mode !== "fullscreen")
1531
- return null;
1532
- try {
1533
- if (!outro.snapshot().enabled)
1534
- return null;
1535
- const viewport = this.runtime.viewport();
1536
- return {
1537
- rows: this.#damageTerminal.presentedRows(), columns: viewport.columns, height: viewport.rows,
1538
- settings: {
1539
- effect: QUIT_OUTRO_EFFECT,
1540
- durationMs: QUIT_OUTRO_DURATION_MS,
1541
- canvasBackgroundAnsi: this.#damageTerminal.canvasBackgroundAnsi,
1542
- },
1543
- };
1544
- }
1545
- catch {
1546
- return null;
1547
- }
1548
- }
1549
- #freezeQuitPresentation() {
1550
- if (!this.#customViewport || !this.runtime.active || this.runtime.mode !== "fullscreen")
1551
- return;
1552
- this.runtime.freezePresentation();
1553
- }
1554
- // Rationale: any failure here only skips the effect; restoration always follows.
1555
- async #playQuitOutro(capture) {
1556
- const outro = this.#quitOutro;
1557
- if (capture === null || outro === undefined || !this.runtime.active)
1558
- return;
1559
- try {
1560
- // Rationale: the effects stay off the startup graph; quit is the only time they load.
1561
- const { captureQuitOutroFrame, playQuitOutro } = await import("./quit-outro.js");
1562
- const frame = captureQuitOutroFrame(capture.rows, capture.columns, capture.height);
1563
- if (frame === null || !this.runtime.active)
1564
- return;
1565
- await playQuitOutro(frame, capture.settings.effect, capture.settings.durationMs, {
1566
- write: data => this.runtime.writeControl(data),
1567
- ...(capture.settings.canvasBackgroundAnsi === null
1568
- ? {}
1569
- : { canvasBackgroundAnsi: capture.settings.canvasBackgroundAnsi }),
1570
- ...(outro.now === undefined ? {} : { now: outro.now }),
1571
- ...(outro.sleep === undefined ? {} : { sleep: outro.sleep }),
1572
- ...(outro.seed === undefined ? {} : { seed: outro.seed }),
1573
- });
1574
- }
1575
- catch {
1576
- // Rationale: a failed or interrupted effect must never hold the terminal; restoration follows.
1577
- }
1453
+ // Invariant: the host settles a presenter only once the terminal is restored, so these never stall it.
1454
+ const settle = async () => {
1455
+ const settled = [];
1456
+ try {
1457
+ this.root.dispose();
1458
+ }
1459
+ catch (error) {
1460
+ settled.push(error);
1461
+ }
1462
+ await historyCleanup.catch(() => false); // Security: background durability outcomes never enter terminal output.
1463
+ await boundedCleanup(() => pasteCleanup).catch(error => settled.push(error));
1464
+ await boundedCleanup(() => this.backend.extensions.unbindExtensionUi()).catch(error => settled.push(error));
1465
+ return settled;
1466
+ };
1467
+ this.#released = { exitText, failures, settle };
1468
+ return this.#released;
1578
1469
  }
1579
1470
  #settleStoppedLifecycle() {
1580
1471
  void this.dispose().then(() => this.#resolveStoppedLifecycle(), () => this.#resolveStoppedLifecycle());
@@ -1602,9 +1493,9 @@ export class OwnedUiSessionShell {
1602
1493
  return view;
1603
1494
  }
1604
1495
  #syncTerminalProgress(view) {
1605
- if (!this.runtime.active)
1496
+ if (!this.#terminal.running)
1606
1497
  return;
1607
- this.runtime.setTerminalProgress(this.#terminalProgressEnabled && view.lifecycle === "busy");
1498
+ this.#terminal.setTerminalProgress(this.#terminalProgressEnabled && view.lifecycle === "busy");
1608
1499
  if (view.lifecycle === "stopped" || view.lifecycle === "stopping") {
1609
1500
  this.#programStatus.clear();
1610
1501
  return;
@@ -1642,7 +1533,6 @@ export class OwnedUiSessionShell {
1642
1533
  this.#dialogHandle = undefined;
1643
1534
  this.#dialogId = undefined;
1644
1535
  this.#dialogSource = undefined;
1645
- this.#setPointerReporting(false);
1646
1536
  this.root.setInputSurface(null);
1647
1537
  this.root.resetExtensionUi();
1648
1538
  this.root.resetWorkflowPresentation();
@@ -1650,7 +1540,7 @@ export class OwnedUiSessionShell {
1650
1540
  this.root.update(view);
1651
1541
  this.#syncDialog(view.dialog);
1652
1542
  this.#presentStartupTrustWarning(view);
1653
- this.runtime.requestRender();
1543
+ this.#terminal.requestRender();
1654
1544
  for (const listener of this.#listeners)
1655
1545
  listener(view);
1656
1546
  return view;
@@ -1661,16 +1551,16 @@ export class OwnedUiSessionShell {
1661
1551
  if (this.#startupTrustHandled || !this.#customViewport)
1662
1552
  return;
1663
1553
  const diagnostic = view.diagnostics.find(candidate => candidate.code === "project-trust");
1664
- if (diagnostic === undefined || !this.runtime.active)
1554
+ if (diagnostic === undefined || !this.#terminal.running)
1665
1555
  return;
1666
1556
  this.#startupTrustHandled = true;
1667
1557
  this.root.appendWorkflowMessage({ kind: "warning", message: diagnostic.message });
1668
1558
  }
1669
1559
  #presentStartupRoute() {
1670
1560
  const pending = this.#startupRoute;
1671
- if (pending === undefined || this.#disposed || !this.runtime.active)
1561
+ if (pending === undefined || this.#disposed || !this.#terminal.running)
1672
1562
  return;
1673
- if (this.runtime.hasOverlay() || !this.root.usesDefaultInputSurface()) {
1563
+ if (this.#terminal.hasOverlay() || !this.root.usesDefaultInputSurface()) {
1674
1564
  this.#startupRouteTimer = setTimeout(() => {
1675
1565
  this.#startupRouteTimer = undefined;
1676
1566
  this.#presentStartupRoute();
@@ -1685,121 +1575,33 @@ export class OwnedUiSessionShell {
1685
1575
  const surface = this.#routeHost?.open(route, input) ?? null;
1686
1576
  if (surface === null)
1687
1577
  return { outcome: "failed", diagnostic: `route is unavailable: ${route}` };
1688
- if (!this.runtime.active)
1578
+ if (!this.#terminal.running)
1689
1579
  return { outcome: "failed", diagnostic: "runtime is not active" };
1690
1580
  this.root.dismissNotice();
1691
1581
  this.#dialogHandle?.hide();
1692
1582
  this.#dialogSource = "route";
1693
- // Protocol: any-event reporting: hover and drag are what the screen is driven by, and
1694
- // it also stops the terminal treating a drag as a text selection.
1695
- this.#setPointerReporting(true);
1696
- // Protocol: the interrupt chord is global, so it is watched on raw input rather than
1697
- // through the overlay: the pinned shell handles that key before an overlay
1698
- // ever sees it, which is why an owned screen must not rely on being asked.
1699
- let armedAt = 0;
1700
- const removeInterruptWatch = this.runtime.addInputListener(data => {
1701
- if (!data.includes(INTERRUPT))
1702
- return undefined;
1703
- // Protocol: route hosts decide whether one interrupt closes their app. Raw-input forwarding
1704
- // is required because Pi handles Ctrl+C before the fullscreen overlay receives normal input.
1705
- const consumedBySurface = surface.handleInput(INTERRUPT);
1706
- if (surface.isClosed()) {
1707
- armedAt = 0;
1708
- closeSurface();
1709
- return { consume: true };
1710
- }
1711
- if (consumedBySurface) {
1712
- armedAt = 0;
1713
- this.runtime.requestRender();
1714
- return { consume: true };
1715
- }
1716
- const now = Date.now();
1717
- if (armedAt !== 0 && now - armedAt <= INTERRUPT_CHORD_MS) {
1718
- armedAt = 0;
1719
- closeSurface();
1720
- void this.shutdown();
1721
- return { consume: true };
1722
- }
1723
- armedAt = now;
1724
- this.runtime.requestRender();
1725
- // Invariant: the presented screen owns the chord, so the pinned shell never sees a
1726
- // stray interrupt while it is up.
1727
- return { consume: true };
1728
- });
1729
- let removeSurfacePreInput = () => { };
1730
- let rendered = false;
1731
- let closed = false;
1732
- const closeSurface = () => {
1733
- if (closed)
1734
- return;
1735
- closed = true;
1736
- removeSurfacePreInput();
1737
- removeInterruptWatch();
1738
- this.#setPointerReporting(false);
1739
- this.#dialogHandle?.hide();
1740
- this.#dialogHandle = undefined;
1741
- this.#dialogId = undefined;
1742
- this.#dialogSource = undefined;
1743
- if (rendered) {
1744
- try {
1745
- void onClosed?.()?.catch(() => undefined);
1583
+ const handle = this.#terminal.openRoute(surface, {
1584
+ exit: () => { void this.shutdown(); },
1585
+ closed: rendered => {
1586
+ if (this.#dialogHandle === handle) {
1587
+ this.#dialogHandle = undefined;
1588
+ this.#dialogId = undefined;
1589
+ this.#dialogSource = undefined;
1746
1590
  }
1747
- catch { /* Rationale: a failed acknowledgement stays pending. */ }
1748
- }
1749
- };
1750
- // Compatibility: fullscreen Pi owns a fallback text-selection layer before focused overlay
1751
- // components see pointer input. Route every mouse report to the owned screen
1752
- // at the pre-input boundary so dropdowns, value hover, and numeric +/- work,
1753
- // and consume even unhandled reports so settings content is never selected.
1754
- removeSurfacePreInput = this.runtime.addPreInputListener(data => {
1755
- const { events, rest } = parseMouseInput(data);
1756
- if (events.length === 0)
1757
- return undefined;
1758
- for (const event of events)
1759
- surface.handleMouse(event);
1760
- if (surface.isClosed())
1761
- closeSurface();
1762
- else
1763
- this.runtime.requestRender();
1764
- return rest.length === 0 ? { consume: true } : { data: rest };
1765
- });
1766
- const rows = () => Math.max(1, this.runtime.viewport().rows);
1767
- const component = {
1768
- render: (width) => {
1769
- const frame = [...surface.render(Math.max(1, width), rows())];
1770
- rendered = true;
1771
- return frame;
1772
- },
1773
- handleInput: (data) => {
1774
- const { events, rest } = parseMouseInput(data);
1775
- for (const event of events)
1776
- surface.handleMouse(event);
1777
- if (rest.length > 0)
1778
- surface.handleInput(rest);
1779
- if (surface.isClosed()) {
1780
- closeSurface();
1781
- return;
1591
+ if (rendered) {
1592
+ try {
1593
+ void onClosed?.()?.catch(() => undefined);
1594
+ }
1595
+ catch { /* Rationale: a failed acknowledgement stays pending. */ }
1782
1596
  }
1783
- this.runtime.requestRender();
1784
1597
  },
1785
- invalidate: () => this.runtime.requestRender(),
1786
- };
1787
- surface.onRenderRequested(() => this.runtime.requestRender());
1788
- surface.onExitRequested(() => {
1789
- closeSurface();
1790
- void this.shutdown();
1791
- });
1792
- this.#dialogHandle = this.runtime.showOverlay(component, {
1793
- width: "100%",
1794
- maxHeight: "100%",
1795
- anchor: "top-left",
1796
- inputCoordination: "owned",
1797
1598
  });
1599
+ this.#dialogHandle = handle;
1798
1600
  this.#dialogId = surface.id;
1799
1601
  return { outcome: "completed", diagnostic: null };
1800
1602
  }
1801
1603
  #syncDialog(dialog) {
1802
- if (!this.runtime.active)
1604
+ if (!this.#terminal.running)
1803
1605
  return;
1804
1606
  if (dialog === null) {
1805
1607
  // Invariant: locally owned routes (notably /settings) are independent of backend
@@ -1830,7 +1632,7 @@ export class OwnedUiSessionShell {
1830
1632
  this.#dialogSource = undefined;
1831
1633
  },
1832
1634
  });
1833
- this.#dialogHandle = this.runtime.showOverlay(component, {
1635
+ this.#dialogHandle = this.#terminal.showOverlay(component, {
1834
1636
  width: "70%",
1835
1637
  maxHeight: "80%",
1836
1638
  anchor: "center",
@@ -1896,7 +1698,7 @@ export class OwnedUiSessionShell {
1896
1698
  if (skill === undefined) {
1897
1699
  const message = "Unknown skill: " + token;
1898
1700
  this.root.appendWorkflowMessage({ kind: "error", message });
1899
- this.runtime.requestRender();
1701
+ this.#terminal.requestRender();
1900
1702
  return { outcome: "failed", diagnostic: message };
1901
1703
  }
1902
1704
  return this.#submitSkillPrompt(skillPrompt(skill.name, separator < 0 ? "" : trimmed.slice(separator + 1)), text);
@@ -1915,7 +1717,7 @@ export class OwnedUiSessionShell {
1915
1717
  #treeSummary() {
1916
1718
  return new Promise(resolve => {
1917
1719
  this.root.setInputSurface(createPiShellExtensionSelector("Summarize Branch?", ["No summary", "Summarize", "Summarize with custom prompt"], resolve, () => resolve(undefined)), true, "opaque");
1918
- this.runtime.requestRender();
1720
+ this.#terminal.requestRender();
1919
1721
  });
1920
1722
  }
1921
1723
  async #completeTreeSelection(entryId, skipSummaryPrompt) {
@@ -1938,7 +1740,7 @@ export class OwnedUiSessionShell {
1938
1740
  }
1939
1741
  }
1940
1742
  this.root.setInputSurface(null);
1941
- this.runtime.requestRender();
1743
+ this.#terminal.requestRender();
1942
1744
  const result = await this.runWorkflow({
1943
1745
  command: "tree",
1944
1746
  argument: "",
@@ -1954,9 +1756,9 @@ export class OwnedUiSessionShell {
1954
1756
  #startWorkflowLogin(request) {
1955
1757
  this.#finishWorkflowLogin();
1956
1758
  const dialog = createPiShellLoginDialog({
1957
- getColumns: () => this.runtime.viewport().columns,
1958
- getRows: () => this.runtime.viewport().rows,
1959
- requestRender: () => this.runtime.requestRender(),
1759
+ getColumns: () => this.#terminal.viewport().columns,
1760
+ getRows: () => this.#terminal.viewport().rows,
1761
+ requestRender: () => this.#terminal.requestRender(),
1960
1762
  }, request.providerId, success => {
1961
1763
  if (!success)
1962
1764
  this.#finishWorkflowLogin();
@@ -1968,7 +1770,7 @@ export class OwnedUiSessionShell {
1968
1770
  message: `Log in to ${request.providerName}`,
1969
1771
  });
1970
1772
  this.#syncTerminalProgress(this.view());
1971
- this.runtime.requestRender();
1773
+ this.#terminal.requestRender();
1972
1774
  }
1973
1775
  #requestWorkflowInput(request) {
1974
1776
  const dialog = this.#activeLoginDialog;
@@ -1981,7 +1783,7 @@ export class OwnedUiSessionShell {
1981
1783
  const restoreDialog = () => {
1982
1784
  if (this.#activeLoginDialog === dialog)
1983
1785
  this.root.setInputSurface(dialog);
1984
- this.runtime.requestRender();
1786
+ this.#terminal.requestRender();
1985
1787
  };
1986
1788
  const selector = createPiShellExtensionSelector(request.message, labels, label => {
1987
1789
  const id = options.find(option => option.label === label)?.id;
@@ -1992,13 +1794,13 @@ export class OwnedUiSessionShell {
1992
1794
  resolve(null);
1993
1795
  });
1994
1796
  this.root.setInputSurface(selector, false);
1995
- this.runtime.requestRender();
1797
+ this.#terminal.requestRender();
1996
1798
  });
1997
1799
  }
1998
1800
  const response = request.type === "manual-code"
1999
1801
  ? dialog.showManualInput(request.message)
2000
1802
  : dialog.showPrompt(request.message, request.placeholder);
2001
- this.runtime.requestRender();
1803
+ this.#terminal.requestRender();
2002
1804
  return response.then(value => value, () => null);
2003
1805
  }
2004
1806
  #notifyWorkflowLogin(event) {
@@ -2017,7 +1819,7 @@ export class OwnedUiSessionShell {
2017
1819
  dialog.showWaiting(event.message);
2018
1820
  else
2019
1821
  dialog.showProgress(event.message);
2020
- this.runtime.requestRender();
1822
+ this.#terminal.requestRender();
2021
1823
  }
2022
1824
  #finishWorkflowLogin() {
2023
1825
  if (!this.#activeLoginDialog)
@@ -2026,7 +1828,7 @@ export class OwnedUiSessionShell {
2026
1828
  this.root.setInputSurface(null);
2027
1829
  this.#programStatus.setBlocked("authentication", undefined);
2028
1830
  this.#syncTerminalProgress(this.view());
2029
- this.runtime.requestRender();
1831
+ this.#terminal.requestRender();
2030
1832
  }
2031
1833
  async #execute(command, draft) {
2032
1834
  if (draft === undefined)
@@ -2080,7 +1882,7 @@ export class OwnedUiSessionShell {
2080
1882
  try {
2081
1883
  this.root.appendWorkflowResult({ command: "debug", outcome: "failed", message: message
2082
1884
  ?? (error instanceof ImageAttachmentError ? error.message : "Submission failed. Check the prompt and try again.") }, error instanceof ImageAttachmentError ? error.code : undefined);
2083
- this.runtime.requestRender();
1885
+ this.#terminal.requestRender();
2084
1886
  }
2085
1887
  catch { /* Security: error presentation cannot create another rejected submission callback. */ }
2086
1888
  }
@@ -2092,6 +1894,29 @@ export class OwnedUiSessionShell {
2092
1894
  return `pi-shell-${prefix}-${this.#sequence}`;
2093
1895
  }
2094
1896
  }
1897
+ /**
1898
+ * One session on a terminal of its own: a terminal host with this presenter attached. Disposing it ends the
1899
+ * terminal, as quitting the single-session product does.
1900
+ */
1901
+ export class OwnedUiSessionShell extends OwnedUiSessionPresenter {
1902
+ constructor(options) {
1903
+ const host = new OwnedUiTerminalHost(sessionTerminalHostOptions(options));
1904
+ try {
1905
+ super(host, options);
1906
+ }
1907
+ catch (error) {
1908
+ void host.dispose().catch(() => { });
1909
+ throw error;
1910
+ }
1911
+ host.attach(this);
1912
+ }
1913
+ get runtime() {
1914
+ return this.terminalHost.runtime;
1915
+ }
1916
+ damagePresentationDecision() {
1917
+ return this.terminalHost.damagePresentationDecision;
1918
+ }
1919
+ }
2095
1920
  function isCoalescedStreamBlock(kind) {
2096
1921
  return kind === "assistant" || kind === "thinking" || kind === "tool-call" || kind === "tool-result";
2097
1922
  }
@@ -2134,9 +1959,4 @@ function workflowAdapterResult(result) {
2134
1959
  return { outcome: "failed", diagnostic: result.message };
2135
1960
  return { outcome: "rejected", diagnostic: result.message };
2136
1961
  }
2137
- const INTERRUPT = "\u0003";
2138
- const INTERRUPT_CHORD_MS = 1_500;
2139
1962
  const RELOAD_SURFACE_MIN_VISIBLE_MS = 400;
2140
- // Rationale: the outro is not configurable; the switch only decides whether this plan plays.
2141
- const QUIT_OUTRO_EFFECT = "fall";
2142
- const QUIT_OUTRO_DURATION_MS = 800;