@timurproko/a1 0.2.6-dev.727 → 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 (116) hide show
  1. package/dist/app/session-shell/clipboard-services.d.ts +19 -0
  2. package/dist/app/session-shell/clipboard-services.js +7 -0
  3. package/dist/app/session-shell/exit-notice.d.ts +9 -5
  4. package/dist/app/session-shell/exit-notice.js +5 -5
  5. package/dist/app/session-shell/index.d.ts +4 -2
  6. package/dist/app/session-shell/index.js +2 -1
  7. package/dist/app/session-shell/paste-preparation-client.d.ts +3 -0
  8. package/dist/app/session-shell/paste-preparation-client.js +5 -2
  9. package/dist/app/session-shell/path-chip-presentation.d.ts +10 -3
  10. package/dist/app/session-shell/path-chip-presentation.js +48 -6
  11. package/dist/app/session-shell/prompt-chips.js +30 -4
  12. package/dist/app/session-shell/quit-outro.d.ts +3 -1
  13. package/dist/app/session-shell/quit-outro.js +23 -8
  14. package/dist/app/session-shell/response-copy-transport.d.ts +6 -1
  15. package/dist/app/session-shell/response-copy-transport.js +8 -2
  16. package/dist/app/session-shell/session-shell-root.d.ts +29 -11
  17. package/dist/app/session-shell/session-shell-root.js +62 -53
  18. package/dist/app/session-shell/session-shell.d.ts +55 -29
  19. package/dist/app/session-shell/session-shell.js +748 -1132
  20. package/dist/app/session-shell/shell-host.d.ts +105 -0
  21. package/dist/app/session-shell/shell-host.js +523 -0
  22. package/dist/cli/session-context.js +6 -3
  23. package/dist/composition/owned-ui.d.ts +20 -2
  24. package/dist/composition/owned-ui.js +107 -56
  25. package/dist/composition/session-info-reference.d.ts +2 -2
  26. package/dist/composition/session-info-reference.js +2 -2
  27. package/dist/contracts/agent-engine/capability-ports.d.ts +1 -85
  28. package/dist/contracts/agent-engine/capability-ports.js +1 -35
  29. package/dist/contracts/agent-engine/index.d.ts +1 -7
  30. package/dist/contracts/agent-engine/index.js +0 -4
  31. package/dist/contracts/owned-ui/index.d.ts +5 -1
  32. package/dist/contracts/owned-ui/index.js +1 -0
  33. package/dist/contracts/owned-ui/model.d.ts +6 -0
  34. package/dist/contracts/owned-ui/session-backend.d.ts +119 -0
  35. package/dist/contracts/owned-ui/session-presenters.d.ts +82 -0
  36. package/dist/contracts/owned-ui/session-presenters.js +1 -0
  37. package/dist/contracts/owned-ui/session-workflows.d.ts +342 -0
  38. package/dist/contracts/owned-ui/session-workflows.js +30 -0
  39. package/dist/features/launch/index.d.ts +1 -1
  40. package/dist/features/launch/index.js +1 -1
  41. package/dist/features/launch/session-context.d.ts +1 -0
  42. package/dist/features/launch/session-context.js +1 -0
  43. package/dist/features/owned-ui/settings-app.js +35 -15
  44. package/dist/foundation/lifecycle/index.d.ts +1 -1
  45. package/dist/foundation/lifecycle/index.js +1 -1
  46. package/dist/foundation/lifecycle/session-repository-context.d.ts +8 -2
  47. package/dist/foundation/lifecycle/session-repository-context.js +200 -65
  48. package/dist/foundation/release/bootstrap.js +1 -2
  49. package/dist/foundation/release/release-gc.js +1 -1
  50. package/dist/foundation/release/update.js +0 -4
  51. package/dist/foundation/storage/control-store.js +0 -12
  52. package/dist/integrations/pi/components/components.js +0 -16
  53. package/dist/integrations/pi/components/index.d.ts +3 -3
  54. package/dist/integrations/pi/components/index.js +2 -2
  55. package/dist/integrations/pi/components/models-dialog.js +1 -30
  56. package/dist/integrations/pi/components/shell-editor-autocomplete.js +11 -9
  57. package/dist/integrations/pi/components/shell-extension-ui.d.ts +3 -3
  58. package/dist/integrations/pi/components/shell-extension-ui.js +6 -2
  59. package/dist/integrations/pi/components/shell-presenters-transcript.js +4 -4
  60. package/dist/integrations/pi/components/shell-shared-facade.d.ts +21 -1
  61. package/dist/integrations/pi/components/shell-shared-facade.js +18 -2
  62. package/dist/integrations/pi/components/upstream/theme/theme.d.ts +5 -1
  63. package/dist/integrations/pi/components/upstream/theme/theme.js +20 -2
  64. package/dist/integrations/pi/engine/adapter.d.ts +44 -24
  65. package/dist/integrations/pi/engine/adapter.js +130 -22
  66. package/dist/integrations/pi/engine/command-dispatch.d.ts +2 -5
  67. package/dist/integrations/pi/engine/extension-ui-binding.d.ts +2 -11
  68. package/dist/integrations/pi/engine/host-ports.d.ts +20 -0
  69. package/dist/integrations/pi/engine/host-ports.js +13 -0
  70. package/dist/integrations/pi/engine/host.d.ts +70 -0
  71. package/dist/integrations/pi/engine/host.js +147 -0
  72. package/dist/integrations/pi/engine/index.d.ts +8 -4
  73. package/dist/integrations/pi/engine/index.js +3 -1
  74. package/dist/integrations/pi/engine/resource-catalog.d.ts +4 -23
  75. package/dist/integrations/pi/engine/runtime-integration.d.ts +8 -2
  76. package/dist/integrations/pi/engine/runtime-integration.js +12 -7
  77. package/dist/integrations/pi/engine/session-integration.d.ts +3 -8
  78. package/dist/integrations/pi/engine/session-integration.js +0 -49
  79. package/dist/integrations/pi/engine/session-runtime.d.ts +18 -3
  80. package/dist/integrations/pi/engine/session-runtime.js +18 -7
  81. package/dist/integrations/pi/engine/settings-bridge.d.ts +2 -2
  82. package/dist/integrations/pi/engine/settings-bridge.js +0 -2
  83. package/dist/integrations/pi/engine/settings-port.d.ts +2 -0
  84. package/dist/integrations/pi/engine/settings-port.js +3 -5
  85. package/dist/integrations/pi/engine/workflows.d.ts +33 -260
  86. package/dist/integrations/pi/engine/workflows.js +6 -28
  87. package/dist/integrations/pi/session-presenters/index.d.ts +2 -0
  88. package/dist/integrations/pi/session-presenters/index.js +1 -0
  89. package/dist/integrations/pi/session-presenters/presenters.d.ts +16 -0
  90. package/dist/integrations/pi/session-presenters/presenters.js +341 -0
  91. package/dist/integrations/pi/tui-runtime/adapter.js +0 -2
  92. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +18 -0
  93. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +84 -3
  94. package/dist/integrations/pi/tui-runtime/index.d.ts +1 -1
  95. package/dist/integrations/pi/tui-runtime/index.js +1 -1
  96. package/dist/native/darwin-arm64/manifest.json +1 -1
  97. package/dist/native/linux-x64/manifest.json +1 -1
  98. package/dist/native/win32-x64/manifest.json +2 -2
  99. package/dist/native/win32-x64/process-guardian.exe +0 -0
  100. package/dist/ui/settings/declarations.d.ts +13 -1
  101. package/dist/ui/settings/declarations.js +12 -1
  102. package/dist/ui/settings/migrations.js +7 -0
  103. package/docs/architecture/boundaries.md +9 -1
  104. package/docs/architecture/project-structure.md +3 -2
  105. package/docs/architecture/ui-reference-provenance.md +1 -1
  106. package/package.json +1 -1
  107. package/dist/contracts/agent-engine/model.d.ts +0 -78
  108. package/dist/contracts/agent-engine/model.js +0 -1
  109. package/dist/contracts/agent-engine/ports.d.ts +0 -18
  110. package/dist/contracts/agent-engine/serialization.d.ts +0 -7
  111. package/dist/contracts/agent-engine/serialization.js +0 -58
  112. package/dist/contracts/agent-engine/validation.d.ts +0 -5
  113. package/dist/contracts/agent-engine/validation.js +0 -128
  114. package/dist/integrations/pi/components/upstream/theme/system-theme.d.ts +0 -61
  115. package/dist/integrations/pi/components/upstream/theme/system-theme.js +0 -709
  116. /package/dist/contracts/{agent-engine/ports.js → owned-ui/session-backend.js} +0 -0
@@ -4,34 +4,35 @@ import { MAX_COPY_CONTROL_BYTES } from "./response-copy-protocol.js";
4
4
  import { PromptHistoryController } from "./prompt-history-controller.js";
5
5
  import { PRODUCT_TEXT } from "../../product-identity.js";
6
6
  import { boundedCleanup } from "../../foundation/terminal-cleanup/terminal-reset.js";
7
- import { assertOwnedUiCommand } from "../../contracts/owned-ui/index.js";
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
- import { PINNED_PI_HIDDEN_COMMAND_NAMES, workflowCommandNames } from "../../integrations/pi/engine/workflows.js";
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
- import { createPiShellEditor } from "../../integrations/pi/components/shell-editor-autocomplete.js";
16
13
  import { SKILLS_COMMAND_NAME, findSkillByArgument, rewriteSkillsTunnelSubmission, skillPrompt, skillsFromCommands, } from "../../integrations/pi/components/skills-command.js";
17
- import { createPiQueuedInputStatus, createPiShellFooter, createPiShellHeader, createPiShellLoadedResources, createPiShellStatus, } from "../../integrations/pi/components/shell-footer-status.js";
18
- import { createPiShellArmin, createPiShellAuthProviderSelector, createPiShellDialog, createPiShellEarendilAnnouncement, createPiShellExtensionSelector, createPiShellLoginDialog, createPiShellModelSelector, createPiShellModelsDialog, createPiShellReloadBox, createPiShellScopedModelsSelector, createPiShellSelector, createPiShellSessionSelector, createPiShellSettingsSelector, createPiShellSkillsSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, } from "../../integrations/pi/components/shell-selectors-dialogs.js";
19
- import { createPiShellChangelog, createPiShellHotkeys, createPiShellSessionInfo, renderPiShellPackageUpdateNotice, renderPiShellStatusText, } from "../../integrations/pi/components/shell-presenters-info.js";
20
- import { createPiShellTranscriptComponent, renderPiShellStartupDiagnostic, renderPiShellTranscriptBlock, } from "../../integrations/pi/components/shell-presenters-transcript.js";
21
- import { onPiThemeChange, piTheme } from "../../integrations/pi/components/upstream/theme/theme.js";
22
- import { DamageAwareTerminalAdapter } from "../../integrations/pi/tui-runtime/damage-aware-terminal.js";
23
- import { PiTuiRuntimeAdapter } from "../../integrations/pi/tui-runtime/adapter.js";
24
- import { classifyPiTuiInput } from "../../integrations/pi/tui-runtime/input-presentation-coordinator.js";
14
+ import { createPiShellAuthProviderSelector, createPiShellDialog, createPiShellExtensionSelector, createPiShellLoginDialog, createPiShellReloadBox, createPiShellSelector, createPiShellSkillsSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, } from "../../integrations/pi/components/shell-selectors-dialogs.js";
15
+ import { renderPiShellStatusText, } from "../../integrations/pi/components/shell-presenters-info.js";
16
+ import { OwnedUiTerminalHost, sessionTerminalHostOptions, } from "./shell-host.js";
25
17
  import { runImageWorker } from "./image-preparation-client.js";
26
18
  import { STREAM_PRESENTATION_INTERVAL_MS, StreamPresentationCoalescer, } from "./stream-presentation-coalescer.js";
27
19
  import { writeSystemClipboardText, } from "./system-clipboard.js";
28
20
  import { OwnedUiSessionShellRoot, shellResourceEntries, } from "./session-shell-root.js";
21
+ // Invariant: a disposed shell leaves the engine a host that holds no reference back to it.
22
+ const DETACHED_WORKFLOW_INTERACTION = { prompt: async () => null, notify() { } };
29
23
  export { OwnedUiSessionShellRoot, } from "./session-shell-root.js";
30
- /** Coordinates backend, owned presentation, and Pi TUI lifecycles for one interactive session. */
31
- 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 {
32
30
  backend;
31
+ #presenters;
32
+ #dialogHost;
33
33
  root;
34
- runtime;
34
+ #host;
35
+ #terminal;
35
36
  #cwd;
36
37
  #listeners = new Set();
37
38
  #unsubscribe;
@@ -57,9 +58,11 @@ export class OwnedUiSessionShell {
57
58
  #editorRevision = 0;
58
59
  #started = false;
59
60
  #disposed = false;
61
+ #released;
60
62
  #shutdownPromise;
61
63
  #disposePromise;
62
- #pointerReporting = false;
64
+ #layoutParts;
65
+ #pendingClipboardWrite = Promise.resolve();
63
66
  // Invariant: translated startup diagnostics create at most one dock notice per kind per shell.
64
67
  #startupTrustHandled = false;
65
68
  #customViewport;
@@ -70,11 +73,9 @@ export class OwnedUiSessionShell {
70
73
  #copyExecutor;
71
74
  #copyIntentSequence = 0;
72
75
  #unbindClipboardWriter;
73
- #damageTerminal;
74
- #quitOutro;
75
76
  #reloadPresentation;
76
77
  #streamPresentation;
77
- #removeViewportPreInput;
78
+ #inputCoordination;
78
79
  #unsubscribeSettings;
79
80
  #unbindPiSettings;
80
81
  #unbindTerminalSettings;
@@ -91,556 +92,572 @@ export class OwnedUiSessionShell {
91
92
  #sessionGeneration;
92
93
  #sessionBindingGeneration;
93
94
  #suggestionModelKey;
94
- constructor(options) {
95
+ constructor(host, options) {
95
96
  const { backend, cwd, routeHost, sessionLayout } = options.engine;
96
- const { terminal, startup, viewportSettings, quitOutro, reload: reloadPresentation, stream: streamPresentationOptions, input: inputPresentation } = options.presentation ?? {};
97
+ const { terminal, startup, viewportSettings, reload: reloadPresentation, stream: streamPresentationOptions, input: inputPresentation, } = options.presentation ?? {};
97
98
  const { clipboard, responseCopy, paste: pasteDiagnostics, pastePreparation } = options.diagnostics ?? {};
98
99
  const promptHistory = options.history;
100
+ const { clipboard: sharedClipboard, keybindings: sharedKeybindings } = options.shared ?? {};
99
101
  this.backend = backend;
100
- this.#sessionGeneration = this.backend.sessionGeneration;
101
- this.#sessionBindingGeneration = this.backend.sessionBindingGeneration;
102
- this.#suggestionModelKey = modelKey(this.backend.view());
102
+ this.#presenters = options.presenters;
103
+ this.#sessionGeneration = this.backend.identity.sessionGeneration;
104
+ this.#sessionBindingGeneration = this.backend.identity.sessionBindingGeneration;
105
+ this.#suggestionModelKey = modelKey(this.backend.session.view());
103
106
  this.#cwd = cwd;
104
107
  this.#routeHost = routeHost ?? null;
105
108
  this.#startupRoute = options.engine.startupRoute;
106
109
  this.#customViewport = sessionLayout === "custom-viewport";
110
+ this.#inputCoordination = inputPresentation?.coordination !== false;
111
+ this.#host = host;
112
+ this.#terminal = host.connect(this);
113
+ // Invariant: the host's optional members follow the layout, so it is built only once the layout is known.
114
+ this.#dialogHost = this.#createDialogHost();
107
115
  this.#lazySelectors = options.diagnostics?.lazySelectors;
108
116
  this.#stopped = new Promise(resolve => {
109
117
  this.#resolveStopped = resolve;
110
118
  });
111
- let runtime;
112
- let damageTerminal;
113
- let streamPresentation;
114
- let pendingClipboardWrite = Promise.resolve();
115
119
  let promptSuggestionController = null;
116
120
  // Invariant: owned prompt limits and collapsed skills are bare-A1 replacements; comparison profiles keep pinned behavior.
117
121
  this.#promptImages = this.#customViewport ? options.promptImages ?? null : null;
118
122
  this.#skills = this.#customViewport ? options.skills ?? null : null;
119
- const terminalCopy = terminal !== undefined || hasAsyncClipboardOutput();
120
- this.#responseCopy = this.#customViewport ? new ResponseCopyCoordinator({
121
- execute: responseCopy?.execute ?? (this.#copyExecutor = createResponseCopyExecutor({
122
- ...(terminal === undefined && clipboard === undefined ? {} : { destination: "terminal" }),
123
- ...(clipboard?.writeText === undefined ? {} : { writeText: (text, signal) => clipboard.writeText(text, signal) }),
124
- ...(terminalCopy ? { terminal: { submit: async (control, signal) => {
125
- if (signal.aborted || this.#disposed || !runtime?.active)
126
- throw new Error("Copy canceled");
127
- // Performance: never grow the real terminal's pending buffer with another clipboard payload.
128
- if (terminal === undefined && process.stdout.writableLength + control.length > MAX_COPY_CONTROL_BYTES) {
129
- throw new Error("Clipboard terminal is busy");
130
- }
131
- runtime.writeControl(control);
132
- } } } : {}),
133
- })),
134
- ...(responseCopy?.onEvent === undefined ? {} : { onEvent: responseCopy.onEvent }),
135
- onFailure: result => {
136
- if (this.#disposed || !runtime?.active)
137
- return;
138
- this.root.appendWorkflowStatus(result.outcome === "timed-out" ? "Copy timed out; the clipboard did not respond."
139
- : result.failure === "size" ? "Selection exceeds this clipboard route's size limit."
140
- : "Could not copy the selection; the clipboard is unavailable.");
141
- runtime.requestRender();
142
- },
143
- }) : null;
144
- const readClipboard = async (signal) => {
145
- if (signal.aborted)
146
- throw new ImageAttachmentError("image-canceled");
147
- if (clipboard === undefined)
148
- return runImageWorker({ kind: "clipboard" }, signal);
149
- try {
150
- const image = await clipboard.readImage?.(signal);
151
- if (image !== null && image !== undefined) {
152
- const canonical = await runImageWorker({ kind: "canonicalize", source: image }, signal);
153
- if (canonical !== null)
154
- return { kind: "image", ...canonical };
155
- }
156
- }
157
- catch (error) {
158
- if (error instanceof ImageAttachmentError)
159
- throw error;
160
- // Compatibility: an unavailable image reader can still provide text.
161
- }
162
- const text = await clipboard.readText(signal);
163
- return text === null ? null : { kind: "text", text };
164
- };
165
- this.root = new OwnedUiSessionShellRoot(this.backend.view(), cwd, {
166
- getColumns: () => runtime?.viewport().columns ?? terminal?.columns ?? 80,
167
- getRows: () => runtime?.viewport().rows ?? terminal?.rows ?? 24,
168
- requestRender: force => runtime?.requestRender(force),
169
- requestHyperlinkCleanup: rows => damageTerminal?.requestHyperlinkCleanup(rows),
170
- onViewportFrame: frame => damageTerminal?.arm(frame.descriptor, {
171
- overlayActive: runtime?.hasOverlay() ?? false,
172
- selectionActive: this.root.hasActiveSelection(),
173
- replacementSurfaceActive: !this.root.usesDefaultInputSurface(),
174
- }),
175
- enableDockInputReuse: inputPresentation?.viewportReuse !== false,
176
- persistentHistory: this.#customViewport && promptHistory !== undefined,
177
- ...(promptHistory === undefined ? {} : { historyEditor: promptHistory.editor }),
178
- onSubmit: text => { void this.submit(text).catch(() => this.#reportSubmissionError()); },
179
- onPasteRejected: error => this.#reportSubmissionError(error),
180
- onInterrupt: () => { void this.interrupt(); },
181
- onClear: () => { void this.clearOrExit(); },
182
- onExit: () => { void this.shutdown(); },
183
- onModelSelect: () => this.#customViewport ? this.showModelsDialog() : this.showModelSelector(),
184
- onModelCycle: direction => { void this.cycleModel(direction); },
185
- onThinkingCycle: () => { void this.cycleThinkingLevel(); },
186
- onThinkingToggle: () => {
187
- this.root.toggleThinkingVisibility();
188
- this.runtime.requestRender();
189
- },
190
- onMessageCopy: () => { void this.runWorkflow({ command: "copy", argument: "" }); },
191
- onFollowUp: () => { void this.queueFollowUp().catch(() => this.#reportSubmissionError()); },
192
- onDequeue: () => this.restoreQueuedInput(),
193
- onEditorChange: text => {
194
- this.#editorRevision++;
195
- promptSuggestionController?.abortPending();
196
- if (text.length === 0) {
197
- // Concurrency: the editor synchronizes deletion-owned autocomplete after onChange.
198
- // Reassert retained ghost text in the microtask before Pi emits its scheduled frame.
199
- queueMicrotask(() => promptSuggestionController?.restoreAvailable());
123
+ // Invariant: construction is transactional. Each binding registers its release as it is made, and a
124
+ // throw releases them in reverse, so a shell that never finished constructing holds nothing.
125
+ const constructed = [() => host.disconnect(this)];
126
+ try {
127
+ const terminalCopy = terminal !== undefined || hasAsyncClipboardOutput();
128
+ this.#responseCopy = this.#customViewport ? new ResponseCopyCoordinator({
129
+ execute: responseCopy?.execute ?? (this.#copyExecutor = createResponseCopyExecutor({
130
+ ...(sharedClipboard === undefined ? {} : { pool: sharedClipboard.copy }),
131
+ ...(terminal === undefined && clipboard === undefined ? {} : { destination: "terminal" }),
132
+ ...(clipboard?.writeText === undefined ? {} : { writeText: (text, signal) => clipboard.writeText(text, signal) }),
133
+ ...(terminalCopy ? { terminal: { submit: async (control, signal) => {
134
+ if (signal.aborted || this.#disposed || !this.#terminal.running)
135
+ throw new Error("Copy canceled");
136
+ // Performance: never grow the real terminal's pending buffer with another clipboard payload.
137
+ if (terminal === undefined && process.stdout.writableLength + control.length > MAX_COPY_CONTROL_BYTES) {
138
+ throw new Error("Clipboard terminal is busy");
139
+ }
140
+ this.#terminal.writeControl(control);
141
+ } } } : {}),
142
+ })),
143
+ ...(responseCopy?.onEvent === undefined ? {} : { onEvent: responseCopy.onEvent }),
144
+ onFailure: result => {
145
+ if (this.#disposed || !this.#terminal.running)
146
+ return;
147
+ this.root.appendWorkflowStatus(result.outcome === "timed-out" ? "Copy timed out; the clipboard did not respond."
148
+ : result.failure === "size" ? "Selection exceeds this clipboard route's size limit."
149
+ : "Could not copy the selection; the clipboard is unavailable.");
150
+ this.#terminal.requestRender();
151
+ },
152
+ }) : null;
153
+ constructed.push(() => { this.#responseCopy?.dispose(); this.#copyExecutor?.dispose(); });
154
+ const readClipboard = async (signal) => {
155
+ if (signal.aborted)
156
+ throw new ImageAttachmentError("image-canceled");
157
+ if (clipboard === undefined)
158
+ return runImageWorker({ kind: "clipboard" }, signal);
159
+ try {
160
+ const image = await clipboard.readImage?.(signal);
161
+ if (image !== null && image !== undefined) {
162
+ const canonical = await runImageWorker({ kind: "canonicalize", source: image }, signal);
163
+ if (canonical !== null)
164
+ return { kind: "image", ...canonical };
165
+ }
200
166
  }
201
- this.root.reconcilePromptImageLimitNotice(text);
202
- },
203
- onPromptSuggestionAccepted: () => promptSuggestionController?.accept(),
204
- onPromptSuggestionEligibilityChanged: () => {
205
- queueMicrotask(() => promptSuggestionController?.restoreAvailable());
206
- },
207
- onInputSurfaceChanged: () => {
208
- promptSuggestionController?.invalidate();
209
- this.#promptHistory?.synchronize();
210
- },
211
- onCopyText: text => {
212
- if (this.#responseCopy !== null) {
213
- void this.#responseCopy.submitText(text);
214
- return;
167
+ catch (error) {
168
+ if (error instanceof ImageAttachmentError)
169
+ throw error;
170
+ // Compatibility: an unavailable image reader can still provide text.
215
171
  }
216
- const write = () => {
217
- if (this.#disposed)
218
- return Promise.resolve();
219
- runtime?.writeControl(`\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
220
- return clipboard === undefined ? writeSystemClipboardText(text)
221
- : clipboard.writeText?.(text) ?? Promise.resolve();
222
- };
223
- // Compatibility: comparison profiles retain their existing clipboard path.
224
- pendingClipboardWrite = write().catch(() => { });
225
- },
226
- readClipboardContent: async (signal = new AbortController().signal) => {
227
- await pendingClipboardWrite;
228
- return readClipboard(signal);
229
- },
230
- captureClipboardPaste: () => {
231
- const before = this.#responseCopy?.capturePasteBarrier() ?? (async () => true);
232
- if (clipboard !== undefined)
233
- return { kind: "provided", read: readClipboard, before };
234
- if (responseCopyDestination(process.env) === "terminal")
235
- return {
236
- kind: "provided", before, read: async () => { throw new ImageAttachmentError("paste-unavailable"); },
172
+ const text = await clipboard.readText(signal);
173
+ return text === null ? null : { kind: "text", text };
174
+ };
175
+ this.root = new OwnedUiSessionShellRoot(this.backend.session.view(), cwd, {
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(),
182
+ selectionActive: this.root.hasActiveSelection(),
183
+ replacementSurfaceActive: !this.root.usesDefaultInputSurface(),
184
+ }),
185
+ enableDockInputReuse: inputPresentation?.viewportReuse !== false,
186
+ persistentHistory: this.#customViewport && promptHistory !== undefined,
187
+ ...(promptHistory === undefined ? {} : { historyEditor: promptHistory.editor }),
188
+ onSubmit: text => { void this.submit(text).catch(() => this.#reportSubmissionError()); },
189
+ onPasteRejected: error => this.#reportSubmissionError(error),
190
+ onInterrupt: () => { void this.interrupt(); },
191
+ onClear: () => { void this.clearOrExit(); },
192
+ onExit: () => { void this.shutdown(); },
193
+ onModelSelect: () => this.#customViewport ? this.showModelsDialog() : this.showModelSelector(),
194
+ onModelCycle: direction => { void this.cycleModel(direction); },
195
+ onThinkingCycle: () => { void this.cycleThinkingLevel(); },
196
+ onThinkingToggle: () => {
197
+ this.root.toggleThinkingVisibility();
198
+ this.#terminal.requestRender();
199
+ },
200
+ onMessageCopy: () => { void this.runWorkflow({ command: "copy", argument: "" }); },
201
+ onFollowUp: () => { void this.queueFollowUp().catch(() => this.#reportSubmissionError()); },
202
+ onDequeue: () => this.restoreQueuedInput(),
203
+ onEditorChange: text => {
204
+ this.#editorRevision++;
205
+ promptSuggestionController?.abortPending();
206
+ if (text.length === 0) {
207
+ // Concurrency: the editor synchronizes deletion-owned autocomplete after onChange.
208
+ // Reassert retained ghost text in the microtask before Pi emits its scheduled frame.
209
+ queueMicrotask(() => promptSuggestionController?.restoreAvailable());
210
+ }
211
+ this.root.reconcilePromptImageLimitNotice(text);
212
+ },
213
+ onPromptSuggestionAccepted: () => promptSuggestionController?.accept(),
214
+ onPromptSuggestionEligibilityChanged: () => {
215
+ queueMicrotask(() => promptSuggestionController?.restoreAvailable());
216
+ },
217
+ onInputSurfaceChanged: () => {
218
+ promptSuggestionController?.invalidate();
219
+ this.#promptHistory?.synchronize();
220
+ },
221
+ onCopyText: text => {
222
+ if (this.#responseCopy !== null) {
223
+ void this.#responseCopy.submitText(text);
224
+ return;
225
+ }
226
+ const write = () => {
227
+ if (this.#disposed)
228
+ return Promise.resolve();
229
+ this.#terminal.writeControl(`\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
230
+ return clipboard === undefined ? writeSystemClipboardText(text)
231
+ : clipboard.writeText?.(text) ?? Promise.resolve();
237
232
  };
238
- return { kind: "native", before };
239
- },
240
- ...(pasteDiagnostics === undefined ? {} : { pasteDiagnostics: pasteDiagnostics }),
241
- ...(pastePreparation === undefined ? {} : { pastePreparation }),
242
- ...(this.#promptImages === null ? {} : { promptImageLimit: this.#promptImages.limit }),
243
- skillsPresentation: () => this.#skills?.presentation() ?? "expand",
244
- }, {
245
- ...startup,
246
- resources: startup?.resources ?? shellResourceEntries(this.backend),
247
- }, this.backend.agentDir, {
248
- getMessageRenderer: customType => this.backend.pinnedMessageRenderer(customType),
249
- getToolRenderers: toolName => this.backend.pinnedToolRenderers(toolName),
250
- getShortcuts: bindings => this.backend.pinnedShortcutDescriptions(bindings),
251
- }, sessionLayout, {
252
- resolve: assetId => this.backend.resolveTranscriptImage(assetId),
253
- });
254
- const initialPiSettings = this.backend.pinnedSettingsSnapshot();
255
- // Invariant: bare A1 owns a bounded viewport and therefore always runs on the alternate
256
- // fullscreen surface. The pinned comparison profiles still honor Pi's mode.
257
- const tuiMode = this.#customViewport
258
- ? "fullscreen"
259
- : this.backend.disposed ? "regular" : initialPiSettings.tuiMode;
260
- const runtimeOptions = {
261
- root: this.root,
262
- mode: tuiMode,
263
- ...(this.#customViewport ? {
264
- // Invariant: the owned shell enables and routes mouse reports itself. Pi's
265
- // enclosing fullscreen renderer must never establish a competing white selection.
266
- mouse: false,
267
- consumeUnhandledMouse: true,
268
- onOverlayGeometry: surfaces => this.root.setViewportOverlaySurfaces(surfaces),
269
- decorateTerminal: (terminal) => {
270
- damageTerminal = new DamageAwareTerminalAdapter(terminal, {
271
- regionalScroll: process.env.TERM !== "dumb",
272
- inspectHyperlinks: readVisibleHyperlinks,
273
- onResize: () => streamPresentation?.noteImmediatePresentation(),
274
- onHyperlinkCleanupRequired: () => runtime?.requestRender(true),
275
- });
276
- return damageTerminal;
233
+ // Compatibility: comparison profiles retain their existing clipboard path.
234
+ this.#pendingClipboardWrite = write().catch(() => { });
235
+ },
236
+ readClipboardContent: async (signal = new AbortController().signal) => {
237
+ await this.#pendingClipboardWrite;
238
+ return readClipboard(signal);
277
239
  },
278
- ...(inputPresentation?.coordination === false ? {} : {
279
- inputCoordination: {
280
- classify: (data, focusedOverlay) => classifyPiTuiInput(data, focusedOverlay ?? this.root.inputCoordinationSurface()),
281
- onReceipt: () => streamPresentation?.noteImmediatePresentation(),
282
- ...(inputPresentation?.scheduler === undefined
283
- ? {}
284
- : { scheduler: inputPresentation.scheduler }),
240
+ captureClipboardPaste: () => {
241
+ const before = this.#responseCopy?.capturePasteBarrier() ?? (async () => true);
242
+ if (clipboard !== undefined)
243
+ return { kind: "provided", read: readClipboard, before };
244
+ if (responseCopyDestination(process.env) === "terminal")
245
+ return {
246
+ kind: "provided", before, read: async () => { throw new ImageAttachmentError("paste-unavailable"); },
247
+ };
248
+ return { kind: "native", before };
249
+ },
250
+ ...(pasteDiagnostics === undefined ? {} : { pasteDiagnostics: pasteDiagnostics }),
251
+ ...(pastePreparation === undefined && sharedClipboard === undefined ? {} : {
252
+ pastePreparation: { ...pastePreparation, ...(sharedClipboard === undefined ? {} : { pool: sharedClipboard.paste }) },
253
+ }),
254
+ ...(sharedKeybindings === undefined ? {} : { keybindings: sharedKeybindings }),
255
+ ...(this.#promptImages === null ? {} : { promptImageLimit: this.#promptImages.limit }),
256
+ skillsPresentation: () => this.#skills?.presentation() ?? "expand",
257
+ }, {
258
+ ...startup,
259
+ resources: startup?.resources ?? shellResourceEntries(this.backend),
260
+ }, this.backend.identity.agentDir, this.#presenters.transcriptRenderers(), sessionLayout, {
261
+ resolve: assetId => this.backend.extensions.resolveTranscriptImage(assetId),
262
+ });
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());
266
+ const initialPiSettings = this.backend.settings.snapshot();
267
+ this.#programStatus = new ProgramStatusReporter(status => this.#terminal.setProgramStatus(status), () => this.view().status.footer?.sessionName ?? undefined);
268
+ this.#reloadPresentation = reloadPresentation;
269
+ const presentationInterval = streamPresentationOptions?.intervalMs ?? STREAM_PRESENTATION_INTERVAL_MS;
270
+ this.#streamPresentation = streamPresentationOptions?.scheduler === undefined
271
+ ? new StreamPresentationCoalescer(() => this.#terminal.requestRender(), presentationInterval)
272
+ : new StreamPresentationCoalescer(() => this.#terminal.requestRender(), presentationInterval, streamPresentationOptions.scheduler);
273
+ constructed.push(() => this.#streamPresentation.dispose());
274
+ const promptSuggestionOptions = this.#customViewport ? options.suggestions : undefined;
275
+ promptSuggestionController = promptSuggestionOptions === undefined ? null : new ContextualPromptSuggestionController({
276
+ generator: promptSuggestionOptions.generator,
277
+ enabled: promptSuggestionOptions.enabled(),
278
+ ...(promptSuggestionOptions.diagnostics === undefined ? {} : { diagnostics: promptSuggestionOptions.diagnostics }),
279
+ surface: {
280
+ canPresent: identity => this.#promptSuggestionPresentationBlockReason(identity) === null,
281
+ presentationBlockReason: identity => this.#promptSuggestionPresentationBlockReason(identity),
282
+ present: text => {
283
+ if (!this.root.canPresentPromptSuggestion())
284
+ return false;
285
+ this.root.setPromptSuggestion(text);
286
+ return true;
285
287
  },
288
+ clear: () => this.root.setPromptSuggestion(null),
289
+ requestRender: () => this.#terminal.requestRender(),
290
+ },
291
+ });
292
+ this.#promptSuggestions = promptSuggestionController;
293
+ constructed.push(() => this.#promptSuggestions?.dispose());
294
+ this.#unsubscribePromptSuggestions = promptSuggestionOptions === undefined
295
+ ? () => { }
296
+ : promptSuggestionOptions.onChange(enabled => this.#promptSuggestions?.setEnabled(enabled));
297
+ constructed.push(this.#unsubscribePromptSuggestions);
298
+ this.#unsubscribePromptImages = this.#promptImages === null ? () => { } : this.#promptImages.onChange(() => {
299
+ if (!this.#disposed && this.root.reconcilePromptImageLimitNotice())
300
+ this.#terminal.requestRender();
301
+ });
302
+ constructed.push(this.#unsubscribePromptImages);
303
+ // Rationale: the same listener refreshes the menu for the A1 presentation choice and for the engine's
304
+ // skill-command registration, both of which the owned settings manager reports through one change.
305
+ this.#unsubscribeSkills = this.#skills === null ? () => { } : this.#skills.onChange(() => {
306
+ // Invariant: reinstalling drops extension provider wrappers, so an unrelated setting change leaves the list alone.
307
+ if (this.#disposed || this.#commandListSignature() === this.#installedCommandSignature)
308
+ return;
309
+ this.#installAutocompleteCommands();
310
+ this.#terminal.requestRender();
311
+ });
312
+ constructed.push(this.#unsubscribeSkills);
313
+ this.#terminal.setHardwareCursor(initialPiSettings.showHardwareCursor);
314
+ this.#terminal.setClearOnShrink(initialPiSettings.clearOnShrink);
315
+ this.#terminalProgressEnabled = initialPiSettings.showTerminalProgress;
316
+ this.#fullscreenExitOutput = initialPiSettings.fullscreenExitOutput;
317
+ // Invariant: bare A1 prints only the resume hint at exit, so the pinned exit-output
318
+ // choice is hidden there and cannot be bound; the comparison profile binds and honors it.
319
+ this.#unbindShutdownSettings = this.backend.settings.productMode === "bare" ? () => { } : this.backend.settings.bindOwner("shutdown", {
320
+ fullscreenExitOutput: { apply() { } },
321
+ });
322
+ constructed.push(this.#unbindShutdownSettings);
323
+ this.#unbindTerminalSettings = this.backend.settings.bindOwner("terminal", {
324
+ showHardwareCursor: { apply: value => {
325
+ if (typeof value !== "boolean")
326
+ throw new TypeError("Hardware cursor setting is invalid");
327
+ this.#terminal.setHardwareCursor(value);
328
+ } },
329
+ clearOnShrink: { apply: value => {
330
+ if (typeof value !== "boolean")
331
+ throw new TypeError("Clear-on-shrink setting is invalid");
332
+ this.#terminal.setClearOnShrink(value);
333
+ } },
334
+ showTerminalProgress: { apply: value => {
335
+ if (typeof value !== "boolean")
336
+ throw new TypeError("Terminal progress setting is invalid");
337
+ this.#terminalProgressEnabled = value;
338
+ this.#syncTerminalProgress(this.view());
339
+ } },
340
+ });
341
+ constructed.push(this.#unbindTerminalSettings);
342
+ const applyViewportSettings = () => {
343
+ const snapshot = viewportSettings?.snapshot();
344
+ this.root.setViewportConfig(snapshot ?? {
345
+ scrollbarAppearance: "auto",
346
+ scrollbarStyle: "thin",
347
+ scrollbarSpeed: "normal",
348
+ });
349
+ };
350
+ applyViewportSettings();
351
+ this.#unsubscribeSettings = this.#customViewport && viewportSettings
352
+ ? viewportSettings.onChange(settings => this.root.setViewportConfig(settings))
353
+ : () => { };
354
+ constructed.push(this.#unsubscribeSettings);
355
+ this.root.setEditorPaddingX(initialPiSettings.editorPaddingX);
356
+ this.root.setAutocompleteMaxVisible(initialPiSettings.autocompleteMaxVisible);
357
+ this.root.setOutputPad(initialPiSettings.outputPad);
358
+ this.root.setFullscreenCopyOnSelect(initialPiSettings.fullscreenCopyOnSelect);
359
+ this.root.setHideThinkingBlock(initialPiSettings.hideThinkingBlock);
360
+ this.root.setMermaidRenderingMode(initialPiSettings.mermaidRenderingMode);
361
+ this.#showImages = initialPiSettings.showImages;
362
+ this.#imageWidthCells = initialPiSettings.imageWidthCells;
363
+ this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
364
+ this.#unbindPiSettings = this.backend.settings.bindOwner("shell", {
365
+ editorPaddingX: { apply: value => {
366
+ if (typeof value !== "number")
367
+ throw new TypeError("Editor padding is invalid");
368
+ this.root.setEditorPaddingX(value);
369
+ } },
370
+ autocompleteMaxVisible: { apply: value => {
371
+ if (typeof value !== "number")
372
+ throw new TypeError("Autocomplete maximum is invalid");
373
+ this.root.setAutocompleteMaxVisible(value);
374
+ } },
375
+ outputPad: { apply: value => {
376
+ if (value !== 0 && value !== 1)
377
+ throw new TypeError("Output padding is invalid");
378
+ this.root.setOutputPad(value);
379
+ } },
380
+ hideThinkingBlock: { apply: value => {
381
+ if (typeof value !== "boolean")
382
+ throw new TypeError("Thinking-block visibility is invalid");
383
+ this.root.setHideThinkingBlock(value);
384
+ } },
385
+ mermaidRenderingMode: { apply: value => {
386
+ if (value !== "off" && value !== "final" && value !== "streaming")
387
+ throw new TypeError("Mermaid mode is invalid");
388
+ this.root.setMermaidRenderingMode(value);
389
+ } },
390
+ showImages: { apply: value => {
391
+ if (typeof value !== "boolean")
392
+ throw new TypeError("Image visibility is invalid");
393
+ this.#showImages = value;
394
+ this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
395
+ } },
396
+ imageWidthCells: { apply: value => {
397
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
398
+ throw new TypeError("Image width is invalid");
399
+ this.#imageWidthCells = value;
400
+ this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
401
+ } },
402
+ ...(this.#customViewport ? { fullscreenCopyOnSelect: { apply: (value) => {
403
+ if (typeof value !== "boolean")
404
+ throw new TypeError("Fullscreen copy-on-select setting is invalid");
405
+ this.root.setFullscreenCopyOnSelect(value);
406
+ } } } : {
407
+ fullscreenWheelScrollLines: { apply: (value) => {
408
+ if (value !== "auto" && (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 100)) {
409
+ throw new TypeError("Fullscreen wheel-scroll distance is invalid");
410
+ }
411
+ this.#terminal.setWheelScrollLines(value);
412
+ } },
286
413
  }),
287
- } : { layoutRoot: this.root.layoutRoot() }),
288
- ...(inputPresentation?.onEvent === undefined ? {} : {
289
- inputDiagnostics: {
290
- onEvent: inputPresentation.onEvent,
291
- ...(inputPresentation.now === undefined ? {} : { now: inputPresentation.now }),
414
+ });
415
+ constructed.push(this.#unbindPiSettings);
416
+ if (this.#customViewport && promptHistory !== undefined) {
417
+ this.#promptHistoryImageSidecar = promptHistory.imageSidecar;
418
+ const sidecar = this.#promptHistoryImageSidecar;
419
+ this.#promptHistory = new PromptHistoryController({
420
+ editor: this.root.editor,
421
+ store: promptHistory.store,
422
+ limit: promptHistory.limit,
423
+ fallback: this.view().transcript.flatMap(block => block.kind === "user" ? [block.text] : []),
424
+ active: () => this.root.usesDefaultInputSurface(),
425
+ render: () => this.#terminal.requestRender(),
426
+ rehydrate: value => this.root.rehydrateHistoryText(value, id => sidecar?.readAttachment(id) ?? null),
427
+ });
428
+ constructed.push(() => { void this.#promptHistory?.close().catch(() => false); });
429
+ }
430
+ this.#extensionBridge = createPiExtensionUiBridge({
431
+ runtime: {
432
+ getColumns: () => this.#terminal.viewport().columns,
433
+ getRows: () => this.#terminal.viewport().rows,
434
+ requestRender: () => this.#terminal.requestRender(),
292
435
  },
293
- }),
294
- ...(terminal === undefined ? {} : { terminal: terminal }),
295
- hardwareCursor: this.backend.view().terminal.hardwareCursor,
296
- ...(this.#customViewport ? {} : { wheelScrollLines: initialPiSettings.fullscreenWheelScrollLines }),
297
- };
298
- runtime = new PiTuiRuntimeAdapter(runtimeOptions);
299
- this.runtime = runtime;
300
- this.#programStatus = new ProgramStatusReporter(status => { if (this.runtime.active)
301
- this.runtime.setProgramStatus(status); }, () => this.view().status.footer?.sessionName ?? undefined);
302
- this.#damageTerminal = damageTerminal ?? null;
303
- this.#quitOutro = quitOutro;
304
- this.#reloadPresentation = reloadPresentation;
305
- const presentationInterval = streamPresentationOptions?.intervalMs ?? STREAM_PRESENTATION_INTERVAL_MS;
306
- streamPresentation = streamPresentationOptions?.scheduler === undefined
307
- ? new StreamPresentationCoalescer(() => this.runtime.requestRender(), presentationInterval)
308
- : new StreamPresentationCoalescer(() => this.runtime.requestRender(), presentationInterval, streamPresentationOptions.scheduler);
309
- this.#streamPresentation = streamPresentation;
310
- const promptSuggestionOptions = this.#customViewport ? options.suggestions : undefined;
311
- promptSuggestionController = promptSuggestionOptions === undefined ? null : new ContextualPromptSuggestionController({
312
- generator: promptSuggestionOptions.generator,
313
- enabled: promptSuggestionOptions.enabled(),
314
- ...(promptSuggestionOptions.diagnostics === undefined ? {} : { diagnostics: promptSuggestionOptions.diagnostics }),
315
- surface: {
316
- canPresent: identity => this.#promptSuggestionPresentationBlockReason(identity) === null,
317
- presentationBlockReason: identity => this.#promptSuggestionPresentationBlockReason(identity),
318
- present: text => {
319
- if (!this.root.canPresentPromptSuggestion())
320
- return false;
321
- this.root.setPromptSuggestion(text);
322
- return true;
436
+ agentDir: this.backend.identity.agentDir,
437
+ setInputSurface: component => this.root.setInputSurface(component, true, "opaque"),
438
+ setProgramStatusBlocked: status => this.#programStatus.setBlocked("extension-dialog", status),
439
+ showOverlay: (component, overlayOptions) => this.#terminal.showOverlay(component, overlayOptions),
440
+ listenInput: handler => this.#terminal.addInputListener(handler),
441
+ replaceWidget: (key, component, placement) => this.root.setExtensionWidget(key, component, placement),
442
+ replaceHeader: component => this.root.setExtensionHeader(component),
443
+ replaceFooter: component => this.root.setExtensionFooter(component),
444
+ setStatus: (key, text) => this.root.setExtensionStatus(key, text),
445
+ setWorking: (message, visible) => this.root.setExtensionWorking(message, visible),
446
+ notify: (message, type) => this.root.addExtensionNotification(message, type),
447
+ setTitle: title => this.#terminal.setTitle(title),
448
+ getEditorText: () => this.root.editor.getText(),
449
+ setEditorText: text => this.root.editor.setText(text),
450
+ pasteToEditor: text => this.root.editor.insertText(text),
451
+ addAutocompleteProvider: factory => this.root.editor.addAutocompleteProvider(factory),
452
+ setCustomEditor: component => this.root.setInputSurface(component, true, "opaque"),
453
+ getFooterData: () => this.root.extensionFooterData(),
454
+ getToolsExpanded: () => this.root.toolsExpanded,
455
+ setToolsExpanded: expanded => this.root.setToolsExpanded(expanded),
456
+ });
457
+ constructed.push(() => this.#extensionBridge.dispose());
458
+ constructed.push(() => this.backend.workflows.setWorkflowInteractionHost(DETACHED_WORKFLOW_INTERACTION));
459
+ this.backend.workflows.setWorkflowInteractionHost({
460
+ startLogin: request => this.#startWorkflowLogin(request),
461
+ prompt: request => this.#requestWorkflowInput(request),
462
+ notify: event => this.#notifyWorkflowLogin(event),
463
+ publish: message => {
464
+ this.root.appendWorkflowMessage(message);
465
+ this.#terminal.requestRender();
323
466
  },
324
- clear: () => this.root.setPromptSuggestion(null),
325
- requestRender: () => this.runtime.requestRender(),
326
- },
327
- });
328
- this.#promptSuggestions = promptSuggestionController;
329
- this.#unsubscribePromptSuggestions = promptSuggestionOptions === undefined
330
- ? () => { }
331
- : promptSuggestionOptions.onChange(enabled => this.#promptSuggestions?.setEnabled(enabled));
332
- this.#unsubscribePromptImages = this.#promptImages === null ? () => { } : this.#promptImages.onChange(() => {
333
- if (!this.#disposed && this.root.reconcilePromptImageLimitNotice())
334
- this.runtime.requestRender();
335
- });
336
- // Rationale: the same listener refreshes the menu for the A1 presentation choice and for the engine's
337
- // skill-command registration, both of which the owned settings manager reports through one change.
338
- this.#unsubscribeSkills = this.#skills === null ? () => { } : this.#skills.onChange(() => {
339
- // Invariant: reinstalling drops extension provider wrappers, so an unrelated setting change leaves the list alone.
340
- if (this.#disposed || this.#commandListSignature() === this.#installedCommandSignature)
341
- return;
467
+ finishLogin: () => this.#finishWorkflowLogin(),
468
+ });
342
469
  this.#installAutocompleteCommands();
343
- this.runtime.requestRender();
344
- });
345
- this.runtime.setHardwareCursor(initialPiSettings.showHardwareCursor);
346
- this.runtime.setClearOnShrink(initialPiSettings.clearOnShrink);
347
- this.#terminalProgressEnabled = initialPiSettings.showTerminalProgress;
348
- this.#fullscreenExitOutput = initialPiSettings.fullscreenExitOutput;
349
- // Invariant: bare A1 prints only the resume hint at exit, so the pinned exit-output
350
- // choice is hidden there and cannot be bound; the comparison profile binds and honors it.
351
- this.#unbindShutdownSettings = this.backend.settingsProductMode === "bare" ? () => { } : this.backend.bindSettingsOwner("shutdown", {
352
- fullscreenExitOutput: { apply() { } },
353
- });
354
- this.#unbindTerminalSettings = this.backend.bindSettingsOwner("terminal", {
355
- showHardwareCursor: { apply: value => {
356
- if (typeof value !== "boolean")
357
- throw new TypeError("Hardware cursor setting is invalid");
358
- this.runtime.setHardwareCursor(value);
359
- } },
360
- clearOnShrink: { apply: value => {
361
- if (typeof value !== "boolean")
362
- throw new TypeError("Clear-on-shrink setting is invalid");
363
- this.runtime.setClearOnShrink(value);
364
- } },
365
- showTerminalProgress: { apply: value => {
366
- if (typeof value !== "boolean")
367
- throw new TypeError("Terminal progress setting is invalid");
368
- this.#terminalProgressEnabled = value;
369
- this.#syncTerminalProgress(this.view());
370
- } },
371
- });
372
- this.#removeViewportPreInput = this.#customViewport
373
- ? this.runtime.addPreInputListener(data => {
374
- if (inputPresentation?.coordination === false)
375
- this.#streamPresentation.noteImmediatePresentation();
376
- // Compatibility: route modal paging and boundary keys before Pi's fullscreen handlers.
377
- const isDefault = this.root.usesDefaultInputSurface();
378
- if (!this.runtime.hasOverlay() && !isDefault
379
- && (this.root.editor.matchesTerminalKey(data, "pageUp")
380
- || this.root.editor.matchesTerminalKey(data, "pageDown"))) {
381
- this.root.handleInput(data);
382
- this.runtime.requestRender();
383
- return { consume: true };
470
+ this.#unsubscribe = this.backend.session.onEvent(event => {
471
+ // Invariant: once disposal begins no event touches the view; only the stop still settles the shell.
472
+ if (this.#disposed) {
473
+ if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
474
+ this.#settleStoppedLifecycle();
475
+ return;
476
+ }
477
+ // Performance: a streamed chunk names one block, and touching only that block is what keeps the
478
+ // cost of a chunk the same in a long session as in a new one. Everything else
479
+ // resynchronizes the view, which is cheap next to re-reading the transcript.
480
+ if (event.type === "agent-run-started") {
481
+ this.#promptSuggestions?.invalidate();
482
+ this.root.resumeViewportFollowing();
384
483
  }
385
- if (!this.runtime.hasOverlay() && (this.root.editor.matchesTerminalKey(data, "home")
386
- || this.root.editor.matchesTerminalKey(data, "end")
387
- || !isDefault && (this.root.editor.matchesTerminalKey(data, "ctrl+home")
388
- || this.root.editor.matchesTerminalKey(data, "ctrl+end")))) {
389
- if (isDefault)
390
- this.root.handleViewportPreInput(data, true);
391
- this.root.handleInput(data);
392
- this.runtime.requestRender();
393
- return { consume: true };
484
+ if (event.type === "assistant-message-completed" && !event.successful) {
485
+ this.#programStatus.setErrorMessage(this.view().status.diagnostics.at(-1));
394
486
  }
395
- // Invariant: geometry must belong to the painted frame, including newly opened/nested
396
- // surfaces. Steady pointer input does not trigger a synchronous composition.
397
- const viewport = this.runtime.viewport();
398
- if (data.includes("\u001b[<") && !this.root.viewportInputGeometryReady(viewport.columns, viewport.rows)) {
399
- this.runtime.renderNow();
487
+ if (event.type === "agent-run-started" || event.type === "assistant-message-completed" || event.type === "agent-run-settled") {
488
+ this.#programStatus.handleEvent(event);
489
+ }
490
+ if (event.type === "assistant-message-completed") {
491
+ this.root.noteCompletedAssistantMessage();
492
+ const identity = {
493
+ sessionId: event.sessionId,
494
+ sessionGeneration: event.sessionGeneration,
495
+ runSequence: event.runSequence,
496
+ responseSequence: event.responseSequence,
497
+ model: event.model,
498
+ };
499
+ if (event.model === null)
500
+ this.#promptSuggestions?.skip(identity, "no-model");
501
+ else
502
+ this.#promptSuggestions?.consider({ ...identity, model: event.model }, !event.successful ? "failed-response"
503
+ : event.toolContinuation || event.stopReason === "toolUse" ? "tool-continuation"
504
+ : event.stopReason !== "stop" ? "incomplete-response"
505
+ : event.assistantMessageCount < 2 ? "early-conversation"
506
+ : this.root.promptSuggestionPrepareBlockReason());
400
507
  }
401
- const routed = this.root.handleViewportPreInput(data, true, Date.now(), this.root.usesDefaultInputSurface() && !this.runtime.hasFocusedOverlay());
402
- if (routed.copySelection !== undefined) {
403
- const snapshot = routed.copySelection;
404
- const acknowledgement = copyAcknowledgement(snapshot);
405
- const intent = ++this.#copyIntentSequence;
406
- void this.#responseCopy?.submit(snapshot, pendingClipboardWrite).then(result => {
407
- if (this.#disposed || !runtime.active || intent !== this.#copyIntentSequence
408
- || result.outcome !== "delivered" && result.outcome !== "submitted-unverified")
409
- return;
410
- if (acknowledgement.hasNonWhitespace) {
411
- this.root.showCopyAcknowledgement(`copied ${acknowledgement.characters} chars to clipboard`);
412
- }
413
- });
508
+ const semanticOnly = event.type === "agent-run-started" || event.type === "assistant-message-completed";
509
+ const view = event.type === "transcript-block" && this.#sessionGeneration === this.backend.identity.sessionGeneration
510
+ ? this.#syncBlock(event.block)
511
+ : semanticOnly ? this.view() : this.#syncView();
512
+ this.#syncTerminalProgress(view);
513
+ const currentModelKey = modelKey(view);
514
+ if (this.backend.identity.sessionGeneration !== this.#sessionGeneration || currentModelKey !== this.#suggestionModelKey) {
515
+ this.#promptSuggestions?.invalidate();
516
+ this.#suggestionModelKey = currentModelKey;
414
517
  }
415
- if (!routed.consumed)
416
- return routed.data === data ? undefined : { data: routed.data };
417
- return routed.data.length === 0 ? { consume: true } : { data: routed.data };
418
- })
419
- : () => { };
420
- const applyViewportSettings = () => {
421
- const snapshot = viewportSettings?.snapshot();
422
- this.root.setViewportConfig(snapshot ?? {
423
- scrollbarAppearance: "auto",
424
- scrollbarStyle: "thin",
425
- scrollbarSpeed: "normal",
518
+ if (event.type === "status" && /^(Retrying|Compacting)/.test(event.status.workingMessage ?? "")) {
519
+ this.#promptSuggestions?.invalidate();
520
+ }
521
+ if (event.type === "agent-run-settled") {
522
+ const identity = {
523
+ sessionId: event.sessionId,
524
+ sessionGeneration: event.sessionGeneration,
525
+ runSequence: event.runSequence,
526
+ responseSequence: event.responseSequence,
527
+ model: event.model,
528
+ };
529
+ if (event.model === null)
530
+ this.#promptSuggestions?.skip(identity, "no-model");
531
+ else
532
+ this.#promptSuggestions?.settle({ ...identity, model: event.model }, !event.successful ? "failed-response"
533
+ : event.toolContinuation || event.stopReason === "toolUse" ? "tool-continuation"
534
+ : event.stopReason !== "stop" ? "incomplete-response"
535
+ : event.assistantMessageCount < 2 ? "early-conversation"
536
+ : this.root.promptSuggestionPrepareBlockReason());
537
+ }
538
+ if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
539
+ this.#settleStoppedLifecycle();
426
540
  });
427
- };
428
- applyViewportSettings();
429
- this.#unsubscribeSettings = this.#customViewport && viewportSettings
430
- ? viewportSettings.onChange(settings => this.root.setViewportConfig(settings))
431
- : () => { };
432
- this.root.setEditorPaddingX(initialPiSettings.editorPaddingX);
433
- this.root.setAutocompleteMaxVisible(initialPiSettings.autocompleteMaxVisible);
434
- this.root.setOutputPad(initialPiSettings.outputPad);
435
- this.root.setFullscreenCopyOnSelect(initialPiSettings.fullscreenCopyOnSelect);
436
- this.root.setHideThinkingBlock(initialPiSettings.hideThinkingBlock);
437
- this.root.setMermaidRenderingMode(initialPiSettings.mermaidRenderingMode);
438
- this.#showImages = initialPiSettings.showImages;
439
- this.#imageWidthCells = initialPiSettings.imageWidthCells;
440
- this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
441
- this.#unbindPiSettings = this.backend.bindSettingsOwner("shell", {
442
- editorPaddingX: { apply: value => {
443
- if (typeof value !== "number")
444
- throw new TypeError("Editor padding is invalid");
445
- this.root.setEditorPaddingX(value);
446
- } },
447
- autocompleteMaxVisible: { apply: value => {
448
- if (typeof value !== "number")
449
- throw new TypeError("Autocomplete maximum is invalid");
450
- this.root.setAutocompleteMaxVisible(value);
451
- } },
452
- outputPad: { apply: value => {
453
- if (value !== 0 && value !== 1)
454
- throw new TypeError("Output padding is invalid");
455
- this.root.setOutputPad(value);
456
- } },
457
- hideThinkingBlock: { apply: value => {
458
- if (typeof value !== "boolean")
459
- throw new TypeError("Thinking-block visibility is invalid");
460
- this.root.setHideThinkingBlock(value);
461
- } },
462
- mermaidRenderingMode: { apply: value => {
463
- if (value !== "off" && value !== "final" && value !== "streaming")
464
- throw new TypeError("Mermaid mode is invalid");
465
- this.root.setMermaidRenderingMode(value);
466
- } },
467
- showImages: { apply: value => {
468
- if (typeof value !== "boolean")
469
- throw new TypeError("Image visibility is invalid");
470
- this.#showImages = value;
471
- this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
472
- } },
473
- imageWidthCells: { apply: value => {
474
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
475
- throw new TypeError("Image width is invalid");
476
- this.#imageWidthCells = value;
477
- this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
478
- } },
479
- ...(this.#customViewport ? { fullscreenCopyOnSelect: { apply: (value) => {
480
- if (typeof value !== "boolean")
481
- throw new TypeError("Fullscreen copy-on-select setting is invalid");
482
- this.root.setFullscreenCopyOnSelect(value);
483
- } } } : {
484
- fullscreenWheelScrollLines: { apply: (value) => {
485
- if (value !== "auto" && (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 100)) {
486
- throw new TypeError("Fullscreen wheel-scroll distance is invalid");
487
- }
488
- this.runtime.setWheelScrollLines(value);
489
- } },
490
- }),
491
- });
492
- if (this.#customViewport && promptHistory !== undefined) {
493
- this.#promptHistoryImageSidecar = promptHistory.imageSidecar;
494
- const sidecar = this.#promptHistoryImageSidecar;
495
- this.#promptHistory = new PromptHistoryController({
496
- editor: this.root.editor,
497
- store: promptHistory.store,
498
- limit: promptHistory.limit,
499
- fallback: this.view().transcript.flatMap(block => block.kind === "user" ? [block.text] : []),
500
- active: () => this.root.usesDefaultInputSurface(),
501
- render: () => this.runtime.requestRender(),
502
- rehydrate: value => this.root.rehydrateHistoryText(value, id => sidecar?.readAttachment(id) ?? null),
541
+ constructed.push(this.#unsubscribe);
542
+ this.#unbindClipboardWriter = this.#responseCopy === null ? () => { } : this.backend.extensions.bindClipboardWriter(async (text) => {
543
+ const result = await this.#responseCopy.submitText(text);
544
+ if (result.outcome === "delivered")
545
+ return true;
546
+ if (result.outcome === "submitted-unverified")
547
+ return false;
548
+ throw new Error(result.outcome === "timed-out" ? "Clipboard delivery timed out" : "Clipboard delivery could not be completed");
503
549
  });
550
+ constructed.push(this.#unbindClipboardWriter);
551
+ if (this.backend.session.view().lifecycle === "stopped")
552
+ this.#resolveStopped?.();
504
553
  }
505
- this.#extensionBridge = createPiExtensionUiBridge({
506
- runtime: {
507
- getColumns: () => this.runtime.viewport().columns,
508
- getRows: () => this.runtime.viewport().rows,
509
- requestRender: () => this.runtime.requestRender(),
510
- },
511
- agentDir: this.backend.agentDir,
512
- setInputSurface: component => this.root.setInputSurface(component, true, "opaque"),
513
- setProgramStatusBlocked: status => this.#programStatus.setBlocked("extension-dialog", status),
514
- showOverlay: (component, overlayOptions) => this.runtime.showOverlay(component, overlayOptions),
515
- listenInput: handler => this.runtime.addInputListener(handler),
516
- replaceWidget: (key, component, placement) => this.root.setExtensionWidget(key, component, placement),
517
- replaceHeader: component => this.root.setExtensionHeader(component),
518
- replaceFooter: component => this.root.setExtensionFooter(component),
519
- setStatus: (key, text) => this.root.setExtensionStatus(key, text),
520
- setWorking: (message, visible) => this.root.setExtensionWorking(message, visible),
521
- notify: (message, type) => this.root.addExtensionNotification(message, type),
522
- setTitle: title => this.runtime.setTitle(title),
523
- getEditorText: () => this.root.editor.getText(),
524
- setEditorText: text => this.root.editor.setText(text),
525
- pasteToEditor: text => this.root.editor.insertText(text),
526
- addAutocompleteProvider: factory => this.root.editor.addAutocompleteProvider(factory),
527
- setCustomEditor: component => this.root.setInputSurface(component, true, "opaque"),
528
- getFooterData: () => this.root.extensionFooterData(),
529
- getToolsExpanded: () => this.root.toolsExpanded,
530
- setToolsExpanded: expanded => this.root.setToolsExpanded(expanded),
531
- });
532
- this.backend.setWorkflowInteractionHost({
533
- startLogin: request => this.#startWorkflowLogin(request),
534
- prompt: request => this.#requestWorkflowInput(request),
535
- notify: event => this.#notifyWorkflowLogin(event),
536
- publish: message => {
537
- this.root.appendWorkflowMessage(message);
538
- this.runtime.requestRender();
539
- },
540
- finishLogin: () => this.#finishWorkflowLogin(),
541
- });
542
- this.#installAutocompleteCommands();
543
- this.#unsubscribe = this.backend.onEvent(event => {
544
- // Performance: a streamed chunk names one block, and touching only that block is what keeps the
545
- // cost of a chunk the same in a long session as in a new one. Everything else
546
- // resynchronizes the view, which is cheap next to re-reading the transcript.
547
- if (event.type === "agent-run-started") {
548
- this.#promptSuggestions?.invalidate();
549
- this.root.resumeViewportFollowing();
550
- }
551
- if (event.type === "assistant-message-completed" && !event.successful) {
552
- this.#programStatus.setErrorMessage(this.view().status.diagnostics.at(-1));
553
- }
554
- if (event.type === "agent-run-started" || event.type === "assistant-message-completed" || event.type === "agent-run-settled") {
555
- this.#programStatus.handleEvent(event);
556
- }
557
- if (event.type === "assistant-message-completed") {
558
- this.root.noteCompletedAssistantMessage();
559
- const identity = {
560
- sessionId: event.sessionId,
561
- sessionGeneration: event.sessionGeneration,
562
- runSequence: event.runSequence,
563
- responseSequence: event.responseSequence,
564
- model: event.model,
565
- };
566
- if (event.model === null)
567
- this.#promptSuggestions?.skip(identity, "no-model");
568
- else
569
- this.#promptSuggestions?.consider({ ...identity, model: event.model }, !event.successful ? "failed-response"
570
- : event.toolContinuation || event.stopReason === "toolUse" ? "tool-continuation"
571
- : event.stopReason !== "stop" ? "incomplete-response"
572
- : event.assistantMessageCount < 2 ? "early-conversation"
573
- : this.root.promptSuggestionPrepareBlockReason());
574
- }
575
- const semanticOnly = event.type === "agent-run-started" || event.type === "assistant-message-completed";
576
- const view = event.type === "transcript-block" && this.#sessionGeneration === this.backend.sessionGeneration
577
- ? this.#syncBlock(event.block)
578
- : semanticOnly ? this.view() : this.#syncView();
579
- this.#syncTerminalProgress(view);
580
- const currentModelKey = modelKey(view);
581
- if (this.backend.sessionGeneration !== this.#sessionGeneration || currentModelKey !== this.#suggestionModelKey) {
582
- this.#promptSuggestions?.invalidate();
583
- this.#suggestionModelKey = currentModelKey;
584
- }
585
- if (event.type === "status" && /^(Retrying|Compacting)/.test(event.status.workingMessage ?? "")) {
586
- this.#promptSuggestions?.invalidate();
587
- }
588
- if (event.type === "agent-run-settled") {
589
- const identity = {
590
- sessionId: event.sessionId,
591
- sessionGeneration: event.sessionGeneration,
592
- runSequence: event.runSequence,
593
- responseSequence: event.responseSequence,
594
- model: event.model,
595
- };
596
- if (event.model === null)
597
- this.#promptSuggestions?.skip(identity, "no-model");
598
- else
599
- this.#promptSuggestions?.settle({ ...identity, model: event.model }, !event.successful ? "failed-response"
600
- : event.toolContinuation || event.stopReason === "toolUse" ? "tool-continuation"
601
- : event.stopReason !== "stop" ? "incomplete-response"
602
- : event.assistantMessageCount < 2 ? "early-conversation"
603
- : this.root.promptSuggestionPrepareBlockReason());
554
+ catch (error) {
555
+ for (const release of constructed.reverse()) {
556
+ try {
557
+ release();
558
+ }
559
+ catch { /* Rationale: the construction failure is the error worth reporting. */ }
604
560
  }
605
- if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
606
- this.#settleStoppedLifecycle();
607
- });
608
- this.#unbindClipboardWriter = this.#responseCopy === null ? () => { } : this.backend.bindClipboardWriter(async (text) => {
609
- const result = await this.#responseCopy.submitText(text);
610
- if (result.outcome === "delivered")
611
- return true;
612
- if (result.outcome === "submitted-unverified")
613
- return false;
614
- throw new Error(result.outcome === "timed-out" ? "Clipboard delivery timed out" : "Clipboard delivery could not be completed");
615
- });
616
- if (this.backend.view().lifecycle === "stopped")
617
- this.#resolveStopped?.();
561
+ throw error;
562
+ }
618
563
  }
619
564
  view() {
620
- return this.backend.view();
565
+ return this.backend.session.view();
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 };
621
643
  }
622
644
  #promptSuggestionPresentationBlockReason(identity) {
623
645
  const view = this.view();
624
646
  if (this.#disposed)
625
647
  return "disposed";
626
- if (identity.sessionId !== view.sessionId || identity.sessionGeneration !== this.backend.sessionGeneration
648
+ if (identity.sessionId !== view.sessionId || identity.sessionGeneration !== this.backend.identity.sessionGeneration
627
649
  || modelKey(view) !== `${identity.model.providerId}/${identity.model.modelId}`)
628
650
  return "stale-identity";
629
651
  return this.root.promptSuggestionPresentationBlockReason();
630
652
  }
631
- damagePresentationDecision() {
632
- return this.#damageTerminal?.lastDecision ?? null;
633
- }
634
653
  start() {
635
654
  if (this.#started)
636
655
  return;
637
656
  this.#started = true;
638
- this.runtime.start();
657
+ this.#terminal.start();
639
658
  this.#promptHistory?.start();
640
659
  this.#syncTerminalProgress(this.view());
641
- if (this.#customViewport)
642
- this.#setPointerReporting(true);
643
- void this.backend.bindExtensionUi(this.#extensionBridge.context, () => { void this.shutdown(); });
660
+ void this.backend.extensions.bindExtensionUi(this.#extensionBridge.context, () => { void this.shutdown(); });
644
661
  this.#syncView();
645
662
  // Performance: the spare clipboard helpers fork after the first frame is out, so startup never waits on them.
646
663
  setImmediate(() => {
@@ -670,10 +687,10 @@ export class OwnedUiSessionShell {
670
687
  if (!this.root.hasPendingPastes(draft))
671
688
  return this.#guardSubmission(draft, action);
672
689
  const controller = new AbortController();
673
- const generation = this.backend.sessionGeneration;
690
+ const generation = this.backend.identity.sessionGeneration;
674
691
  const result = this.#guardSubmission(draft, async () => {
675
692
  await this.root.waitForPromptPastes(draft, controller.signal);
676
- if (controller.signal.aborted || this.#disposed || this.backend.sessionGeneration !== generation)
693
+ if (controller.signal.aborted || this.#disposed || this.backend.identity.sessionGeneration !== generation)
677
694
  return rejected("image submission canceled");
678
695
  return action();
679
696
  }).finally(() => {
@@ -693,7 +710,7 @@ export class OwnedUiSessionShell {
693
710
  render: width => [...renderPiShellStatusText(`Waiting for images (${count} submission${count === 1 ? "" : "s"}) — Esc cancels; dequeue restores`, width)],
694
711
  invalidate: () => { },
695
712
  }, "aboveEditor");
696
- this.runtime.requestRender();
713
+ this.#terminal.requestRender();
697
714
  }
698
715
  #cancelWaitingImages() {
699
716
  for (const item of this.#waitingImages.values())
@@ -707,7 +724,7 @@ export class OwnedUiSessionShell {
707
724
  this.#promptHistory.rememberRecovery(text);
708
725
  else {
709
726
  this.#persistImageSidecars(text);
710
- this.#promptHistory.capture(reusable, kind, this.#cwd, this.backend.sessionId);
727
+ this.#promptHistory.capture(reusable, kind, this.#cwd, this.backend.identity.sessionId);
711
728
  }
712
729
  }
713
730
  }
@@ -738,7 +755,7 @@ export class OwnedUiSessionShell {
738
755
  if (command) {
739
756
  this.#rememberInput(displayInput, "bash");
740
757
  try {
741
- const result = await this.backend.executeBashWorkflow(command, excludeFromContext);
758
+ const result = await this.backend.workflows.executeBashWorkflow(command, excludeFromContext);
742
759
  const workflow = {
743
760
  command: "debug",
744
761
  outcome: result.cancelled ? "cancelled" : result.exitCode === 0 || result.exitCode === undefined ? "completed" : "failed",
@@ -746,13 +763,13 @@ export class OwnedUiSessionShell {
746
763
  ...(result.output ? { detail: result.output } : {}),
747
764
  };
748
765
  this.root.appendWorkflowResult(workflow);
749
- this.runtime.requestRender();
766
+ this.#terminal.requestRender();
750
767
  return workflow.outcome === "failed" ? rejected(workflow.message) : { outcome: "completed", diagnostic: null };
751
768
  }
752
769
  catch (error) {
753
770
  const message = `Bash command failed: ${error instanceof Error ? error.message : String(error)}`;
754
771
  this.root.appendWorkflowResult({ command: "debug", outcome: "failed", message });
755
- this.runtime.requestRender();
772
+ this.#terminal.requestRender();
756
773
  return rejected(message);
757
774
  }
758
775
  }
@@ -767,7 +784,7 @@ export class OwnedUiSessionShell {
767
784
  return await this.#execute({
768
785
  type,
769
786
  correlationId: this.#correlation(type),
770
- sessionId: this.backend.sessionId,
787
+ sessionId: this.backend.identity.sessionId,
771
788
  text: input,
772
789
  ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
773
790
  }, displayInput);
@@ -782,7 +799,7 @@ export class OwnedUiSessionShell {
782
799
  return this.shutdown();
783
800
  this.root.editor.setText("");
784
801
  this.#lastClearTime = now;
785
- this.runtime.requestRender();
802
+ this.#terminal.requestRender();
786
803
  return { outcome: "completed", diagnostic: null };
787
804
  }
788
805
  async interrupt(now = Date.now()) {
@@ -800,7 +817,7 @@ export class OwnedUiSessionShell {
800
817
  // Compatibility: match Pi: with an empty editor, two escapes inside 500 ms open the
801
818
  // configured session navigator. The first escape intentionally does not
802
819
  // interrupt or mutate the prompt.
803
- const action = this.backend.pinnedSettingsSnapshot().doubleEscapeAction;
820
+ const action = this.backend.settings.snapshot().doubleEscapeAction;
804
821
  if (action === "none")
805
822
  return { outcome: "rejected", diagnostic: "nothing to interrupt" };
806
823
  if (now - this.#lastEscapeTime < 500) {
@@ -834,7 +851,7 @@ export class OwnedUiSessionShell {
834
851
  return this.#execute({
835
852
  type: "resume-session",
836
853
  correlationId: this.#correlation("resume"),
837
- sessionId: this.backend.sessionId,
854
+ sessionId: this.backend.identity.sessionId,
838
855
  sessionPath,
839
856
  });
840
857
  }
@@ -843,7 +860,7 @@ export class OwnedUiSessionShell {
843
860
  return this.#execute({
844
861
  type: "set-model",
845
862
  correlationId: this.#correlation("model"),
846
- sessionId: this.backend.sessionId,
863
+ sessionId: this.backend.identity.sessionId,
847
864
  model: { providerId, modelId, displayName: modelId },
848
865
  });
849
866
  }
@@ -851,7 +868,7 @@ export class OwnedUiSessionShell {
851
868
  return this.#execute({
852
869
  type: "set-thinking-level",
853
870
  correlationId: this.#correlation("thinking"),
854
- sessionId: this.backend.sessionId,
871
+ sessionId: this.backend.identity.sessionId,
855
872
  thinkingLevel,
856
873
  });
857
874
  }
@@ -861,9 +878,9 @@ export class OwnedUiSessionShell {
861
878
  return this.setThinkingLevel(levels[(current + 1) % levels.length] ?? "off");
862
879
  }
863
880
  async cycleModel(direction) {
864
- const result = await this.backend.cycleModelWorkflow(direction);
881
+ const result = await this.backend.workflows.cycleModelWorkflow(direction);
865
882
  this.root.appendWorkflowResult(result);
866
- this.runtime.requestRender();
883
+ this.#terminal.requestRender();
867
884
  return workflowAdapterResult(result);
868
885
  }
869
886
  async queueFollowUp() {
@@ -884,18 +901,18 @@ export class OwnedUiSessionShell {
884
901
  return this.#execute({
885
902
  type: "follow-up",
886
903
  correlationId: this.#correlation("follow-up"),
887
- sessionId: this.backend.sessionId,
904
+ sessionId: this.backend.identity.sessionId,
888
905
  text,
889
906
  ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
890
907
  }, displayInput);
891
908
  }
892
909
  restoreQueuedInput() {
893
- const queued = [...this.#waitingImages.keys(), ...this.backend.clearQueuedWorkflows()];
910
+ const queued = [...this.#waitingImages.keys(), ...this.backend.workflows.clearQueuedWorkflows()];
894
911
  this.#cancelWaitingImages();
895
912
  if (queued.length === 0)
896
913
  return;
897
914
  this.root.editor.setText(queued.join("\n"));
898
- this.runtime.requestRender();
915
+ this.#terminal.requestRender();
899
916
  }
900
917
  showSelector(title, options, onSelect, onCancel) {
901
918
  this.#dialogHandle?.hide();
@@ -917,7 +934,7 @@ export class OwnedUiSessionShell {
917
934
  onCancel?.();
918
935
  },
919
936
  });
920
- const handle = this.runtime.showOverlay(component, {
937
+ const handle = this.#terminal.showOverlay(component, {
921
938
  width: "70%",
922
939
  maxHeight: "80%",
923
940
  anchor: "center",
@@ -926,6 +943,24 @@ export class OwnedUiSessionShell {
926
943
  this.#dialogHandle = handle;
927
944
  return handle;
928
945
  }
946
+ // Rationale: presenters see the shell only as a dialog host; selectors open in the input dock and run choices as workflows.
947
+ #createDialogHost() {
948
+ const isDisposed = () => this.#disposed;
949
+ return {
950
+ get disposed() { return isDisposed(); },
951
+ setInputSurface: surface => this.root.setInputSurface(surface),
952
+ requestRender: () => this.#terminal.requestRender(),
953
+ viewport: () => this.#terminal.viewport(),
954
+ appendWorkflowStatus: text => this.root.appendWorkflowStatus(text),
955
+ appendWorkflowResult: result => this.root.appendWorkflowResult(result),
956
+ runWorkflow: request => this.runWorkflow(request),
957
+ // Invariant: the custom bare-A1 surface holds frames while optional selectors load and is permanently
958
+ // fullscreen; the pinned comparison profiles render immediately and keep Pi's TUI mode switch.
959
+ ...(this.#customViewport
960
+ ? { beginPresentationHold: () => this.#terminal.beginPresentationHold() }
961
+ : { switchTuiMode: mode => this.#terminal.switchMode(mode) }),
962
+ };
963
+ }
929
964
  #loadLazySelectors() {
930
965
  if (this.#lazySelectors !== undefined)
931
966
  return Promise.resolve(this.#lazySelectors);
@@ -935,17 +970,17 @@ export class OwnedUiSessionShell {
935
970
  });
936
971
  }
937
972
  async showThinkingSelector() {
938
- const snapshot = this.backend.pinnedSettingsSnapshot();
973
+ const snapshot = this.backend.settings.snapshot();
939
974
  const close = () => {
940
975
  this.root.setFooterLevel(true);
941
976
  this.root.setInputSurface(null);
942
- this.runtime.requestRender();
977
+ this.#terminal.requestRender();
943
978
  };
944
979
  const select = (level) => {
945
980
  close();
946
981
  void this.runWorkflow({ command: "thinking", argument: "", selection: level });
947
982
  };
948
- const releasePresentation = this.#customViewport ? this.runtime.beginPresentationHold() : undefined;
983
+ const releasePresentation = this.#customViewport ? this.#terminal.beginPresentationHold() : undefined;
949
984
  try {
950
985
  const selectors = await this.#loadLazySelectors();
951
986
  const component = await selectors.createThinking({
@@ -953,7 +988,7 @@ export class OwnedUiSessionShell {
953
988
  availableLevels: snapshot.availableThinkingLevels,
954
989
  onSelect: select,
955
990
  onCancel: close,
956
- onSelectAsDefault: level => this.backend.setDefaultThinkingLevel(level),
991
+ onSelectAsDefault: level => this.backend.settings.setDefaultThinkingLevel(level),
957
992
  defaultLevel: snapshot.defaultThinkingLevel,
958
993
  ...(this.#customViewport ? {
959
994
  presentation: {
@@ -966,7 +1001,7 @@ export class OwnedUiSessionShell {
966
1001
  return;
967
1002
  this.root.setFooterLevel(false);
968
1003
  this.root.setInputSurface(component);
969
- this.runtime.requestRender();
1004
+ this.#terminal.requestRender();
970
1005
  }
971
1006
  finally {
972
1007
  releasePresentation?.();
@@ -976,7 +1011,7 @@ export class OwnedUiSessionShell {
976
1011
  showSkillsSelector(skills = this.#skillSummaries()) {
977
1012
  const close = () => {
978
1013
  this.root.setInputSurface(null);
979
- this.runtime.requestRender();
1014
+ this.#terminal.requestRender();
980
1015
  };
981
1016
  const component = createPiShellSkillsSelector({
982
1017
  skills,
@@ -987,57 +1022,33 @@ export class OwnedUiSessionShell {
987
1022
  onCancel: close,
988
1023
  });
989
1024
  this.root.setInputSurface(component);
990
- this.runtime.requestRender();
1025
+ this.#terminal.requestRender();
991
1026
  }
992
1027
  showModelSelector(initialSearchInput) {
993
- const context = this.backend.pinnedModelSelectorContext();
994
- const close = () => {
995
- this.root.setInputSurface(null);
996
- this.runtime.requestRender();
997
- };
998
- const component = createPiShellModelSelector({
999
- ...context,
1000
- runtime: {
1001
- getColumns: () => this.runtime.viewport().columns,
1002
- getRows: () => this.runtime.viewport().rows,
1003
- requestRender: () => this.runtime.requestRender(),
1004
- },
1005
- ...(initialSearchInput === undefined ? {} : { initialSearchInput }),
1006
- onSelect: model => {
1007
- close();
1008
- void this.runWorkflow({ command: "model", argument: "", selection: modelReference(model) });
1009
- },
1010
- onSelectAsDefault: model => {
1011
- close();
1012
- void this.runWorkflow({ command: "model", argument: "", selection: modelReference(model), persist: true });
1013
- },
1014
- onCancel: close,
1015
- });
1016
- this.root.setInputSurface(component);
1017
- this.runtime.requestRender();
1028
+ this.#presenters.openModelSelector(this.#dialogHost, initialSearchInput);
1018
1029
  }
1019
1030
  showForkSelector() {
1020
- const options = this.backend.pinnedForkOptions();
1031
+ const options = this.backend.catalog.pinnedForkOptions();
1021
1032
  if (options.length === 0) {
1022
1033
  this.root.appendWorkflowResult({ command: "fork", outcome: "completed", message: "No messages to fork from", messageKind: "status" });
1023
- this.runtime.requestRender();
1034
+ this.#terminal.requestRender();
1024
1035
  return;
1025
1036
  }
1026
1037
  const close = () => {
1027
1038
  this.root.setInputSurface(null);
1028
- this.runtime.requestRender();
1039
+ this.#terminal.requestRender();
1029
1040
  };
1030
1041
  const component = createPiShellUserMessageSelector(options, selection => {
1031
1042
  close();
1032
1043
  void this.runWorkflow({ command: "fork", argument: "", selection });
1033
1044
  }, close);
1034
1045
  this.root.setInputSurface(component);
1035
- this.runtime.requestRender();
1046
+ this.#terminal.requestRender();
1036
1047
  }
1037
1048
  async showLogoutSelector() {
1038
1049
  let options;
1039
1050
  try {
1040
- options = await this.backend.pinnedLogoutOptions();
1051
+ options = await this.backend.catalog.pinnedLogoutOptions();
1041
1052
  }
1042
1053
  catch (error) {
1043
1054
  this.root.appendWorkflowResult({
@@ -1045,27 +1056,27 @@ export class OwnedUiSessionShell {
1045
1056
  outcome: "failed",
1046
1057
  message: `Could not read stored credentials: ${error instanceof Error ? error.message : String(error)}`,
1047
1058
  });
1048
- this.runtime.requestRender();
1059
+ this.#terminal.requestRender();
1049
1060
  return;
1050
1061
  }
1051
1062
  if (options.length === 0) {
1052
1063
  this.root.appendWorkflowStatus("No stored credentials to remove. /logout only removes credentials saved by /login; environment variables and models.json config are unchanged.");
1053
- this.runtime.requestRender();
1064
+ this.#terminal.requestRender();
1054
1065
  return;
1055
1066
  }
1056
1067
  const close = () => {
1057
1068
  this.root.setInputSurface(null);
1058
- this.runtime.requestRender();
1069
+ this.#terminal.requestRender();
1059
1070
  };
1060
1071
  const component = createPiShellAuthProviderSelector("logout", options, selection => {
1061
1072
  close();
1062
1073
  void this.runWorkflow({ command: "logout", argument: "", selection });
1063
1074
  }, close);
1064
1075
  this.root.setInputSurface(component);
1065
- this.runtime.requestRender();
1076
+ this.#terminal.requestRender();
1066
1077
  }
1067
1078
  showLoginMethodSelector(providerReference) {
1068
- const method = this.backend.pinnedLoginMethodOptions(providerReference);
1079
+ const method = this.backend.catalog.pinnedLoginMethodOptions(providerReference);
1069
1080
  if (method.options.length === 0) {
1070
1081
  this.showLoginProviderSelector(undefined, providerReference);
1071
1082
  return;
@@ -1078,7 +1089,7 @@ export class OwnedUiSessionShell {
1078
1089
  }
1079
1090
  const close = () => {
1080
1091
  this.root.setInputSurface(null);
1081
- this.runtime.requestRender();
1092
+ this.#terminal.requestRender();
1082
1093
  };
1083
1094
  const labels = method.options.map(option => option.label);
1084
1095
  const component = createPiShellExtensionSelector(method.title, labels, label => {
@@ -1089,74 +1100,17 @@ export class OwnedUiSessionShell {
1089
1100
  void this.runWorkflow({ command: "login", argument: "", selection });
1090
1101
  }, close);
1091
1102
  this.root.setInputSurface(component);
1092
- this.runtime.requestRender();
1103
+ this.#terminal.requestRender();
1093
1104
  }
1094
- async showTreeSelector(initialSelectedId) {
1095
- const context = this.backend.pinnedTreeSelectorContext();
1096
- if (context.tree.length === 0) {
1097
- this.root.appendWorkflowStatus("No entries in session");
1098
- this.runtime.requestRender();
1099
- return;
1100
- }
1101
- const close = () => {
1102
- this.root.setInputSurface(null);
1103
- this.runtime.requestRender();
1104
- };
1105
- const releasePresentation = this.#customViewport ? this.runtime.beginPresentationHold() : undefined;
1106
- try {
1107
- const selectors = await this.#loadLazySelectors();
1108
- const component = await selectors.createTree({
1109
- tree: context.tree,
1110
- currentLeafId: context.currentLeafId,
1111
- terminalHeight: this.runtime.viewport().rows,
1112
- initialFilterMode: context.filterMode,
1113
- ...(initialSelectedId === undefined ? {} : { initialSelectedId }),
1114
- onLabelChange: context.appendLabelChange,
1115
- onCopy: text => {
1116
- if (!text) {
1117
- this.root.appendWorkflowResult({ command: "tree", outcome: "failed", message: "Selected entry has no text to copy" });
1118
- this.runtime.requestRender();
1119
- return;
1120
- }
1121
- const generation = this.backend.sessionBindingGeneration;
1122
- void this.backend.copyWorkflowText(text).then(acknowledged => {
1123
- if (this.#disposed || generation !== this.backend.sessionBindingGeneration)
1124
- return;
1125
- this.root.appendWorkflowStatus(acknowledged ? "Copied selected message to clipboard" : "Submitted selected message to clipboard");
1126
- this.runtime.requestRender();
1127
- }).catch(error => {
1128
- if (this.#disposed || generation !== this.backend.sessionBindingGeneration)
1129
- return;
1130
- this.root.appendWorkflowResult({ command: "tree", outcome: "failed", message: error instanceof Error ? error.message : String(error) });
1131
- this.runtime.requestRender();
1132
- });
1133
- },
1134
- onCancel: close,
1135
- onSelect: entryId => {
1136
- if (entryId === context.currentLeafId) {
1137
- close();
1138
- this.root.appendWorkflowStatus("Already at this point");
1139
- this.runtime.requestRender();
1140
- return;
1141
- }
1142
- if (context.skipSummaryPrompt)
1143
- close();
1144
- void this.#completeTreeSelection(entryId, context.skipSummaryPrompt);
1145
- },
1146
- });
1147
- if (this.#disposed)
1148
- return;
1149
- this.root.setInputSurface(component);
1150
- this.runtime.requestRender();
1151
- }
1152
- finally {
1153
- releasePresentation?.();
1154
- }
1105
+ showTreeSelector(initialSelectedId) {
1106
+ return this.#presenters.openTreeSelector(this.#dialogHost, (entryId, skipSummaryPrompt) => {
1107
+ void this.#completeTreeSelection(entryId, skipSummaryPrompt);
1108
+ }, initialSelectedId);
1155
1109
  }
1156
1110
  showLoginAuthTypeSelector() {
1157
1111
  const close = () => {
1158
1112
  this.root.setInputSurface(null);
1159
- this.runtime.requestRender();
1113
+ this.#terminal.requestRender();
1160
1114
  };
1161
1115
  const labels = ["Sign in with an account", "Sign in with an API key"];
1162
1116
  const component = createPiShellExtensionSelector("Select authentication method:", labels, label => {
@@ -1164,18 +1118,18 @@ export class OwnedUiSessionShell {
1164
1118
  this.showLoginProviderSelector(label === labels[0] ? "oauth" : "api_key");
1165
1119
  }, close);
1166
1120
  this.root.setInputSurface(component);
1167
- this.runtime.requestRender();
1121
+ this.#terminal.requestRender();
1168
1122
  }
1169
1123
  showLoginProviderSelector(authType, initialSearchInput) {
1170
- const options = this.backend.pinnedLoginOptions(authType);
1124
+ const options = this.backend.catalog.pinnedLoginOptions(authType);
1171
1125
  if (options.length === 0) {
1172
1126
  this.root.appendWorkflowStatus(authType === "oauth" ? "No account providers available." : authType === "api_key" ? "No API key providers available." : "No login providers available.");
1173
- this.runtime.requestRender();
1127
+ this.#terminal.requestRender();
1174
1128
  return;
1175
1129
  }
1176
1130
  const close = () => {
1177
1131
  this.root.setInputSurface(null);
1178
- this.runtime.requestRender();
1132
+ this.#terminal.requestRender();
1179
1133
  };
1180
1134
  const component = createPiShellAuthProviderSelector("login", options, id => {
1181
1135
  close();
@@ -1186,84 +1140,13 @@ export class OwnedUiSessionShell {
1186
1140
  this.showLoginAuthTypeSelector();
1187
1141
  }, initialSearchInput);
1188
1142
  this.root.setInputSurface(component);
1189
- this.runtime.requestRender();
1143
+ this.#terminal.requestRender();
1190
1144
  }
1191
- async showSessionSelector() {
1192
- const context = this.backend.pinnedSessionSelectorContext();
1193
- const close = () => {
1194
- this.root.setInputSurface(null);
1195
- this.runtime.requestRender();
1196
- };
1197
- const component = await createPiShellSessionSelector({
1198
- currentSessionsLoader: context.loadCurrentSessions,
1199
- allSessionsLoader: context.loadAllSessions,
1200
- currentSessionFilePath: context.currentSessionFilePath,
1201
- renameSession: context.renameSession,
1202
- requestRender: () => this.runtime.requestRender(),
1203
- onSelect: sessionPath => {
1204
- close();
1205
- void this.runWorkflow({ command: "resume", argument: sessionPath });
1206
- },
1207
- onCancel: close,
1208
- onExit: () => {
1209
- close();
1210
- void this.shutdown();
1211
- },
1212
- });
1213
- this.root.setInputSurface(component);
1214
- this.runtime.requestRender();
1145
+ showSessionSelector() {
1146
+ return this.#presenters.openSessionSelector(this.#dialogHost, () => { void this.shutdown(); });
1215
1147
  }
1216
1148
  showSettingsSelector() {
1217
- const snapshot = this.backend.pinnedSettingsSnapshot();
1218
- const close = () => {
1219
- this.root.setInputSurface(null);
1220
- this.runtime.requestRender();
1221
- };
1222
- const { currentModel, ...settingsSnapshot } = snapshot;
1223
- const component = createPiShellSettingsSelector({
1224
- config: {
1225
- ...settingsSnapshot,
1226
- availableThinkingLevels: [...snapshot.availableThinkingLevels],
1227
- availableThemes: [...snapshot.availableThemes],
1228
- warnings: { ...snapshot.warnings },
1229
- modelThinkingLevels: { ...snapshot.modelThinkingLevels },
1230
- ...(currentModel === undefined ? {} : { currentModel: currentModel }),
1231
- availableDefaultModels: snapshot.availableDefaultModels,
1232
- },
1233
- onChange: (callback, value) => {
1234
- if (callback === "onCancel") {
1235
- close();
1236
- return;
1237
- }
1238
- if (callback === "onTuiModeChange") {
1239
- // Invariant: the custom bare-A1 surface is permanently fullscreen; this callback
1240
- // remains available only to pinned comparison profiles.
1241
- if (this.#customViewport)
1242
- return;
1243
- if (value !== "regular" && value !== "fullscreen")
1244
- return;
1245
- if (!this.runtime.switchMode(value)) {
1246
- this.root.appendWorkflowStatus("Close active overlays before changing TUI mode");
1247
- this.runtime.requestRender();
1248
- return;
1249
- }
1250
- if (this.#customViewport) {
1251
- this.#pointerReporting = false;
1252
- this.#setPointerReporting(true);
1253
- }
1254
- }
1255
- void this.backend.applyPinnedSettingValue(callback, value).then(result => {
1256
- if (result.outcome === "failed")
1257
- this.root.appendWorkflowResult(result);
1258
- else if (callback === "onTuiModeChange")
1259
- this.root.appendWorkflowStatus(`TUI mode: ${value}`);
1260
- this.runtime.requestRender();
1261
- });
1262
- },
1263
- onCancel: close,
1264
- });
1265
- this.root.setInputSurface(component);
1266
- this.runtime.requestRender();
1149
+ this.#presenters.openSettingsSelector(this.#dialogHost);
1267
1150
  }
1268
1151
  shutdown() {
1269
1152
  this.#shutdownPromise ??= this.#shutdown();
@@ -1271,7 +1154,13 @@ export class OwnedUiSessionShell {
1271
1154
  }
1272
1155
  async #shutdown() {
1273
1156
  try {
1274
- const result = await this.backend.executeWorkflow({ command: "quit", argument: "" });
1157
+ const quit = this.backend.workflows.executeWorkflow({ command: "quit", argument: "" });
1158
+ // Invariant: a slow or hung engine quit never holds the terminal past the cleanup deadline. The
1159
+ // shell restores it then, and the result still waits for the engine to finish quitting.
1160
+ const timedOut = await boundedCleanup(() => quit.catch(() => { })).then(() => false, () => true);
1161
+ if (timedOut)
1162
+ await this.dispose();
1163
+ const result = await quit;
1275
1164
  await this.dispose();
1276
1165
  return workflowAdapterResult(result);
1277
1166
  }
@@ -1291,26 +1180,26 @@ export class OwnedUiSessionShell {
1291
1180
  if (this.#disposed)
1292
1181
  return;
1293
1182
  this.root.appendWorkflowResult({ command: "name", outcome: "failed", message: error instanceof Error ? error.message : String(error) });
1294
- this.runtime.requestRender();
1183
+ this.#terminal.requestRender();
1295
1184
  });
1296
1185
  return { outcome: "completed", diagnostic: null };
1297
1186
  }
1298
- const copyGeneration = request.command === "copy" ? this.backend.sessionBindingGeneration : undefined;
1187
+ const copyGeneration = request.command === "copy" ? this.backend.identity.sessionBindingGeneration : undefined;
1299
1188
  if (request.command === "login" && request.selection !== undefined) {
1300
- const setup = this.backend.pinnedAmbientAuthentication(request.selection);
1189
+ const setup = this.backend.catalog.pinnedAmbientAuthentication(request.selection);
1301
1190
  if (setup) {
1302
1191
  const close = () => {
1303
1192
  this.root.setInputSurface(null);
1304
- this.runtime.requestRender();
1193
+ this.#terminal.requestRender();
1305
1194
  };
1306
1195
  const dialog = createPiShellLoginDialog({
1307
- getColumns: () => this.runtime.viewport().columns,
1308
- getRows: () => this.runtime.viewport().rows,
1309
- requestRender: () => this.runtime.requestRender(),
1196
+ getColumns: () => this.#terminal.viewport().columns,
1197
+ getRows: () => this.#terminal.viewport().rows,
1198
+ requestRender: () => this.#terminal.requestRender(),
1310
1199
  }, setup.providerId, close, setup.providerName, setup.title);
1311
1200
  dialog.showInfo(setup.message, [], true);
1312
1201
  this.root.setInputSurface(dialog);
1313
- this.runtime.requestRender();
1202
+ this.#terminal.requestRender();
1314
1203
  return { outcome: "completed", diagnostic: null };
1315
1204
  }
1316
1205
  }
@@ -1362,10 +1251,10 @@ export class OwnedUiSessionShell {
1362
1251
  return { outcome: "completed", diagnostic: null };
1363
1252
  }
1364
1253
  if (request.command === "reload") {
1365
- const blocked = this.backend.reloadBlockedResult();
1254
+ const blocked = this.backend.workflows.reloadBlockedResult();
1366
1255
  if (blocked) {
1367
1256
  this.root.appendWorkflowResult(blocked);
1368
- this.runtime.requestRender();
1257
+ this.#terminal.requestRender();
1369
1258
  return workflowAdapterResult(blocked);
1370
1259
  }
1371
1260
  this.root.resetExtensionUi();
@@ -1375,20 +1264,20 @@ export class OwnedUiSessionShell {
1375
1264
  : undefined;
1376
1265
  const shareSurface = shareDialog === undefined ? undefined
1377
1266
  : (this.#customViewport ? shareDialog.createPiShellShareOperationDialog : shareDialog.createPiShellOperationLoader)({
1378
- getColumns: () => this.runtime.viewport().columns,
1379
- getRows: () => this.runtime.viewport().rows,
1380
- requestRender: () => this.runtime.requestRender(),
1267
+ getColumns: () => this.#terminal.viewport().columns,
1268
+ getRows: () => this.#terminal.viewport().rows,
1269
+ requestRender: () => this.#terminal.requestRender(),
1381
1270
  }, "Creating gist…");
1382
1271
  const operationSurface = shareSurface ?? (request.command === "reload" ? createPiShellReloadBox() : undefined);
1383
1272
  const now = this.#reloadPresentation?.now ?? Date.now;
1384
1273
  const shownAt = now();
1385
1274
  if (operationSurface) {
1386
1275
  this.root.setInputSurface(operationSurface);
1387
- this.runtime.requestRender();
1276
+ this.#terminal.requestRender();
1388
1277
  }
1389
1278
  let result;
1390
1279
  try {
1391
- result = await this.backend.executeWorkflow(shareSurface === undefined ? request : { ...request, signal: shareSurface.signal });
1280
+ result = await this.backend.workflows.executeWorkflow(shareSurface === undefined ? request : { ...request, signal: shareSurface.signal });
1392
1281
  }
1393
1282
  finally {
1394
1283
  if (operationSurface) {
@@ -1397,10 +1286,10 @@ export class OwnedUiSessionShell {
1397
1286
  if (request.command === "reload")
1398
1287
  await this.#holdReloadSurface(now() - shownAt);
1399
1288
  this.root.setInputSurface(null);
1400
- this.runtime.requestRender();
1289
+ this.#terminal.requestRender();
1401
1290
  }
1402
1291
  }
1403
- if (copyGeneration !== undefined && (this.#disposed || copyGeneration !== this.backend.sessionBindingGeneration)) {
1292
+ if (copyGeneration !== undefined && (this.#disposed || copyGeneration !== this.backend.identity.sessionBindingGeneration)) {
1404
1293
  return workflowAdapterResult({ command: "copy", outcome: "cancelled", message: "" });
1405
1294
  }
1406
1295
  if (result.outcome === "requires-selection" && request.command === "model") {
@@ -1426,7 +1315,7 @@ export class OwnedUiSessionShell {
1426
1315
  }
1427
1316
  if (result.outcome === "requires-selection" || result.outcome === "requires-confirmation") {
1428
1317
  this.root.appendWorkflowResult({ command: request.command, outcome: "failed", message: `Owned controller missing for ${request.command}` });
1429
- this.runtime.requestRender();
1318
+ this.#terminal.requestRender();
1430
1319
  return { outcome: "failed", diagnostic: `Owned controller missing for ${request.command}` };
1431
1320
  }
1432
1321
  if (request.command === "reload" && result.outcome === "completed") {
@@ -1445,7 +1334,7 @@ export class OwnedUiSessionShell {
1445
1334
  else {
1446
1335
  this.root.appendWorkflowResult(result);
1447
1336
  }
1448
- this.runtime.requestRender();
1337
+ this.#terminal.requestRender();
1449
1338
  return workflowAdapterResult(result);
1450
1339
  }
1451
1340
  async #holdReloadSurface(visibleMs) {
@@ -1457,16 +1346,16 @@ export class OwnedUiSessionShell {
1457
1346
  await sleep(remaining);
1458
1347
  }
1459
1348
  showTrustSelector() {
1460
- const context = this.backend.pinnedProjectTrustContext();
1349
+ const context = this.backend.catalog.pinnedProjectTrustContext();
1461
1350
  const close = () => {
1462
1351
  this.root.setInputSurface(null);
1463
- this.runtime.requestRender();
1352
+ this.#terminal.requestRender();
1464
1353
  };
1465
1354
  const component = createPiShellTrustSelector({
1466
1355
  ...context,
1467
1356
  onSelect: selection => {
1468
1357
  try {
1469
- this.backend.persistProjectTrust(selection.updates);
1358
+ this.backend.catalog.persistProjectTrust(selection.updates);
1470
1359
  close();
1471
1360
  this.root.appendWorkflowStatus(`Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`);
1472
1361
  }
@@ -1474,196 +1363,45 @@ export class OwnedUiSessionShell {
1474
1363
  close();
1475
1364
  this.root.appendWorkflowResult({ command: "trust", outcome: "failed", message: error instanceof Error ? error.message : String(error) });
1476
1365
  }
1477
- this.runtime.requestRender();
1366
+ this.#terminal.requestRender();
1478
1367
  },
1479
1368
  onCancel: close,
1480
1369
  });
1481
1370
  this.root.setInputSurface(component);
1482
- this.runtime.requestRender();
1371
+ this.#terminal.requestRender();
1483
1372
  }
1484
1373
  showScopedModelsSelector() {
1485
- const initial = this.backend.pinnedScopedModelsContext();
1486
- let currentEnabledIds = initial.enabledModelIds === null ? null : [...initial.enabledModelIds];
1487
- let selectionChanged = false;
1488
- let disposed = false;
1489
- let timedOut = false;
1490
- const controller = new AbortController();
1491
- const timeout = setTimeout(() => {
1492
- timedOut = true;
1493
- controller.abort();
1494
- }, 15_000);
1495
- const close = () => {
1496
- disposed = true;
1497
- clearTimeout(timeout);
1498
- controller.abort();
1499
- this.root.setInputSurface(null);
1500
- this.runtime.requestRender();
1501
- };
1502
- const selector = createPiShellScopedModelsSelector({
1503
- models: initial.models,
1504
- enabledModelIds: currentEnabledIds,
1505
- refreshStatus: "Refreshing model catalogs…",
1506
- onChange: enabledIds => {
1507
- selectionChanged = true;
1508
- currentEnabledIds = enabledIds === null ? null : [...enabledIds];
1509
- this.backend.updateScopedModels(currentEnabledIds);
1510
- this.runtime.requestRender();
1511
- },
1512
- onPersist: enabledIds => {
1513
- currentEnabledIds = enabledIds === null ? null : [...enabledIds];
1514
- this.backend.persistScopedModels(currentEnabledIds);
1515
- this.root.appendWorkflowStatus("Model selection saved to settings");
1516
- this.runtime.requestRender();
1517
- },
1518
- onCancel: close,
1519
- });
1520
- const component = {
1521
- ...selector,
1522
- dispose: () => {
1523
- disposed = true;
1524
- clearTimeout(timeout);
1525
- controller.abort();
1526
- selector.dispose?.();
1527
- },
1528
- };
1529
- this.root.setInputSurface(component);
1530
- this.runtime.requestRender();
1531
- void this.backend.refreshScopedModels(controller.signal).then(refreshed => {
1532
- if (disposed)
1533
- return;
1534
- if (!selectionChanged) {
1535
- currentEnabledIds = refreshed.enabledModelIds === null ? null : [...refreshed.enabledModelIds];
1536
- component.updateModels(refreshed.models, currentEnabledIds);
1537
- }
1538
- else {
1539
- component.updateModels(refreshed.models);
1540
- this.backend.updateScopedModels(currentEnabledIds);
1541
- }
1542
- component.setRefreshStatus(timedOut ? "Model refresh timed out; showing cached models." : refreshed.status, timedOut ? "warning" : refreshed.statusKind);
1543
- this.runtime.requestRender();
1544
- }).catch(error => {
1545
- if (disposed)
1546
- return;
1547
- component.setRefreshStatus(timedOut
1548
- ? "Model refresh timed out; showing cached models."
1549
- : `Could not refresh model catalogs: ${error instanceof Error ? error.message : String(error)}`, "warning");
1550
- this.runtime.requestRender();
1551
- }).finally(() => clearTimeout(timeout));
1374
+ this.#presenters.openScopedModelsSelector(this.#dialogHost);
1552
1375
  }
1553
1376
  // Invariant: bare A1 routes `models`; the comparison profile keeps the pinned `model`/`scoped-models` pair. Hidden routes are shared.
1554
1377
  #isWorkflowRoute(value) {
1555
1378
  return workflowCommandNames(this.#customViewport ? "bare" : "comparison").includes(value)
1556
- || PINNED_PI_HIDDEN_COMMAND_NAMES.includes(value);
1379
+ || OWNED_UI_HIDDEN_COMMAND_NAMES.includes(value);
1557
1380
  }
1558
1381
  /** The bare-A1 unified Models dialog: switch on Enter, scope on Space, persist on Ctrl+S, all through the engine. */
1559
1382
  showModelsDialog(initialQuery) {
1560
- const context = this.backend.modelsContext();
1561
- const available = new Set(context.models.map(model => `${model.provider}/${model.id}`));
1562
- const savedScopeIds = context.persistedScopeIds.filter(id => available.has(id));
1563
- // Invariant: an explicit session scope wins; otherwise the dialog starts from what is persisted, never from "all rows scoped".
1564
- const scopeIds = context.sessionScopeIds.length > 0 ? context.sessionScopeIds : savedScopeIds;
1565
- let disposed = false;
1566
- let timedOut = false;
1567
- const controller = new AbortController();
1568
- const timeout = setTimeout(() => {
1569
- timedOut = true;
1570
- controller.abort();
1571
- }, 15_000);
1572
- const close = () => {
1573
- disposed = true;
1574
- clearTimeout(timeout);
1575
- controller.abort();
1576
- this.root.setInputSurface(null);
1577
- this.runtime.requestRender();
1578
- };
1579
- const dialog = createPiShellModelsDialog({
1580
- models: context.models,
1581
- activeModelId: context.activeModelId,
1582
- scopeIds,
1583
- savedScopeIds,
1584
- ...(initialQuery === undefined ? {} : { initialQuery }),
1585
- refreshStatus: "Refreshing model catalogs…",
1586
- requestRender: () => this.runtime.requestRender(),
1587
- onSelect: modelId => {
1588
- void this.runWorkflow({ command: "models", argument: "", selection: modelId }).then(result => {
1589
- if (!disposed && result.outcome === "completed")
1590
- close();
1591
- });
1592
- },
1593
- onScopeChange: ids => {
1594
- this.backend.setSessionModelScope(ids);
1595
- this.runtime.requestRender();
1596
- },
1597
- onSave: ids => {
1598
- try {
1599
- this.backend.persistModelScope(ids);
1600
- }
1601
- catch (error) {
1602
- this.root.appendWorkflowResult({ command: "models", outcome: "failed", message: error instanceof Error ? error.message : String(error) });
1603
- this.runtime.requestRender();
1604
- throw error;
1605
- }
1606
- this.root.appendWorkflowStatus("Model selection saved to settings");
1607
- this.runtime.requestRender();
1608
- },
1609
- onCancel: close,
1610
- });
1611
- const component = {
1612
- ...dialog,
1613
- dispose: () => {
1614
- disposed = true;
1615
- clearTimeout(timeout);
1616
- controller.abort();
1617
- dialog.dispose?.();
1618
- },
1619
- };
1620
- this.root.setInputSurface(component);
1621
- this.runtime.requestRender();
1622
- void this.backend.refreshModels(controller.signal).then(refreshed => {
1623
- if (disposed)
1624
- return;
1625
- component.updateModels(refreshed.models);
1626
- component.setRefreshStatus(timedOut ? "Model refresh timed out; showing cached models." : refreshed.status, timedOut ? "warning" : refreshed.statusKind);
1627
- this.runtime.requestRender();
1628
- }).catch(error => {
1629
- if (disposed)
1630
- return;
1631
- component.setRefreshStatus(timedOut
1632
- ? "Model refresh timed out; showing cached models."
1633
- : `Could not refresh model catalogs: ${error instanceof Error ? error.message : String(error)}`, "warning");
1634
- this.runtime.requestRender();
1635
- }).finally(() => clearTimeout(timeout));
1636
- }
1637
- // Invariant: pointer reporting is disabled on every path that ends the owning screen.
1638
- #setPointerReporting(enabled, forceOff = false) {
1639
- const effective = forceOff ? false : this.#customViewport || enabled;
1640
- if (this.#pointerReporting === effective)
1641
- return;
1642
- this.#pointerReporting = effective;
1643
- if (!this.runtime.active)
1644
- return;
1645
- this.runtime.writeControl(effective ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
1383
+ this.#presenters.openModelsDialog(this.#dialogHost, initialQuery);
1646
1384
  }
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.
1647
1388
  dispose() {
1648
1389
  if (this.#disposePromise !== undefined)
1649
1390
  return this.#disposePromise;
1650
1391
  if (this.#disposed)
1651
1392
  return Promise.resolve();
1652
- const pending = Promise.resolve().then(() => this.#dispose());
1393
+ this.#disposed = true;
1394
+ const pending = Promise.resolve().then(() => this.#host.close(this));
1653
1395
  this.#disposePromise = pending;
1654
1396
  const clear = () => { if (this.#disposePromise === pending)
1655
1397
  this.#disposePromise = undefined; };
1656
1398
  void pending.then(clear, clear);
1657
1399
  return pending;
1658
1400
  }
1659
- async #dispose() {
1401
+ release(terminal) {
1402
+ if (this.#released !== undefined)
1403
+ return { exitText: "", failures: [], settle: async () => [] };
1660
1404
  this.#disposed = true;
1661
- this.#copyIntentSequence += 1;
1662
- // Invariant: the outro frame is what the terminal shows now, before any cleanup writes.
1663
- const outroFrame = this.#captureQuitOutroFrame();
1664
- this.#responseCopy?.dispose();
1665
- this.#copyExecutor?.dispose();
1666
- this.#cancelWaitingImages();
1667
1405
  const failures = [];
1668
1406
  const attempt = (action) => { try {
1669
1407
  action();
@@ -1671,13 +1409,17 @@ export class OwnedUiSessionShell {
1671
1409
  catch (error) {
1672
1410
  failures.push(error);
1673
1411
  } };
1412
+ // Invariant: events stop first, so nothing re-enters the view during history close, outro, or restoration.
1413
+ attempt(() => this.#unsubscribe());
1414
+ this.#copyIntentSequence += 1;
1415
+ this.#responseCopy?.dispose();
1416
+ this.#copyExecutor?.dispose();
1417
+ this.#cancelWaitingImages();
1674
1418
  let pasteCleanup = Promise.resolve();
1675
1419
  let historyCleanup = Promise.resolve(true);
1676
1420
  attempt(() => { historyCleanup = this.#promptHistory?.close() ?? Promise.resolve(true); });
1677
1421
  attempt(() => { pasteCleanup = this.root.disposePendingPastes(); });
1678
1422
  attempt(() => this.root.clearViewportPointerState());
1679
- attempt(() => this.#setPointerReporting(false, true));
1680
- attempt(() => this.#removeViewportPreInput());
1681
1423
  attempt(() => this.#streamPresentation.dispose());
1682
1424
  attempt(() => {
1683
1425
  if (this.#startupRouteTimer !== undefined)
@@ -1689,88 +1431,41 @@ export class OwnedUiSessionShell {
1689
1431
  attempt(() => this.#unsubscribePromptImages());
1690
1432
  attempt(() => this.#unsubscribeSkills());
1691
1433
  attempt(() => this.#unsubscribeSettings());
1692
- let fullscreenExitText = "";
1434
+ let exitText = "";
1693
1435
  attempt(() => {
1694
- const exitMode = this.backend.disposed ? this.#fullscreenExitOutput : this.backend.pinnedSettingsSnapshot().fullscreenExitOutput;
1695
- const resume = this.backend.currentSessionResumeMetadata();
1436
+ const exitMode = this.backend.identity.disposed ? this.#fullscreenExitOutput : this.backend.settings.snapshot().fullscreenExitOutput;
1437
+ const resume = this.backend.identity.currentSessionResumeMetadata();
1696
1438
  const resumeHint = resume === null ? "" : `${dim("To resume this session:")} ${formatSessionResumeCommand(resume)}`;
1697
1439
  // Invariant: bare A1 leaves only the hint behind; the pinned comparison profile still
1698
1440
  // honors fullscreenExitOutput, including the styled transcript.
1699
- fullscreenExitText = this.runtime.mode !== "fullscreen" ? ""
1441
+ exitText = terminal.mode !== "fullscreen" ? ""
1700
1442
  : this.#customViewport || exitMode === "resume-hint" ? resumeHint
1701
- : [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");
1702
1444
  });
1703
1445
  attempt(() => this.#unbindClipboardWriter());
1704
1446
  attempt(() => this.#unbindPiSettings());
1705
1447
  attempt(() => this.#unbindTerminalSettings());
1706
1448
  attempt(() => this.#unbindShutdownSettings());
1707
- attempt(() => this.#unsubscribe());
1449
+ attempt(() => this.backend.workflows.setWorkflowInteractionHost(DETACHED_WORKFLOW_INTERACTION));
1708
1450
  attempt(() => this.#dialogHandle?.hide());
1709
1451
  attempt(() => this.#extensionBridge.dispose());
1710
1452
  attempt(() => this.#programStatus.clear());
1711
- // Invariant: from here to the leave nothing but the outro paints. A throttled frame the
1712
- // renderer still has queued would otherwise land during the stop-time input drain and
1713
- // flash the prompt and footer, whether or not an effect plays.
1714
- attempt(() => this.#freezeQuitPresentation());
1715
- await this.#playQuitOutro(outroFrame);
1716
- // Invariant: terminal restoration precedes any potentially stalled backend teardown. The
1717
- // fullscreen leave preserves the screen: the pinned runtime never dumps its final document
1718
- // into the parent terminal, so only the configured exit text follows the leave.
1719
- await this.runtime.dispose({ preserveScreen: this.runtime.mode === "fullscreen" }).catch(error => failures.push(error));
1720
- await historyCleanup.catch(() => false); // Security: background durability outcomes never enter terminal output.
1721
- await boundedCleanup(() => pasteCleanup).catch(error => failures.push(error));
1722
- await boundedCleanup(() => this.backend.unbindExtensionUi()).catch(error => failures.push(error));
1723
- if (failures.length > 0)
1724
- throw new AggregateError(failures, "Owned UI disposal failed");
1725
- if (fullscreenExitText.length > 0)
1726
- this.runtime.writeAfterStop(`${fullscreenExitText}\n`);
1727
- }
1728
- // Invariant: the snapshot is synchronous; the outro module itself loads only at quit.
1729
- #captureQuitOutroFrame() {
1730
- const outro = this.#quitOutro;
1731
- if (outro === undefined || !outro.interactive || !this.#customViewport || this.#damageTerminal === null)
1732
- return null;
1733
- if (!this.runtime.active || this.runtime.mode !== "fullscreen")
1734
- return null;
1735
- try {
1736
- if (!outro.snapshot().enabled)
1737
- return null;
1738
- const viewport = this.runtime.viewport();
1739
- return {
1740
- rows: this.#damageTerminal.presentedRows(), columns: viewport.columns, height: viewport.rows,
1741
- settings: { effect: QUIT_OUTRO_EFFECT, durationMs: QUIT_OUTRO_DURATION_MS },
1742
- };
1743
- }
1744
- catch {
1745
- return null;
1746
- }
1747
- }
1748
- #freezeQuitPresentation() {
1749
- if (!this.#customViewport || !this.runtime.active || this.runtime.mode !== "fullscreen")
1750
- return;
1751
- this.runtime.freezePresentation();
1752
- }
1753
- // Rationale: any failure here only skips the effect; restoration always follows.
1754
- async #playQuitOutro(capture) {
1755
- const outro = this.#quitOutro;
1756
- if (capture === null || outro === undefined || !this.runtime.active)
1757
- return;
1758
- try {
1759
- // Rationale: the effects stay off the startup graph; quit is the only time they load.
1760
- const { captureQuitOutroFrame, playQuitOutro } = await import("./quit-outro.js");
1761
- const frame = captureQuitOutroFrame(capture.rows, capture.columns, capture.height);
1762
- if (frame === null || !this.runtime.active)
1763
- return;
1764
- await playQuitOutro(frame, capture.settings.effect, capture.settings.durationMs, {
1765
- write: data => this.runtime.writeControl(data),
1766
- ...(outro.now === undefined ? {} : { now: outro.now }),
1767
- ...(outro.sleep === undefined ? {} : { sleep: outro.sleep }),
1768
- ...(outro.seed === undefined ? {} : { seed: outro.seed }),
1769
- });
1770
- }
1771
- catch {
1772
- // Rationale: a failed or interrupted effect must never hold the terminal; restoration follows.
1773
- }
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;
1774
1469
  }
1775
1470
  #settleStoppedLifecycle() {
1776
1471
  void this.dispose().then(() => this.#resolveStoppedLifecycle(), () => this.#resolveStoppedLifecycle());
@@ -1798,9 +1493,9 @@ export class OwnedUiSessionShell {
1798
1493
  return view;
1799
1494
  }
1800
1495
  #syncTerminalProgress(view) {
1801
- if (!this.runtime.active)
1496
+ if (!this.#terminal.running)
1802
1497
  return;
1803
- this.runtime.setTerminalProgress(this.#terminalProgressEnabled && view.lifecycle === "busy");
1498
+ this.#terminal.setTerminalProgress(this.#terminalProgressEnabled && view.lifecycle === "busy");
1804
1499
  if (view.lifecycle === "stopped" || view.lifecycle === "stopping") {
1805
1500
  this.#programStatus.clear();
1806
1501
  return;
@@ -1816,17 +1511,17 @@ export class OwnedUiSessionShell {
1816
1511
  #syncView() {
1817
1512
  this.#streamPresentation.noteImmediatePresentation();
1818
1513
  const view = this.view();
1819
- if (this.backend.sessionGeneration !== this.#sessionGeneration) {
1514
+ if (this.backend.identity.sessionGeneration !== this.#sessionGeneration) {
1820
1515
  this.#copyIntentSequence += 1;
1821
1516
  this.#responseCopy?.reset();
1822
1517
  this.#cancelWaitingImages();
1823
1518
  this.root.resetPendingPastes();
1824
1519
  this.#promptSuggestions?.invalidate();
1825
- this.#sessionGeneration = this.backend.sessionGeneration;
1520
+ this.#sessionGeneration = this.backend.identity.sessionGeneration;
1826
1521
  this.#programStatus.reset();
1827
1522
  // Invariant: delivery recovery invalidates callbacks, not same-session local recall or its draft.
1828
- if (this.#sessionBindingGeneration !== this.backend.sessionBindingGeneration) {
1829
- this.#sessionBindingGeneration = this.backend.sessionBindingGeneration;
1523
+ if (this.#sessionBindingGeneration !== this.backend.identity.sessionBindingGeneration) {
1524
+ this.#sessionBindingGeneration = this.backend.identity.sessionBindingGeneration;
1830
1525
  this.root.resetTranscript();
1831
1526
  this.#promptHistory?.reset(view.transcript.flatMap(block => block.kind === "user" ? [block.text] : []));
1832
1527
  }
@@ -1838,7 +1533,6 @@ export class OwnedUiSessionShell {
1838
1533
  this.#dialogHandle = undefined;
1839
1534
  this.#dialogId = undefined;
1840
1535
  this.#dialogSource = undefined;
1841
- this.#setPointerReporting(false);
1842
1536
  this.root.setInputSurface(null);
1843
1537
  this.root.resetExtensionUi();
1844
1538
  this.root.resetWorkflowPresentation();
@@ -1846,7 +1540,7 @@ export class OwnedUiSessionShell {
1846
1540
  this.root.update(view);
1847
1541
  this.#syncDialog(view.dialog);
1848
1542
  this.#presentStartupTrustWarning(view);
1849
- this.runtime.requestRender();
1543
+ this.#terminal.requestRender();
1850
1544
  for (const listener of this.#listeners)
1851
1545
  listener(view);
1852
1546
  return view;
@@ -1857,16 +1551,16 @@ export class OwnedUiSessionShell {
1857
1551
  if (this.#startupTrustHandled || !this.#customViewport)
1858
1552
  return;
1859
1553
  const diagnostic = view.diagnostics.find(candidate => candidate.code === "project-trust");
1860
- if (diagnostic === undefined || !this.runtime.active)
1554
+ if (diagnostic === undefined || !this.#terminal.running)
1861
1555
  return;
1862
1556
  this.#startupTrustHandled = true;
1863
1557
  this.root.appendWorkflowMessage({ kind: "warning", message: diagnostic.message });
1864
1558
  }
1865
1559
  #presentStartupRoute() {
1866
1560
  const pending = this.#startupRoute;
1867
- if (pending === undefined || this.#disposed || !this.runtime.active)
1561
+ if (pending === undefined || this.#disposed || !this.#terminal.running)
1868
1562
  return;
1869
- if (this.runtime.hasOverlay() || !this.root.usesDefaultInputSurface()) {
1563
+ if (this.#terminal.hasOverlay() || !this.root.usesDefaultInputSurface()) {
1870
1564
  this.#startupRouteTimer = setTimeout(() => {
1871
1565
  this.#startupRouteTimer = undefined;
1872
1566
  this.#presentStartupRoute();
@@ -1881,121 +1575,33 @@ export class OwnedUiSessionShell {
1881
1575
  const surface = this.#routeHost?.open(route, input) ?? null;
1882
1576
  if (surface === null)
1883
1577
  return { outcome: "failed", diagnostic: `route is unavailable: ${route}` };
1884
- if (!this.runtime.active)
1578
+ if (!this.#terminal.running)
1885
1579
  return { outcome: "failed", diagnostic: "runtime is not active" };
1886
1580
  this.root.dismissNotice();
1887
1581
  this.#dialogHandle?.hide();
1888
1582
  this.#dialogSource = "route";
1889
- // Protocol: any-event reporting: hover and drag are what the screen is driven by, and
1890
- // it also stops the terminal treating a drag as a text selection.
1891
- this.#setPointerReporting(true);
1892
- // Protocol: the interrupt chord is global, so it is watched on raw input rather than
1893
- // through the overlay: the pinned shell handles that key before an overlay
1894
- // ever sees it, which is why an owned screen must not rely on being asked.
1895
- let armedAt = 0;
1896
- const removeInterruptWatch = this.runtime.addInputListener(data => {
1897
- if (!data.includes(INTERRUPT))
1898
- return undefined;
1899
- // Protocol: route hosts decide whether one interrupt closes their app. Raw-input forwarding
1900
- // is required because Pi handles Ctrl+C before the fullscreen overlay receives normal input.
1901
- const consumedBySurface = surface.handleInput(INTERRUPT);
1902
- if (surface.isClosed()) {
1903
- armedAt = 0;
1904
- closeSurface();
1905
- return { consume: true };
1906
- }
1907
- if (consumedBySurface) {
1908
- armedAt = 0;
1909
- this.runtime.requestRender();
1910
- return { consume: true };
1911
- }
1912
- const now = Date.now();
1913
- if (armedAt !== 0 && now - armedAt <= INTERRUPT_CHORD_MS) {
1914
- armedAt = 0;
1915
- closeSurface();
1916
- void this.shutdown();
1917
- return { consume: true };
1918
- }
1919
- armedAt = now;
1920
- this.runtime.requestRender();
1921
- // Invariant: the presented screen owns the chord, so the pinned shell never sees a
1922
- // stray interrupt while it is up.
1923
- return { consume: true };
1924
- });
1925
- let removeSurfacePreInput = () => { };
1926
- let rendered = false;
1927
- let closed = false;
1928
- const closeSurface = () => {
1929
- if (closed)
1930
- return;
1931
- closed = true;
1932
- removeSurfacePreInput();
1933
- removeInterruptWatch();
1934
- this.#setPointerReporting(false);
1935
- this.#dialogHandle?.hide();
1936
- this.#dialogHandle = undefined;
1937
- this.#dialogId = undefined;
1938
- this.#dialogSource = undefined;
1939
- if (rendered) {
1940
- try {
1941
- 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;
1942
1590
  }
1943
- catch { /* Rationale: a failed acknowledgement stays pending. */ }
1944
- }
1945
- };
1946
- // Compatibility: fullscreen Pi owns a fallback text-selection layer before focused overlay
1947
- // components see pointer input. Route every mouse report to the owned screen
1948
- // at the pre-input boundary so dropdowns, value hover, and numeric +/- work,
1949
- // and consume even unhandled reports so settings content is never selected.
1950
- removeSurfacePreInput = this.runtime.addPreInputListener(data => {
1951
- const { events, rest } = parseMouseInput(data);
1952
- if (events.length === 0)
1953
- return undefined;
1954
- for (const event of events)
1955
- surface.handleMouse(event);
1956
- if (surface.isClosed())
1957
- closeSurface();
1958
- else
1959
- this.runtime.requestRender();
1960
- return rest.length === 0 ? { consume: true } : { data: rest };
1961
- });
1962
- const rows = () => Math.max(1, this.runtime.viewport().rows);
1963
- const component = {
1964
- render: (width) => {
1965
- const frame = [...surface.render(Math.max(1, width), rows())];
1966
- rendered = true;
1967
- return frame;
1968
- },
1969
- handleInput: (data) => {
1970
- const { events, rest } = parseMouseInput(data);
1971
- for (const event of events)
1972
- surface.handleMouse(event);
1973
- if (rest.length > 0)
1974
- surface.handleInput(rest);
1975
- if (surface.isClosed()) {
1976
- closeSurface();
1977
- return;
1591
+ if (rendered) {
1592
+ try {
1593
+ void onClosed?.()?.catch(() => undefined);
1594
+ }
1595
+ catch { /* Rationale: a failed acknowledgement stays pending. */ }
1978
1596
  }
1979
- this.runtime.requestRender();
1980
1597
  },
1981
- invalidate: () => this.runtime.requestRender(),
1982
- };
1983
- surface.onRenderRequested(() => this.runtime.requestRender());
1984
- surface.onExitRequested(() => {
1985
- closeSurface();
1986
- void this.shutdown();
1987
- });
1988
- this.#dialogHandle = this.runtime.showOverlay(component, {
1989
- width: "100%",
1990
- maxHeight: "100%",
1991
- anchor: "top-left",
1992
- inputCoordination: "owned",
1993
1598
  });
1599
+ this.#dialogHandle = handle;
1994
1600
  this.#dialogId = surface.id;
1995
1601
  return { outcome: "completed", diagnostic: null };
1996
1602
  }
1997
1603
  #syncDialog(dialog) {
1998
- if (!this.runtime.active)
1604
+ if (!this.#terminal.running)
1999
1605
  return;
2000
1606
  if (dialog === null) {
2001
1607
  // Invariant: locally owned routes (notably /settings) are independent of backend
@@ -2026,7 +1632,7 @@ export class OwnedUiSessionShell {
2026
1632
  this.#dialogSource = undefined;
2027
1633
  },
2028
1634
  });
2029
- this.#dialogHandle = this.runtime.showOverlay(component, {
1635
+ this.#dialogHandle = this.#terminal.showOverlay(component, {
2030
1636
  width: "70%",
2031
1637
  maxHeight: "80%",
2032
1638
  anchor: "center",
@@ -2059,24 +1665,24 @@ export class OwnedUiSessionShell {
2059
1665
  return this.#execute({
2060
1666
  type: this.view().lifecycle === "busy" ? "steer" : "prompt",
2061
1667
  correlationId: this.#correlation("prompt-command"),
2062
- sessionId: this.backend.sessionId,
1668
+ sessionId: this.backend.identity.sessionId,
2063
1669
  text,
2064
1670
  });
2065
1671
  }
2066
1672
  #installAutocompleteCommands() {
2067
- this.root.editor.setAutocompleteCommands(this.backend.workflowAutocompleteCommands());
1673
+ this.root.editor.setAutocompleteCommands(this.backend.workflows.workflowAutocompleteCommands());
2068
1674
  this.#installedCommandSignature = this.#commandListSignature();
2069
1675
  }
2070
1676
  #commandListSignature() {
2071
- return JSON.stringify([this.#skills?.presentation() ?? "expand", this.backend.disposed ? [] : this.backend.workflowAutocompleteCommands().map(command => command.name)]);
1677
+ return JSON.stringify([this.#skills?.presentation() ?? "expand", this.backend.identity.disposed ? [] : this.backend.workflows.workflowAutocompleteCommands().map(command => command.name)]);
2072
1678
  }
2073
1679
  // Invariant: collapse applies only while the engine registers skill commands; otherwise there is nothing to collapse.
2074
1680
  #skillsCollapsed() {
2075
1681
  return this.#skills !== null && this.#skills.presentation() === "collapse"
2076
- && !this.backend.disposed && this.backend.pinnedSettingsSnapshot().enableSkillCommands;
1682
+ && !this.backend.identity.disposed && this.backend.settings.snapshot().enableSkillCommands;
2077
1683
  }
2078
1684
  #skillSummaries() {
2079
- return skillsFromCommands(this.backend.workflowAutocompleteCommands());
1685
+ return skillsFromCommands(this.backend.workflows.workflowAutocompleteCommands());
2080
1686
  }
2081
1687
  // Protocol: bare "/skills" opens the dialog, "/skills <name> [args]" applies directly, and an unknown name is a command outcome.
2082
1688
  async #runSkillsCommand(text, argument) {
@@ -2092,7 +1698,7 @@ export class OwnedUiSessionShell {
2092
1698
  if (skill === undefined) {
2093
1699
  const message = "Unknown skill: " + token;
2094
1700
  this.root.appendWorkflowMessage({ kind: "error", message });
2095
- this.runtime.requestRender();
1701
+ this.#terminal.requestRender();
2096
1702
  return { outcome: "failed", diagnostic: message };
2097
1703
  }
2098
1704
  return this.#submitSkillPrompt(skillPrompt(skill.name, separator < 0 ? "" : trimmed.slice(separator + 1)), text);
@@ -2104,14 +1710,14 @@ export class OwnedUiSessionShell {
2104
1710
  return this.#execute({
2105
1711
  type: this.view().lifecycle === "busy" ? "steer" : "prompt",
2106
1712
  correlationId: this.#correlation("prompt-command"),
2107
- sessionId: this.backend.sessionId,
1713
+ sessionId: this.backend.identity.sessionId,
2108
1714
  text: prompt,
2109
1715
  });
2110
1716
  }
2111
1717
  #treeSummary() {
2112
1718
  return new Promise(resolve => {
2113
1719
  this.root.setInputSurface(createPiShellExtensionSelector("Summarize Branch?", ["No summary", "Summarize", "Summarize with custom prompt"], resolve, () => resolve(undefined)), true, "opaque");
2114
- this.runtime.requestRender();
1720
+ this.#terminal.requestRender();
2115
1721
  });
2116
1722
  }
2117
1723
  async #completeTreeSelection(entryId, skipSummaryPrompt) {
@@ -2134,7 +1740,7 @@ export class OwnedUiSessionShell {
2134
1740
  }
2135
1741
  }
2136
1742
  this.root.setInputSurface(null);
2137
- this.runtime.requestRender();
1743
+ this.#terminal.requestRender();
2138
1744
  const result = await this.runWorkflow({
2139
1745
  command: "tree",
2140
1746
  argument: "",
@@ -2150,9 +1756,9 @@ export class OwnedUiSessionShell {
2150
1756
  #startWorkflowLogin(request) {
2151
1757
  this.#finishWorkflowLogin();
2152
1758
  const dialog = createPiShellLoginDialog({
2153
- getColumns: () => this.runtime.viewport().columns,
2154
- getRows: () => this.runtime.viewport().rows,
2155
- requestRender: () => this.runtime.requestRender(),
1759
+ getColumns: () => this.#terminal.viewport().columns,
1760
+ getRows: () => this.#terminal.viewport().rows,
1761
+ requestRender: () => this.#terminal.requestRender(),
2156
1762
  }, request.providerId, success => {
2157
1763
  if (!success)
2158
1764
  this.#finishWorkflowLogin();
@@ -2164,7 +1770,7 @@ export class OwnedUiSessionShell {
2164
1770
  message: `Log in to ${request.providerName}`,
2165
1771
  });
2166
1772
  this.#syncTerminalProgress(this.view());
2167
- this.runtime.requestRender();
1773
+ this.#terminal.requestRender();
2168
1774
  }
2169
1775
  #requestWorkflowInput(request) {
2170
1776
  const dialog = this.#activeLoginDialog;
@@ -2177,7 +1783,7 @@ export class OwnedUiSessionShell {
2177
1783
  const restoreDialog = () => {
2178
1784
  if (this.#activeLoginDialog === dialog)
2179
1785
  this.root.setInputSurface(dialog);
2180
- this.runtime.requestRender();
1786
+ this.#terminal.requestRender();
2181
1787
  };
2182
1788
  const selector = createPiShellExtensionSelector(request.message, labels, label => {
2183
1789
  const id = options.find(option => option.label === label)?.id;
@@ -2188,13 +1794,13 @@ export class OwnedUiSessionShell {
2188
1794
  resolve(null);
2189
1795
  });
2190
1796
  this.root.setInputSurface(selector, false);
2191
- this.runtime.requestRender();
1797
+ this.#terminal.requestRender();
2192
1798
  });
2193
1799
  }
2194
1800
  const response = request.type === "manual-code"
2195
1801
  ? dialog.showManualInput(request.message)
2196
1802
  : dialog.showPrompt(request.message, request.placeholder);
2197
- this.runtime.requestRender();
1803
+ this.#terminal.requestRender();
2198
1804
  return response.then(value => value, () => null);
2199
1805
  }
2200
1806
  #notifyWorkflowLogin(event) {
@@ -2213,7 +1819,7 @@ export class OwnedUiSessionShell {
2213
1819
  dialog.showWaiting(event.message);
2214
1820
  else
2215
1821
  dialog.showProgress(event.message);
2216
- this.runtime.requestRender();
1822
+ this.#terminal.requestRender();
2217
1823
  }
2218
1824
  #finishWorkflowLogin() {
2219
1825
  if (!this.#activeLoginDialog)
@@ -2222,11 +1828,11 @@ export class OwnedUiSessionShell {
2222
1828
  this.root.setInputSurface(null);
2223
1829
  this.#programStatus.setBlocked("authentication", undefined);
2224
1830
  this.#syncTerminalProgress(this.view());
2225
- this.runtime.requestRender();
1831
+ this.#terminal.requestRender();
2226
1832
  }
2227
1833
  async #execute(command, draft) {
2228
1834
  if (draft === undefined)
2229
- return this.backend.execute(command);
1835
+ return this.backend.session.execute(command);
2230
1836
  const revision = this.#editorRevision;
2231
1837
  try {
2232
1838
  assertOwnedUiCommand(command);
@@ -2235,7 +1841,7 @@ export class OwnedUiSessionShell {
2235
1841
  return this.#recoverSubmission(draft, revision, error);
2236
1842
  }
2237
1843
  try {
2238
- const result = await this.backend.execute(command);
1844
+ const result = await this.backend.session.execute(command);
2239
1845
  if (result.outcome === "rejected")
2240
1846
  return this.#recoverSubmission(draft, revision);
2241
1847
  return result;
@@ -2276,18 +1882,41 @@ export class OwnedUiSessionShell {
2276
1882
  try {
2277
1883
  this.root.appendWorkflowResult({ command: "debug", outcome: "failed", message: message
2278
1884
  ?? (error instanceof ImageAttachmentError ? error.message : "Submission failed. Check the prompt and try again.") }, error instanceof ImageAttachmentError ? error.code : undefined);
2279
- this.runtime.requestRender();
1885
+ this.#terminal.requestRender();
2280
1886
  }
2281
1887
  catch { /* Security: error presentation cannot create another rejected submission callback. */ }
2282
1888
  }
2283
1889
  #simple(type) {
2284
- return { type, correlationId: this.#correlation(type), sessionId: this.backend.sessionId };
1890
+ return { type, correlationId: this.#correlation(type), sessionId: this.backend.identity.sessionId };
2285
1891
  }
2286
1892
  #correlation(prefix) {
2287
1893
  this.#sequence += 1;
2288
1894
  return `pi-shell-${prefix}-${this.#sequence}`;
2289
1895
  }
2290
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
+ }
2291
1920
  function isCoalescedStreamBlock(kind) {
2292
1921
  return kind === "assistant" || kind === "thinking" || kind === "tool-call" || kind === "tool-result";
2293
1922
  }
@@ -2317,14 +1946,6 @@ export function quoteCommandArgument(value) {
2317
1946
  function dim(value) {
2318
1947
  return `\u001b[2m${value}\u001b[22m`;
2319
1948
  }
2320
- function modelReference(model) {
2321
- if (typeof model !== "object" || model === null)
2322
- return "";
2323
- const value = model;
2324
- const provider = typeof value.provider === "string" ? value.provider : "";
2325
- const id = typeof value.id === "string" ? value.id : typeof value.modelId === "string" ? value.modelId : "";
2326
- return provider && id ? `${provider}/${id}` : "";
2327
- }
2328
1949
  function rejected(diagnostic) {
2329
1950
  return { outcome: "rejected", diagnostic };
2330
1951
  }
@@ -2338,9 +1959,4 @@ function workflowAdapterResult(result) {
2338
1959
  return { outcome: "failed", diagnostic: result.message };
2339
1960
  return { outcome: "rejected", diagnostic: result.message };
2340
1961
  }
2341
- const INTERRUPT = "\u0003";
2342
- const INTERRUPT_CHORD_MS = 1_500;
2343
1962
  const RELOAD_SURFACE_MIN_VISIBLE_MS = 400;
2344
- // Rationale: the outro is not configurable; the switch only decides whether this plan plays.
2345
- const QUIT_OUTRO_EFFECT = "fall";
2346
- const QUIT_OUTRO_DURATION_MS = 800;