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

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 (104) 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 +3 -1
  6. package/dist/app/session-shell/index.js +1 -0
  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 +20 -10
  17. package/dist/app/session-shell/session-shell-root.js +6 -6
  18. package/dist/app/session-shell/session-shell.d.ts +19 -21
  19. package/dist/app/session-shell/session-shell.js +656 -860
  20. package/dist/composition/owned-ui.d.ts +20 -2
  21. package/dist/composition/owned-ui.js +66 -35
  22. package/dist/composition/session-info-reference.d.ts +2 -2
  23. package/dist/composition/session-info-reference.js +2 -2
  24. package/dist/contracts/agent-engine/capability-ports.d.ts +1 -85
  25. package/dist/contracts/agent-engine/capability-ports.js +1 -35
  26. package/dist/contracts/agent-engine/index.d.ts +1 -7
  27. package/dist/contracts/agent-engine/index.js +0 -4
  28. package/dist/contracts/owned-ui/index.d.ts +5 -1
  29. package/dist/contracts/owned-ui/index.js +1 -0
  30. package/dist/contracts/owned-ui/model.d.ts +6 -0
  31. package/dist/contracts/owned-ui/session-backend.d.ts +119 -0
  32. package/dist/contracts/owned-ui/session-presenters.d.ts +55 -0
  33. package/dist/contracts/owned-ui/session-presenters.js +1 -0
  34. package/dist/contracts/owned-ui/session-workflows.d.ts +342 -0
  35. package/dist/contracts/owned-ui/session-workflows.js +30 -0
  36. package/dist/features/owned-ui/settings-app.js +35 -15
  37. package/dist/foundation/release/bootstrap.js +1 -2
  38. package/dist/foundation/release/release-gc.js +1 -1
  39. package/dist/foundation/release/update.js +0 -4
  40. package/dist/foundation/storage/control-store.js +0 -12
  41. package/dist/integrations/pi/components/components.js +0 -16
  42. package/dist/integrations/pi/components/index.d.ts +3 -3
  43. package/dist/integrations/pi/components/index.js +2 -2
  44. package/dist/integrations/pi/components/models-dialog.js +1 -30
  45. package/dist/integrations/pi/components/shell-editor-autocomplete.js +11 -9
  46. package/dist/integrations/pi/components/shell-extension-ui.d.ts +3 -3
  47. package/dist/integrations/pi/components/shell-extension-ui.js +6 -2
  48. package/dist/integrations/pi/components/shell-presenters-transcript.js +4 -4
  49. package/dist/integrations/pi/components/shell-shared-facade.d.ts +21 -1
  50. package/dist/integrations/pi/components/shell-shared-facade.js +18 -2
  51. package/dist/integrations/pi/components/upstream/theme/theme.d.ts +5 -1
  52. package/dist/integrations/pi/components/upstream/theme/theme.js +20 -2
  53. package/dist/integrations/pi/engine/adapter.d.ts +44 -24
  54. package/dist/integrations/pi/engine/adapter.js +130 -22
  55. package/dist/integrations/pi/engine/command-dispatch.d.ts +2 -5
  56. package/dist/integrations/pi/engine/extension-ui-binding.d.ts +2 -11
  57. package/dist/integrations/pi/engine/host-ports.d.ts +20 -0
  58. package/dist/integrations/pi/engine/host-ports.js +13 -0
  59. package/dist/integrations/pi/engine/host.d.ts +70 -0
  60. package/dist/integrations/pi/engine/host.js +147 -0
  61. package/dist/integrations/pi/engine/index.d.ts +8 -4
  62. package/dist/integrations/pi/engine/index.js +3 -1
  63. package/dist/integrations/pi/engine/resource-catalog.d.ts +4 -23
  64. package/dist/integrations/pi/engine/runtime-integration.d.ts +8 -2
  65. package/dist/integrations/pi/engine/runtime-integration.js +12 -7
  66. package/dist/integrations/pi/engine/session-integration.d.ts +3 -8
  67. package/dist/integrations/pi/engine/session-integration.js +0 -49
  68. package/dist/integrations/pi/engine/session-runtime.d.ts +18 -3
  69. package/dist/integrations/pi/engine/session-runtime.js +18 -7
  70. package/dist/integrations/pi/engine/settings-bridge.d.ts +2 -2
  71. package/dist/integrations/pi/engine/settings-bridge.js +0 -2
  72. package/dist/integrations/pi/engine/settings-port.d.ts +2 -0
  73. package/dist/integrations/pi/engine/settings-port.js +3 -5
  74. package/dist/integrations/pi/engine/workflows.d.ts +33 -260
  75. package/dist/integrations/pi/engine/workflows.js +6 -28
  76. package/dist/integrations/pi/session-presenters/index.d.ts +2 -0
  77. package/dist/integrations/pi/session-presenters/index.js +1 -0
  78. package/dist/integrations/pi/session-presenters/presenters.d.ts +16 -0
  79. package/dist/integrations/pi/session-presenters/presenters.js +341 -0
  80. package/dist/integrations/pi/tui-runtime/adapter.js +0 -2
  81. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +7 -0
  82. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +66 -1
  83. package/dist/integrations/pi/tui-runtime/index.d.ts +1 -1
  84. package/dist/integrations/pi/tui-runtime/index.js +1 -1
  85. package/dist/native/darwin-arm64/manifest.json +1 -1
  86. package/dist/native/linux-x64/manifest.json +1 -1
  87. package/dist/native/win32-x64/manifest.json +2 -2
  88. package/dist/native/win32-x64/process-guardian.exe +0 -0
  89. package/dist/ui/settings/declarations.d.ts +13 -1
  90. package/dist/ui/settings/declarations.js +12 -1
  91. package/dist/ui/settings/migrations.js +7 -0
  92. package/docs/architecture/boundaries.md +9 -1
  93. package/docs/architecture/project-structure.md +3 -2
  94. package/package.json +1 -1
  95. package/dist/contracts/agent-engine/model.d.ts +0 -78
  96. package/dist/contracts/agent-engine/model.js +0 -1
  97. package/dist/contracts/agent-engine/ports.d.ts +0 -18
  98. package/dist/contracts/agent-engine/serialization.d.ts +0 -7
  99. package/dist/contracts/agent-engine/serialization.js +0 -58
  100. package/dist/contracts/agent-engine/validation.d.ts +0 -5
  101. package/dist/contracts/agent-engine/validation.js +0 -128
  102. package/dist/integrations/pi/components/upstream/theme/system-theme.d.ts +0 -61
  103. package/dist/integrations/pi/components/upstream/theme/system-theme.js +0 -709
  104. /package/dist/contracts/{agent-engine/ports.js → owned-ui/session-backend.js} +0 -0
@@ -4,21 +4,18 @@ 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
10
  import { MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseMouseInput } from "../../ui/components/mouse.js";
11
11
  import { readVisibleHyperlinks } from "../../ui/components/visible-hyperlinks.js";
12
- import { PINNED_PI_HIDDEN_COMMAND_NAMES, workflowCommandNames } from "../../integrations/pi/engine/workflows.js";
12
+ import { workflowCommandNames } from "../../integrations/pi/engine/workflows.js";
13
13
  import { createPiExtensionUiBridge } from "../../integrations/pi/components/shell-extension-ui.js";
14
14
  import { ProgramStatusReporter } from "../../integrations/pi/components/upstream/program-status-reporter.js";
15
- import { createPiShellEditor } from "../../integrations/pi/components/shell-editor-autocomplete.js";
16
15
  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";
16
+ import { createPiShellAuthProviderSelector, createPiShellDialog, createPiShellExtensionSelector, createPiShellLoginDialog, createPiShellReloadBox, createPiShellSelector, createPiShellSkillsSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, } from "../../integrations/pi/components/shell-selectors-dialogs.js";
17
+ import { renderPiShellStatusText, } from "../../integrations/pi/components/shell-presenters-info.js";
18
+ import { piCanvasBackgroundAnsi } from "../../integrations/pi/components/theme.js";
22
19
  import { DamageAwareTerminalAdapter } from "../../integrations/pi/tui-runtime/damage-aware-terminal.js";
23
20
  import { PiTuiRuntimeAdapter } from "../../integrations/pi/tui-runtime/adapter.js";
24
21
  import { classifyPiTuiInput } from "../../integrations/pi/tui-runtime/input-presentation-coordinator.js";
@@ -26,10 +23,14 @@ import { runImageWorker } from "./image-preparation-client.js";
26
23
  import { STREAM_PRESENTATION_INTERVAL_MS, StreamPresentationCoalescer, } from "./stream-presentation-coalescer.js";
27
24
  import { writeSystemClipboardText, } from "./system-clipboard.js";
28
25
  import { OwnedUiSessionShellRoot, shellResourceEntries, } from "./session-shell-root.js";
26
+ // Invariant: a disposed shell leaves the engine a host that holds no reference back to it.
27
+ const DETACHED_WORKFLOW_INTERACTION = { prompt: async () => null, notify() { } };
29
28
  export { OwnedUiSessionShellRoot, } from "./session-shell-root.js";
30
29
  /** Coordinates backend, owned presentation, and Pi TUI lifecycles for one interactive session. */
31
30
  export class OwnedUiSessionShell {
32
31
  backend;
32
+ #presenters;
33
+ #dialogHost;
33
34
  root;
34
35
  runtime;
35
36
  #cwd;
@@ -76,6 +77,7 @@ export class OwnedUiSessionShell {
76
77
  #streamPresentation;
77
78
  #removeViewportPreInput;
78
79
  #unsubscribeSettings;
80
+ #unsubscribeBackgroundSettings;
79
81
  #unbindPiSettings;
80
82
  #unbindTerminalSettings;
81
83
  #unbindShutdownSettings;
@@ -93,17 +95,21 @@ export class OwnedUiSessionShell {
93
95
  #suggestionModelKey;
94
96
  constructor(options) {
95
97
  const { backend, cwd, routeHost, sessionLayout } = options.engine;
96
- const { terminal, startup, viewportSettings, quitOutro, reload: reloadPresentation, stream: streamPresentationOptions, input: inputPresentation } = options.presentation ?? {};
98
+ const { terminal, startup, viewportSettings, backgroundSettings, quitOutro, reload: reloadPresentation, stream: streamPresentationOptions, input: inputPresentation, } = options.presentation ?? {};
97
99
  const { clipboard, responseCopy, paste: pasteDiagnostics, pastePreparation } = options.diagnostics ?? {};
98
100
  const promptHistory = options.history;
101
+ const { clipboard: sharedClipboard, keybindings: sharedKeybindings } = options.shared ?? {};
99
102
  this.backend = backend;
100
- this.#sessionGeneration = this.backend.sessionGeneration;
101
- this.#sessionBindingGeneration = this.backend.sessionBindingGeneration;
102
- this.#suggestionModelKey = modelKey(this.backend.view());
103
+ this.#presenters = options.presenters;
104
+ this.#sessionGeneration = this.backend.identity.sessionGeneration;
105
+ this.#sessionBindingGeneration = this.backend.identity.sessionBindingGeneration;
106
+ this.#suggestionModelKey = modelKey(this.backend.session.view());
103
107
  this.#cwd = cwd;
104
108
  this.#routeHost = routeHost ?? null;
105
109
  this.#startupRoute = options.engine.startupRoute;
106
110
  this.#customViewport = sessionLayout === "custom-viewport";
111
+ // Invariant: the host's optional members follow the layout, so it is built only once the layout is known.
112
+ this.#dialogHost = this.#createDialogHost();
107
113
  this.#lazySelectors = options.diagnostics?.lazySelectors;
108
114
  this.#stopped = new Promise(resolve => {
109
115
  this.#resolveStopped = resolve;
@@ -116,514 +122,569 @@ export class OwnedUiSessionShell {
116
122
  // Invariant: owned prompt limits and collapsed skills are bare-A1 replacements; comparison profiles keep pinned behavior.
117
123
  this.#promptImages = this.#customViewport ? options.promptImages ?? null : null;
118
124
  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());
125
+ // Invariant: construction is transactional. Each binding registers its release as it is made, and a
126
+ // throw releases them in reverse, so a shell that never finished constructing holds nothing.
127
+ const constructed = [];
128
+ try {
129
+ const terminalCopy = terminal !== undefined || hasAsyncClipboardOutput();
130
+ this.#responseCopy = this.#customViewport ? new ResponseCopyCoordinator({
131
+ execute: responseCopy?.execute ?? (this.#copyExecutor = createResponseCopyExecutor({
132
+ ...(sharedClipboard === undefined ? {} : { pool: sharedClipboard.copy }),
133
+ ...(terminal === undefined && clipboard === undefined ? {} : { destination: "terminal" }),
134
+ ...(clipboard?.writeText === undefined ? {} : { writeText: (text, signal) => clipboard.writeText(text, signal) }),
135
+ ...(terminalCopy ? { terminal: { submit: async (control, signal) => {
136
+ if (signal.aborted || this.#disposed || !runtime?.active)
137
+ throw new Error("Copy canceled");
138
+ // Performance: never grow the real terminal's pending buffer with another clipboard payload.
139
+ if (terminal === undefined && process.stdout.writableLength + control.length > MAX_COPY_CONTROL_BYTES) {
140
+ throw new Error("Clipboard terminal is busy");
141
+ }
142
+ runtime.writeControl(control);
143
+ } } } : {}),
144
+ })),
145
+ ...(responseCopy?.onEvent === undefined ? {} : { onEvent: responseCopy.onEvent }),
146
+ onFailure: result => {
147
+ if (this.#disposed || !runtime?.active)
148
+ return;
149
+ this.root.appendWorkflowStatus(result.outcome === "timed-out" ? "Copy timed out; the clipboard did not respond."
150
+ : result.failure === "size" ? "Selection exceeds this clipboard route's size limit."
151
+ : "Could not copy the selection; the clipboard is unavailable.");
152
+ runtime.requestRender();
153
+ },
154
+ }) : null;
155
+ constructed.push(() => { this.#responseCopy?.dispose(); this.#copyExecutor?.dispose(); });
156
+ const readClipboard = async (signal) => {
157
+ if (signal.aborted)
158
+ throw new ImageAttachmentError("image-canceled");
159
+ if (clipboard === undefined)
160
+ return runImageWorker({ kind: "clipboard" }, signal);
161
+ try {
162
+ const image = await clipboard.readImage?.(signal);
163
+ if (image !== null && image !== undefined) {
164
+ const canonical = await runImageWorker({ kind: "canonicalize", source: image }, signal);
165
+ if (canonical !== null)
166
+ return { kind: "image", ...canonical };
167
+ }
200
168
  }
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;
169
+ catch (error) {
170
+ if (error instanceof ImageAttachmentError)
171
+ throw error;
172
+ // Compatibility: an unavailable image reader can still provide text.
215
173
  }
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"); },
174
+ const text = await clipboard.readText(signal);
175
+ return text === null ? null : { kind: "text", text };
176
+ };
177
+ this.root = new OwnedUiSessionShellRoot(this.backend.session.view(), cwd, {
178
+ getColumns: () => runtime?.viewport().columns ?? terminal?.columns ?? 80,
179
+ getRows: () => runtime?.viewport().rows ?? terminal?.rows ?? 24,
180
+ requestRender: force => runtime?.requestRender(force),
181
+ requestHyperlinkCleanup: rows => damageTerminal?.requestHyperlinkCleanup(rows),
182
+ onViewportFrame: frame => damageTerminal?.arm(frame.descriptor, {
183
+ overlayActive: runtime?.hasOverlay() ?? false,
184
+ selectionActive: this.root.hasActiveSelection(),
185
+ replacementSurfaceActive: !this.root.usesDefaultInputSurface(),
186
+ }),
187
+ enableDockInputReuse: inputPresentation?.viewportReuse !== false,
188
+ persistentHistory: this.#customViewport && promptHistory !== undefined,
189
+ ...(promptHistory === undefined ? {} : { historyEditor: promptHistory.editor }),
190
+ onSubmit: text => { void this.submit(text).catch(() => this.#reportSubmissionError()); },
191
+ onPasteRejected: error => this.#reportSubmissionError(error),
192
+ onInterrupt: () => { void this.interrupt(); },
193
+ onClear: () => { void this.clearOrExit(); },
194
+ onExit: () => { void this.shutdown(); },
195
+ onModelSelect: () => this.#customViewport ? this.showModelsDialog() : this.showModelSelector(),
196
+ onModelCycle: direction => { void this.cycleModel(direction); },
197
+ onThinkingCycle: () => { void this.cycleThinkingLevel(); },
198
+ onThinkingToggle: () => {
199
+ this.root.toggleThinkingVisibility();
200
+ this.runtime.requestRender();
201
+ },
202
+ onMessageCopy: () => { void this.runWorkflow({ command: "copy", argument: "" }); },
203
+ onFollowUp: () => { void this.queueFollowUp().catch(() => this.#reportSubmissionError()); },
204
+ onDequeue: () => this.restoreQueuedInput(),
205
+ onEditorChange: text => {
206
+ this.#editorRevision++;
207
+ promptSuggestionController?.abortPending();
208
+ if (text.length === 0) {
209
+ // Concurrency: the editor synchronizes deletion-owned autocomplete after onChange.
210
+ // Reassert retained ghost text in the microtask before Pi emits its scheduled frame.
211
+ queueMicrotask(() => promptSuggestionController?.restoreAvailable());
212
+ }
213
+ this.root.reconcilePromptImageLimitNotice(text);
214
+ },
215
+ onPromptSuggestionAccepted: () => promptSuggestionController?.accept(),
216
+ onPromptSuggestionEligibilityChanged: () => {
217
+ queueMicrotask(() => promptSuggestionController?.restoreAvailable());
218
+ },
219
+ onInputSurfaceChanged: () => {
220
+ promptSuggestionController?.invalidate();
221
+ this.#promptHistory?.synchronize();
222
+ },
223
+ onCopyText: text => {
224
+ if (this.#responseCopy !== null) {
225
+ void this.#responseCopy.submitText(text);
226
+ return;
227
+ }
228
+ const write = () => {
229
+ if (this.#disposed)
230
+ return Promise.resolve();
231
+ runtime?.writeControl(`\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
232
+ return clipboard === undefined ? writeSystemClipboardText(text)
233
+ : clipboard.writeText?.(text) ?? Promise.resolve();
237
234
  };
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;
235
+ // Compatibility: comparison profiles retain their existing clipboard path.
236
+ pendingClipboardWrite = write().catch(() => { });
237
+ },
238
+ readClipboardContent: async (signal = new AbortController().signal) => {
239
+ await pendingClipboardWrite;
240
+ return readClipboard(signal);
241
+ },
242
+ captureClipboardPaste: () => {
243
+ const before = this.#responseCopy?.capturePasteBarrier() ?? (async () => true);
244
+ if (clipboard !== undefined)
245
+ return { kind: "provided", read: readClipboard, before };
246
+ if (responseCopyDestination(process.env) === "terminal")
247
+ return {
248
+ kind: "provided", before, read: async () => { throw new ImageAttachmentError("paste-unavailable"); },
249
+ };
250
+ return { kind: "native", before };
277
251
  },
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 }),
252
+ ...(pasteDiagnostics === undefined ? {} : { pasteDiagnostics: pasteDiagnostics }),
253
+ ...(pastePreparation === undefined && sharedClipboard === undefined ? {} : {
254
+ pastePreparation: { ...pastePreparation, ...(sharedClipboard === undefined ? {} : { pool: sharedClipboard.paste }) },
255
+ }),
256
+ ...(sharedKeybindings === undefined ? {} : { keybindings: sharedKeybindings }),
257
+ ...(this.#promptImages === null ? {} : { promptImageLimit: this.#promptImages.limit }),
258
+ skillsPresentation: () => this.#skills?.presentation() ?? "expand",
259
+ }, {
260
+ ...startup,
261
+ resources: startup?.resources ?? shellResourceEntries(this.backend),
262
+ }, this.backend.identity.agentDir, this.#presenters.transcriptRenderers(), sessionLayout, {
263
+ resolve: assetId => this.backend.extensions.resolveTranscriptImage(assetId),
264
+ });
265
+ // Invariant: the runtime owns the root (its theme subscription and helper clients) once constructed;
266
+ // stopping an idle runtime disposes it synchronously.
267
+ constructed.push(() => {
268
+ if (runtime === undefined)
269
+ this.root.dispose();
270
+ else
271
+ void runtime.dispose().catch(() => { });
272
+ });
273
+ const initialPiSettings = this.backend.settings.snapshot();
274
+ // Invariant: bare A1 owns a bounded viewport and therefore always runs on the alternate
275
+ // fullscreen surface. The pinned comparison profiles still honor Pi's mode.
276
+ const tuiMode = this.#customViewport
277
+ ? "fullscreen"
278
+ : this.backend.identity.disposed ? "regular" : initialPiSettings.tuiMode;
279
+ const runtimeOptions = {
280
+ root: this.root,
281
+ mode: tuiMode,
282
+ ...(this.#customViewport ? {
283
+ // Invariant: the owned shell enables and routes mouse reports itself. Pi's
284
+ // enclosing fullscreen renderer must never establish a competing white selection.
285
+ mouse: false,
286
+ consumeUnhandledMouse: true,
287
+ onOverlayGeometry: surfaces => this.root.setViewportOverlaySurfaces(surfaces),
288
+ decorateTerminal: (terminal) => {
289
+ damageTerminal = new DamageAwareTerminalAdapter(terminal, {
290
+ regionalScroll: process.env.TERM !== "dumb",
291
+ inspectHyperlinks: readVisibleHyperlinks,
292
+ onResize: () => streamPresentation?.noteImmediatePresentation(),
293
+ onHyperlinkCleanupRequired: () => runtime?.requestRender(true),
294
+ });
295
+ return damageTerminal;
296
+ },
297
+ ...(inputPresentation?.coordination === false ? {} : {
298
+ inputCoordination: {
299
+ classify: (data, focusedOverlay) => classifyPiTuiInput(data, focusedOverlay ?? this.root.inputCoordinationSurface()),
300
+ onReceipt: () => streamPresentation?.noteImmediatePresentation(),
301
+ ...(inputPresentation?.scheduler === undefined
302
+ ? {}
303
+ : { scheduler: inputPresentation.scheduler }),
304
+ },
305
+ }),
306
+ } : { layoutRoot: this.root.layoutRoot() }),
307
+ ...(inputPresentation?.onEvent === undefined ? {} : {
308
+ inputDiagnostics: {
309
+ onEvent: inputPresentation.onEvent,
310
+ ...(inputPresentation.now === undefined ? {} : { now: inputPresentation.now }),
285
311
  },
286
312
  }),
287
- } : { layoutRoot: this.root.layoutRoot() }),
288
- ...(inputPresentation?.onEvent === undefined ? {} : {
289
- inputDiagnostics: {
290
- onEvent: inputPresentation.onEvent,
291
- ...(inputPresentation.now === undefined ? {} : { now: inputPresentation.now }),
292
- },
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;
313
+ ...(terminal === undefined ? {} : { terminal: terminal }),
314
+ hardwareCursor: this.backend.session.view().terminal.hardwareCursor,
315
+ ...(this.#customViewport ? {} : { wheelScrollLines: initialPiSettings.fullscreenWheelScrollLines }),
316
+ };
317
+ runtime = new PiTuiRuntimeAdapter(runtimeOptions);
318
+ this.runtime = runtime;
319
+ this.#programStatus = new ProgramStatusReporter(status => { if (this.runtime.active)
320
+ this.runtime.setProgramStatus(status); }, () => this.view().status.footer?.sessionName ?? undefined);
321
+ this.#damageTerminal = damageTerminal ?? null;
322
+ if (this.#customViewport && backgroundSettings !== undefined && damageTerminal !== undefined) {
323
+ damageTerminal.setCanvasBackground(piCanvasBackgroundAnsi(backgroundSettings.snapshot()));
324
+ }
325
+ this.#quitOutro = quitOutro;
326
+ this.#reloadPresentation = reloadPresentation;
327
+ const presentationInterval = streamPresentationOptions?.intervalMs ?? STREAM_PRESENTATION_INTERVAL_MS;
328
+ streamPresentation = streamPresentationOptions?.scheduler === undefined
329
+ ? new StreamPresentationCoalescer(() => this.runtime.requestRender(), presentationInterval)
330
+ : new StreamPresentationCoalescer(() => this.runtime.requestRender(), presentationInterval, streamPresentationOptions.scheduler);
331
+ this.#streamPresentation = streamPresentation;
332
+ constructed.push(() => this.#streamPresentation.dispose());
333
+ const promptSuggestionOptions = this.#customViewport ? options.suggestions : undefined;
334
+ promptSuggestionController = promptSuggestionOptions === undefined ? null : new ContextualPromptSuggestionController({
335
+ generator: promptSuggestionOptions.generator,
336
+ enabled: promptSuggestionOptions.enabled(),
337
+ ...(promptSuggestionOptions.diagnostics === undefined ? {} : { diagnostics: promptSuggestionOptions.diagnostics }),
338
+ surface: {
339
+ canPresent: identity => this.#promptSuggestionPresentationBlockReason(identity) === null,
340
+ presentationBlockReason: identity => this.#promptSuggestionPresentationBlockReason(identity),
341
+ present: text => {
342
+ if (!this.root.canPresentPromptSuggestion())
343
+ return false;
344
+ this.root.setPromptSuggestion(text);
345
+ return true;
346
+ },
347
+ clear: () => this.root.setPromptSuggestion(null),
348
+ requestRender: () => this.runtime.requestRender(),
323
349
  },
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())
350
+ });
351
+ this.#promptSuggestions = promptSuggestionController;
352
+ constructed.push(() => this.#promptSuggestions?.dispose());
353
+ this.#unsubscribePromptSuggestions = promptSuggestionOptions === undefined
354
+ ? () => { }
355
+ : promptSuggestionOptions.onChange(enabled => this.#promptSuggestions?.setEnabled(enabled));
356
+ constructed.push(this.#unsubscribePromptSuggestions);
357
+ this.#unsubscribePromptImages = this.#promptImages === null ? () => { } : this.#promptImages.onChange(() => {
358
+ if (!this.#disposed && this.root.reconcilePromptImageLimitNotice())
359
+ this.runtime.requestRender();
360
+ });
361
+ constructed.push(this.#unsubscribePromptImages);
362
+ // Rationale: the same listener refreshes the menu for the A1 presentation choice and for the engine's
363
+ // skill-command registration, both of which the owned settings manager reports through one change.
364
+ this.#unsubscribeSkills = this.#skills === null ? () => { } : this.#skills.onChange(() => {
365
+ // Invariant: reinstalling drops extension provider wrappers, so an unrelated setting change leaves the list alone.
366
+ if (this.#disposed || this.#commandListSignature() === this.#installedCommandSignature)
367
+ return;
368
+ this.#installAutocompleteCommands();
334
369
  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;
342
- 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);
370
+ });
371
+ constructed.push(this.#unsubscribeSkills);
372
+ this.runtime.setHardwareCursor(initialPiSettings.showHardwareCursor);
373
+ this.runtime.setClearOnShrink(initialPiSettings.clearOnShrink);
374
+ this.#terminalProgressEnabled = initialPiSettings.showTerminalProgress;
375
+ this.#fullscreenExitOutput = initialPiSettings.fullscreenExitOutput;
376
+ // Invariant: bare A1 prints only the resume hint at exit, so the pinned exit-output
377
+ // choice is hidden there and cannot be bound; the comparison profile binds and honors it.
378
+ this.#unbindShutdownSettings = this.backend.settings.productMode === "bare" ? () => { } : this.backend.settings.bindOwner("shutdown", {
379
+ fullscreenExitOutput: { apply() { } },
380
+ });
381
+ constructed.push(this.#unbindShutdownSettings);
382
+ this.#unbindTerminalSettings = this.backend.settings.bindOwner("terminal", {
383
+ showHardwareCursor: { apply: value => {
384
+ if (typeof value !== "boolean")
385
+ throw new TypeError("Hardware cursor setting is invalid");
386
+ this.runtime.setHardwareCursor(value);
387
+ } },
388
+ clearOnShrink: { apply: value => {
389
+ if (typeof value !== "boolean")
390
+ throw new TypeError("Clear-on-shrink setting is invalid");
391
+ this.runtime.setClearOnShrink(value);
392
+ } },
393
+ showTerminalProgress: { apply: value => {
394
+ if (typeof value !== "boolean")
395
+ throw new TypeError("Terminal progress setting is invalid");
396
+ this.#terminalProgressEnabled = value;
397
+ this.#syncTerminalProgress(this.view());
398
+ } },
399
+ });
400
+ constructed.push(this.#unbindTerminalSettings);
401
+ this.#removeViewportPreInput = this.#customViewport
402
+ ? this.runtime.addPreInputListener(data => {
403
+ if (inputPresentation?.coordination === false)
404
+ this.#streamPresentation.noteImmediatePresentation();
405
+ // Compatibility: route modal paging and boundary keys before Pi's fullscreen handlers.
406
+ const isDefault = this.root.usesDefaultInputSurface();
407
+ if (!this.runtime.hasOverlay() && !isDefault
408
+ && (this.root.editor.matchesTerminalKey(data, "pageUp")
409
+ || this.root.editor.matchesTerminalKey(data, "pageDown"))) {
410
+ this.root.handleInput(data);
411
+ this.runtime.requestRender();
412
+ return { consume: true };
413
+ }
414
+ if (!this.runtime.hasOverlay() && (this.root.editor.matchesTerminalKey(data, "home")
415
+ || this.root.editor.matchesTerminalKey(data, "end")
416
+ || !isDefault && (this.root.editor.matchesTerminalKey(data, "ctrl+home")
417
+ || this.root.editor.matchesTerminalKey(data, "ctrl+end")))) {
418
+ if (isDefault)
419
+ this.root.handleViewportPreInput(data, true);
420
+ this.root.handleInput(data);
421
+ this.runtime.requestRender();
422
+ return { consume: true };
423
+ }
424
+ // Invariant: geometry must belong to the painted frame, including newly opened/nested
425
+ // surfaces. Steady pointer input does not trigger a synchronous composition.
426
+ const viewport = this.runtime.viewport();
427
+ if (data.includes("\u001b[<") && !this.root.viewportInputGeometryReady(viewport.columns, viewport.rows)) {
428
+ this.runtime.renderNow();
429
+ }
430
+ const routed = this.root.handleViewportPreInput(data, true, Date.now(), this.root.usesDefaultInputSurface() && !this.runtime.hasFocusedOverlay());
431
+ if (routed.copySelection !== undefined) {
432
+ const snapshot = routed.copySelection;
433
+ const acknowledgement = copyAcknowledgement(snapshot);
434
+ const intent = ++this.#copyIntentSequence;
435
+ void this.#responseCopy?.submit(snapshot, pendingClipboardWrite).then(result => {
436
+ if (this.#disposed || !this.runtime.active || intent !== this.#copyIntentSequence
437
+ || result.outcome !== "delivered" && result.outcome !== "submitted-unverified")
438
+ return;
439
+ if (acknowledgement.hasNonWhitespace) {
440
+ this.root.showCopyAcknowledgement(`copied ${acknowledgement.characters} chars to clipboard`);
441
+ }
442
+ });
443
+ }
444
+ if (!routed.consumed)
445
+ return routed.data === data ? undefined : { data: routed.data };
446
+ return routed.data.length === 0 ? { consume: true } : { data: routed.data };
447
+ })
448
+ : () => { };
449
+ constructed.push(this.#removeViewportPreInput);
450
+ const applyViewportSettings = () => {
451
+ const snapshot = viewportSettings?.snapshot();
452
+ this.root.setViewportConfig(snapshot ?? {
453
+ scrollbarAppearance: "auto",
454
+ scrollbarStyle: "thin",
455
+ scrollbarSpeed: "normal",
456
+ });
457
+ };
458
+ applyViewportSettings();
459
+ this.#unsubscribeSettings = this.#customViewport && viewportSettings
460
+ ? viewportSettings.onChange(settings => this.root.setViewportConfig(settings))
461
+ : () => { };
462
+ constructed.push(this.#unsubscribeSettings);
463
+ this.#unsubscribeBackgroundSettings = this.#customViewport && backgroundSettings
464
+ ? backgroundSettings.onChange(style => {
465
+ if (this.#damageTerminal?.setCanvasBackground(piCanvasBackgroundAnsi(style))) {
466
+ this.runtime.requestRender(true);
467
+ }
468
+ })
469
+ : () => { };
470
+ constructed.push(this.#unsubscribeBackgroundSettings);
471
+ this.root.setEditorPaddingX(initialPiSettings.editorPaddingX);
472
+ this.root.setAutocompleteMaxVisible(initialPiSettings.autocompleteMaxVisible);
473
+ this.root.setOutputPad(initialPiSettings.outputPad);
474
+ this.root.setFullscreenCopyOnSelect(initialPiSettings.fullscreenCopyOnSelect);
475
+ this.root.setHideThinkingBlock(initialPiSettings.hideThinkingBlock);
476
+ this.root.setMermaidRenderingMode(initialPiSettings.mermaidRenderingMode);
477
+ this.#showImages = initialPiSettings.showImages;
478
+ this.#imageWidthCells = initialPiSettings.imageWidthCells;
479
+ this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
480
+ this.#unbindPiSettings = this.backend.settings.bindOwner("shell", {
481
+ editorPaddingX: { apply: value => {
482
+ if (typeof value !== "number")
483
+ throw new TypeError("Editor padding is invalid");
484
+ this.root.setEditorPaddingX(value);
485
+ } },
486
+ autocompleteMaxVisible: { apply: value => {
487
+ if (typeof value !== "number")
488
+ throw new TypeError("Autocomplete maximum is invalid");
489
+ this.root.setAutocompleteMaxVisible(value);
490
+ } },
491
+ outputPad: { apply: value => {
492
+ if (value !== 0 && value !== 1)
493
+ throw new TypeError("Output padding is invalid");
494
+ this.root.setOutputPad(value);
495
+ } },
496
+ hideThinkingBlock: { apply: value => {
497
+ if (typeof value !== "boolean")
498
+ throw new TypeError("Thinking-block visibility is invalid");
499
+ this.root.setHideThinkingBlock(value);
500
+ } },
501
+ mermaidRenderingMode: { apply: value => {
502
+ if (value !== "off" && value !== "final" && value !== "streaming")
503
+ throw new TypeError("Mermaid mode is invalid");
504
+ this.root.setMermaidRenderingMode(value);
505
+ } },
506
+ showImages: { apply: value => {
507
+ if (typeof value !== "boolean")
508
+ throw new TypeError("Image visibility is invalid");
509
+ this.#showImages = value;
510
+ this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
511
+ } },
512
+ imageWidthCells: { apply: value => {
513
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
514
+ throw new TypeError("Image width is invalid");
515
+ this.#imageWidthCells = value;
516
+ this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
517
+ } },
518
+ ...(this.#customViewport ? { fullscreenCopyOnSelect: { apply: (value) => {
519
+ if (typeof value !== "boolean")
520
+ throw new TypeError("Fullscreen copy-on-select setting is invalid");
521
+ this.root.setFullscreenCopyOnSelect(value);
522
+ } } } : {
523
+ fullscreenWheelScrollLines: { apply: (value) => {
524
+ if (value !== "auto" && (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 100)) {
525
+ throw new TypeError("Fullscreen wheel-scroll distance is invalid");
526
+ }
527
+ this.runtime.setWheelScrollLines(value);
528
+ } },
529
+ }),
530
+ });
531
+ constructed.push(this.#unbindPiSettings);
532
+ if (this.#customViewport && promptHistory !== undefined) {
533
+ this.#promptHistoryImageSidecar = promptHistory.imageSidecar;
534
+ const sidecar = this.#promptHistoryImageSidecar;
535
+ this.#promptHistory = new PromptHistoryController({
536
+ editor: this.root.editor,
537
+ store: promptHistory.store,
538
+ limit: promptHistory.limit,
539
+ fallback: this.view().transcript.flatMap(block => block.kind === "user" ? [block.text] : []),
540
+ active: () => this.root.usesDefaultInputSurface(),
541
+ render: () => this.runtime.requestRender(),
542
+ rehydrate: value => this.root.rehydrateHistoryText(value, id => sidecar?.readAttachment(id) ?? null),
543
+ });
544
+ constructed.push(() => { void this.#promptHistory?.close().catch(() => false); });
545
+ }
546
+ this.#extensionBridge = createPiExtensionUiBridge({
547
+ runtime: {
548
+ getColumns: () => this.runtime.viewport().columns,
549
+ getRows: () => this.runtime.viewport().rows,
550
+ requestRender: () => this.runtime.requestRender(),
551
+ },
552
+ agentDir: this.backend.identity.agentDir,
553
+ setInputSurface: component => this.root.setInputSurface(component, true, "opaque"),
554
+ setProgramStatusBlocked: status => this.#programStatus.setBlocked("extension-dialog", status),
555
+ showOverlay: (component, overlayOptions) => this.runtime.showOverlay(component, overlayOptions),
556
+ listenInput: handler => this.runtime.addInputListener(handler),
557
+ replaceWidget: (key, component, placement) => this.root.setExtensionWidget(key, component, placement),
558
+ replaceHeader: component => this.root.setExtensionHeader(component),
559
+ replaceFooter: component => this.root.setExtensionFooter(component),
560
+ setStatus: (key, text) => this.root.setExtensionStatus(key, text),
561
+ setWorking: (message, visible) => this.root.setExtensionWorking(message, visible),
562
+ notify: (message, type) => this.root.addExtensionNotification(message, type),
563
+ setTitle: title => this.runtime.setTitle(title),
564
+ getEditorText: () => this.root.editor.getText(),
565
+ setEditorText: text => this.root.editor.setText(text),
566
+ pasteToEditor: text => this.root.editor.insertText(text),
567
+ addAutocompleteProvider: factory => this.root.editor.addAutocompleteProvider(factory),
568
+ setCustomEditor: component => this.root.setInputSurface(component, true, "opaque"),
569
+ getFooterData: () => this.root.extensionFooterData(),
570
+ getToolsExpanded: () => this.root.toolsExpanded,
571
+ setToolsExpanded: expanded => this.root.setToolsExpanded(expanded),
572
+ });
573
+ constructed.push(() => this.#extensionBridge.dispose());
574
+ constructed.push(() => this.backend.workflows.setWorkflowInteractionHost(DETACHED_WORKFLOW_INTERACTION));
575
+ this.backend.workflows.setWorkflowInteractionHost({
576
+ startLogin: request => this.#startWorkflowLogin(request),
577
+ prompt: request => this.#requestWorkflowInput(request),
578
+ notify: event => this.#notifyWorkflowLogin(event),
579
+ publish: message => {
580
+ this.root.appendWorkflowMessage(message);
382
581
  this.runtime.requestRender();
383
- return { consume: true };
582
+ },
583
+ finishLogin: () => this.#finishWorkflowLogin(),
584
+ });
585
+ this.#installAutocompleteCommands();
586
+ this.#unsubscribe = this.backend.session.onEvent(event => {
587
+ // Invariant: once disposal begins no event touches the view; only the stop still settles the shell.
588
+ if (this.#disposed) {
589
+ if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
590
+ this.#settleStoppedLifecycle();
591
+ return;
384
592
  }
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 };
593
+ // Performance: a streamed chunk names one block, and touching only that block is what keeps the
594
+ // cost of a chunk the same in a long session as in a new one. Everything else
595
+ // resynchronizes the view, which is cheap next to re-reading the transcript.
596
+ if (event.type === "agent-run-started") {
597
+ this.#promptSuggestions?.invalidate();
598
+ this.root.resumeViewportFollowing();
599
+ }
600
+ if (event.type === "assistant-message-completed" && !event.successful) {
601
+ this.#programStatus.setErrorMessage(this.view().status.diagnostics.at(-1));
602
+ }
603
+ if (event.type === "agent-run-started" || event.type === "assistant-message-completed" || event.type === "agent-run-settled") {
604
+ this.#programStatus.handleEvent(event);
605
+ }
606
+ if (event.type === "assistant-message-completed") {
607
+ this.root.noteCompletedAssistantMessage();
608
+ const identity = {
609
+ sessionId: event.sessionId,
610
+ sessionGeneration: event.sessionGeneration,
611
+ runSequence: event.runSequence,
612
+ responseSequence: event.responseSequence,
613
+ model: event.model,
614
+ };
615
+ if (event.model === null)
616
+ this.#promptSuggestions?.skip(identity, "no-model");
617
+ else
618
+ this.#promptSuggestions?.consider({ ...identity, model: event.model }, !event.successful ? "failed-response"
619
+ : event.toolContinuation || event.stopReason === "toolUse" ? "tool-continuation"
620
+ : event.stopReason !== "stop" ? "incomplete-response"
621
+ : event.assistantMessageCount < 2 ? "early-conversation"
622
+ : this.root.promptSuggestionPrepareBlockReason());
394
623
  }
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();
624
+ const semanticOnly = event.type === "agent-run-started" || event.type === "assistant-message-completed";
625
+ const view = event.type === "transcript-block" && this.#sessionGeneration === this.backend.identity.sessionGeneration
626
+ ? this.#syncBlock(event.block)
627
+ : semanticOnly ? this.view() : this.#syncView();
628
+ this.#syncTerminalProgress(view);
629
+ const currentModelKey = modelKey(view);
630
+ if (this.backend.identity.sessionGeneration !== this.#sessionGeneration || currentModelKey !== this.#suggestionModelKey) {
631
+ this.#promptSuggestions?.invalidate();
632
+ this.#suggestionModelKey = currentModelKey;
400
633
  }
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
- });
634
+ if (event.type === "status" && /^(Retrying|Compacting)/.test(event.status.workingMessage ?? "")) {
635
+ this.#promptSuggestions?.invalidate();
414
636
  }
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",
637
+ if (event.type === "agent-run-settled") {
638
+ const identity = {
639
+ sessionId: event.sessionId,
640
+ sessionGeneration: event.sessionGeneration,
641
+ runSequence: event.runSequence,
642
+ responseSequence: event.responseSequence,
643
+ model: event.model,
644
+ };
645
+ if (event.model === null)
646
+ this.#promptSuggestions?.skip(identity, "no-model");
647
+ else
648
+ this.#promptSuggestions?.settle({ ...identity, model: event.model }, !event.successful ? "failed-response"
649
+ : event.toolContinuation || event.stopReason === "toolUse" ? "tool-continuation"
650
+ : event.stopReason !== "stop" ? "incomplete-response"
651
+ : event.assistantMessageCount < 2 ? "early-conversation"
652
+ : this.root.promptSuggestionPrepareBlockReason());
653
+ }
654
+ if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
655
+ this.#settleStoppedLifecycle();
426
656
  });
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),
657
+ constructed.push(this.#unsubscribe);
658
+ this.#unbindClipboardWriter = this.#responseCopy === null ? () => { } : this.backend.extensions.bindClipboardWriter(async (text) => {
659
+ const result = await this.#responseCopy.submitText(text);
660
+ if (result.outcome === "delivered")
661
+ return true;
662
+ if (result.outcome === "submitted-unverified")
663
+ return false;
664
+ throw new Error(result.outcome === "timed-out" ? "Clipboard delivery timed out" : "Clipboard delivery could not be completed");
503
665
  });
666
+ constructed.push(this.#unbindClipboardWriter);
667
+ if (this.backend.session.view().lifecycle === "stopped")
668
+ this.#resolveStopped?.();
504
669
  }
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());
670
+ catch (error) {
671
+ for (const release of constructed.reverse()) {
672
+ try {
673
+ release();
674
+ }
675
+ catch { /* Rationale: the construction failure is the error worth reporting. */ }
604
676
  }
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?.();
677
+ throw error;
678
+ }
618
679
  }
619
680
  view() {
620
- return this.backend.view();
681
+ return this.backend.session.view();
621
682
  }
622
683
  #promptSuggestionPresentationBlockReason(identity) {
623
684
  const view = this.view();
624
685
  if (this.#disposed)
625
686
  return "disposed";
626
- if (identity.sessionId !== view.sessionId || identity.sessionGeneration !== this.backend.sessionGeneration
687
+ if (identity.sessionId !== view.sessionId || identity.sessionGeneration !== this.backend.identity.sessionGeneration
627
688
  || modelKey(view) !== `${identity.model.providerId}/${identity.model.modelId}`)
628
689
  return "stale-identity";
629
690
  return this.root.promptSuggestionPresentationBlockReason();
@@ -640,7 +701,7 @@ export class OwnedUiSessionShell {
640
701
  this.#syncTerminalProgress(this.view());
641
702
  if (this.#customViewport)
642
703
  this.#setPointerReporting(true);
643
- void this.backend.bindExtensionUi(this.#extensionBridge.context, () => { void this.shutdown(); });
704
+ void this.backend.extensions.bindExtensionUi(this.#extensionBridge.context, () => { void this.shutdown(); });
644
705
  this.#syncView();
645
706
  // Performance: the spare clipboard helpers fork after the first frame is out, so startup never waits on them.
646
707
  setImmediate(() => {
@@ -670,10 +731,10 @@ export class OwnedUiSessionShell {
670
731
  if (!this.root.hasPendingPastes(draft))
671
732
  return this.#guardSubmission(draft, action);
672
733
  const controller = new AbortController();
673
- const generation = this.backend.sessionGeneration;
734
+ const generation = this.backend.identity.sessionGeneration;
674
735
  const result = this.#guardSubmission(draft, async () => {
675
736
  await this.root.waitForPromptPastes(draft, controller.signal);
676
- if (controller.signal.aborted || this.#disposed || this.backend.sessionGeneration !== generation)
737
+ if (controller.signal.aborted || this.#disposed || this.backend.identity.sessionGeneration !== generation)
677
738
  return rejected("image submission canceled");
678
739
  return action();
679
740
  }).finally(() => {
@@ -707,7 +768,7 @@ export class OwnedUiSessionShell {
707
768
  this.#promptHistory.rememberRecovery(text);
708
769
  else {
709
770
  this.#persistImageSidecars(text);
710
- this.#promptHistory.capture(reusable, kind, this.#cwd, this.backend.sessionId);
771
+ this.#promptHistory.capture(reusable, kind, this.#cwd, this.backend.identity.sessionId);
711
772
  }
712
773
  }
713
774
  }
@@ -738,7 +799,7 @@ export class OwnedUiSessionShell {
738
799
  if (command) {
739
800
  this.#rememberInput(displayInput, "bash");
740
801
  try {
741
- const result = await this.backend.executeBashWorkflow(command, excludeFromContext);
802
+ const result = await this.backend.workflows.executeBashWorkflow(command, excludeFromContext);
742
803
  const workflow = {
743
804
  command: "debug",
744
805
  outcome: result.cancelled ? "cancelled" : result.exitCode === 0 || result.exitCode === undefined ? "completed" : "failed",
@@ -767,7 +828,7 @@ export class OwnedUiSessionShell {
767
828
  return await this.#execute({
768
829
  type,
769
830
  correlationId: this.#correlation(type),
770
- sessionId: this.backend.sessionId,
831
+ sessionId: this.backend.identity.sessionId,
771
832
  text: input,
772
833
  ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
773
834
  }, displayInput);
@@ -800,7 +861,7 @@ export class OwnedUiSessionShell {
800
861
  // Compatibility: match Pi: with an empty editor, two escapes inside 500 ms open the
801
862
  // configured session navigator. The first escape intentionally does not
802
863
  // interrupt or mutate the prompt.
803
- const action = this.backend.pinnedSettingsSnapshot().doubleEscapeAction;
864
+ const action = this.backend.settings.snapshot().doubleEscapeAction;
804
865
  if (action === "none")
805
866
  return { outcome: "rejected", diagnostic: "nothing to interrupt" };
806
867
  if (now - this.#lastEscapeTime < 500) {
@@ -834,7 +895,7 @@ export class OwnedUiSessionShell {
834
895
  return this.#execute({
835
896
  type: "resume-session",
836
897
  correlationId: this.#correlation("resume"),
837
- sessionId: this.backend.sessionId,
898
+ sessionId: this.backend.identity.sessionId,
838
899
  sessionPath,
839
900
  });
840
901
  }
@@ -843,7 +904,7 @@ export class OwnedUiSessionShell {
843
904
  return this.#execute({
844
905
  type: "set-model",
845
906
  correlationId: this.#correlation("model"),
846
- sessionId: this.backend.sessionId,
907
+ sessionId: this.backend.identity.sessionId,
847
908
  model: { providerId, modelId, displayName: modelId },
848
909
  });
849
910
  }
@@ -851,7 +912,7 @@ export class OwnedUiSessionShell {
851
912
  return this.#execute({
852
913
  type: "set-thinking-level",
853
914
  correlationId: this.#correlation("thinking"),
854
- sessionId: this.backend.sessionId,
915
+ sessionId: this.backend.identity.sessionId,
855
916
  thinkingLevel,
856
917
  });
857
918
  }
@@ -861,7 +922,7 @@ export class OwnedUiSessionShell {
861
922
  return this.setThinkingLevel(levels[(current + 1) % levels.length] ?? "off");
862
923
  }
863
924
  async cycleModel(direction) {
864
- const result = await this.backend.cycleModelWorkflow(direction);
925
+ const result = await this.backend.workflows.cycleModelWorkflow(direction);
865
926
  this.root.appendWorkflowResult(result);
866
927
  this.runtime.requestRender();
867
928
  return workflowAdapterResult(result);
@@ -884,13 +945,13 @@ export class OwnedUiSessionShell {
884
945
  return this.#execute({
885
946
  type: "follow-up",
886
947
  correlationId: this.#correlation("follow-up"),
887
- sessionId: this.backend.sessionId,
948
+ sessionId: this.backend.identity.sessionId,
888
949
  text,
889
950
  ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
890
951
  }, displayInput);
891
952
  }
892
953
  restoreQueuedInput() {
893
- const queued = [...this.#waitingImages.keys(), ...this.backend.clearQueuedWorkflows()];
954
+ const queued = [...this.#waitingImages.keys(), ...this.backend.workflows.clearQueuedWorkflows()];
894
955
  this.#cancelWaitingImages();
895
956
  if (queued.length === 0)
896
957
  return;
@@ -926,6 +987,24 @@ export class OwnedUiSessionShell {
926
987
  this.#dialogHandle = handle;
927
988
  return handle;
928
989
  }
990
+ // Rationale: presenters see the shell only as a dialog host; selectors open in the input dock and run choices as workflows.
991
+ #createDialogHost() {
992
+ const isDisposed = () => this.#disposed;
993
+ return {
994
+ get disposed() { return isDisposed(); },
995
+ setInputSurface: surface => this.root.setInputSurface(surface),
996
+ requestRender: () => this.runtime.requestRender(),
997
+ viewport: () => this.runtime.viewport(),
998
+ appendWorkflowStatus: text => this.root.appendWorkflowStatus(text),
999
+ appendWorkflowResult: result => this.root.appendWorkflowResult(result),
1000
+ runWorkflow: request => this.runWorkflow(request),
1001
+ // Invariant: the custom bare-A1 surface holds frames while optional selectors load and is permanently
1002
+ // fullscreen; the pinned comparison profiles render immediately and keep Pi's TUI mode switch.
1003
+ ...(this.#customViewport
1004
+ ? { beginPresentationHold: () => this.runtime.beginPresentationHold() }
1005
+ : { switchTuiMode: mode => this.runtime.switchMode(mode) }),
1006
+ };
1007
+ }
929
1008
  #loadLazySelectors() {
930
1009
  if (this.#lazySelectors !== undefined)
931
1010
  return Promise.resolve(this.#lazySelectors);
@@ -935,7 +1014,7 @@ export class OwnedUiSessionShell {
935
1014
  });
936
1015
  }
937
1016
  async showThinkingSelector() {
938
- const snapshot = this.backend.pinnedSettingsSnapshot();
1017
+ const snapshot = this.backend.settings.snapshot();
939
1018
  const close = () => {
940
1019
  this.root.setFooterLevel(true);
941
1020
  this.root.setInputSurface(null);
@@ -953,7 +1032,7 @@ export class OwnedUiSessionShell {
953
1032
  availableLevels: snapshot.availableThinkingLevels,
954
1033
  onSelect: select,
955
1034
  onCancel: close,
956
- onSelectAsDefault: level => this.backend.setDefaultThinkingLevel(level),
1035
+ onSelectAsDefault: level => this.backend.settings.setDefaultThinkingLevel(level),
957
1036
  defaultLevel: snapshot.defaultThinkingLevel,
958
1037
  ...(this.#customViewport ? {
959
1038
  presentation: {
@@ -990,34 +1069,10 @@ export class OwnedUiSessionShell {
990
1069
  this.runtime.requestRender();
991
1070
  }
992
1071
  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();
1072
+ this.#presenters.openModelSelector(this.#dialogHost, initialSearchInput);
1018
1073
  }
1019
1074
  showForkSelector() {
1020
- const options = this.backend.pinnedForkOptions();
1075
+ const options = this.backend.catalog.pinnedForkOptions();
1021
1076
  if (options.length === 0) {
1022
1077
  this.root.appendWorkflowResult({ command: "fork", outcome: "completed", message: "No messages to fork from", messageKind: "status" });
1023
1078
  this.runtime.requestRender();
@@ -1037,7 +1092,7 @@ export class OwnedUiSessionShell {
1037
1092
  async showLogoutSelector() {
1038
1093
  let options;
1039
1094
  try {
1040
- options = await this.backend.pinnedLogoutOptions();
1095
+ options = await this.backend.catalog.pinnedLogoutOptions();
1041
1096
  }
1042
1097
  catch (error) {
1043
1098
  this.root.appendWorkflowResult({
@@ -1065,7 +1120,7 @@ export class OwnedUiSessionShell {
1065
1120
  this.runtime.requestRender();
1066
1121
  }
1067
1122
  showLoginMethodSelector(providerReference) {
1068
- const method = this.backend.pinnedLoginMethodOptions(providerReference);
1123
+ const method = this.backend.catalog.pinnedLoginMethodOptions(providerReference);
1069
1124
  if (method.options.length === 0) {
1070
1125
  this.showLoginProviderSelector(undefined, providerReference);
1071
1126
  return;
@@ -1091,67 +1146,10 @@ export class OwnedUiSessionShell {
1091
1146
  this.root.setInputSurface(component);
1092
1147
  this.runtime.requestRender();
1093
1148
  }
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
- }
1149
+ showTreeSelector(initialSelectedId) {
1150
+ return this.#presenters.openTreeSelector(this.#dialogHost, (entryId, skipSummaryPrompt) => {
1151
+ void this.#completeTreeSelection(entryId, skipSummaryPrompt);
1152
+ }, initialSelectedId);
1155
1153
  }
1156
1154
  showLoginAuthTypeSelector() {
1157
1155
  const close = () => {
@@ -1167,7 +1165,7 @@ export class OwnedUiSessionShell {
1167
1165
  this.runtime.requestRender();
1168
1166
  }
1169
1167
  showLoginProviderSelector(authType, initialSearchInput) {
1170
- const options = this.backend.pinnedLoginOptions(authType);
1168
+ const options = this.backend.catalog.pinnedLoginOptions(authType);
1171
1169
  if (options.length === 0) {
1172
1170
  this.root.appendWorkflowStatus(authType === "oauth" ? "No account providers available." : authType === "api_key" ? "No API key providers available." : "No login providers available.");
1173
1171
  this.runtime.requestRender();
@@ -1188,82 +1186,11 @@ export class OwnedUiSessionShell {
1188
1186
  this.root.setInputSurface(component);
1189
1187
  this.runtime.requestRender();
1190
1188
  }
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();
1189
+ showSessionSelector() {
1190
+ return this.#presenters.openSessionSelector(this.#dialogHost, () => { void this.shutdown(); });
1215
1191
  }
1216
1192
  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();
1193
+ this.#presenters.openSettingsSelector(this.#dialogHost);
1267
1194
  }
1268
1195
  shutdown() {
1269
1196
  this.#shutdownPromise ??= this.#shutdown();
@@ -1271,7 +1198,13 @@ export class OwnedUiSessionShell {
1271
1198
  }
1272
1199
  async #shutdown() {
1273
1200
  try {
1274
- const result = await this.backend.executeWorkflow({ command: "quit", argument: "" });
1201
+ const quit = this.backend.workflows.executeWorkflow({ command: "quit", argument: "" });
1202
+ // Invariant: a slow or hung engine quit never holds the terminal past the cleanup deadline. The
1203
+ // shell restores it then, and the result still waits for the engine to finish quitting.
1204
+ const timedOut = await boundedCleanup(() => quit.catch(() => { })).then(() => false, () => true);
1205
+ if (timedOut)
1206
+ await this.dispose();
1207
+ const result = await quit;
1275
1208
  await this.dispose();
1276
1209
  return workflowAdapterResult(result);
1277
1210
  }
@@ -1295,9 +1228,9 @@ export class OwnedUiSessionShell {
1295
1228
  });
1296
1229
  return { outcome: "completed", diagnostic: null };
1297
1230
  }
1298
- const copyGeneration = request.command === "copy" ? this.backend.sessionBindingGeneration : undefined;
1231
+ const copyGeneration = request.command === "copy" ? this.backend.identity.sessionBindingGeneration : undefined;
1299
1232
  if (request.command === "login" && request.selection !== undefined) {
1300
- const setup = this.backend.pinnedAmbientAuthentication(request.selection);
1233
+ const setup = this.backend.catalog.pinnedAmbientAuthentication(request.selection);
1301
1234
  if (setup) {
1302
1235
  const close = () => {
1303
1236
  this.root.setInputSurface(null);
@@ -1362,7 +1295,7 @@ export class OwnedUiSessionShell {
1362
1295
  return { outcome: "completed", diagnostic: null };
1363
1296
  }
1364
1297
  if (request.command === "reload") {
1365
- const blocked = this.backend.reloadBlockedResult();
1298
+ const blocked = this.backend.workflows.reloadBlockedResult();
1366
1299
  if (blocked) {
1367
1300
  this.root.appendWorkflowResult(blocked);
1368
1301
  this.runtime.requestRender();
@@ -1388,7 +1321,7 @@ export class OwnedUiSessionShell {
1388
1321
  }
1389
1322
  let result;
1390
1323
  try {
1391
- result = await this.backend.executeWorkflow(shareSurface === undefined ? request : { ...request, signal: shareSurface.signal });
1324
+ result = await this.backend.workflows.executeWorkflow(shareSurface === undefined ? request : { ...request, signal: shareSurface.signal });
1392
1325
  }
1393
1326
  finally {
1394
1327
  if (operationSurface) {
@@ -1400,7 +1333,7 @@ export class OwnedUiSessionShell {
1400
1333
  this.runtime.requestRender();
1401
1334
  }
1402
1335
  }
1403
- if (copyGeneration !== undefined && (this.#disposed || copyGeneration !== this.backend.sessionBindingGeneration)) {
1336
+ if (copyGeneration !== undefined && (this.#disposed || copyGeneration !== this.backend.identity.sessionBindingGeneration)) {
1404
1337
  return workflowAdapterResult({ command: "copy", outcome: "cancelled", message: "" });
1405
1338
  }
1406
1339
  if (result.outcome === "requires-selection" && request.command === "model") {
@@ -1457,7 +1390,7 @@ export class OwnedUiSessionShell {
1457
1390
  await sleep(remaining);
1458
1391
  }
1459
1392
  showTrustSelector() {
1460
- const context = this.backend.pinnedProjectTrustContext();
1393
+ const context = this.backend.catalog.pinnedProjectTrustContext();
1461
1394
  const close = () => {
1462
1395
  this.root.setInputSurface(null);
1463
1396
  this.runtime.requestRender();
@@ -1466,7 +1399,7 @@ export class OwnedUiSessionShell {
1466
1399
  ...context,
1467
1400
  onSelect: selection => {
1468
1401
  try {
1469
- this.backend.persistProjectTrust(selection.updates);
1402
+ this.backend.catalog.persistProjectTrust(selection.updates);
1470
1403
  close();
1471
1404
  this.root.appendWorkflowStatus(`Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`);
1472
1405
  }
@@ -1482,157 +1415,16 @@ export class OwnedUiSessionShell {
1482
1415
  this.runtime.requestRender();
1483
1416
  }
1484
1417
  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));
1418
+ this.#presenters.openScopedModelsSelector(this.#dialogHost);
1552
1419
  }
1553
1420
  // Invariant: bare A1 routes `models`; the comparison profile keeps the pinned `model`/`scoped-models` pair. Hidden routes are shared.
1554
1421
  #isWorkflowRoute(value) {
1555
1422
  return workflowCommandNames(this.#customViewport ? "bare" : "comparison").includes(value)
1556
- || PINNED_PI_HIDDEN_COMMAND_NAMES.includes(value);
1423
+ || OWNED_UI_HIDDEN_COMMAND_NAMES.includes(value);
1557
1424
  }
1558
1425
  /** The bare-A1 unified Models dialog: switch on Enter, scope on Space, persist on Ctrl+S, all through the engine. */
1559
1426
  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));
1427
+ this.#presenters.openModelsDialog(this.#dialogHost, initialQuery);
1636
1428
  }
1637
1429
  // Invariant: pointer reporting is disabled on every path that ends the owning screen.
1638
1430
  #setPointerReporting(enabled, forceOff = false) {
@@ -1644,11 +1436,14 @@ export class OwnedUiSessionShell {
1644
1436
  return;
1645
1437
  this.runtime.writeControl(effective ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
1646
1438
  }
1439
+ // Invariant: one teardown per shell, starting on a microtask so never inside the event listener that
1440
+ // requested it. Callers during the teardown share its outcome; a later call finds it done.
1647
1441
  dispose() {
1648
1442
  if (this.#disposePromise !== undefined)
1649
1443
  return this.#disposePromise;
1650
1444
  if (this.#disposed)
1651
1445
  return Promise.resolve();
1446
+ this.#disposed = true;
1652
1447
  const pending = Promise.resolve().then(() => this.#dispose());
1653
1448
  this.#disposePromise = pending;
1654
1449
  const clear = () => { if (this.#disposePromise === pending)
@@ -1657,13 +1452,6 @@ export class OwnedUiSessionShell {
1657
1452
  return pending;
1658
1453
  }
1659
1454
  async #dispose() {
1660
- 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
1455
  const failures = [];
1668
1456
  const attempt = (action) => { try {
1669
1457
  action();
@@ -1671,6 +1459,14 @@ export class OwnedUiSessionShell {
1671
1459
  catch (error) {
1672
1460
  failures.push(error);
1673
1461
  } };
1462
+ // Invariant: events stop first, so nothing re-enters the view during history close, outro, or restoration.
1463
+ attempt(() => this.#unsubscribe());
1464
+ this.#copyIntentSequence += 1;
1465
+ // Invariant: the outro frame is what the terminal shows now, before any cleanup writes.
1466
+ const outroFrame = this.#captureQuitOutroFrame();
1467
+ this.#responseCopy?.dispose();
1468
+ this.#copyExecutor?.dispose();
1469
+ this.#cancelWaitingImages();
1674
1470
  let pasteCleanup = Promise.resolve();
1675
1471
  let historyCleanup = Promise.resolve(true);
1676
1472
  attempt(() => { historyCleanup = this.#promptHistory?.close() ?? Promise.resolve(true); });
@@ -1689,10 +1485,11 @@ export class OwnedUiSessionShell {
1689
1485
  attempt(() => this.#unsubscribePromptImages());
1690
1486
  attempt(() => this.#unsubscribeSkills());
1691
1487
  attempt(() => this.#unsubscribeSettings());
1488
+ attempt(() => this.#unsubscribeBackgroundSettings());
1692
1489
  let fullscreenExitText = "";
1693
1490
  attempt(() => {
1694
- const exitMode = this.backend.disposed ? this.#fullscreenExitOutput : this.backend.pinnedSettingsSnapshot().fullscreenExitOutput;
1695
- const resume = this.backend.currentSessionResumeMetadata();
1491
+ const exitMode = this.backend.identity.disposed ? this.#fullscreenExitOutput : this.backend.settings.snapshot().fullscreenExitOutput;
1492
+ const resume = this.backend.identity.currentSessionResumeMetadata();
1696
1493
  const resumeHint = resume === null ? "" : `${dim("To resume this session:")} ${formatSessionResumeCommand(resume)}`;
1697
1494
  // Invariant: bare A1 leaves only the hint behind; the pinned comparison profile still
1698
1495
  // honors fullscreenExitOutput, including the styled transcript.
@@ -1704,7 +1501,7 @@ export class OwnedUiSessionShell {
1704
1501
  attempt(() => this.#unbindPiSettings());
1705
1502
  attempt(() => this.#unbindTerminalSettings());
1706
1503
  attempt(() => this.#unbindShutdownSettings());
1707
- attempt(() => this.#unsubscribe());
1504
+ attempt(() => this.backend.workflows.setWorkflowInteractionHost(DETACHED_WORKFLOW_INTERACTION));
1708
1505
  attempt(() => this.#dialogHandle?.hide());
1709
1506
  attempt(() => this.#extensionBridge.dispose());
1710
1507
  attempt(() => this.#programStatus.clear());
@@ -1719,7 +1516,7 @@ export class OwnedUiSessionShell {
1719
1516
  await this.runtime.dispose({ preserveScreen: this.runtime.mode === "fullscreen" }).catch(error => failures.push(error));
1720
1517
  await historyCleanup.catch(() => false); // Security: background durability outcomes never enter terminal output.
1721
1518
  await boundedCleanup(() => pasteCleanup).catch(error => failures.push(error));
1722
- await boundedCleanup(() => this.backend.unbindExtensionUi()).catch(error => failures.push(error));
1519
+ await boundedCleanup(() => this.backend.extensions.unbindExtensionUi()).catch(error => failures.push(error));
1723
1520
  if (failures.length > 0)
1724
1521
  throw new AggregateError(failures, "Owned UI disposal failed");
1725
1522
  if (fullscreenExitText.length > 0)
@@ -1738,7 +1535,11 @@ export class OwnedUiSessionShell {
1738
1535
  const viewport = this.runtime.viewport();
1739
1536
  return {
1740
1537
  rows: this.#damageTerminal.presentedRows(), columns: viewport.columns, height: viewport.rows,
1741
- settings: { effect: QUIT_OUTRO_EFFECT, durationMs: QUIT_OUTRO_DURATION_MS },
1538
+ settings: {
1539
+ effect: QUIT_OUTRO_EFFECT,
1540
+ durationMs: QUIT_OUTRO_DURATION_MS,
1541
+ canvasBackgroundAnsi: this.#damageTerminal.canvasBackgroundAnsi,
1542
+ },
1742
1543
  };
1743
1544
  }
1744
1545
  catch {
@@ -1763,6 +1564,9 @@ export class OwnedUiSessionShell {
1763
1564
  return;
1764
1565
  await playQuitOutro(frame, capture.settings.effect, capture.settings.durationMs, {
1765
1566
  write: data => this.runtime.writeControl(data),
1567
+ ...(capture.settings.canvasBackgroundAnsi === null
1568
+ ? {}
1569
+ : { canvasBackgroundAnsi: capture.settings.canvasBackgroundAnsi }),
1766
1570
  ...(outro.now === undefined ? {} : { now: outro.now }),
1767
1571
  ...(outro.sleep === undefined ? {} : { sleep: outro.sleep }),
1768
1572
  ...(outro.seed === undefined ? {} : { seed: outro.seed }),
@@ -1816,17 +1620,17 @@ export class OwnedUiSessionShell {
1816
1620
  #syncView() {
1817
1621
  this.#streamPresentation.noteImmediatePresentation();
1818
1622
  const view = this.view();
1819
- if (this.backend.sessionGeneration !== this.#sessionGeneration) {
1623
+ if (this.backend.identity.sessionGeneration !== this.#sessionGeneration) {
1820
1624
  this.#copyIntentSequence += 1;
1821
1625
  this.#responseCopy?.reset();
1822
1626
  this.#cancelWaitingImages();
1823
1627
  this.root.resetPendingPastes();
1824
1628
  this.#promptSuggestions?.invalidate();
1825
- this.#sessionGeneration = this.backend.sessionGeneration;
1629
+ this.#sessionGeneration = this.backend.identity.sessionGeneration;
1826
1630
  this.#programStatus.reset();
1827
1631
  // 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;
1632
+ if (this.#sessionBindingGeneration !== this.backend.identity.sessionBindingGeneration) {
1633
+ this.#sessionBindingGeneration = this.backend.identity.sessionBindingGeneration;
1830
1634
  this.root.resetTranscript();
1831
1635
  this.#promptHistory?.reset(view.transcript.flatMap(block => block.kind === "user" ? [block.text] : []));
1832
1636
  }
@@ -2059,24 +1863,24 @@ export class OwnedUiSessionShell {
2059
1863
  return this.#execute({
2060
1864
  type: this.view().lifecycle === "busy" ? "steer" : "prompt",
2061
1865
  correlationId: this.#correlation("prompt-command"),
2062
- sessionId: this.backend.sessionId,
1866
+ sessionId: this.backend.identity.sessionId,
2063
1867
  text,
2064
1868
  });
2065
1869
  }
2066
1870
  #installAutocompleteCommands() {
2067
- this.root.editor.setAutocompleteCommands(this.backend.workflowAutocompleteCommands());
1871
+ this.root.editor.setAutocompleteCommands(this.backend.workflows.workflowAutocompleteCommands());
2068
1872
  this.#installedCommandSignature = this.#commandListSignature();
2069
1873
  }
2070
1874
  #commandListSignature() {
2071
- return JSON.stringify([this.#skills?.presentation() ?? "expand", this.backend.disposed ? [] : this.backend.workflowAutocompleteCommands().map(command => command.name)]);
1875
+ return JSON.stringify([this.#skills?.presentation() ?? "expand", this.backend.identity.disposed ? [] : this.backend.workflows.workflowAutocompleteCommands().map(command => command.name)]);
2072
1876
  }
2073
1877
  // Invariant: collapse applies only while the engine registers skill commands; otherwise there is nothing to collapse.
2074
1878
  #skillsCollapsed() {
2075
1879
  return this.#skills !== null && this.#skills.presentation() === "collapse"
2076
- && !this.backend.disposed && this.backend.pinnedSettingsSnapshot().enableSkillCommands;
1880
+ && !this.backend.identity.disposed && this.backend.settings.snapshot().enableSkillCommands;
2077
1881
  }
2078
1882
  #skillSummaries() {
2079
- return skillsFromCommands(this.backend.workflowAutocompleteCommands());
1883
+ return skillsFromCommands(this.backend.workflows.workflowAutocompleteCommands());
2080
1884
  }
2081
1885
  // Protocol: bare "/skills" opens the dialog, "/skills <name> [args]" applies directly, and an unknown name is a command outcome.
2082
1886
  async #runSkillsCommand(text, argument) {
@@ -2104,7 +1908,7 @@ export class OwnedUiSessionShell {
2104
1908
  return this.#execute({
2105
1909
  type: this.view().lifecycle === "busy" ? "steer" : "prompt",
2106
1910
  correlationId: this.#correlation("prompt-command"),
2107
- sessionId: this.backend.sessionId,
1911
+ sessionId: this.backend.identity.sessionId,
2108
1912
  text: prompt,
2109
1913
  });
2110
1914
  }
@@ -2226,7 +2030,7 @@ export class OwnedUiSessionShell {
2226
2030
  }
2227
2031
  async #execute(command, draft) {
2228
2032
  if (draft === undefined)
2229
- return this.backend.execute(command);
2033
+ return this.backend.session.execute(command);
2230
2034
  const revision = this.#editorRevision;
2231
2035
  try {
2232
2036
  assertOwnedUiCommand(command);
@@ -2235,7 +2039,7 @@ export class OwnedUiSessionShell {
2235
2039
  return this.#recoverSubmission(draft, revision, error);
2236
2040
  }
2237
2041
  try {
2238
- const result = await this.backend.execute(command);
2042
+ const result = await this.backend.session.execute(command);
2239
2043
  if (result.outcome === "rejected")
2240
2044
  return this.#recoverSubmission(draft, revision);
2241
2045
  return result;
@@ -2281,7 +2085,7 @@ export class OwnedUiSessionShell {
2281
2085
  catch { /* Security: error presentation cannot create another rejected submission callback. */ }
2282
2086
  }
2283
2087
  #simple(type) {
2284
- return { type, correlationId: this.#correlation(type), sessionId: this.backend.sessionId };
2088
+ return { type, correlationId: this.#correlation(type), sessionId: this.backend.identity.sessionId };
2285
2089
  }
2286
2090
  #correlation(prefix) {
2287
2091
  this.#sequence += 1;
@@ -2317,14 +2121,6 @@ export function quoteCommandArgument(value) {
2317
2121
  function dim(value) {
2318
2122
  return `\u001b[2m${value}\u001b[22m`;
2319
2123
  }
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
2124
  function rejected(diagnostic) {
2329
2125
  return { outcome: "rejected", diagnostic };
2330
2126
  }