@timurproko/a1 0.2.6-dev.717 → 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 (213) hide show
  1. package/bin/cli.js +13 -2
  2. package/bin/ui.js +13 -0
  3. package/dist/app/session-shell/clipboard-services.d.ts +19 -0
  4. package/dist/app/session-shell/clipboard-services.js +7 -0
  5. package/dist/app/session-shell/exit-notice.d.ts +9 -5
  6. package/dist/app/session-shell/exit-notice.js +5 -5
  7. package/dist/app/session-shell/index.d.ts +3 -1
  8. package/dist/app/session-shell/index.js +1 -0
  9. package/dist/app/session-shell/paste-preparation-client.d.ts +3 -0
  10. package/dist/app/session-shell/paste-preparation-client.js +5 -2
  11. package/dist/app/session-shell/path-chip-presentation.d.ts +10 -3
  12. package/dist/app/session-shell/path-chip-presentation.js +48 -6
  13. package/dist/app/session-shell/prompt-chips.js +30 -4
  14. package/dist/app/session-shell/prompt-suggestion-controller.d.ts +5 -4
  15. package/dist/app/session-shell/prompt-suggestion-controller.js +148 -58
  16. package/dist/app/session-shell/quit-outro.d.ts +3 -1
  17. package/dist/app/session-shell/quit-outro.js +23 -8
  18. package/dist/app/session-shell/response-copy-transport.d.ts +6 -1
  19. package/dist/app/session-shell/response-copy-transport.js +8 -2
  20. package/dist/app/session-shell/session-shell-root.d.ts +22 -11
  21. package/dist/app/session-shell/session-shell-root.js +19 -12
  22. package/dist/app/session-shell/session-shell.d.ts +19 -21
  23. package/dist/app/session-shell/session-shell.js +678 -840
  24. package/dist/app/session-shell/session-viewport-controller.js +33 -21
  25. package/dist/cli/dispatch.js +5 -3
  26. package/dist/cli/session-context.d.ts +5 -1
  27. package/dist/cli/session-context.js +25 -8
  28. package/dist/composition/owned-ui.d.ts +23 -2
  29. package/dist/composition/owned-ui.js +124 -33
  30. package/dist/composition/session-info-reference.d.ts +2 -2
  31. package/dist/composition/session-info-reference.js +2 -2
  32. package/dist/composition/settings-route-host.js +2 -1
  33. package/dist/contracts/agent-engine/capability-ports.d.ts +1 -85
  34. package/dist/contracts/agent-engine/capability-ports.js +1 -35
  35. package/dist/contracts/agent-engine/index.d.ts +1 -7
  36. package/dist/contracts/agent-engine/index.js +0 -4
  37. package/dist/contracts/owned-ui/index.d.ts +9 -3
  38. package/dist/contracts/owned-ui/index.js +2 -1
  39. package/dist/contracts/owned-ui/model.d.ts +10 -0
  40. package/dist/contracts/owned-ui/session-backend.d.ts +119 -0
  41. package/dist/contracts/owned-ui/session-presenters.d.ts +55 -0
  42. package/dist/contracts/owned-ui/session-presenters.js +1 -0
  43. package/dist/contracts/owned-ui/session-workflows.d.ts +342 -0
  44. package/dist/contracts/owned-ui/session-workflows.js +30 -0
  45. package/dist/contracts/owned-ui/suggestion-diagnostics.d.ts +5 -1
  46. package/dist/contracts/owned-ui/suggestion-diagnostics.js +3 -1
  47. package/dist/contracts/owned-ui/validation.js +7 -0
  48. package/dist/features/launch/index.d.ts +1 -1
  49. package/dist/features/launch/session-context.d.ts +17 -2
  50. package/dist/features/launch/session-context.js +9 -5
  51. package/dist/features/owned-ui/settings-app.js +56 -54
  52. package/dist/features/prompt-suggestions/diagnostics.js +4 -1
  53. package/dist/foundation/lifecycle/index.d.ts +2 -2
  54. package/dist/foundation/lifecycle/index.js +1 -1
  55. package/dist/foundation/lifecycle/session-repository-context.d.ts +19 -2
  56. package/dist/foundation/lifecycle/session-repository-context.js +434 -66
  57. package/dist/foundation/process-containment/index.d.ts +1 -0
  58. package/dist/foundation/process-containment/index.js +1 -0
  59. package/dist/foundation/process-containment/native-process-inspector.d.ts +3 -0
  60. package/dist/foundation/process-containment/native-process-inspector.js +21 -0
  61. package/dist/foundation/release/bootstrap.js +1 -2
  62. package/dist/foundation/release/progress-palette.generated.d.ts +2 -2
  63. package/dist/foundation/release/progress-palette.generated.js +2 -2
  64. package/dist/foundation/release/release-gc.js +1 -1
  65. package/dist/foundation/release/update.js +0 -4
  66. package/dist/foundation/startup/startup-descriptor.js +2 -2
  67. package/dist/foundation/storage/control-store.js +0 -12
  68. package/dist/integrations/pi/components/components.js +2 -18
  69. package/dist/integrations/pi/components/index.d.ts +4 -4
  70. package/dist/integrations/pi/components/index.js +2 -2
  71. package/dist/integrations/pi/components/modal-frame.js +3 -4
  72. package/dist/integrations/pi/components/models-dialog.js +6 -35
  73. package/dist/integrations/pi/components/prompt-input-port.d.ts +1 -0
  74. package/dist/integrations/pi/components/resources/builtin-themes.d.ts +2 -2
  75. package/dist/integrations/pi/components/resources/builtin-themes.js +2 -2
  76. package/dist/integrations/pi/components/share-operation-dialog.js +3 -3
  77. package/dist/integrations/pi/components/shell-editor-autocomplete.js +19 -16
  78. package/dist/integrations/pi/components/shell-extension-ui.d.ts +5 -3
  79. package/dist/integrations/pi/components/shell-extension-ui.js +26 -12
  80. package/dist/integrations/pi/components/shell-hotkey-sections.js +2 -2
  81. package/dist/integrations/pi/components/shell-presenters-info.js +10 -10
  82. package/dist/integrations/pi/components/shell-presenters-transcript.js +13 -13
  83. package/dist/integrations/pi/components/shell-selectors-dialogs.js +5 -6
  84. package/dist/integrations/pi/components/shell-shared-facade.d.ts +21 -1
  85. package/dist/integrations/pi/components/shell-shared-facade.js +19 -3
  86. package/dist/integrations/pi/components/skills-dialog.js +3 -3
  87. package/dist/integrations/pi/components/submitted-prompt-adapter.js +2 -3
  88. package/dist/integrations/pi/components/theme.d.ts +9 -0
  89. package/dist/integrations/pi/components/theme.js +30 -0
  90. package/dist/integrations/pi/components/tool-result-adapter.js +6 -1
  91. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +1 -1
  92. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.js +1 -1
  93. package/dist/integrations/pi/components/upstream/assets/earendil-image.json +2 -2
  94. package/dist/integrations/pi/components/upstream/components/countdown-timer.d.ts +1 -1
  95. package/dist/integrations/pi/components/upstream/components/earendil-announcement.js +1 -1
  96. package/dist/integrations/pi/components/upstream/components/extension-editor.d.ts +1 -1
  97. package/dist/integrations/pi/components/upstream/components/extension-editor.js +6 -6
  98. package/dist/integrations/pi/components/upstream/components/extension-input.js +4 -4
  99. package/dist/integrations/pi/components/upstream/components/extension-selector.js +4 -4
  100. package/dist/integrations/pi/components/upstream/components/mermaid.js +1 -1
  101. package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +5 -5
  102. package/dist/integrations/pi/components/upstream/components/owned-editor.js +8 -5
  103. package/dist/integrations/pi/components/upstream/components/pi-logo.js +1 -1
  104. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +1 -1
  105. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.js +3 -3
  106. package/dist/integrations/pi/components/upstream/components/session-footer.js +1 -1
  107. package/dist/integrations/pi/components/upstream/components/session-selector-search.js +1 -1
  108. package/dist/integrations/pi/components/upstream/components/session-selector.js +9 -9
  109. package/dist/integrations/pi/components/upstream/components/skill-invocation-message.d.ts +3 -2
  110. package/dist/integrations/pi/components/upstream/components/skill-invocation-message.js +6 -3
  111. package/dist/integrations/pi/components/upstream/components/status-indicator.d.ts +1 -1
  112. package/dist/integrations/pi/components/upstream/components/status-indicator.js +1 -1
  113. package/dist/integrations/pi/components/upstream/components/themed-text.d.ts +1 -1
  114. package/dist/integrations/pi/components/upstream/components/themed-text.js +1 -1
  115. package/dist/integrations/pi/components/upstream/components/thinking-selector.d.ts +1 -1
  116. package/dist/integrations/pi/components/upstream/components/thinking-selector.js +2 -2
  117. package/dist/integrations/pi/components/upstream/components/tool-execution.d.ts +4 -0
  118. package/dist/integrations/pi/components/upstream/components/tool-execution.js +11 -1
  119. package/dist/integrations/pi/components/upstream/components/tree-selector.d.ts +2 -2
  120. package/dist/integrations/pi/components/upstream/components/tree-selector.js +27 -18
  121. package/dist/integrations/pi/components/upstream/components/trust-selector.js +1 -1
  122. package/dist/integrations/pi/components/upstream/external-editor.js +1 -1
  123. package/dist/integrations/pi/components/upstream/history/editor-core.d.ts +4 -4
  124. package/dist/integrations/pi/components/upstream/history/editor-core.js +11 -7
  125. package/dist/integrations/pi/components/upstream/history/kill-ring.d.ts +1 -1
  126. package/dist/integrations/pi/components/upstream/history/kill-ring.js +1 -1
  127. package/dist/integrations/pi/components/upstream/history/printable-key.js +1 -1
  128. package/dist/integrations/pi/components/upstream/history/text-helpers.js +1 -1
  129. package/dist/integrations/pi/components/upstream/history/undo-stack.d.ts +1 -1
  130. package/dist/integrations/pi/components/upstream/history/undo-stack.js +1 -1
  131. package/dist/integrations/pi/components/upstream/history/word-navigation.js +1 -1
  132. package/dist/integrations/pi/components/upstream/model-search.d.ts +1 -1
  133. package/dist/integrations/pi/components/upstream/program-status-reporter.d.ts +31 -0
  134. package/dist/integrations/pi/components/upstream/program-status-reporter.js +89 -0
  135. package/dist/integrations/pi/components/upstream/theme/theme-controller.d.ts +1 -1
  136. package/dist/integrations/pi/components/upstream/theme/theme.d.ts +22 -1
  137. package/dist/integrations/pi/components/upstream/theme/theme.js +225 -10
  138. package/dist/integrations/pi/engine/adapter.d.ts +44 -24
  139. package/dist/integrations/pi/engine/adapter.js +130 -22
  140. package/dist/integrations/pi/engine/command-dispatch.d.ts +2 -5
  141. package/dist/integrations/pi/engine/extension-ui-binding.d.ts +2 -11
  142. package/dist/integrations/pi/engine/host-ports.d.ts +20 -0
  143. package/dist/integrations/pi/engine/host-ports.js +13 -0
  144. package/dist/integrations/pi/engine/host.d.ts +70 -0
  145. package/dist/integrations/pi/engine/host.js +147 -0
  146. package/dist/integrations/pi/engine/index.d.ts +8 -4
  147. package/dist/integrations/pi/engine/index.js +3 -1
  148. package/dist/integrations/pi/engine/pi-settings-metadata.json +1 -1
  149. package/dist/integrations/pi/engine/resource-catalog.d.ts +4 -23
  150. package/dist/integrations/pi/engine/resources/changelog.json +4 -4
  151. package/dist/integrations/pi/engine/runtime-integration.d.ts +8 -2
  152. package/dist/integrations/pi/engine/runtime-integration.js +12 -7
  153. package/dist/integrations/pi/engine/session-events.js +10 -2
  154. package/dist/integrations/pi/engine/session-integration.d.ts +3 -8
  155. package/dist/integrations/pi/engine/session-integration.js +0 -49
  156. package/dist/integrations/pi/engine/session-runtime.d.ts +18 -3
  157. package/dist/integrations/pi/engine/session-runtime.js +18 -7
  158. package/dist/integrations/pi/engine/settings-bridge.d.ts +2 -2
  159. package/dist/integrations/pi/engine/settings-bridge.js +0 -2
  160. package/dist/integrations/pi/engine/settings-port.d.ts +2 -0
  161. package/dist/integrations/pi/engine/settings-port.js +3 -5
  162. package/dist/integrations/pi/engine/transcript-projection.js +2 -1
  163. package/dist/integrations/pi/engine/workflows.d.ts +33 -260
  164. package/dist/integrations/pi/engine/workflows.js +6 -28
  165. package/dist/integrations/pi/session-presenters/index.d.ts +2 -0
  166. package/dist/integrations/pi/session-presenters/index.js +1 -0
  167. package/dist/integrations/pi/session-presenters/presenters.d.ts +16 -0
  168. package/dist/integrations/pi/session-presenters/presenters.js +341 -0
  169. package/dist/integrations/pi/startup-public.js +306 -290
  170. package/dist/integrations/pi/startup-public.manifest.json +289 -265
  171. package/dist/integrations/pi/tui-runtime/adapter.d.ts +2 -1
  172. package/dist/integrations/pi/tui-runtime/adapter.js +7 -2
  173. package/dist/integrations/pi/tui-runtime/contracts.d.ts +3 -1
  174. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +8 -1
  175. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +67 -2
  176. package/dist/integrations/pi/tui-runtime/index.d.ts +1 -1
  177. package/dist/integrations/pi/tui-runtime/index.js +1 -1
  178. package/dist/native/darwin-arm64/manifest.json +1 -1
  179. package/dist/native/linux-x64/manifest.json +1 -1
  180. package/dist/native/win32-x64/manifest.json +2 -2
  181. package/dist/native/win32-x64/process-guardian.exe +0 -0
  182. package/dist/product-identity.d.ts +2 -2
  183. package/dist/product-identity.js +2 -2
  184. package/dist/product-identity.json +3 -1
  185. package/dist/runtime-payload-inventory.json +3092 -2878
  186. package/dist/ui/components/prompt-input.d.ts +2 -0
  187. package/dist/ui/components/prompt-input.js +5 -1
  188. package/dist/ui/components/text-selection.d.ts +1 -1
  189. package/dist/ui/components/text-selection.js +3 -1
  190. package/dist/ui/components/theme.d.ts +2 -0
  191. package/dist/ui/components/transcript-viewport.d.ts +6 -4
  192. package/dist/ui/components/transcript-viewport.js +94 -16
  193. package/dist/ui/components/value-menu.d.ts +2 -0
  194. package/dist/ui/components/value-menu.js +13 -8
  195. package/dist/ui/settings/declarations.d.ts +38 -14
  196. package/dist/ui/settings/declarations.js +35 -11
  197. package/dist/ui/settings/migrations.js +23 -0
  198. package/docs/architecture/boundaries.md +9 -1
  199. package/docs/architecture/project-structure.md +7 -5
  200. package/docs/architecture/prompt-suggestions.md +14 -10
  201. package/docs/local-worktree-cleanup.md +11 -5
  202. package/docs/openspec-archive-automation.md +6 -5
  203. package/package.json +3 -3
  204. package/dist/contracts/agent-engine/model.d.ts +0 -78
  205. package/dist/contracts/agent-engine/model.js +0 -1
  206. package/dist/contracts/agent-engine/ports.d.ts +0 -18
  207. package/dist/contracts/agent-engine/serialization.d.ts +0 -7
  208. package/dist/contracts/agent-engine/serialization.js +0 -58
  209. package/dist/contracts/agent-engine/validation.d.ts +0 -5
  210. package/dist/contracts/agent-engine/validation.js +0 -128
  211. package/dist/integrations/pi/components/upstream/theme/system-theme.d.ts +0 -61
  212. package/dist/integrations/pi/components/upstream/theme/system-theme.js +0 -709
  213. /package/dist/contracts/{agent-engine/ports.js → owned-ui/session-backend.js} +0 -0
@@ -4,20 +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
- import { createPiShellEditor } from "../../integrations/pi/components/shell-editor-autocomplete.js";
14
+ import { ProgramStatusReporter } from "../../integrations/pi/components/upstream/program-status-reporter.js";
15
15
  import { SKILLS_COMMAND_NAME, findSkillByArgument, rewriteSkillsTunnelSubmission, skillPrompt, skillsFromCommands, } from "../../integrations/pi/components/skills-command.js";
16
- import { createPiQueuedInputStatus, createPiShellFooter, createPiShellHeader, createPiShellLoadedResources, createPiShellStatus, } from "../../integrations/pi/components/shell-footer-status.js";
17
- 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";
18
- import { createPiShellChangelog, createPiShellHotkeys, createPiShellSessionInfo, renderPiShellPackageUpdateNotice, renderPiShellStatusText, } from "../../integrations/pi/components/shell-presenters-info.js";
19
- import { createPiShellTranscriptComponent, renderPiShellStartupDiagnostic, renderPiShellTranscriptBlock, } from "../../integrations/pi/components/shell-presenters-transcript.js";
20
- 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";
21
19
  import { DamageAwareTerminalAdapter } from "../../integrations/pi/tui-runtime/damage-aware-terminal.js";
22
20
  import { PiTuiRuntimeAdapter } from "../../integrations/pi/tui-runtime/adapter.js";
23
21
  import { classifyPiTuiInput } from "../../integrations/pi/tui-runtime/input-presentation-coordinator.js";
@@ -25,10 +23,14 @@ import { runImageWorker } from "./image-preparation-client.js";
25
23
  import { STREAM_PRESENTATION_INTERVAL_MS, StreamPresentationCoalescer, } from "./stream-presentation-coalescer.js";
26
24
  import { writeSystemClipboardText, } from "./system-clipboard.js";
27
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() { } };
28
28
  export { OwnedUiSessionShellRoot, } from "./session-shell-root.js";
29
29
  /** Coordinates backend, owned presentation, and Pi TUI lifecycles for one interactive session. */
30
30
  export class OwnedUiSessionShell {
31
31
  backend;
32
+ #presenters;
33
+ #dialogHost;
32
34
  root;
33
35
  runtime;
34
36
  #cwd;
@@ -75,10 +77,12 @@ export class OwnedUiSessionShell {
75
77
  #streamPresentation;
76
78
  #removeViewportPreInput;
77
79
  #unsubscribeSettings;
80
+ #unsubscribeBackgroundSettings;
78
81
  #unbindPiSettings;
79
82
  #unbindTerminalSettings;
80
83
  #unbindShutdownSettings;
81
84
  #terminalProgressEnabled = false;
85
+ #programStatus;
82
86
  #showImages = true;
83
87
  #imageWidthCells = 80;
84
88
  #fullscreenExitOutput = "transcript";
@@ -91,17 +95,21 @@ export class OwnedUiSessionShell {
91
95
  #suggestionModelKey;
92
96
  constructor(options) {
93
97
  const { backend, cwd, routeHost, sessionLayout } = options.engine;
94
- 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 ?? {};
95
99
  const { clipboard, responseCopy, paste: pasteDiagnostics, pastePreparation } = options.diagnostics ?? {};
96
100
  const promptHistory = options.history;
101
+ const { clipboard: sharedClipboard, keybindings: sharedKeybindings } = options.shared ?? {};
97
102
  this.backend = backend;
98
- this.#sessionGeneration = this.backend.sessionGeneration;
99
- this.#sessionBindingGeneration = this.backend.sessionBindingGeneration;
100
- 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());
101
107
  this.#cwd = cwd;
102
108
  this.#routeHost = routeHost ?? null;
103
109
  this.#startupRoute = options.engine.startupRoute;
104
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();
105
113
  this.#lazySelectors = options.diagnostics?.lazySelectors;
106
114
  this.#stopped = new Promise(resolve => {
107
115
  this.#resolveStopped = resolve;
@@ -114,494 +122,569 @@ export class OwnedUiSessionShell {
114
122
  // Invariant: owned prompt limits and collapsed skills are bare-A1 replacements; comparison profiles keep pinned behavior.
115
123
  this.#promptImages = this.#customViewport ? options.promptImages ?? null : null;
116
124
  this.#skills = this.#customViewport ? options.skills ?? null : null;
117
- const terminalCopy = terminal !== undefined || hasAsyncClipboardOutput();
118
- this.#responseCopy = this.#customViewport ? new ResponseCopyCoordinator({
119
- execute: responseCopy?.execute ?? (this.#copyExecutor = createResponseCopyExecutor({
120
- ...(terminal === undefined && clipboard === undefined ? {} : { destination: "terminal" }),
121
- ...(clipboard?.writeText === undefined ? {} : { writeText: (text, signal) => clipboard.writeText(text, signal) }),
122
- ...(terminalCopy ? { terminal: { submit: async (control, signal) => {
123
- if (signal.aborted || this.#disposed || !runtime?.active)
124
- throw new Error("Copy canceled");
125
- // Performance: never grow the real terminal's pending buffer with another clipboard payload.
126
- if (terminal === undefined && process.stdout.writableLength + control.length > MAX_COPY_CONTROL_BYTES) {
127
- throw new Error("Clipboard terminal is busy");
128
- }
129
- runtime.writeControl(control);
130
- } } } : {}),
131
- })),
132
- ...(responseCopy?.onEvent === undefined ? {} : { onEvent: responseCopy.onEvent }),
133
- onFailure: result => {
134
- if (this.#disposed || !runtime?.active)
135
- return;
136
- this.root.appendWorkflowStatus(result.outcome === "timed-out" ? "Copy timed out; the clipboard did not respond."
137
- : result.failure === "size" ? "Selection exceeds this clipboard route's size limit."
138
- : "Could not copy the selection; the clipboard is unavailable.");
139
- runtime.requestRender();
140
- },
141
- }) : null;
142
- const readClipboard = async (signal) => {
143
- if (signal.aborted)
144
- throw new ImageAttachmentError("image-canceled");
145
- if (clipboard === undefined)
146
- return runImageWorker({ kind: "clipboard" }, signal);
147
- try {
148
- const image = await clipboard.readImage?.(signal);
149
- if (image !== null && image !== undefined) {
150
- const canonical = await runImageWorker({ kind: "canonicalize", source: image }, signal);
151
- if (canonical !== null)
152
- return { kind: "image", ...canonical };
153
- }
154
- }
155
- catch (error) {
156
- if (error instanceof ImageAttachmentError)
157
- throw error;
158
- // Compatibility: an unavailable image reader can still provide text.
159
- }
160
- const text = await clipboard.readText(signal);
161
- return text === null ? null : { kind: "text", text };
162
- };
163
- this.root = new OwnedUiSessionShellRoot(this.backend.view(), cwd, {
164
- getColumns: () => runtime?.viewport().columns ?? terminal?.columns ?? 80,
165
- getRows: () => runtime?.viewport().rows ?? terminal?.rows ?? 24,
166
- requestRender: force => runtime?.requestRender(force),
167
- requestHyperlinkCleanup: rows => damageTerminal?.requestHyperlinkCleanup(rows),
168
- onViewportFrame: frame => damageTerminal?.arm(frame.descriptor, {
169
- overlayActive: runtime?.hasOverlay() ?? false,
170
- selectionActive: this.root.hasActiveSelection(),
171
- replacementSurfaceActive: !this.root.usesDefaultInputSurface(),
172
- }),
173
- enableDockInputReuse: inputPresentation?.viewportReuse !== false,
174
- persistentHistory: this.#customViewport && promptHistory !== undefined,
175
- ...(promptHistory === undefined ? {} : { historyEditor: promptHistory.editor }),
176
- onSubmit: text => { void this.submit(text).catch(() => this.#reportSubmissionError()); },
177
- onPasteRejected: error => this.#reportSubmissionError(error),
178
- onInterrupt: () => { void this.interrupt(); },
179
- onClear: () => { void this.clearOrExit(); },
180
- onExit: () => { void this.shutdown(); },
181
- onModelSelect: () => this.#customViewport ? this.showModelsDialog() : this.showModelSelector(),
182
- onModelCycle: direction => { void this.cycleModel(direction); },
183
- onThinkingCycle: () => { void this.cycleThinkingLevel(); },
184
- onThinkingToggle: () => {
185
- this.root.toggleThinkingVisibility();
186
- this.runtime.requestRender();
187
- },
188
- onMessageCopy: () => { void this.runWorkflow({ command: "copy", argument: "" }); },
189
- onFollowUp: () => { void this.queueFollowUp().catch(() => this.#reportSubmissionError()); },
190
- onDequeue: () => this.restoreQueuedInput(),
191
- onEditorChange: text => {
192
- this.#editorRevision++;
193
- promptSuggestionController?.abortPending();
194
- if (text.length === 0) {
195
- // Concurrency: the editor synchronizes deletion-owned autocomplete after onChange.
196
- // Reassert retained ghost text in the microtask before Pi emits its scheduled frame.
197
- 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
+ }
198
168
  }
199
- this.root.reconcilePromptImageLimitNotice(text);
200
- },
201
- onPromptSuggestionAccepted: () => promptSuggestionController?.accept(),
202
- onInputSurfaceChanged: () => {
203
- promptSuggestionController?.invalidate();
204
- this.#promptHistory?.synchronize();
205
- },
206
- onCopyText: text => {
207
- if (this.#responseCopy !== null) {
208
- void this.#responseCopy.submitText(text);
209
- return;
169
+ catch (error) {
170
+ if (error instanceof ImageAttachmentError)
171
+ throw error;
172
+ // Compatibility: an unavailable image reader can still provide text.
210
173
  }
211
- const write = () => {
212
- if (this.#disposed)
213
- return Promise.resolve();
214
- runtime?.writeControl(`\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
215
- return clipboard === undefined ? writeSystemClipboardText(text)
216
- : clipboard.writeText?.(text) ?? Promise.resolve();
217
- };
218
- // Compatibility: comparison profiles retain their existing clipboard path.
219
- pendingClipboardWrite = write().catch(() => { });
220
- },
221
- readClipboardContent: async (signal = new AbortController().signal) => {
222
- await pendingClipboardWrite;
223
- return readClipboard(signal);
224
- },
225
- captureClipboardPaste: () => {
226
- const before = this.#responseCopy?.capturePasteBarrier() ?? (async () => true);
227
- if (clipboard !== undefined)
228
- return { kind: "provided", read: readClipboard, before };
229
- if (responseCopyDestination(process.env) === "terminal")
230
- return {
231
- 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();
232
234
  };
233
- return { kind: "native", before };
234
- },
235
- ...(pasteDiagnostics === undefined ? {} : { pasteDiagnostics: pasteDiagnostics }),
236
- ...(pastePreparation === undefined ? {} : { pastePreparation }),
237
- ...(this.#promptImages === null ? {} : { promptImageLimit: this.#promptImages.limit }),
238
- skillsPresentation: () => this.#skills?.presentation() ?? "expand",
239
- }, {
240
- ...startup,
241
- resources: startup?.resources ?? shellResourceEntries(this.backend),
242
- }, this.backend.agentDir, {
243
- getMessageRenderer: customType => this.backend.pinnedMessageRenderer(customType),
244
- getToolRenderers: toolName => this.backend.pinnedToolRenderers(toolName),
245
- getShortcuts: bindings => this.backend.pinnedShortcutDescriptions(bindings),
246
- }, sessionLayout, {
247
- resolve: assetId => this.backend.resolveTranscriptImage(assetId),
248
- });
249
- const initialPiSettings = this.backend.pinnedSettingsSnapshot();
250
- // Invariant: bare A1 owns a bounded viewport and therefore always runs on the alternate
251
- // fullscreen surface. The pinned comparison profiles still honor Pi's mode.
252
- const tuiMode = this.#customViewport
253
- ? "fullscreen"
254
- : this.backend.disposed ? "regular" : initialPiSettings.tuiMode;
255
- const runtimeOptions = {
256
- root: this.root,
257
- mode: tuiMode,
258
- ...(this.#customViewport ? {
259
- // Invariant: the owned shell enables and routes mouse reports itself. Pi's
260
- // enclosing fullscreen renderer must never establish a competing white selection.
261
- mouse: false,
262
- consumeUnhandledMouse: true,
263
- onOverlayGeometry: surfaces => this.root.setViewportOverlaySurfaces(surfaces),
264
- decorateTerminal: (terminal) => {
265
- damageTerminal = new DamageAwareTerminalAdapter(terminal, {
266
- regionalScroll: process.env.TERM !== "dumb",
267
- inspectHyperlinks: readVisibleHyperlinks,
268
- onResize: () => streamPresentation?.noteImmediatePresentation(),
269
- onHyperlinkCleanupRequired: () => runtime?.requestRender(true),
270
- });
271
- 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);
272
241
  },
273
- ...(inputPresentation?.coordination === false ? {} : {
274
- inputCoordination: {
275
- classify: (data, focusedOverlay) => classifyPiTuiInput(data, focusedOverlay ?? this.root.inputCoordinationSurface()),
276
- onReceipt: () => streamPresentation?.noteImmediatePresentation(),
277
- ...(inputPresentation?.scheduler === undefined
278
- ? {}
279
- : { scheduler: inputPresentation.scheduler }),
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 };
251
+ },
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 }),
280
311
  },
281
312
  }),
282
- } : { layoutRoot: this.root.layoutRoot() }),
283
- ...(inputPresentation?.onEvent === undefined ? {} : {
284
- inputDiagnostics: {
285
- onEvent: inputPresentation.onEvent,
286
- ...(inputPresentation.now === undefined ? {} : { now: inputPresentation.now }),
287
- },
288
- }),
289
- ...(terminal === undefined ? {} : { terminal: terminal }),
290
- hardwareCursor: this.backend.view().terminal.hardwareCursor,
291
- ...(this.#customViewport ? {} : { wheelScrollLines: initialPiSettings.fullscreenWheelScrollLines }),
292
- };
293
- runtime = new PiTuiRuntimeAdapter(runtimeOptions);
294
- this.runtime = runtime;
295
- this.#damageTerminal = damageTerminal ?? null;
296
- this.#quitOutro = quitOutro;
297
- this.#reloadPresentation = reloadPresentation;
298
- const presentationInterval = streamPresentationOptions?.intervalMs ?? STREAM_PRESENTATION_INTERVAL_MS;
299
- streamPresentation = streamPresentationOptions?.scheduler === undefined
300
- ? new StreamPresentationCoalescer(() => this.runtime.requestRender(), presentationInterval)
301
- : new StreamPresentationCoalescer(() => this.runtime.requestRender(), presentationInterval, streamPresentationOptions.scheduler);
302
- this.#streamPresentation = streamPresentation;
303
- const promptSuggestionOptions = this.#customViewport ? options.suggestions : undefined;
304
- promptSuggestionController = promptSuggestionOptions === undefined ? null : new ContextualPromptSuggestionController({
305
- generator: promptSuggestionOptions.generator,
306
- enabled: promptSuggestionOptions.enabled(),
307
- ...(promptSuggestionOptions.diagnostics === undefined ? {} : { diagnostics: promptSuggestionOptions.diagnostics }),
308
- surface: {
309
- canPresent: identity => this.#promptSuggestionPresentationBlockReason(identity) === null,
310
- presentationBlockReason: identity => this.#promptSuggestionPresentationBlockReason(identity),
311
- present: text => {
312
- if (!this.root.canPresentPromptSuggestion())
313
- return false;
314
- this.root.setPromptSuggestion(text);
315
- 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(),
316
349
  },
317
- clear: () => this.root.setPromptSuggestion(null),
318
- requestRender: () => this.runtime.requestRender(),
319
- },
320
- });
321
- this.#promptSuggestions = promptSuggestionController;
322
- this.#unsubscribePromptSuggestions = promptSuggestionOptions === undefined
323
- ? () => { }
324
- : promptSuggestionOptions.onChange(enabled => this.#promptSuggestions?.setEnabled(enabled));
325
- this.#unsubscribePromptImages = this.#promptImages === null ? () => { } : this.#promptImages.onChange(() => {
326
- 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();
327
369
  this.runtime.requestRender();
328
- });
329
- // Rationale: the same listener refreshes the menu for the A1 presentation choice and for the engine's
330
- // skill-command registration, both of which the owned settings manager reports through one change.
331
- this.#unsubscribeSkills = this.#skills === null ? () => { } : this.#skills.onChange(() => {
332
- // Invariant: reinstalling drops extension provider wrappers, so an unrelated setting change leaves the list alone.
333
- if (this.#disposed || this.#commandListSignature() === this.#installedCommandSignature)
334
- return;
335
- this.#installAutocompleteCommands();
336
- this.runtime.requestRender();
337
- });
338
- this.runtime.setHardwareCursor(initialPiSettings.showHardwareCursor);
339
- this.runtime.setClearOnShrink(initialPiSettings.clearOnShrink);
340
- this.#terminalProgressEnabled = initialPiSettings.showTerminalProgress;
341
- this.#fullscreenExitOutput = initialPiSettings.fullscreenExitOutput;
342
- // Invariant: bare A1 prints only the resume hint at exit, so the pinned exit-output
343
- // choice is hidden there and cannot be bound; the comparison profile binds and honors it.
344
- this.#unbindShutdownSettings = this.backend.settingsProductMode === "bare" ? () => { } : this.backend.bindSettingsOwner("shutdown", {
345
- fullscreenExitOutput: { apply() { } },
346
- });
347
- this.#unbindTerminalSettings = this.backend.bindSettingsOwner("terminal", {
348
- showHardwareCursor: { apply: value => {
349
- if (typeof value !== "boolean")
350
- throw new TypeError("Hardware cursor setting is invalid");
351
- this.runtime.setHardwareCursor(value);
352
- } },
353
- clearOnShrink: { apply: value => {
354
- if (typeof value !== "boolean")
355
- throw new TypeError("Clear-on-shrink setting is invalid");
356
- this.runtime.setClearOnShrink(value);
357
- } },
358
- showTerminalProgress: { apply: value => {
359
- if (typeof value !== "boolean")
360
- throw new TypeError("Terminal progress setting is invalid");
361
- this.#terminalProgressEnabled = value;
362
- this.#syncTerminalProgress(this.view());
363
- } },
364
- });
365
- this.#removeViewportPreInput = this.#customViewport
366
- ? this.runtime.addPreInputListener(data => {
367
- if (inputPresentation?.coordination === false)
368
- this.#streamPresentation.noteImmediatePresentation();
369
- // Compatibility: route modal paging and boundary keys before Pi's fullscreen handlers.
370
- const isDefault = this.root.usesDefaultInputSurface();
371
- if (!this.runtime.hasOverlay() && !isDefault
372
- && (this.root.editor.matchesTerminalKey(data, "pageUp")
373
- || this.root.editor.matchesTerminalKey(data, "pageDown"))) {
374
- 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);
375
581
  this.runtime.requestRender();
376
- 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;
377
592
  }
378
- if (!this.runtime.hasOverlay() && (this.root.editor.matchesTerminalKey(data, "home")
379
- || this.root.editor.matchesTerminalKey(data, "end")
380
- || !isDefault && (this.root.editor.matchesTerminalKey(data, "ctrl+home")
381
- || this.root.editor.matchesTerminalKey(data, "ctrl+end")))) {
382
- if (isDefault)
383
- this.root.handleViewportPreInput(data, true);
384
- this.root.handleInput(data);
385
- this.runtime.requestRender();
386
- 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();
387
599
  }
388
- // Invariant: geometry must belong to the painted frame, including newly opened/nested
389
- // surfaces. Steady pointer input does not trigger a synchronous composition.
390
- const viewport = this.runtime.viewport();
391
- if (data.includes("\u001b[<") && !this.root.viewportInputGeometryReady(viewport.columns, viewport.rows)) {
392
- this.runtime.renderNow();
600
+ if (event.type === "assistant-message-completed" && !event.successful) {
601
+ this.#programStatus.setErrorMessage(this.view().status.diagnostics.at(-1));
393
602
  }
394
- const routed = this.root.handleViewportPreInput(data, true, Date.now(), this.root.usesDefaultInputSurface() && !this.runtime.hasFocusedOverlay());
395
- if (routed.copySelection !== undefined) {
396
- const snapshot = routed.copySelection;
397
- const acknowledgement = copyAcknowledgement(snapshot);
398
- const intent = ++this.#copyIntentSequence;
399
- void this.#responseCopy?.submit(snapshot, pendingClipboardWrite).then(result => {
400
- if (this.#disposed || !runtime.active || intent !== this.#copyIntentSequence
401
- || result.outcome !== "delivered" && result.outcome !== "submitted-unverified")
402
- return;
403
- if (acknowledgement.hasNonWhitespace) {
404
- this.root.showCopyAcknowledgement(`copied ${acknowledgement.characters} chars to clipboard`);
405
- }
406
- });
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());
623
+ }
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;
633
+ }
634
+ if (event.type === "status" && /^(Retrying|Compacting)/.test(event.status.workingMessage ?? "")) {
635
+ this.#promptSuggestions?.invalidate();
636
+ }
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());
407
653
  }
408
- if (!routed.consumed)
409
- return routed.data === data ? undefined : { data: routed.data };
410
- return routed.data.length === 0 ? { consume: true } : { data: routed.data };
411
- })
412
- : () => { };
413
- const applyViewportSettings = () => {
414
- const snapshot = viewportSettings?.snapshot();
415
- this.root.setViewportConfig(snapshot ?? {
416
- scrollbarAppearance: "auto",
417
- scrollbarStyle: "thin",
418
- scrollbarSpeed: "normal",
654
+ if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
655
+ this.#settleStoppedLifecycle();
419
656
  });
420
- };
421
- applyViewportSettings();
422
- this.#unsubscribeSettings = this.#customViewport && viewportSettings
423
- ? viewportSettings.onChange(settings => this.root.setViewportConfig(settings))
424
- : () => { };
425
- this.root.setEditorPaddingX(initialPiSettings.editorPaddingX);
426
- this.root.setAutocompleteMaxVisible(initialPiSettings.autocompleteMaxVisible);
427
- this.root.setOutputPad(initialPiSettings.outputPad);
428
- this.root.setFullscreenCopyOnSelect(initialPiSettings.fullscreenCopyOnSelect);
429
- this.root.setHideThinkingBlock(initialPiSettings.hideThinkingBlock);
430
- this.root.setMermaidRenderingMode(initialPiSettings.mermaidRenderingMode);
431
- this.#showImages = initialPiSettings.showImages;
432
- this.#imageWidthCells = initialPiSettings.imageWidthCells;
433
- this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
434
- this.#unbindPiSettings = this.backend.bindSettingsOwner("shell", {
435
- editorPaddingX: { apply: value => {
436
- if (typeof value !== "number")
437
- throw new TypeError("Editor padding is invalid");
438
- this.root.setEditorPaddingX(value);
439
- } },
440
- autocompleteMaxVisible: { apply: value => {
441
- if (typeof value !== "number")
442
- throw new TypeError("Autocomplete maximum is invalid");
443
- this.root.setAutocompleteMaxVisible(value);
444
- } },
445
- outputPad: { apply: value => {
446
- if (value !== 0 && value !== 1)
447
- throw new TypeError("Output padding is invalid");
448
- this.root.setOutputPad(value);
449
- } },
450
- hideThinkingBlock: { apply: value => {
451
- if (typeof value !== "boolean")
452
- throw new TypeError("Thinking-block visibility is invalid");
453
- this.root.setHideThinkingBlock(value);
454
- } },
455
- mermaidRenderingMode: { apply: value => {
456
- if (value !== "off" && value !== "final" && value !== "streaming")
457
- throw new TypeError("Mermaid mode is invalid");
458
- this.root.setMermaidRenderingMode(value);
459
- } },
460
- showImages: { apply: value => {
461
- if (typeof value !== "boolean")
462
- throw new TypeError("Image visibility is invalid");
463
- this.#showImages = value;
464
- this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
465
- } },
466
- imageWidthCells: { apply: value => {
467
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
468
- throw new TypeError("Image width is invalid");
469
- this.#imageWidthCells = value;
470
- this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
471
- } },
472
- ...(this.#customViewport ? { fullscreenCopyOnSelect: { apply: (value) => {
473
- if (typeof value !== "boolean")
474
- throw new TypeError("Fullscreen copy-on-select setting is invalid");
475
- this.root.setFullscreenCopyOnSelect(value);
476
- } } } : {
477
- fullscreenWheelScrollLines: { apply: (value) => {
478
- if (value !== "auto" && (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 100)) {
479
- throw new TypeError("Fullscreen wheel-scroll distance is invalid");
480
- }
481
- this.runtime.setWheelScrollLines(value);
482
- } },
483
- }),
484
- });
485
- if (this.#customViewport && promptHistory !== undefined) {
486
- this.#promptHistoryImageSidecar = promptHistory.imageSidecar;
487
- const sidecar = this.#promptHistoryImageSidecar;
488
- this.#promptHistory = new PromptHistoryController({
489
- editor: this.root.editor,
490
- store: promptHistory.store,
491
- limit: promptHistory.limit,
492
- fallback: this.view().transcript.flatMap(block => block.kind === "user" ? [block.text] : []),
493
- active: () => this.root.usesDefaultInputSurface(),
494
- render: () => this.runtime.requestRender(),
495
- 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");
496
665
  });
666
+ constructed.push(this.#unbindClipboardWriter);
667
+ if (this.backend.session.view().lifecycle === "stopped")
668
+ this.#resolveStopped?.();
497
669
  }
498
- this.#extensionBridge = createPiExtensionUiBridge({
499
- runtime: {
500
- getColumns: () => this.runtime.viewport().columns,
501
- getRows: () => this.runtime.viewport().rows,
502
- requestRender: () => this.runtime.requestRender(),
503
- },
504
- agentDir: this.backend.agentDir,
505
- setInputSurface: component => this.root.setInputSurface(component, true, "opaque"),
506
- showOverlay: (component, overlayOptions) => this.runtime.showOverlay(component, overlayOptions),
507
- listenInput: handler => this.runtime.addInputListener(handler),
508
- replaceWidget: (key, component, placement) => this.root.setExtensionWidget(key, component, placement),
509
- replaceHeader: component => this.root.setExtensionHeader(component),
510
- replaceFooter: component => this.root.setExtensionFooter(component),
511
- setStatus: (key, text) => this.root.setExtensionStatus(key, text),
512
- setWorking: (message, visible) => this.root.setExtensionWorking(message, visible),
513
- notify: (message, type) => this.root.addExtensionNotification(message, type),
514
- setTitle: title => this.runtime.setTitle(title),
515
- getEditorText: () => this.root.editor.getText(),
516
- setEditorText: text => this.root.editor.setText(text),
517
- pasteToEditor: text => this.root.editor.insertText(text),
518
- addAutocompleteProvider: factory => this.root.editor.addAutocompleteProvider(factory),
519
- setCustomEditor: component => this.root.setInputSurface(component, true, "opaque"),
520
- getFooterData: () => this.root.extensionFooterData(),
521
- getToolsExpanded: () => this.root.toolsExpanded,
522
- setToolsExpanded: expanded => this.root.setToolsExpanded(expanded),
523
- });
524
- this.backend.setWorkflowInteractionHost({
525
- startLogin: request => this.#startWorkflowLogin(request),
526
- prompt: request => this.#requestWorkflowInput(request),
527
- notify: event => this.#notifyWorkflowLogin(event),
528
- publish: message => {
529
- this.root.appendWorkflowMessage(message);
530
- this.runtime.requestRender();
531
- },
532
- finishLogin: () => this.#finishWorkflowLogin(),
533
- });
534
- this.#installAutocompleteCommands();
535
- this.#unsubscribe = this.backend.onEvent(event => {
536
- // Performance: a streamed chunk names one block, and touching only that block is what keeps the
537
- // cost of a chunk the same in a long session as in a new one. Everything else
538
- // resynchronizes the view, which is cheap next to re-reading the transcript.
539
- if (event.type === "agent-run-started") {
540
- this.#promptSuggestions?.invalidate();
541
- this.root.resumeViewportFollowing();
542
- }
543
- if (event.type === "assistant-message-completed") {
544
- this.root.noteCompletedAssistantMessage();
545
- const identity = {
546
- sessionId: event.sessionId,
547
- sessionGeneration: event.sessionGeneration,
548
- runSequence: event.runSequence,
549
- responseSequence: event.responseSequence,
550
- model: event.model,
551
- };
552
- if (event.model === null)
553
- this.#promptSuggestions?.skip(identity, "no-model");
554
- else
555
- this.#promptSuggestions?.consider({ ...identity, model: event.model }, !event.successful ? "failed-response"
556
- : event.toolContinuation || event.stopReason === "toolUse" ? "tool-continuation"
557
- : event.stopReason !== "stop" ? "incomplete-response"
558
- : event.assistantMessageCount < 2 ? "early-conversation"
559
- : this.root.promptSuggestionPrepareBlockReason());
560
- }
561
- const semanticOnly = event.type === "agent-run-started" || event.type === "assistant-message-completed";
562
- const view = event.type === "transcript-block" && this.#sessionGeneration === this.backend.sessionGeneration
563
- ? this.#syncBlock(event.block)
564
- : semanticOnly ? this.view() : this.#syncView();
565
- this.#syncTerminalProgress(view);
566
- const currentModelKey = modelKey(view);
567
- if (this.backend.sessionGeneration !== this.#sessionGeneration || currentModelKey !== this.#suggestionModelKey) {
568
- this.#promptSuggestions?.invalidate();
569
- this.#suggestionModelKey = currentModelKey;
570
- }
571
- if (event.type === "status" && /^(Retrying|Compacting)/.test(event.status.workingMessage ?? "")) {
572
- this.#promptSuggestions?.invalidate();
573
- }
574
- if (event.type === "agent-run-settled" && event.model !== null) {
575
- this.#promptSuggestions?.settle({
576
- sessionId: event.sessionId,
577
- sessionGeneration: event.sessionGeneration,
578
- runSequence: event.runSequence,
579
- responseSequence: event.responseSequence,
580
- model: event.model,
581
- });
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. */ }
582
676
  }
583
- if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
584
- this.#settleStoppedLifecycle();
585
- });
586
- this.#unbindClipboardWriter = this.#responseCopy === null ? () => { } : this.backend.bindClipboardWriter(async (text) => {
587
- const result = await this.#responseCopy.submitText(text);
588
- if (result.outcome === "delivered")
589
- return true;
590
- if (result.outcome === "submitted-unverified")
591
- return false;
592
- throw new Error(result.outcome === "timed-out" ? "Clipboard delivery timed out" : "Clipboard delivery could not be completed");
593
- });
594
- if (this.backend.view().lifecycle === "stopped")
595
- this.#resolveStopped?.();
677
+ throw error;
678
+ }
596
679
  }
597
680
  view() {
598
- return this.backend.view();
681
+ return this.backend.session.view();
599
682
  }
600
683
  #promptSuggestionPresentationBlockReason(identity) {
601
684
  const view = this.view();
602
685
  if (this.#disposed)
603
686
  return "disposed";
604
- if (identity.sessionId !== view.sessionId || identity.sessionGeneration !== this.backend.sessionGeneration
687
+ if (identity.sessionId !== view.sessionId || identity.sessionGeneration !== this.backend.identity.sessionGeneration
605
688
  || modelKey(view) !== `${identity.model.providerId}/${identity.model.modelId}`)
606
689
  return "stale-identity";
607
690
  return this.root.promptSuggestionPresentationBlockReason();
@@ -618,7 +701,7 @@ export class OwnedUiSessionShell {
618
701
  this.#syncTerminalProgress(this.view());
619
702
  if (this.#customViewport)
620
703
  this.#setPointerReporting(true);
621
- void this.backend.bindExtensionUi(this.#extensionBridge.context, () => { void this.shutdown(); });
704
+ void this.backend.extensions.bindExtensionUi(this.#extensionBridge.context, () => { void this.shutdown(); });
622
705
  this.#syncView();
623
706
  // Performance: the spare clipboard helpers fork after the first frame is out, so startup never waits on them.
624
707
  setImmediate(() => {
@@ -648,10 +731,10 @@ export class OwnedUiSessionShell {
648
731
  if (!this.root.hasPendingPastes(draft))
649
732
  return this.#guardSubmission(draft, action);
650
733
  const controller = new AbortController();
651
- const generation = this.backend.sessionGeneration;
734
+ const generation = this.backend.identity.sessionGeneration;
652
735
  const result = this.#guardSubmission(draft, async () => {
653
736
  await this.root.waitForPromptPastes(draft, controller.signal);
654
- if (controller.signal.aborted || this.#disposed || this.backend.sessionGeneration !== generation)
737
+ if (controller.signal.aborted || this.#disposed || this.backend.identity.sessionGeneration !== generation)
655
738
  return rejected("image submission canceled");
656
739
  return action();
657
740
  }).finally(() => {
@@ -685,7 +768,7 @@ export class OwnedUiSessionShell {
685
768
  this.#promptHistory.rememberRecovery(text);
686
769
  else {
687
770
  this.#persistImageSidecars(text);
688
- this.#promptHistory.capture(reusable, kind, this.#cwd, this.backend.sessionId);
771
+ this.#promptHistory.capture(reusable, kind, this.#cwd, this.backend.identity.sessionId);
689
772
  }
690
773
  }
691
774
  }
@@ -716,7 +799,7 @@ export class OwnedUiSessionShell {
716
799
  if (command) {
717
800
  this.#rememberInput(displayInput, "bash");
718
801
  try {
719
- const result = await this.backend.executeBashWorkflow(command, excludeFromContext);
802
+ const result = await this.backend.workflows.executeBashWorkflow(command, excludeFromContext);
720
803
  const workflow = {
721
804
  command: "debug",
722
805
  outcome: result.cancelled ? "cancelled" : result.exitCode === 0 || result.exitCode === undefined ? "completed" : "failed",
@@ -745,7 +828,7 @@ export class OwnedUiSessionShell {
745
828
  return await this.#execute({
746
829
  type,
747
830
  correlationId: this.#correlation(type),
748
- sessionId: this.backend.sessionId,
831
+ sessionId: this.backend.identity.sessionId,
749
832
  text: input,
750
833
  ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
751
834
  }, displayInput);
@@ -778,7 +861,7 @@ export class OwnedUiSessionShell {
778
861
  // Compatibility: match Pi: with an empty editor, two escapes inside 500 ms open the
779
862
  // configured session navigator. The first escape intentionally does not
780
863
  // interrupt or mutate the prompt.
781
- const action = this.backend.pinnedSettingsSnapshot().doubleEscapeAction;
864
+ const action = this.backend.settings.snapshot().doubleEscapeAction;
782
865
  if (action === "none")
783
866
  return { outcome: "rejected", diagnostic: "nothing to interrupt" };
784
867
  if (now - this.#lastEscapeTime < 500) {
@@ -812,7 +895,7 @@ export class OwnedUiSessionShell {
812
895
  return this.#execute({
813
896
  type: "resume-session",
814
897
  correlationId: this.#correlation("resume"),
815
- sessionId: this.backend.sessionId,
898
+ sessionId: this.backend.identity.sessionId,
816
899
  sessionPath,
817
900
  });
818
901
  }
@@ -821,7 +904,7 @@ export class OwnedUiSessionShell {
821
904
  return this.#execute({
822
905
  type: "set-model",
823
906
  correlationId: this.#correlation("model"),
824
- sessionId: this.backend.sessionId,
907
+ sessionId: this.backend.identity.sessionId,
825
908
  model: { providerId, modelId, displayName: modelId },
826
909
  });
827
910
  }
@@ -829,7 +912,7 @@ export class OwnedUiSessionShell {
829
912
  return this.#execute({
830
913
  type: "set-thinking-level",
831
914
  correlationId: this.#correlation("thinking"),
832
- sessionId: this.backend.sessionId,
915
+ sessionId: this.backend.identity.sessionId,
833
916
  thinkingLevel,
834
917
  });
835
918
  }
@@ -839,7 +922,7 @@ export class OwnedUiSessionShell {
839
922
  return this.setThinkingLevel(levels[(current + 1) % levels.length] ?? "off");
840
923
  }
841
924
  async cycleModel(direction) {
842
- const result = await this.backend.cycleModelWorkflow(direction);
925
+ const result = await this.backend.workflows.cycleModelWorkflow(direction);
843
926
  this.root.appendWorkflowResult(result);
844
927
  this.runtime.requestRender();
845
928
  return workflowAdapterResult(result);
@@ -862,13 +945,13 @@ export class OwnedUiSessionShell {
862
945
  return this.#execute({
863
946
  type: "follow-up",
864
947
  correlationId: this.#correlation("follow-up"),
865
- sessionId: this.backend.sessionId,
948
+ sessionId: this.backend.identity.sessionId,
866
949
  text,
867
950
  ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
868
951
  }, displayInput);
869
952
  }
870
953
  restoreQueuedInput() {
871
- const queued = [...this.#waitingImages.keys(), ...this.backend.clearQueuedWorkflows()];
954
+ const queued = [...this.#waitingImages.keys(), ...this.backend.workflows.clearQueuedWorkflows()];
872
955
  this.#cancelWaitingImages();
873
956
  if (queued.length === 0)
874
957
  return;
@@ -904,6 +987,24 @@ export class OwnedUiSessionShell {
904
987
  this.#dialogHandle = handle;
905
988
  return handle;
906
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
+ }
907
1008
  #loadLazySelectors() {
908
1009
  if (this.#lazySelectors !== undefined)
909
1010
  return Promise.resolve(this.#lazySelectors);
@@ -913,7 +1014,7 @@ export class OwnedUiSessionShell {
913
1014
  });
914
1015
  }
915
1016
  async showThinkingSelector() {
916
- const snapshot = this.backend.pinnedSettingsSnapshot();
1017
+ const snapshot = this.backend.settings.snapshot();
917
1018
  const close = () => {
918
1019
  this.root.setFooterLevel(true);
919
1020
  this.root.setInputSurface(null);
@@ -931,7 +1032,7 @@ export class OwnedUiSessionShell {
931
1032
  availableLevels: snapshot.availableThinkingLevels,
932
1033
  onSelect: select,
933
1034
  onCancel: close,
934
- onSelectAsDefault: level => this.backend.setDefaultThinkingLevel(level),
1035
+ onSelectAsDefault: level => this.backend.settings.setDefaultThinkingLevel(level),
935
1036
  defaultLevel: snapshot.defaultThinkingLevel,
936
1037
  ...(this.#customViewport ? {
937
1038
  presentation: {
@@ -968,34 +1069,10 @@ export class OwnedUiSessionShell {
968
1069
  this.runtime.requestRender();
969
1070
  }
970
1071
  showModelSelector(initialSearchInput) {
971
- const context = this.backend.pinnedModelSelectorContext();
972
- const close = () => {
973
- this.root.setInputSurface(null);
974
- this.runtime.requestRender();
975
- };
976
- const component = createPiShellModelSelector({
977
- ...context,
978
- runtime: {
979
- getColumns: () => this.runtime.viewport().columns,
980
- getRows: () => this.runtime.viewport().rows,
981
- requestRender: () => this.runtime.requestRender(),
982
- },
983
- ...(initialSearchInput === undefined ? {} : { initialSearchInput }),
984
- onSelect: model => {
985
- close();
986
- void this.runWorkflow({ command: "model", argument: "", selection: modelReference(model) });
987
- },
988
- onSelectAsDefault: model => {
989
- close();
990
- void this.runWorkflow({ command: "model", argument: "", selection: modelReference(model), persist: true });
991
- },
992
- onCancel: close,
993
- });
994
- this.root.setInputSurface(component);
995
- this.runtime.requestRender();
1072
+ this.#presenters.openModelSelector(this.#dialogHost, initialSearchInput);
996
1073
  }
997
1074
  showForkSelector() {
998
- const options = this.backend.pinnedForkOptions();
1075
+ const options = this.backend.catalog.pinnedForkOptions();
999
1076
  if (options.length === 0) {
1000
1077
  this.root.appendWorkflowResult({ command: "fork", outcome: "completed", message: "No messages to fork from", messageKind: "status" });
1001
1078
  this.runtime.requestRender();
@@ -1015,7 +1092,7 @@ export class OwnedUiSessionShell {
1015
1092
  async showLogoutSelector() {
1016
1093
  let options;
1017
1094
  try {
1018
- options = await this.backend.pinnedLogoutOptions();
1095
+ options = await this.backend.catalog.pinnedLogoutOptions();
1019
1096
  }
1020
1097
  catch (error) {
1021
1098
  this.root.appendWorkflowResult({
@@ -1043,7 +1120,7 @@ export class OwnedUiSessionShell {
1043
1120
  this.runtime.requestRender();
1044
1121
  }
1045
1122
  showLoginMethodSelector(providerReference) {
1046
- const method = this.backend.pinnedLoginMethodOptions(providerReference);
1123
+ const method = this.backend.catalog.pinnedLoginMethodOptions(providerReference);
1047
1124
  if (method.options.length === 0) {
1048
1125
  this.showLoginProviderSelector(undefined, providerReference);
1049
1126
  return;
@@ -1069,67 +1146,10 @@ export class OwnedUiSessionShell {
1069
1146
  this.root.setInputSurface(component);
1070
1147
  this.runtime.requestRender();
1071
1148
  }
1072
- async showTreeSelector(initialSelectedId) {
1073
- const context = this.backend.pinnedTreeSelectorContext();
1074
- if (context.tree.length === 0) {
1075
- this.root.appendWorkflowStatus("No entries in session");
1076
- this.runtime.requestRender();
1077
- return;
1078
- }
1079
- const close = () => {
1080
- this.root.setInputSurface(null);
1081
- this.runtime.requestRender();
1082
- };
1083
- const releasePresentation = this.#customViewport ? this.runtime.beginPresentationHold() : undefined;
1084
- try {
1085
- const selectors = await this.#loadLazySelectors();
1086
- const component = await selectors.createTree({
1087
- tree: context.tree,
1088
- currentLeafId: context.currentLeafId,
1089
- terminalHeight: this.runtime.viewport().rows,
1090
- initialFilterMode: context.filterMode,
1091
- ...(initialSelectedId === undefined ? {} : { initialSelectedId }),
1092
- onLabelChange: context.appendLabelChange,
1093
- onCopy: text => {
1094
- if (!text) {
1095
- this.root.appendWorkflowResult({ command: "tree", outcome: "failed", message: "Selected entry has no text to copy" });
1096
- this.runtime.requestRender();
1097
- return;
1098
- }
1099
- const generation = this.backend.sessionBindingGeneration;
1100
- void this.backend.copyWorkflowText(text).then(acknowledged => {
1101
- if (this.#disposed || generation !== this.backend.sessionBindingGeneration)
1102
- return;
1103
- this.root.appendWorkflowStatus(acknowledged ? "Copied selected message to clipboard" : "Submitted selected message to clipboard");
1104
- this.runtime.requestRender();
1105
- }).catch(error => {
1106
- if (this.#disposed || generation !== this.backend.sessionBindingGeneration)
1107
- return;
1108
- this.root.appendWorkflowResult({ command: "tree", outcome: "failed", message: error instanceof Error ? error.message : String(error) });
1109
- this.runtime.requestRender();
1110
- });
1111
- },
1112
- onCancel: close,
1113
- onSelect: entryId => {
1114
- if (entryId === context.currentLeafId) {
1115
- close();
1116
- this.root.appendWorkflowStatus("Already at this point");
1117
- this.runtime.requestRender();
1118
- return;
1119
- }
1120
- if (context.skipSummaryPrompt)
1121
- close();
1122
- void this.#completeTreeSelection(entryId, context.skipSummaryPrompt);
1123
- },
1124
- });
1125
- if (this.#disposed)
1126
- return;
1127
- this.root.setInputSurface(component);
1128
- this.runtime.requestRender();
1129
- }
1130
- finally {
1131
- releasePresentation?.();
1132
- }
1149
+ showTreeSelector(initialSelectedId) {
1150
+ return this.#presenters.openTreeSelector(this.#dialogHost, (entryId, skipSummaryPrompt) => {
1151
+ void this.#completeTreeSelection(entryId, skipSummaryPrompt);
1152
+ }, initialSelectedId);
1133
1153
  }
1134
1154
  showLoginAuthTypeSelector() {
1135
1155
  const close = () => {
@@ -1145,7 +1165,7 @@ export class OwnedUiSessionShell {
1145
1165
  this.runtime.requestRender();
1146
1166
  }
1147
1167
  showLoginProviderSelector(authType, initialSearchInput) {
1148
- const options = this.backend.pinnedLoginOptions(authType);
1168
+ const options = this.backend.catalog.pinnedLoginOptions(authType);
1149
1169
  if (options.length === 0) {
1150
1170
  this.root.appendWorkflowStatus(authType === "oauth" ? "No account providers available." : authType === "api_key" ? "No API key providers available." : "No login providers available.");
1151
1171
  this.runtime.requestRender();
@@ -1166,82 +1186,11 @@ export class OwnedUiSessionShell {
1166
1186
  this.root.setInputSurface(component);
1167
1187
  this.runtime.requestRender();
1168
1188
  }
1169
- async showSessionSelector() {
1170
- const context = this.backend.pinnedSessionSelectorContext();
1171
- const close = () => {
1172
- this.root.setInputSurface(null);
1173
- this.runtime.requestRender();
1174
- };
1175
- const component = await createPiShellSessionSelector({
1176
- currentSessionsLoader: context.loadCurrentSessions,
1177
- allSessionsLoader: context.loadAllSessions,
1178
- currentSessionFilePath: context.currentSessionFilePath,
1179
- renameSession: context.renameSession,
1180
- requestRender: () => this.runtime.requestRender(),
1181
- onSelect: sessionPath => {
1182
- close();
1183
- void this.runWorkflow({ command: "resume", argument: sessionPath });
1184
- },
1185
- onCancel: close,
1186
- onExit: () => {
1187
- close();
1188
- void this.shutdown();
1189
- },
1190
- });
1191
- this.root.setInputSurface(component);
1192
- this.runtime.requestRender();
1189
+ showSessionSelector() {
1190
+ return this.#presenters.openSessionSelector(this.#dialogHost, () => { void this.shutdown(); });
1193
1191
  }
1194
1192
  showSettingsSelector() {
1195
- const snapshot = this.backend.pinnedSettingsSnapshot();
1196
- const close = () => {
1197
- this.root.setInputSurface(null);
1198
- this.runtime.requestRender();
1199
- };
1200
- const { currentModel, ...settingsSnapshot } = snapshot;
1201
- const component = createPiShellSettingsSelector({
1202
- config: {
1203
- ...settingsSnapshot,
1204
- availableThinkingLevels: [...snapshot.availableThinkingLevels],
1205
- availableThemes: [...snapshot.availableThemes],
1206
- warnings: { ...snapshot.warnings },
1207
- modelThinkingLevels: { ...snapshot.modelThinkingLevels },
1208
- ...(currentModel === undefined ? {} : { currentModel: currentModel }),
1209
- availableDefaultModels: snapshot.availableDefaultModels,
1210
- },
1211
- onChange: (callback, value) => {
1212
- if (callback === "onCancel") {
1213
- close();
1214
- return;
1215
- }
1216
- if (callback === "onTuiModeChange") {
1217
- // Invariant: the custom bare-A1 surface is permanently fullscreen; this callback
1218
- // remains available only to pinned comparison profiles.
1219
- if (this.#customViewport)
1220
- return;
1221
- if (value !== "regular" && value !== "fullscreen")
1222
- return;
1223
- if (!this.runtime.switchMode(value)) {
1224
- this.root.appendWorkflowStatus("Close active overlays before changing TUI mode");
1225
- this.runtime.requestRender();
1226
- return;
1227
- }
1228
- if (this.#customViewport) {
1229
- this.#pointerReporting = false;
1230
- this.#setPointerReporting(true);
1231
- }
1232
- }
1233
- void this.backend.applyPinnedSettingValue(callback, value).then(result => {
1234
- if (result.outcome === "failed")
1235
- this.root.appendWorkflowResult(result);
1236
- else if (callback === "onTuiModeChange")
1237
- this.root.appendWorkflowStatus(`TUI mode: ${value}`);
1238
- this.runtime.requestRender();
1239
- });
1240
- },
1241
- onCancel: close,
1242
- });
1243
- this.root.setInputSurface(component);
1244
- this.runtime.requestRender();
1193
+ this.#presenters.openSettingsSelector(this.#dialogHost);
1245
1194
  }
1246
1195
  shutdown() {
1247
1196
  this.#shutdownPromise ??= this.#shutdown();
@@ -1249,7 +1198,13 @@ export class OwnedUiSessionShell {
1249
1198
  }
1250
1199
  async #shutdown() {
1251
1200
  try {
1252
- 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;
1253
1208
  await this.dispose();
1254
1209
  return workflowAdapterResult(result);
1255
1210
  }
@@ -1273,9 +1228,9 @@ export class OwnedUiSessionShell {
1273
1228
  });
1274
1229
  return { outcome: "completed", diagnostic: null };
1275
1230
  }
1276
- const copyGeneration = request.command === "copy" ? this.backend.sessionBindingGeneration : undefined;
1231
+ const copyGeneration = request.command === "copy" ? this.backend.identity.sessionBindingGeneration : undefined;
1277
1232
  if (request.command === "login" && request.selection !== undefined) {
1278
- const setup = this.backend.pinnedAmbientAuthentication(request.selection);
1233
+ const setup = this.backend.catalog.pinnedAmbientAuthentication(request.selection);
1279
1234
  if (setup) {
1280
1235
  const close = () => {
1281
1236
  this.root.setInputSurface(null);
@@ -1340,7 +1295,7 @@ export class OwnedUiSessionShell {
1340
1295
  return { outcome: "completed", diagnostic: null };
1341
1296
  }
1342
1297
  if (request.command === "reload") {
1343
- const blocked = this.backend.reloadBlockedResult();
1298
+ const blocked = this.backend.workflows.reloadBlockedResult();
1344
1299
  if (blocked) {
1345
1300
  this.root.appendWorkflowResult(blocked);
1346
1301
  this.runtime.requestRender();
@@ -1366,7 +1321,7 @@ export class OwnedUiSessionShell {
1366
1321
  }
1367
1322
  let result;
1368
1323
  try {
1369
- 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 });
1370
1325
  }
1371
1326
  finally {
1372
1327
  if (operationSurface) {
@@ -1378,7 +1333,7 @@ export class OwnedUiSessionShell {
1378
1333
  this.runtime.requestRender();
1379
1334
  }
1380
1335
  }
1381
- if (copyGeneration !== undefined && (this.#disposed || copyGeneration !== this.backend.sessionBindingGeneration)) {
1336
+ if (copyGeneration !== undefined && (this.#disposed || copyGeneration !== this.backend.identity.sessionBindingGeneration)) {
1382
1337
  return workflowAdapterResult({ command: "copy", outcome: "cancelled", message: "" });
1383
1338
  }
1384
1339
  if (result.outcome === "requires-selection" && request.command === "model") {
@@ -1435,7 +1390,7 @@ export class OwnedUiSessionShell {
1435
1390
  await sleep(remaining);
1436
1391
  }
1437
1392
  showTrustSelector() {
1438
- const context = this.backend.pinnedProjectTrustContext();
1393
+ const context = this.backend.catalog.pinnedProjectTrustContext();
1439
1394
  const close = () => {
1440
1395
  this.root.setInputSurface(null);
1441
1396
  this.runtime.requestRender();
@@ -1444,7 +1399,7 @@ export class OwnedUiSessionShell {
1444
1399
  ...context,
1445
1400
  onSelect: selection => {
1446
1401
  try {
1447
- this.backend.persistProjectTrust(selection.updates);
1402
+ this.backend.catalog.persistProjectTrust(selection.updates);
1448
1403
  close();
1449
1404
  this.root.appendWorkflowStatus(`Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`);
1450
1405
  }
@@ -1460,157 +1415,16 @@ export class OwnedUiSessionShell {
1460
1415
  this.runtime.requestRender();
1461
1416
  }
1462
1417
  showScopedModelsSelector() {
1463
- const initial = this.backend.pinnedScopedModelsContext();
1464
- let currentEnabledIds = initial.enabledModelIds === null ? null : [...initial.enabledModelIds];
1465
- let selectionChanged = false;
1466
- let disposed = false;
1467
- let timedOut = false;
1468
- const controller = new AbortController();
1469
- const timeout = setTimeout(() => {
1470
- timedOut = true;
1471
- controller.abort();
1472
- }, 15_000);
1473
- const close = () => {
1474
- disposed = true;
1475
- clearTimeout(timeout);
1476
- controller.abort();
1477
- this.root.setInputSurface(null);
1478
- this.runtime.requestRender();
1479
- };
1480
- const selector = createPiShellScopedModelsSelector({
1481
- models: initial.models,
1482
- enabledModelIds: currentEnabledIds,
1483
- refreshStatus: "Refreshing model catalogs…",
1484
- onChange: enabledIds => {
1485
- selectionChanged = true;
1486
- currentEnabledIds = enabledIds === null ? null : [...enabledIds];
1487
- this.backend.updateScopedModels(currentEnabledIds);
1488
- this.runtime.requestRender();
1489
- },
1490
- onPersist: enabledIds => {
1491
- currentEnabledIds = enabledIds === null ? null : [...enabledIds];
1492
- this.backend.persistScopedModels(currentEnabledIds);
1493
- this.root.appendWorkflowStatus("Model selection saved to settings");
1494
- this.runtime.requestRender();
1495
- },
1496
- onCancel: close,
1497
- });
1498
- const component = {
1499
- ...selector,
1500
- dispose: () => {
1501
- disposed = true;
1502
- clearTimeout(timeout);
1503
- controller.abort();
1504
- selector.dispose?.();
1505
- },
1506
- };
1507
- this.root.setInputSurface(component);
1508
- this.runtime.requestRender();
1509
- void this.backend.refreshScopedModels(controller.signal).then(refreshed => {
1510
- if (disposed)
1511
- return;
1512
- if (!selectionChanged) {
1513
- currentEnabledIds = refreshed.enabledModelIds === null ? null : [...refreshed.enabledModelIds];
1514
- component.updateModels(refreshed.models, currentEnabledIds);
1515
- }
1516
- else {
1517
- component.updateModels(refreshed.models);
1518
- this.backend.updateScopedModels(currentEnabledIds);
1519
- }
1520
- component.setRefreshStatus(timedOut ? "Model refresh timed out; showing cached models." : refreshed.status, timedOut ? "warning" : refreshed.statusKind);
1521
- this.runtime.requestRender();
1522
- }).catch(error => {
1523
- if (disposed)
1524
- return;
1525
- component.setRefreshStatus(timedOut
1526
- ? "Model refresh timed out; showing cached models."
1527
- : `Could not refresh model catalogs: ${error instanceof Error ? error.message : String(error)}`, "warning");
1528
- this.runtime.requestRender();
1529
- }).finally(() => clearTimeout(timeout));
1418
+ this.#presenters.openScopedModelsSelector(this.#dialogHost);
1530
1419
  }
1531
1420
  // Invariant: bare A1 routes `models`; the comparison profile keeps the pinned `model`/`scoped-models` pair. Hidden routes are shared.
1532
1421
  #isWorkflowRoute(value) {
1533
1422
  return workflowCommandNames(this.#customViewport ? "bare" : "comparison").includes(value)
1534
- || PINNED_PI_HIDDEN_COMMAND_NAMES.includes(value);
1423
+ || OWNED_UI_HIDDEN_COMMAND_NAMES.includes(value);
1535
1424
  }
1536
1425
  /** The bare-A1 unified Models dialog: switch on Enter, scope on Space, persist on Ctrl+S, all through the engine. */
1537
1426
  showModelsDialog(initialQuery) {
1538
- const context = this.backend.modelsContext();
1539
- const available = new Set(context.models.map(model => `${model.provider}/${model.id}`));
1540
- const savedScopeIds = context.persistedScopeIds.filter(id => available.has(id));
1541
- // Invariant: an explicit session scope wins; otherwise the dialog starts from what is persisted, never from "all rows scoped".
1542
- const scopeIds = context.sessionScopeIds.length > 0 ? context.sessionScopeIds : savedScopeIds;
1543
- let disposed = false;
1544
- let timedOut = false;
1545
- const controller = new AbortController();
1546
- const timeout = setTimeout(() => {
1547
- timedOut = true;
1548
- controller.abort();
1549
- }, 15_000);
1550
- const close = () => {
1551
- disposed = true;
1552
- clearTimeout(timeout);
1553
- controller.abort();
1554
- this.root.setInputSurface(null);
1555
- this.runtime.requestRender();
1556
- };
1557
- const dialog = createPiShellModelsDialog({
1558
- models: context.models,
1559
- activeModelId: context.activeModelId,
1560
- scopeIds,
1561
- savedScopeIds,
1562
- ...(initialQuery === undefined ? {} : { initialQuery }),
1563
- refreshStatus: "Refreshing model catalogs…",
1564
- requestRender: () => this.runtime.requestRender(),
1565
- onSelect: modelId => {
1566
- void this.runWorkflow({ command: "models", argument: "", selection: modelId }).then(result => {
1567
- if (!disposed && result.outcome === "completed")
1568
- close();
1569
- });
1570
- },
1571
- onScopeChange: ids => {
1572
- this.backend.setSessionModelScope(ids);
1573
- this.runtime.requestRender();
1574
- },
1575
- onSave: ids => {
1576
- try {
1577
- this.backend.persistModelScope(ids);
1578
- }
1579
- catch (error) {
1580
- this.root.appendWorkflowResult({ command: "models", outcome: "failed", message: error instanceof Error ? error.message : String(error) });
1581
- this.runtime.requestRender();
1582
- throw error;
1583
- }
1584
- this.root.appendWorkflowStatus("Model selection saved to settings");
1585
- this.runtime.requestRender();
1586
- },
1587
- onCancel: close,
1588
- });
1589
- const component = {
1590
- ...dialog,
1591
- dispose: () => {
1592
- disposed = true;
1593
- clearTimeout(timeout);
1594
- controller.abort();
1595
- dialog.dispose?.();
1596
- },
1597
- };
1598
- this.root.setInputSurface(component);
1599
- this.runtime.requestRender();
1600
- void this.backend.refreshModels(controller.signal).then(refreshed => {
1601
- if (disposed)
1602
- return;
1603
- component.updateModels(refreshed.models);
1604
- component.setRefreshStatus(timedOut ? "Model refresh timed out; showing cached models." : refreshed.status, timedOut ? "warning" : refreshed.statusKind);
1605
- this.runtime.requestRender();
1606
- }).catch(error => {
1607
- if (disposed)
1608
- return;
1609
- component.setRefreshStatus(timedOut
1610
- ? "Model refresh timed out; showing cached models."
1611
- : `Could not refresh model catalogs: ${error instanceof Error ? error.message : String(error)}`, "warning");
1612
- this.runtime.requestRender();
1613
- }).finally(() => clearTimeout(timeout));
1427
+ this.#presenters.openModelsDialog(this.#dialogHost, initialQuery);
1614
1428
  }
1615
1429
  // Invariant: pointer reporting is disabled on every path that ends the owning screen.
1616
1430
  #setPointerReporting(enabled, forceOff = false) {
@@ -1622,11 +1436,14 @@ export class OwnedUiSessionShell {
1622
1436
  return;
1623
1437
  this.runtime.writeControl(effective ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
1624
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.
1625
1441
  dispose() {
1626
1442
  if (this.#disposePromise !== undefined)
1627
1443
  return this.#disposePromise;
1628
1444
  if (this.#disposed)
1629
1445
  return Promise.resolve();
1446
+ this.#disposed = true;
1630
1447
  const pending = Promise.resolve().then(() => this.#dispose());
1631
1448
  this.#disposePromise = pending;
1632
1449
  const clear = () => { if (this.#disposePromise === pending)
@@ -1635,13 +1452,6 @@ export class OwnedUiSessionShell {
1635
1452
  return pending;
1636
1453
  }
1637
1454
  async #dispose() {
1638
- this.#disposed = true;
1639
- this.#copyIntentSequence += 1;
1640
- // Invariant: the outro frame is what the terminal shows now, before any cleanup writes.
1641
- const outroFrame = this.#captureQuitOutroFrame();
1642
- this.#responseCopy?.dispose();
1643
- this.#copyExecutor?.dispose();
1644
- this.#cancelWaitingImages();
1645
1455
  const failures = [];
1646
1456
  const attempt = (action) => { try {
1647
1457
  action();
@@ -1649,6 +1459,14 @@ export class OwnedUiSessionShell {
1649
1459
  catch (error) {
1650
1460
  failures.push(error);
1651
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();
1652
1470
  let pasteCleanup = Promise.resolve();
1653
1471
  let historyCleanup = Promise.resolve(true);
1654
1472
  attempt(() => { historyCleanup = this.#promptHistory?.close() ?? Promise.resolve(true); });
@@ -1667,10 +1485,11 @@ export class OwnedUiSessionShell {
1667
1485
  attempt(() => this.#unsubscribePromptImages());
1668
1486
  attempt(() => this.#unsubscribeSkills());
1669
1487
  attempt(() => this.#unsubscribeSettings());
1488
+ attempt(() => this.#unsubscribeBackgroundSettings());
1670
1489
  let fullscreenExitText = "";
1671
1490
  attempt(() => {
1672
- const exitMode = this.backend.disposed ? this.#fullscreenExitOutput : this.backend.pinnedSettingsSnapshot().fullscreenExitOutput;
1673
- 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();
1674
1493
  const resumeHint = resume === null ? "" : `${dim("To resume this session:")} ${formatSessionResumeCommand(resume)}`;
1675
1494
  // Invariant: bare A1 leaves only the hint behind; the pinned comparison profile still
1676
1495
  // honors fullscreenExitOutput, including the styled transcript.
@@ -1682,9 +1501,10 @@ export class OwnedUiSessionShell {
1682
1501
  attempt(() => this.#unbindPiSettings());
1683
1502
  attempt(() => this.#unbindTerminalSettings());
1684
1503
  attempt(() => this.#unbindShutdownSettings());
1685
- attempt(() => this.#unsubscribe());
1504
+ attempt(() => this.backend.workflows.setWorkflowInteractionHost(DETACHED_WORKFLOW_INTERACTION));
1686
1505
  attempt(() => this.#dialogHandle?.hide());
1687
1506
  attempt(() => this.#extensionBridge.dispose());
1507
+ attempt(() => this.#programStatus.clear());
1688
1508
  // Invariant: from here to the leave nothing but the outro paints. A throttled frame the
1689
1509
  // renderer still has queued would otherwise land during the stop-time input drain and
1690
1510
  // flash the prompt and footer, whether or not an effect plays.
@@ -1696,7 +1516,7 @@ export class OwnedUiSessionShell {
1696
1516
  await this.runtime.dispose({ preserveScreen: this.runtime.mode === "fullscreen" }).catch(error => failures.push(error));
1697
1517
  await historyCleanup.catch(() => false); // Security: background durability outcomes never enter terminal output.
1698
1518
  await boundedCleanup(() => pasteCleanup).catch(error => failures.push(error));
1699
- await boundedCleanup(() => this.backend.unbindExtensionUi()).catch(error => failures.push(error));
1519
+ await boundedCleanup(() => this.backend.extensions.unbindExtensionUi()).catch(error => failures.push(error));
1700
1520
  if (failures.length > 0)
1701
1521
  throw new AggregateError(failures, "Owned UI disposal failed");
1702
1522
  if (fullscreenExitText.length > 0)
@@ -1715,7 +1535,11 @@ export class OwnedUiSessionShell {
1715
1535
  const viewport = this.runtime.viewport();
1716
1536
  return {
1717
1537
  rows: this.#damageTerminal.presentedRows(), columns: viewport.columns, height: viewport.rows,
1718
- 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
+ },
1719
1543
  };
1720
1544
  }
1721
1545
  catch {
@@ -1740,6 +1564,9 @@ export class OwnedUiSessionShell {
1740
1564
  return;
1741
1565
  await playQuitOutro(frame, capture.settings.effect, capture.settings.durationMs, {
1742
1566
  write: data => this.runtime.writeControl(data),
1567
+ ...(capture.settings.canvasBackgroundAnsi === null
1568
+ ? {}
1569
+ : { canvasBackgroundAnsi: capture.settings.canvasBackgroundAnsi }),
1743
1570
  ...(outro.now === undefined ? {} : { now: outro.now }),
1744
1571
  ...(outro.sleep === undefined ? {} : { sleep: outro.sleep }),
1745
1572
  ...(outro.seed === undefined ? {} : { seed: outro.seed }),
@@ -1778,20 +1605,32 @@ export class OwnedUiSessionShell {
1778
1605
  if (!this.runtime.active)
1779
1606
  return;
1780
1607
  this.runtime.setTerminalProgress(this.#terminalProgressEnabled && view.lifecycle === "busy");
1608
+ if (view.lifecycle === "stopped" || view.lifecycle === "stopping") {
1609
+ this.#programStatus.clear();
1610
+ return;
1611
+ }
1612
+ this.#programStatus.setBlocked("dialog", view.dialog === null ? undefined : {
1613
+ kind: "question",
1614
+ message: view.dialog.title,
1615
+ });
1616
+ if (view.lifecycle === "failed")
1617
+ this.#programStatus.setErrorMessage(view.status.diagnostics.at(-1));
1618
+ this.#programStatus.report();
1781
1619
  }
1782
1620
  #syncView() {
1783
1621
  this.#streamPresentation.noteImmediatePresentation();
1784
1622
  const view = this.view();
1785
- if (this.backend.sessionGeneration !== this.#sessionGeneration) {
1623
+ if (this.backend.identity.sessionGeneration !== this.#sessionGeneration) {
1786
1624
  this.#copyIntentSequence += 1;
1787
1625
  this.#responseCopy?.reset();
1788
1626
  this.#cancelWaitingImages();
1789
1627
  this.root.resetPendingPastes();
1790
1628
  this.#promptSuggestions?.invalidate();
1791
- this.#sessionGeneration = this.backend.sessionGeneration;
1629
+ this.#sessionGeneration = this.backend.identity.sessionGeneration;
1630
+ this.#programStatus.reset();
1792
1631
  // Invariant: delivery recovery invalidates callbacks, not same-session local recall or its draft.
1793
- if (this.#sessionBindingGeneration !== this.backend.sessionBindingGeneration) {
1794
- this.#sessionBindingGeneration = this.backend.sessionBindingGeneration;
1632
+ if (this.#sessionBindingGeneration !== this.backend.identity.sessionBindingGeneration) {
1633
+ this.#sessionBindingGeneration = this.backend.identity.sessionBindingGeneration;
1795
1634
  this.root.resetTranscript();
1796
1635
  this.#promptHistory?.reset(view.transcript.flatMap(block => block.kind === "user" ? [block.text] : []));
1797
1636
  }
@@ -2024,24 +1863,24 @@ export class OwnedUiSessionShell {
2024
1863
  return this.#execute({
2025
1864
  type: this.view().lifecycle === "busy" ? "steer" : "prompt",
2026
1865
  correlationId: this.#correlation("prompt-command"),
2027
- sessionId: this.backend.sessionId,
1866
+ sessionId: this.backend.identity.sessionId,
2028
1867
  text,
2029
1868
  });
2030
1869
  }
2031
1870
  #installAutocompleteCommands() {
2032
- this.root.editor.setAutocompleteCommands(this.backend.workflowAutocompleteCommands());
1871
+ this.root.editor.setAutocompleteCommands(this.backend.workflows.workflowAutocompleteCommands());
2033
1872
  this.#installedCommandSignature = this.#commandListSignature();
2034
1873
  }
2035
1874
  #commandListSignature() {
2036
- 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)]);
2037
1876
  }
2038
1877
  // Invariant: collapse applies only while the engine registers skill commands; otherwise there is nothing to collapse.
2039
1878
  #skillsCollapsed() {
2040
1879
  return this.#skills !== null && this.#skills.presentation() === "collapse"
2041
- && !this.backend.disposed && this.backend.pinnedSettingsSnapshot().enableSkillCommands;
1880
+ && !this.backend.identity.disposed && this.backend.settings.snapshot().enableSkillCommands;
2042
1881
  }
2043
1882
  #skillSummaries() {
2044
- return skillsFromCommands(this.backend.workflowAutocompleteCommands());
1883
+ return skillsFromCommands(this.backend.workflows.workflowAutocompleteCommands());
2045
1884
  }
2046
1885
  // Protocol: bare "/skills" opens the dialog, "/skills <name> [args]" applies directly, and an unknown name is a command outcome.
2047
1886
  async #runSkillsCommand(text, argument) {
@@ -2069,7 +1908,7 @@ export class OwnedUiSessionShell {
2069
1908
  return this.#execute({
2070
1909
  type: this.view().lifecycle === "busy" ? "steer" : "prompt",
2071
1910
  correlationId: this.#correlation("prompt-command"),
2072
- sessionId: this.backend.sessionId,
1911
+ sessionId: this.backend.identity.sessionId,
2073
1912
  text: prompt,
2074
1913
  });
2075
1914
  }
@@ -2124,6 +1963,11 @@ export class OwnedUiSessionShell {
2124
1963
  }, request.providerName);
2125
1964
  this.#activeLoginDialog = dialog;
2126
1965
  this.root.setInputSurface(dialog);
1966
+ this.#programStatus.setBlocked("authentication", {
1967
+ kind: "auth",
1968
+ message: `Log in to ${request.providerName}`,
1969
+ });
1970
+ this.#syncTerminalProgress(this.view());
2127
1971
  this.runtime.requestRender();
2128
1972
  }
2129
1973
  #requestWorkflowInput(request) {
@@ -2180,11 +2024,13 @@ export class OwnedUiSessionShell {
2180
2024
  return;
2181
2025
  this.#activeLoginDialog = undefined;
2182
2026
  this.root.setInputSurface(null);
2027
+ this.#programStatus.setBlocked("authentication", undefined);
2028
+ this.#syncTerminalProgress(this.view());
2183
2029
  this.runtime.requestRender();
2184
2030
  }
2185
2031
  async #execute(command, draft) {
2186
2032
  if (draft === undefined)
2187
- return this.backend.execute(command);
2033
+ return this.backend.session.execute(command);
2188
2034
  const revision = this.#editorRevision;
2189
2035
  try {
2190
2036
  assertOwnedUiCommand(command);
@@ -2193,7 +2039,7 @@ export class OwnedUiSessionShell {
2193
2039
  return this.#recoverSubmission(draft, revision, error);
2194
2040
  }
2195
2041
  try {
2196
- const result = await this.backend.execute(command);
2042
+ const result = await this.backend.session.execute(command);
2197
2043
  if (result.outcome === "rejected")
2198
2044
  return this.#recoverSubmission(draft, revision);
2199
2045
  return result;
@@ -2239,7 +2085,7 @@ export class OwnedUiSessionShell {
2239
2085
  catch { /* Security: error presentation cannot create another rejected submission callback. */ }
2240
2086
  }
2241
2087
  #simple(type) {
2242
- return { type, correlationId: this.#correlation(type), sessionId: this.backend.sessionId };
2088
+ return { type, correlationId: this.#correlation(type), sessionId: this.backend.identity.sessionId };
2243
2089
  }
2244
2090
  #correlation(prefix) {
2245
2091
  this.#sequence += 1;
@@ -2275,14 +2121,6 @@ export function quoteCommandArgument(value) {
2275
2121
  function dim(value) {
2276
2122
  return `\u001b[2m${value}\u001b[22m`;
2277
2123
  }
2278
- function modelReference(model) {
2279
- if (typeof model !== "object" || model === null)
2280
- return "";
2281
- const value = model;
2282
- const provider = typeof value.provider === "string" ? value.provider : "";
2283
- const id = typeof value.id === "string" ? value.id : typeof value.modelId === "string" ? value.modelId : "";
2284
- return provider && id ? `${provider}/${id}` : "";
2285
- }
2286
2124
  function rejected(diagnostic) {
2287
2125
  return { outcome: "rejected", diagnostic };
2288
2126
  }