@timurproko/a1 0.2.7-dev.808 → 0.2.7-dev.809

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 (77) hide show
  1. package/dist/app/session-shell/dialog-coordinator.d.ts +42 -0
  2. package/dist/app/session-shell/dialog-coordinator.js +133 -0
  3. package/dist/app/session-shell/dock-chrome.d.ts +34 -0
  4. package/dist/app/session-shell/dock-chrome.js +109 -0
  5. package/dist/app/session-shell/exit-notice.d.ts +1 -1
  6. package/dist/app/session-shell/exit-notice.js +1 -1
  7. package/dist/app/session-shell/extension-surface.d.ts +30 -0
  8. package/dist/app/session-shell/extension-surface.js +105 -0
  9. package/dist/app/session-shell/input-surface-switch.d.ts +36 -0
  10. package/dist/app/session-shell/input-surface-switch.js +85 -0
  11. package/dist/app/session-shell/login-interaction.d.ts +27 -0
  12. package/dist/app/session-shell/login-interaction.js +91 -0
  13. package/dist/app/session-shell/prompt-submission.d.ts +48 -0
  14. package/dist/app/session-shell/prompt-submission.js +276 -0
  15. package/dist/app/session-shell/resident-tab-coordinator.d.ts +41 -0
  16. package/dist/app/session-shell/resident-tab-coordinator.js +120 -0
  17. package/dist/app/session-shell/session-concern.d.ts +22 -0
  18. package/dist/app/session-shell/session-concern.js +29 -0
  19. package/dist/app/session-shell/session-lifecycle.d.ts +29 -0
  20. package/dist/app/session-shell/session-lifecycle.js +49 -0
  21. package/dist/app/session-shell/session-shell-root.d.ts +1 -1
  22. package/dist/app/session-shell/session-shell-root.js +182 -931
  23. package/dist/app/session-shell/session-shell.d.ts +2 -8
  24. package/dist/app/session-shell/session-shell.js +202 -901
  25. package/dist/app/session-shell/settings-bindings.d.ts +17 -0
  26. package/dist/app/session-shell/settings-bindings.js +91 -0
  27. package/dist/app/session-shell/shell-editor-factory.d.ts +27 -0
  28. package/dist/app/session-shell/shell-editor-factory.js +102 -0
  29. package/dist/app/session-shell/shell-host.d.ts +6 -0
  30. package/dist/app/session-shell/shell-host.js +8 -0
  31. package/dist/app/session-shell/shell-layout.d.ts +64 -0
  32. package/dist/app/session-shell/shell-layout.js +18 -0
  33. package/dist/app/session-shell/suggestion-surface.d.ts +25 -0
  34. package/dist/app/session-shell/suggestion-surface.js +63 -0
  35. package/dist/app/session-shell/transcript-document.d.ts +78 -0
  36. package/dist/app/session-shell/transcript-document.js +365 -0
  37. package/dist/app/session-shell/viewport-layout.d.ts +31 -0
  38. package/dist/app/session-shell/viewport-layout.js +160 -0
  39. package/dist/app/session-shell/workflow-output.d.ts +33 -0
  40. package/dist/app/session-shell/workflow-output.js +174 -0
  41. package/dist/app/session-shell/workflow-router.d.ts +25 -0
  42. package/dist/app/session-shell/workflow-router.js +39 -0
  43. package/dist/composition/owned-ui.d.ts +4 -25
  44. package/dist/composition/owned-ui.js +26 -270
  45. package/dist/composition/resident-tab-bridge.d.ts +16 -0
  46. package/dist/composition/resident-tab-bridge.js +159 -0
  47. package/dist/composition/session-backend.d.ts +19 -0
  48. package/dist/composition/session-backend.js +1 -0
  49. package/dist/composition/session-runtime-registry.d.ts +20 -0
  50. package/dist/composition/session-runtime-registry.js +85 -0
  51. package/dist/composition/startup-route.d.ts +32 -0
  52. package/dist/composition/startup-route.js +72 -0
  53. package/dist/integrations/pi/engine/adapter.d.ts +4 -77
  54. package/dist/integrations/pi/engine/adapter.js +85 -393
  55. package/dist/integrations/pi/engine/command-dispatch.d.ts +13 -0
  56. package/dist/integrations/pi/engine/command-dispatch.js +57 -0
  57. package/dist/integrations/pi/engine/host.js +2 -2
  58. package/dist/integrations/pi/engine/ports.d.ts +56 -0
  59. package/dist/integrations/pi/engine/ports.js +89 -0
  60. package/dist/integrations/pi/engine/view-state.d.ts +42 -0
  61. package/dist/integrations/pi/engine/view-state.js +126 -0
  62. package/dist/native/darwin-arm64/manifest.json +1 -1
  63. package/dist/native/darwin-arm64/terminal-host.manifest.json +3 -3
  64. package/dist/native/darwin-arm64/terminal-host.node +0 -0
  65. package/dist/native/darwin-x64/manifest.json +1 -1
  66. package/dist/native/darwin-x64/terminal-host.manifest.json +3 -3
  67. package/dist/native/darwin-x64/terminal-host.node +0 -0
  68. package/dist/native/linux-x64/manifest.json +1 -1
  69. package/dist/native/linux-x64/terminal-host.manifest.json +3 -3
  70. package/dist/native/linux-x64/terminal-host.node +0 -0
  71. package/dist/native/win32-x64/manifest.json +2 -2
  72. package/dist/native/win32-x64/process-guardian.node +0 -0
  73. package/dist/native/win32-x64/terminal-host.manifest.json +3 -3
  74. package/dist/native/win32-x64/terminal-host.node +0 -0
  75. package/docs/architecture/boundaries.md +14 -0
  76. package/docs/architecture/resident-tabs.md +6 -6
  77. package/package.json +1 -1
@@ -2,18 +2,25 @@ import { ResponseCopyCoordinator } from "./response-copy-coordinator.js";
2
2
  import { createResponseCopyExecutor, responseCopyDestination } from "./response-copy-transport.js";
3
3
  import { MAX_COPY_CONTROL_BYTES } from "./response-copy-protocol.js";
4
4
  import { PromptHistoryController } from "./prompt-history-controller.js";
5
- import { PRODUCT_TEXT } from "../../product-identity.js";
5
+ import { SessionDialogCoordinator } from "./dialog-coordinator.js";
6
+ import { LoginInteraction } from "./login-interaction.js";
7
+ import { PromptSubmission } from "./prompt-submission.js";
8
+ import { ResidentTabCoordinator } from "./resident-tab-coordinator.js";
9
+ import { SessionConcerns } from "./session-concern.js";
10
+ import { composeExitText, SessionStopLatch } from "./session-lifecycle.js";
11
+ import { openWorkflowSelector } from "./workflow-router.js";
12
+ import { bindSessionSettings } from "./settings-bindings.js";
13
+ import { promptSuggestionSurface, routeResponseToSuggestions, suggestionModelKey, suggestionPresentationBlockReason, } from "./suggestion-surface.js";
6
14
  import { CLEAR_EXIT_INTERVAL_MS, boundedCleanup } from "../../foundation/terminal-cleanup/terminal-reset.js";
7
- import { assertOwnedUiCommand, OWNED_UI_HIDDEN_COMMAND_NAMES } from "../../contracts/owned-ui/index.js";
8
- import { assertPromptImages, ImageAttachmentError } from "../../contracts/owned-ui/index.js";
15
+ import { OWNED_UI_HIDDEN_COMMAND_NAMES } from "../../contracts/owned-ui/index.js";
16
+ import { ImageAttachmentError } from "../../contracts/owned-ui/index.js";
9
17
  import { ContextualPromptSuggestionController } from "./prompt-suggestion-controller.js";
10
18
  import { workflowCommandNames } from "../../integrations/pi/engine/workflows.js";
11
19
  import { createPiExtensionUiBridge } from "../../integrations/pi/components/shell-extension-ui.js";
12
20
  import { ProgramStatusReporter } from "../../integrations/pi/components/upstream/program-status-reporter.js";
13
21
  import { SKILLS_COMMAND_NAME, findSkillByArgument, rewriteSkillsTunnelSubmission, skillPrompt, skillsFromCommands, } from "../../integrations/pi/components/skills-command.js";
14
- import { createPiShellAuthProviderSelector, createPiShellDialog, createPiShellExtensionSelector, createPiShellLoginDialog, createPiShellReloadBox, createPiShellSelector, createPiShellSkillsSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, } from "../../integrations/pi/components/shell-selectors-dialogs.js";
15
- import { renderPiShellStatusText, } from "../../integrations/pi/components/shell-presenters-info.js";
16
- import { OwnedUiTerminalHost, sessionTerminalHostOptions, } from "./shell-host.js";
22
+ import { createPiShellAuthProviderSelector, createPiShellExtensionSelector, createPiShellLoginDialog, createPiShellReloadBox, createPiShellSkillsSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, } from "../../integrations/pi/components/shell-selectors-dialogs.js";
23
+ import { OwnedUiTerminalHost, componentRuntime, sessionTerminalHostOptions, } from "./shell-host.js";
17
24
  import { runImageWorker } from "./image-preparation-client.js";
18
25
  import { STREAM_PRESENTATION_INTERVAL_MS, StreamPresentationCoalescer, } from "./stream-presentation-coalescer.js";
19
26
  import { writeSystemClipboardText, } from "./system-clipboard.js";
@@ -22,6 +29,7 @@ import { OwnedUiSessionShellRoot, shellResourceEntries, } from "./session-shell-
22
29
  const DETACHED_WORKFLOW_INTERACTION = { prompt: async () => null, notify() { } };
23
30
  export { OwnedUiSessionShellRoot, } from "./session-shell-root.js";
24
31
  export { OwnedUiTerminalHost, sessionTerminalHostOptions } from "./shell-host.js";
32
+ export { formatSessionResumeCommand, quoteCommandArgument } from "./session-lifecycle.js";
25
33
  /**
26
34
  * Presents one engine session: its transcript root, editor, controllers, submission, workflows, and dialogs.
27
35
  * It reaches the terminal only through its terminal host's handle, and paints only while it is attached.
@@ -33,8 +41,10 @@ export class OwnedUiSessionPresenter {
33
41
  root;
34
42
  #host;
35
43
  #terminal;
36
- #cwd;
37
- #residentTabOptions;
44
+ #resident;
45
+ #submission;
46
+ #login;
47
+ #concerns;
38
48
  #listeners = new Set();
39
49
  #unsubscribe;
40
50
  #unsubscribePromptSuggestions;
@@ -47,19 +57,9 @@ export class OwnedUiSessionPresenter {
47
57
  #promptHistory = null;
48
58
  #promptHistoryImageSidecar;
49
59
  #extensionBridge;
50
- #stopped;
51
- #resolveStopped;
52
- #dialogId;
53
- #dialogSource;
54
- #routeHost;
55
- #startupRoute;
56
- #startupRouteTimer;
57
- #residentProgressTimer;
58
- #residentWorking = false;
59
- #residentStalled = false;
60
- #dialogHandle;
60
+ #stop = new SessionStopLatch();
61
+ #dialogs;
61
62
  #sequence = 0;
62
- #editorRevision = 0;
63
63
  #started = false;
64
64
  #disposed = false;
65
65
  #released;
@@ -81,18 +81,12 @@ export class OwnedUiSessionPresenter {
81
81
  #streamPresentation;
82
82
  #inputCoordination;
83
83
  #unsubscribeSettings;
84
- #unbindPiSettings;
85
- #unbindTerminalSettings;
86
- #unbindShutdownSettings;
84
+ #unbindSessionSettings;
87
85
  #terminalProgressEnabled = false;
88
86
  #programStatus;
89
- #showImages = true;
90
- #imageWidthCells = 80;
91
87
  #fullscreenExitOutput = "transcript";
92
- #waitingImages = new Map();
93
88
  #lastClearTime = 0;
94
89
  #lastEscapeTime = 0;
95
- #activeLoginDialog;
96
90
  #sessionGeneration;
97
91
  #sessionBindingGeneration;
98
92
  #suggestionModelKey;
@@ -106,11 +100,7 @@ export class OwnedUiSessionPresenter {
106
100
  this.#presenters = options.presenters;
107
101
  this.#sessionGeneration = this.backend.identity.sessionGeneration;
108
102
  this.#sessionBindingGeneration = this.backend.identity.sessionBindingGeneration;
109
- this.#suggestionModelKey = modelKey(this.backend.session.view());
110
- this.#cwd = cwd;
111
- this.#residentTabOptions = options.engine.residentTab;
112
- this.#routeHost = routeHost ?? null;
113
- this.#startupRoute = options.engine.startupRoute;
103
+ this.#suggestionModelKey = suggestionModelKey(this.backend.session.view());
114
104
  this.#customViewport = sessionLayout === "custom-viewport";
115
105
  this.#inputCoordination = inputPresentation?.coordination !== false;
116
106
  this.#host = host;
@@ -118,9 +108,6 @@ export class OwnedUiSessionPresenter {
118
108
  // Invariant: the host's optional members follow the layout, so it is built only once the layout is known.
119
109
  this.#dialogHost = this.#createDialogHost();
120
110
  this.#lazySelectors = options.diagnostics?.lazySelectors;
121
- this.#stopped = new Promise(resolve => {
122
- this.#resolveStopped = resolve;
123
- });
124
111
  let promptSuggestionController = null;
125
112
  // Invariant: owned prompt limits and collapsed skills are bare-A1 replacements; comparison profiles keep pinned behavior.
126
113
  this.#promptImages = this.#customViewport ? options.promptImages ?? null : null;
@@ -181,12 +168,12 @@ export class OwnedUiSessionPresenter {
181
168
  replacementSurfaceActive: !this.root.usesDefaultInputSurface(),
182
169
  }),
183
170
  enableDockInputReuse: inputPresentation?.viewportReuse !== false,
184
- footerSessionName: this.#residentTabOptions === undefined,
185
- hideSettingsCommand: this.#residentTabOptions !== undefined,
171
+ footerSessionName: options.engine.residentTab === undefined,
172
+ hideSettingsCommand: options.engine.residentTab !== undefined,
186
173
  persistentHistory: this.#customViewport && promptHistory !== undefined,
187
174
  ...(promptHistory === undefined ? {} : { historyEditor: promptHistory.editor }),
188
- onSubmit: text => { void this.submit(text).catch(() => this.#reportSubmissionError()); },
189
- onPasteRejected: error => this.#reportSubmissionError(error),
175
+ onSubmit: text => { void this.submit(text).catch(() => this.#submission.reportError()); },
176
+ onPasteRejected: error => this.#submission.reportError(error),
190
177
  onInterrupt: () => { void this.interrupt(); },
191
178
  onClear: () => { void this.clearOrExit(); },
192
179
  onExit: () => { void this.shutdown(); },
@@ -198,11 +185,11 @@ export class OwnedUiSessionPresenter {
198
185
  this.#terminal.requestRender();
199
186
  },
200
187
  onMessageCopy: () => { void this.runWorkflow({ command: "copy", argument: "" }); },
201
- onFollowUp: () => { void this.queueFollowUp().catch(() => this.#reportSubmissionError()); },
188
+ onFollowUp: () => { void this.queueFollowUp().catch(() => this.#submission.reportError()); },
202
189
  onDequeue: () => this.restoreQueuedInput(),
203
190
  onEditorChange: text => {
204
- this.#editorRevision++;
205
- this.#residentTabOptions?.checkpoint?.(text);
191
+ this.#submission.noteEditorChange();
192
+ this.#resident.checkpoint(text);
206
193
  promptSuggestionController?.abortPending();
207
194
  if (text.length === 0) {
208
195
  // Concurrency: the editor synchronizes deletion-owned autocomplete after onChange.
@@ -264,9 +251,27 @@ export class OwnedUiSessionPresenter {
264
251
  // Invariant: the presenter owns its root (theme subscription and helper clients) and disposes it once the
265
252
  // terminal no longer renders it.
266
253
  constructed.push(() => this.root.dispose());
267
- this.#restoreResidentInput(options.history?.imageSidecar);
254
+ this.#resident = new ResidentTabCoordinator(options.engine.residentTab, { root: this.root, terminal: this.#terminal, disposed: () => this.#disposed });
255
+ constructed.push(() => this.#resident.dispose());
256
+ const startupRoute = options.engine.startupRoute;
257
+ this.#dialogs = new SessionDialogCoordinator({
258
+ terminal: this.#terminal,
259
+ root: this.root,
260
+ routeHost: routeHost ?? null,
261
+ disposed: () => this.#disposed,
262
+ exit: () => { void this.shutdown(); },
263
+ routeClosed: route => this.#resident.closeOwnedRoute(route),
264
+ ...(startupRoute === undefined ? {} : { startupRoute }),
265
+ });
266
+ constructed.push(() => this.#dialogs.dispose());
268
267
  const initialPiSettings = this.backend.settings.snapshot();
269
268
  this.#programStatus = new ProgramStatusReporter(status => this.#terminal.setProgramStatus(status), () => this.view().status.footer?.sessionName ?? undefined);
269
+ this.#login = new LoginInteraction({
270
+ terminal: this.#terminal, root: this.root, programStatus: this.#programStatus,
271
+ syncProgress: () => this.#syncTerminalProgress(this.view()),
272
+ });
273
+ this.#submission = this.#createSubmission(cwd);
274
+ this.#resident.restoreInput(options.history?.imageSidecar);
270
275
  this.#reloadPresentation = reloadPresentation;
271
276
  const presentationInterval = streamPresentationOptions?.intervalMs ?? STREAM_PRESENTATION_INTERVAL_MS;
272
277
  this.#streamPresentation = streamPresentationOptions?.scheduler === undefined
@@ -278,18 +283,7 @@ export class OwnedUiSessionPresenter {
278
283
  generator: promptSuggestionOptions.generator,
279
284
  enabled: promptSuggestionOptions.enabled(),
280
285
  ...(promptSuggestionOptions.diagnostics === undefined ? {} : { diagnostics: promptSuggestionOptions.diagnostics }),
281
- surface: {
282
- canPresent: identity => this.#promptSuggestionPresentationBlockReason(identity) === null,
283
- presentationBlockReason: identity => this.#promptSuggestionPresentationBlockReason(identity),
284
- present: text => {
285
- if (!this.root.canPresentPromptSuggestion())
286
- return false;
287
- this.root.setPromptSuggestion(text);
288
- return true;
289
- },
290
- clear: () => this.root.setPromptSuggestion(null),
291
- requestRender: () => this.#terminal.requestRender(),
292
- },
286
+ surface: promptSuggestionSurface(this.root, identity => this.#promptSuggestionPresentationBlockReason(identity), () => this.#terminal.requestRender()),
293
287
  });
294
288
  this.#promptSuggestions = promptSuggestionController;
295
289
  constructed.push(() => this.#promptSuggestions?.dispose());
@@ -312,35 +306,7 @@ export class OwnedUiSessionPresenter {
312
306
  this.#terminal.requestRender();
313
307
  });
314
308
  constructed.push(this.#unsubscribeSkills);
315
- this.#terminal.setHardwareCursor(initialPiSettings.showHardwareCursor);
316
- this.#terminal.setClearOnShrink(initialPiSettings.clearOnShrink);
317
- this.#terminalProgressEnabled = initialPiSettings.showTerminalProgress;
318
309
  this.#fullscreenExitOutput = initialPiSettings.fullscreenExitOutput;
319
- // Invariant: bare A1 prints only the resume hint at exit, so the pinned exit-output
320
- // choice is hidden there and cannot be bound; the comparison profile binds and honors it.
321
- this.#unbindShutdownSettings = this.backend.settings.productMode === "bare" ? () => { } : this.backend.settings.bindOwner("shutdown", {
322
- fullscreenExitOutput: { apply() { } },
323
- });
324
- constructed.push(this.#unbindShutdownSettings);
325
- this.#unbindTerminalSettings = this.backend.settings.bindOwner("terminal", {
326
- showHardwareCursor: { apply: value => {
327
- if (typeof value !== "boolean")
328
- throw new TypeError("Hardware cursor setting is invalid");
329
- this.#terminal.setHardwareCursor(value);
330
- } },
331
- clearOnShrink: { apply: value => {
332
- if (typeof value !== "boolean")
333
- throw new TypeError("Clear-on-shrink setting is invalid");
334
- this.#terminal.setClearOnShrink(value);
335
- } },
336
- showTerminalProgress: { apply: value => {
337
- if (typeof value !== "boolean")
338
- throw new TypeError("Terminal progress setting is invalid");
339
- this.#terminalProgressEnabled = value;
340
- this.#syncTerminalProgress(this.view());
341
- } },
342
- });
343
- constructed.push(this.#unbindTerminalSettings);
344
310
  const applyViewportSettings = () => {
345
311
  const snapshot = viewportSettings?.snapshot();
346
312
  this.root.setViewportConfig(snapshot ?? {
@@ -354,67 +320,16 @@ export class OwnedUiSessionPresenter {
354
320
  ? viewportSettings.onChange(settings => this.root.setViewportConfig(settings))
355
321
  : () => { };
356
322
  constructed.push(this.#unsubscribeSettings);
357
- this.root.setEditorPaddingX(initialPiSettings.editorPaddingX);
358
- this.root.setAutocompleteMaxVisible(initialPiSettings.autocompleteMaxVisible);
359
- this.root.setOutputPad(initialPiSettings.outputPad);
360
- this.root.setFullscreenCopyOnSelect(initialPiSettings.fullscreenCopyOnSelect);
361
- this.root.setHideThinkingBlock(initialPiSettings.hideThinkingBlock);
362
- this.root.setMermaidRenderingMode(initialPiSettings.mermaidRenderingMode);
363
- this.#showImages = initialPiSettings.showImages;
364
- this.#imageWidthCells = initialPiSettings.imageWidthCells;
365
- this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
366
- this.#unbindPiSettings = this.backend.settings.bindOwner("shell", {
367
- editorPaddingX: { apply: value => {
368
- if (typeof value !== "number")
369
- throw new TypeError("Editor padding is invalid");
370
- this.root.setEditorPaddingX(value);
371
- } },
372
- autocompleteMaxVisible: { apply: value => {
373
- if (typeof value !== "number")
374
- throw new TypeError("Autocomplete maximum is invalid");
375
- this.root.setAutocompleteMaxVisible(value);
376
- } },
377
- outputPad: { apply: value => {
378
- if (value !== 0 && value !== 1)
379
- throw new TypeError("Output padding is invalid");
380
- this.root.setOutputPad(value);
381
- } },
382
- hideThinkingBlock: { apply: value => {
383
- if (typeof value !== "boolean")
384
- throw new TypeError("Thinking-block visibility is invalid");
385
- this.root.setHideThinkingBlock(value);
386
- } },
387
- mermaidRenderingMode: { apply: value => {
388
- if (value !== "off" && value !== "final" && value !== "streaming")
389
- throw new TypeError("Mermaid mode is invalid");
390
- this.root.setMermaidRenderingMode(value);
391
- } },
392
- showImages: { apply: value => {
393
- if (typeof value !== "boolean")
394
- throw new TypeError("Image visibility is invalid");
395
- this.#showImages = value;
396
- this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
397
- } },
398
- imageWidthCells: { apply: value => {
399
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
400
- throw new TypeError("Image width is invalid");
401
- this.#imageWidthCells = value;
402
- this.root.setImagePresentation(this.#showImages, this.#imageWidthCells);
403
- } },
404
- ...(this.#customViewport ? { fullscreenCopyOnSelect: { apply: (value) => {
405
- if (typeof value !== "boolean")
406
- throw new TypeError("Fullscreen copy-on-select setting is invalid");
407
- this.root.setFullscreenCopyOnSelect(value);
408
- } } } : {
409
- fullscreenWheelScrollLines: { apply: (value) => {
410
- if (value !== "auto" && (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 100)) {
411
- throw new TypeError("Fullscreen wheel-scroll distance is invalid");
412
- }
413
- this.#terminal.setWheelScrollLines(value);
414
- } },
415
- }),
416
- });
417
- constructed.push(this.#unbindPiSettings);
323
+ this.#unbindSessionSettings = bindSessionSettings(this.backend.settings, initialPiSettings, {
324
+ terminal: this.#terminal,
325
+ root: this.root,
326
+ setTerminalProgress: enabled => {
327
+ this.#terminalProgressEnabled = enabled;
328
+ if (this.#terminal.running)
329
+ this.#syncTerminalProgress(this.view());
330
+ },
331
+ }, { customViewport: this.#customViewport });
332
+ constructed.push(this.#unbindSessionSettings);
418
333
  if (this.#customViewport && promptHistory !== undefined) {
419
334
  this.#promptHistoryImageSidecar = promptHistory.imageSidecar;
420
335
  const sidecar = this.#promptHistoryImageSidecar;
@@ -429,54 +344,29 @@ export class OwnedUiSessionPresenter {
429
344
  });
430
345
  constructed.push(() => { void this.#promptHistory?.close().catch(() => false); });
431
346
  }
432
- this.#extensionBridge = createPiExtensionUiBridge({
433
- runtime: {
434
- getColumns: () => this.#terminal.viewport().columns,
435
- getRows: () => this.#terminal.viewport().rows,
436
- requestRender: () => this.#terminal.requestRender(),
437
- },
438
- agentDir: this.backend.identity.agentDir,
439
- setInputSurface: component => this.root.setInputSurface(component, true, "opaque"),
440
- setProgramStatusBlocked: status => this.#programStatus.setBlocked("extension-dialog", status),
441
- showOverlay: (component, overlayOptions) => this.#terminal.showOverlay(component, overlayOptions),
442
- listenInput: handler => this.#terminal.addInputListener(handler),
443
- replaceWidget: (key, component, placement) => this.root.setExtensionWidget(key, component, placement),
444
- replaceHeader: component => this.root.setExtensionHeader(component),
445
- replaceFooter: component => this.root.setExtensionFooter(component),
446
- setStatus: (key, text) => this.root.setExtensionStatus(key, text),
447
- setWorking: (message, visible) => this.root.setExtensionWorking(message, visible),
448
- notify: (message, type) => this.root.addExtensionNotification(message, type),
449
- setTitle: title => this.#terminal.setTitle(title),
450
- getEditorText: () => this.root.editor.getText(),
451
- setEditorText: text => this.root.editor.setText(text),
452
- pasteToEditor: text => this.root.editor.insertText(text),
453
- addAutocompleteProvider: factory => this.root.editor.addAutocompleteProvider(factory),
454
- setCustomEditor: component => this.root.setInputSurface(component, true, "opaque"),
455
- getFooterData: () => this.root.extensionFooterData(),
456
- getToolsExpanded: () => this.root.toolsExpanded,
457
- setToolsExpanded: expanded => this.root.setToolsExpanded(expanded),
458
- });
347
+ this.#extensionBridge = this.#createExtensionBridge();
459
348
  constructed.push(() => this.#extensionBridge.dispose());
349
+ this.#concerns = this.#createConcerns();
460
350
  constructed.push(() => this.backend.workflows.setWorkflowInteractionHost(DETACHED_WORKFLOW_INTERACTION));
461
351
  this.backend.workflows.setWorkflowInteractionHost({
462
- startLogin: request => this.#startWorkflowLogin(request),
463
- prompt: request => this.#requestWorkflowInput(request),
464
- notify: event => this.#notifyWorkflowLogin(event),
352
+ startLogin: request => this.#login.start(request),
353
+ prompt: request => this.#login.requestInput(request),
354
+ notify: event => this.#login.notify(event),
465
355
  publish: message => {
466
356
  this.root.appendWorkflowMessage(message);
467
357
  this.#terminal.requestRender();
468
358
  },
469
- finishLogin: () => this.#finishWorkflowLogin(),
359
+ finishLogin: () => this.#login.finish(),
470
360
  });
471
361
  this.#installAutocompleteCommands();
472
362
  this.#unsubscribe = this.backend.session.onEvent(event => {
473
363
  // Invariant: once disposal begins no event touches the view; only the stop still settles the shell.
474
364
  if (this.#disposed) {
475
365
  if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
476
- this.#settleStoppedLifecycle();
366
+ this.#stop.settleAfter(() => this.dispose());
477
367
  return;
478
368
  }
479
- this.#observeResidentProgress(event);
369
+ this.#resident.observeProgress(event);
480
370
  // Performance: a streamed chunk names one block, and touching only that block is what keeps the
481
371
  // cost of a chunk the same in a long session as in a new one. Everything else
482
372
  // resynchronizes the view, which is cheap next to re-reading the transcript.
@@ -492,28 +382,14 @@ export class OwnedUiSessionPresenter {
492
382
  }
493
383
  if (event.type === "assistant-message-completed") {
494
384
  this.root.noteCompletedAssistantMessage();
495
- const identity = {
496
- sessionId: event.sessionId,
497
- sessionGeneration: event.sessionGeneration,
498
- runSequence: event.runSequence,
499
- responseSequence: event.responseSequence,
500
- model: event.model,
501
- };
502
- if (event.model === null)
503
- this.#promptSuggestions?.skip(identity, "no-model");
504
- else
505
- this.#promptSuggestions?.consider({ ...identity, model: event.model }, !event.successful ? "failed-response"
506
- : event.toolContinuation || event.stopReason === "toolUse" ? "tool-continuation"
507
- : event.stopReason !== "stop" ? "incomplete-response"
508
- : event.assistantMessageCount < 2 ? "early-conversation"
509
- : this.root.promptSuggestionPrepareBlockReason());
385
+ routeResponseToSuggestions(this.#promptSuggestions, event, () => this.root.promptSuggestionPrepareBlockReason());
510
386
  }
511
387
  const semanticOnly = event.type === "agent-run-started" || event.type === "assistant-message-completed";
512
388
  const view = event.type === "transcript-block" && this.#sessionGeneration === this.backend.identity.sessionGeneration
513
389
  ? this.#syncBlock(event.block)
514
390
  : semanticOnly ? this.view() : this.#syncView();
515
391
  this.#syncTerminalProgress(view);
516
- const currentModelKey = modelKey(view);
392
+ const currentModelKey = suggestionModelKey(view);
517
393
  if (this.backend.identity.sessionGeneration !== this.#sessionGeneration || currentModelKey !== this.#suggestionModelKey) {
518
394
  this.#promptSuggestions?.invalidate();
519
395
  this.#suggestionModelKey = currentModelKey;
@@ -522,24 +398,10 @@ export class OwnedUiSessionPresenter {
522
398
  this.#promptSuggestions?.invalidate();
523
399
  }
524
400
  if (event.type === "agent-run-settled") {
525
- const identity = {
526
- sessionId: event.sessionId,
527
- sessionGeneration: event.sessionGeneration,
528
- runSequence: event.runSequence,
529
- responseSequence: event.responseSequence,
530
- model: event.model,
531
- };
532
- if (event.model === null)
533
- this.#promptSuggestions?.skip(identity, "no-model");
534
- else
535
- this.#promptSuggestions?.settle({ ...identity, model: event.model }, !event.successful ? "failed-response"
536
- : event.toolContinuation || event.stopReason === "toolUse" ? "tool-continuation"
537
- : event.stopReason !== "stop" ? "incomplete-response"
538
- : event.assistantMessageCount < 2 ? "early-conversation"
539
- : this.root.promptSuggestionPrepareBlockReason());
401
+ routeResponseToSuggestions(this.#promptSuggestions, event, () => this.root.promptSuggestionPrepareBlockReason());
540
402
  }
541
403
  if (event.type === "session-lifecycle" && event.lifecycle === "stopped")
542
- this.#settleStoppedLifecycle();
404
+ this.#stop.settleAfter(() => this.dispose());
543
405
  });
544
406
  constructed.push(this.#unsubscribe);
545
407
  this.#unbindClipboardWriter = this.#responseCopy === null ? () => { } : this.backend.extensions.bindClipboardWriter(async (text) => {
@@ -552,7 +414,7 @@ export class OwnedUiSessionPresenter {
552
414
  });
553
415
  constructed.push(this.#unbindClipboardWriter);
554
416
  if (this.backend.session.view().lifecycle === "stopped")
555
- this.#resolveStopped?.();
417
+ this.#stop.resolve();
556
418
  }
557
419
  catch (error) {
558
420
  for (const release of constructed.reverse()) {
@@ -645,13 +507,7 @@ export class OwnedUiSessionPresenter {
645
507
  });
646
508
  }
647
509
  #promptSuggestionPresentationBlockReason(identity) {
648
- const view = this.view();
649
- if (this.#disposed)
650
- return "disposed";
651
- if (identity.sessionId !== view.sessionId || identity.sessionGeneration !== this.backend.identity.sessionGeneration
652
- || modelKey(view) !== `${identity.model.providerId}/${identity.model.modelId}`)
653
- return "stale-identity";
654
- return this.root.promptSuggestionPresentationBlockReason();
510
+ return suggestionPresentationBlockReason(identity, { disposed: this.#disposed, view: this.view(), sessionGeneration: this.backend.identity.sessionGeneration }, () => this.root.promptSuggestionPresentationBlockReason());
655
511
  }
656
512
  start() {
657
513
  if (this.#started)
@@ -666,7 +522,7 @@ export class OwnedUiSessionPresenter {
666
522
  setImmediate(() => {
667
523
  if (this.#disposed || !this.#customViewport)
668
524
  return;
669
- this.#presentStartupRoute();
525
+ this.#dialogs.presentStartupRoute();
670
526
  void this.#loadLazySelectors().then(selectors => selectors.prepare()).catch(() => { });
671
527
  this.root.warmPastePreparation();
672
528
  this.#copyExecutor?.warm();
@@ -678,145 +534,10 @@ export class OwnedUiSessionPresenter {
678
534
  return () => this.#listeners.delete(listener);
679
535
  }
680
536
  waitUntilStopped() {
681
- return this.#stopped;
537
+ return this.#stop.stopped;
682
538
  }
683
539
  async submit(text) {
684
- return this.#submitWhenReady(text, () => this.#submit(text));
685
- }
686
- #submitWhenReady(draft, action) {
687
- const previous = this.#waitingImages.get(draft);
688
- if (previous !== undefined)
689
- return previous.result;
690
- if (!this.root.hasPendingPastes(draft))
691
- return this.#guardSubmission(draft, action);
692
- const controller = new AbortController();
693
- const generation = this.backend.identity.sessionGeneration;
694
- const result = this.#guardSubmission(draft, async () => {
695
- await this.root.waitForPromptPastes(draft, controller.signal);
696
- if (controller.signal.aborted || this.#disposed || this.backend.identity.sessionGeneration !== generation)
697
- return rejected("image submission canceled");
698
- return action();
699
- }).finally(() => {
700
- this.#waitingImages.delete(draft);
701
- if (!this.#disposed)
702
- this.#showWaitingImages();
703
- });
704
- this.#waitingImages.set(draft, { controller, result });
705
- if (this.root.editor.getText() === draft)
706
- this.root.editor.setText("");
707
- this.#showWaitingImages();
708
- return result;
709
- }
710
- #showWaitingImages() {
711
- const count = this.#waitingImages.size;
712
- this.root.setExtensionWidget("owned-image-preparation", count === 0 ? null : {
713
- render: width => [...renderPiShellStatusText(`Waiting for images (${count} submission${count === 1 ? "" : "s"}) — Esc cancels; dequeue restores`, width)],
714
- invalidate: () => { },
715
- }, "aboveEditor");
716
- this.#terminal.requestRender();
717
- }
718
- #cancelWaitingImages() {
719
- for (const item of this.#waitingImages.values())
720
- item.controller.abort();
721
- }
722
- #rememberInput(text, kind) {
723
- this.root.editor.addToHistory(text);
724
- if (this.#promptHistory !== null) {
725
- const reusable = this.root.prepareHistoryText(text);
726
- if (reusable.length === 0)
727
- this.#promptHistory.rememberRecovery(text);
728
- else {
729
- this.#persistImageSidecars(text);
730
- this.#promptHistory.capture(reusable, kind, this.#cwd, this.backend.identity.sessionId);
731
- }
732
- }
733
- }
734
- // Rationale: write the image chip payloads referenced by an editor draft to the sidecar
735
- // directory before their history row commits. Missing sidecar dependency = silent skip; the
736
- // recall path treats an absent sidecar the same as a corrupt one.
737
- #persistImageSidecars(text) {
738
- const sidecar = this.#promptHistoryImageSidecar;
739
- if (sidecar === undefined)
740
- return;
741
- for (const record of this.root.imageChipAttachmentsFromEditor(text)) {
742
- sidecar.write(record.id, { tag: record.tag, data: record.image.data, mimeType: record.image.mimeType, savedAt: new Date().toISOString() });
743
- }
744
- }
745
- async #submit(text) {
746
- this.#promptSuggestions?.invalidate();
747
- const displayInput = text.trim();
748
- if (!displayInput)
749
- return { outcome: "completed", diagnostic: null };
750
- if (displayInput.startsWith("/"))
751
- return this.#slashCommand(displayInput);
752
- const prepared = this.root.preparePromptSubmission(displayInput);
753
- assertPromptImages(prepared.images);
754
- const input = prepared.text.trim();
755
- if (input.startsWith("!")) {
756
- const excludeFromContext = input.startsWith("!!");
757
- const command = input.slice(excludeFromContext ? 2 : 1).trim();
758
- if (command) {
759
- this.#rememberInput(displayInput, "bash");
760
- try {
761
- const result = await this.backend.workflows.executeBashWorkflow(command, excludeFromContext);
762
- const workflow = {
763
- command: "debug",
764
- outcome: result.cancelled ? "cancelled" : result.exitCode === 0 || result.exitCode === undefined ? "completed" : "failed",
765
- message: result.cancelled ? "Bash command cancelled" : `Bash exited ${result.exitCode ?? 0}: ${command}`,
766
- ...(result.output ? { detail: result.output } : {}),
767
- };
768
- this.root.appendWorkflowResult(workflow);
769
- this.#terminal.requestRender();
770
- return workflow.outcome === "failed" ? rejected(workflow.message) : { outcome: "completed", diagnostic: null };
771
- }
772
- catch (error) {
773
- const message = `Bash command failed: ${error instanceof Error ? error.message : String(error)}`;
774
- this.root.appendWorkflowResult({ command: "debug", outcome: "failed", message });
775
- this.#terminal.requestRender();
776
- return rejected(message);
777
- }
778
- }
779
- }
780
- // Compatibility: match interactive Pi: input during compaction is queued steering; the engine
781
- // shows it in the pending rows and delivers it when compaction ends.
782
- const type = this.view().lifecycle === "busy" ? "steer" : "prompt";
783
- this.#rememberInput(this.root.omitUnsentPromptImages(displayInput).trim(), type);
784
- this.root.resumeViewportFollowing();
785
- const command = {
786
- type,
787
- correlationId: this.#correlation(type),
788
- sessionId: this.backend.identity.sessionId,
789
- text: input,
790
- ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
791
- };
792
- let submission;
793
- try {
794
- submission = await this.#residentTabOptions?.admit?.({
795
- sessionId: command.sessionId,
796
- sessionFile: this.#currentSessionFile(),
797
- kind: type,
798
- text: input,
799
- });
800
- }
801
- catch {
802
- if (this.root.editor.getText().length === 0)
803
- this.root.editor.setText(displayInput);
804
- const message = "Prompt was not sent because its recovery journal could not be synchronized.";
805
- this.#reportSubmissionError(undefined, message);
806
- return rejected(message);
807
- }
808
- const finishSending = prepared.images.length === 0 ? () => { } : this.root.beginImageSubmissionStatus();
809
- try {
810
- const result = await this.#execute(command, displayInput);
811
- if (submission !== undefined && result.outcome !== "rejected") {
812
- this.#residentTabOptions?.dispatched?.();
813
- await this.#residentTabOptions?.correlated?.(submission.id, command.correlationId).catch(() => undefined);
814
- }
815
- return result;
816
- }
817
- finally {
818
- finishSending();
819
- }
540
+ return this.#submission.submit(text);
820
541
  }
821
542
  async clearOrExit(now = Date.now()) {
822
543
  this.#promptSuggestions?.abortPending();
@@ -829,8 +550,8 @@ export class OwnedUiSessionPresenter {
829
550
  }
830
551
  async interrupt(now = Date.now()) {
831
552
  this.#promptSuggestions?.invalidate();
832
- if (this.#waitingImages.size > 0) {
833
- this.#cancelWaitingImages();
553
+ if (this.#submission.waiting) {
554
+ this.#submission.cancelWaiting();
834
555
  return { outcome: "completed", diagnostic: null };
835
556
  }
836
557
  if (this.view().lifecycle === "busy")
@@ -857,23 +578,23 @@ export class OwnedUiSessionPresenter {
857
578
  return { outcome: "completed", diagnostic: null };
858
579
  }
859
580
  async abort() {
860
- return this.#execute(this.#simple("abort"));
581
+ return this.#submission.execute(this.#simple("abort"));
861
582
  }
862
583
  async retry() {
863
584
  this.#promptSuggestions?.invalidate();
864
- return this.#execute(this.#simple("retry"));
585
+ return this.#submission.execute(this.#simple("retry"));
865
586
  }
866
587
  async compact() {
867
588
  this.#promptSuggestions?.invalidate();
868
- return this.#execute(this.#simple("compact"));
589
+ return this.#submission.execute(this.#simple("compact"));
869
590
  }
870
591
  async newSession() {
871
592
  this.#promptSuggestions?.invalidate();
872
- return this.#execute(this.#simple("new-session"));
593
+ return this.#submission.execute(this.#simple("new-session"));
873
594
  }
874
595
  async resumeSession(sessionPath) {
875
596
  this.#promptSuggestions?.invalidate();
876
- return this.#execute({
597
+ return this.#submission.execute({
877
598
  type: "resume-session",
878
599
  correlationId: this.#correlation("resume"),
879
600
  sessionId: this.backend.identity.sessionId,
@@ -882,7 +603,7 @@ export class OwnedUiSessionPresenter {
882
603
  }
883
604
  async setModel(providerId, modelId) {
884
605
  this.#promptSuggestions?.invalidate();
885
- return this.#execute({
606
+ return this.#submission.execute({
886
607
  type: "set-model",
887
608
  correlationId: this.#correlation("model"),
888
609
  sessionId: this.backend.identity.sessionId,
@@ -890,7 +611,7 @@ export class OwnedUiSessionPresenter {
890
611
  });
891
612
  }
892
613
  async setThinkingLevel(thinkingLevel) {
893
- return this.#execute({
614
+ return this.#submission.execute({
894
615
  type: "set-thinking-level",
895
616
  correlationId: this.#correlation("thinking"),
896
617
  sessionId: this.backend.identity.sessionId,
@@ -909,64 +630,13 @@ export class OwnedUiSessionPresenter {
909
630
  return workflowAdapterResult(result);
910
631
  }
911
632
  async queueFollowUp() {
912
- const draft = this.root.editor.getText();
913
- return this.#submitWhenReady(draft, () => this.#queueFollowUp(draft));
914
- }
915
- async #queueFollowUp(draft) {
916
- const displayInput = draft.trim();
917
- if (!displayInput)
918
- return rejected("nothing to queue");
919
- const prepared = this.root.preparePromptSubmission(displayInput);
920
- assertPromptImages(prepared.images);
921
- const text = prepared.text.trim();
922
- this.#rememberInput(displayInput, "follow-up");
923
- if (this.root.editor.getText() === draft)
924
- this.root.editor.setText("");
925
- this.root.resumeViewportFollowing();
926
- return this.#execute({
927
- type: "follow-up",
928
- correlationId: this.#correlation("follow-up"),
929
- sessionId: this.backend.identity.sessionId,
930
- text,
931
- ...(prepared.images.length === 0 ? {} : { images: prepared.images }),
932
- }, displayInput);
633
+ return this.#submission.queueFollowUp(this.root.editor.getText());
933
634
  }
934
635
  restoreQueuedInput() {
935
- const queued = [...this.#waitingImages.keys(), ...this.backend.workflows.clearQueuedWorkflows()];
936
- this.#cancelWaitingImages();
937
- if (queued.length === 0)
938
- return;
939
- this.root.editor.setText(queued.join("\n"));
940
- this.#terminal.requestRender();
636
+ this.#submission.restoreQueued();
941
637
  }
942
638
  showSelector(title, options, onSelect, onCancel) {
943
- this.#dialogHandle?.hide();
944
- this.#dialogSource = "local";
945
- this.#dialogId = undefined;
946
- const component = createPiShellSelector({
947
- title,
948
- options,
949
- onSelect: id => {
950
- onSelect(id);
951
- this.#dialogHandle?.hide();
952
- this.#dialogHandle = undefined;
953
- this.#dialogSource = undefined;
954
- },
955
- onCancel: () => {
956
- this.#dialogHandle?.hide();
957
- this.#dialogHandle = undefined;
958
- this.#dialogSource = undefined;
959
- onCancel?.();
960
- },
961
- });
962
- const handle = this.#terminal.showOverlay(component, {
963
- width: "70%",
964
- maxHeight: "80%",
965
- anchor: "center",
966
- inputCoordination: "owned",
967
- });
968
- this.#dialogHandle = handle;
969
- return handle;
639
+ return this.#dialogs.showSelector(title, options, onSelect, onCancel);
970
640
  }
971
641
  // Rationale: presenters see the shell only as a dialog host; selectors open in the input dock and run choices as workflows.
972
642
  #createDialogHost() {
@@ -1034,15 +704,12 @@ export class OwnedUiSessionPresenter {
1034
704
  }
1035
705
  /** The Skills dialog: a replacement input like the model selector, applying the chosen skill through the prompt path. */
1036
706
  showSkillsSelector(skills = this.#skillSummaries()) {
1037
- const close = () => {
1038
- this.root.setInputSurface(null);
1039
- this.#terminal.requestRender();
1040
- };
707
+ const close = this.#dialogs.closeInputSurface;
1041
708
  const component = createPiShellSkillsSelector({
1042
709
  skills,
1043
710
  onSelect: name => {
1044
711
  close();
1045
- void this.#submitSkillPrompt(skillPrompt(name), skillPrompt(name)).catch(() => this.#reportSubmissionError());
712
+ void this.#submission.sendSlashPrompt(skillPrompt(name), skillPrompt(name)).catch(() => this.#submission.reportError());
1046
713
  },
1047
714
  onCancel: close,
1048
715
  });
@@ -1059,10 +726,7 @@ export class OwnedUiSessionPresenter {
1059
726
  this.#terminal.requestRender();
1060
727
  return;
1061
728
  }
1062
- const close = () => {
1063
- this.root.setInputSurface(null);
1064
- this.#terminal.requestRender();
1065
- };
729
+ const close = this.#dialogs.closeInputSurface;
1066
730
  const component = createPiShellUserMessageSelector(options, selection => {
1067
731
  close();
1068
732
  void this.runWorkflow({ command: "fork", argument: "", selection });
@@ -1089,10 +753,7 @@ export class OwnedUiSessionPresenter {
1089
753
  this.#terminal.requestRender();
1090
754
  return;
1091
755
  }
1092
- const close = () => {
1093
- this.root.setInputSurface(null);
1094
- this.#terminal.requestRender();
1095
- };
756
+ const close = this.#dialogs.closeInputSurface;
1096
757
  const component = createPiShellAuthProviderSelector("logout", options, selection => {
1097
758
  close();
1098
759
  void this.runWorkflow({ command: "logout", argument: "", selection });
@@ -1112,10 +773,7 @@ export class OwnedUiSessionPresenter {
1112
773
  void this.runWorkflow({ command: "login", argument: "", selection });
1113
774
  return;
1114
775
  }
1115
- const close = () => {
1116
- this.root.setInputSurface(null);
1117
- this.#terminal.requestRender();
1118
- };
776
+ const close = this.#dialogs.closeInputSurface;
1119
777
  const labels = method.options.map(option => option.label);
1120
778
  const component = createPiShellExtensionSelector(method.title, labels, label => {
1121
779
  const selection = method.options.find(option => option.label === label)?.id;
@@ -1133,10 +791,7 @@ export class OwnedUiSessionPresenter {
1133
791
  }, initialSelectedId);
1134
792
  }
1135
793
  showLoginAuthTypeSelector() {
1136
- const close = () => {
1137
- this.root.setInputSurface(null);
1138
- this.#terminal.requestRender();
1139
- };
794
+ const close = this.#dialogs.closeInputSurface;
1140
795
  const labels = ["Sign in with an account", "Sign in with an API key"];
1141
796
  const component = createPiShellExtensionSelector("Select authentication method:", labels, label => {
1142
797
  close();
@@ -1152,10 +807,7 @@ export class OwnedUiSessionPresenter {
1152
807
  this.#terminal.requestRender();
1153
808
  return;
1154
809
  }
1155
- const close = () => {
1156
- this.root.setInputSurface(null);
1157
- this.#terminal.requestRender();
1158
- };
810
+ const close = this.#dialogs.closeInputSurface;
1159
811
  const component = createPiShellAuthProviderSelector("login", options, id => {
1160
812
  close();
1161
813
  void this.runWorkflow({ command: "login", argument: "", selection: id });
@@ -1182,12 +834,11 @@ export class OwnedUiSessionPresenter {
1182
834
  return this.#shutdownPromise;
1183
835
  }
1184
836
  async #shutdown() {
1185
- const residentTab = this.#residentTab();
1186
- if (residentTab !== undefined) {
1187
- const detached = await residentTab.requestDetach().catch(() => false);
1188
- if (detached)
1189
- return { outcome: "completed", diagnostic: null };
1190
- this.root.appendWorkflowStatus(residentTab.unavailableNotice ?? "Press Ctrl+C twice to leave; this agent keeps running.");
837
+ const detached = await this.#resident.detach();
838
+ if (detached === true)
839
+ return { outcome: "completed", diagnostic: null };
840
+ if (detached !== undefined) {
841
+ this.root.appendWorkflowStatus(detached);
1191
842
  this.#terminal.requestRender();
1192
843
  return rejected("resident tab detach is unavailable");
1193
844
  }
@@ -1203,29 +854,7 @@ export class OwnedUiSessionPresenter {
1203
854
  return workflowAdapterResult(result);
1204
855
  }
1205
856
  finally {
1206
- this.#resolveStoppedLifecycle();
1207
- }
1208
- }
1209
- #restoreResidentInput(imageSidecar) {
1210
- const recovered = this.#residentTabOptions?.recovered;
1211
- if (recovered === undefined)
1212
- return;
1213
- const prompts = recovered.prompts
1214
- .map(prompt => this.root.rehydrateHistoryText(prompt.text, id => imageSidecar?.readAttachment(id) ?? null))
1215
- .filter(Boolean);
1216
- if (prompts.length > 0) {
1217
- this.root.editor.setText(prompts[0]);
1218
- if (recovered.draft)
1219
- this.root.editor.addToHistory(recovered.draft);
1220
- for (const additional of prompts.slice(1))
1221
- this.root.editor.addToHistory(additional);
1222
- this.root.appendWorkflowStatus(recovered.draft
1223
- ? `Recovered an unfinished prompt; the prior draft and ${Math.max(0, prompts.length - 1)} additional prompt${prompts.length === 2 ? "" : "s"} are in editor history.`
1224
- : `Recovered an unfinished prompt${prompts.length > 1 ? `; ${prompts.length - 1} additional prompts are in editor history` : ""}.`);
1225
- }
1226
- else if (recovered.draft) {
1227
- this.root.editor.setText(recovered.draft);
1228
- this.root.appendWorkflowStatus("Recovered the editor draft from the previous tab process.");
857
+ this.#stop.resolve();
1229
858
  }
1230
859
  }
1231
860
  #currentSessionFile() {
@@ -1235,48 +864,6 @@ export class OwnedUiSessionPresenter {
1235
864
  const resume = this.backend.identity.currentSessionResumeMetadata();
1236
865
  return resume === null ? null : `${resume.sessionDir}/${resume.sessionId}`;
1237
866
  }
1238
- #residentTab() {
1239
- return this.#residentTabOptions;
1240
- }
1241
- #observeResidentProgress(event) {
1242
- const resident = this.#residentTabOptions;
1243
- const seconds = resident?.stallNoticeSeconds;
1244
- if (resident === undefined || seconds === undefined)
1245
- return;
1246
- if (event.type === "session-lifecycle") {
1247
- this.#residentWorking = event.lifecycle === "busy";
1248
- if (!this.#residentWorking) {
1249
- if (this.#residentProgressTimer !== undefined)
1250
- clearTimeout(this.#residentProgressTimer);
1251
- this.#residentProgressTimer = undefined;
1252
- this.#residentStalled = false;
1253
- resident.reportStatus?.("idle");
1254
- return;
1255
- }
1256
- }
1257
- else if (!this.#residentWorking || ![
1258
- "transcript-block", "session-view", "assistant-message-completed", "agent-run-started",
1259
- ].includes(event.type)) {
1260
- return;
1261
- }
1262
- if (this.#residentStalled) {
1263
- this.#residentStalled = false;
1264
- resident.reportStatus?.("working");
1265
- }
1266
- if (this.#residentProgressTimer !== undefined)
1267
- clearTimeout(this.#residentProgressTimer);
1268
- this.#residentProgressTimer = setTimeout(() => {
1269
- this.#residentProgressTimer = undefined;
1270
- if (this.#disposed || !this.#residentWorking)
1271
- return;
1272
- this.#residentStalled = true;
1273
- resident.reportStatus?.("stalled");
1274
- const minutes = Math.max(1, Math.round(seconds / 60));
1275
- this.root.appendWorkflowStatus(`No progress for ${minutes} min — Esc interrupts · use the tab restart action to restart`);
1276
- this.#terminal.requestRender();
1277
- }, seconds * 1_000);
1278
- this.#residentProgressTimer.unref?.();
1279
- }
1280
867
  async runWorkflow(request) {
1281
868
  if (request.command === "quit")
1282
869
  return this.shutdown();
@@ -1297,70 +884,17 @@ export class OwnedUiSessionPresenter {
1297
884
  if (request.command === "login" && request.selection !== undefined) {
1298
885
  const setup = this.backend.catalog.pinnedAmbientAuthentication(request.selection);
1299
886
  if (setup) {
1300
- const close = () => {
1301
- this.root.setInputSurface(null);
1302
- this.#terminal.requestRender();
1303
- };
1304
- const dialog = createPiShellLoginDialog({
1305
- getColumns: () => this.#terminal.viewport().columns,
1306
- getRows: () => this.#terminal.viewport().rows,
1307
- requestRender: () => this.#terminal.requestRender(),
1308
- }, setup.providerId, close, setup.providerName, setup.title);
887
+ const close = this.#dialogs.closeInputSurface;
888
+ const dialog = createPiShellLoginDialog(componentRuntime(this.#terminal), setup.providerId, close, setup.providerName, setup.title);
1309
889
  dialog.showInfo(setup.message, [], true);
1310
890
  this.root.setInputSurface(dialog);
1311
891
  this.#terminal.requestRender();
1312
892
  return { outcome: "completed", diagnostic: null };
1313
893
  }
1314
894
  }
1315
- if (request.command === "scoped-models" && request.selection === undefined && request.confirmed === undefined) {
1316
- this.showScopedModelsSelector();
1317
- return { outcome: "completed", diagnostic: null };
1318
- }
1319
- if (request.command === "models" && request.selection === undefined) {
1320
- this.showModelsDialog(request.argument.trim() || undefined);
1321
- return { outcome: "completed", diagnostic: null };
1322
- }
1323
- if (request.command === "model" && request.selection === undefined && request.confirmed === undefined && request.argument.trim().length === 0) {
1324
- this.showModelSelector();
1325
- return { outcome: "completed", diagnostic: null };
1326
- }
1327
- if (request.command === "fork" && request.selection === undefined && request.confirmed === undefined) {
1328
- this.showForkSelector();
1329
- return { outcome: "completed", diagnostic: null };
1330
- }
1331
- if (request.command === "thinking" && request.selection === undefined && request.argument.trim().length === 0) {
1332
- await this.showThinkingSelector();
1333
- return { outcome: "completed", diagnostic: null };
1334
- }
1335
- if (request.command === "trust" && request.selection === undefined && request.confirmed === undefined) {
1336
- this.showTrustSelector();
1337
- return { outcome: "completed", diagnostic: null };
1338
- }
1339
- if (request.command === "settings" && request.selection === undefined && request.confirmed === undefined) {
1340
- if (this.#residentTabOptions !== undefined)
1341
- return rejected("Settings is available from the global Settings tab.");
1342
- this.showSettingsSelector();
1343
- return { outcome: "completed", diagnostic: null };
1344
- }
1345
- if (request.command === "resume" && request.selection === undefined && request.confirmed === undefined && request.argument.trim().length === 0) {
1346
- await this.showSessionSelector();
1347
- return { outcome: "completed", diagnostic: null };
1348
- }
1349
- if (request.command === "login" && request.selection === undefined && request.confirmed === undefined) {
1350
- if (request.argument.trim().length === 0)
1351
- this.showLoginAuthTypeSelector();
1352
- else
1353
- this.showLoginMethodSelector(request.argument);
1354
- return { outcome: "completed", diagnostic: null };
1355
- }
1356
- if (request.command === "logout" && request.selection === undefined && request.confirmed === undefined) {
1357
- await this.showLogoutSelector();
1358
- return { outcome: "completed", diagnostic: null };
1359
- }
1360
- if (request.command === "tree" && request.selection === undefined && request.confirmed === undefined && request.argument.trim().length === 0) {
1361
- await this.showTreeSelector();
1362
- return { outcome: "completed", diagnostic: null };
1363
- }
895
+ const selected = openWorkflowSelector(request, this, { residentTab: this.#resident.active });
896
+ if (selected !== undefined)
897
+ return selected;
1364
898
  if (request.command === "reload") {
1365
899
  const blocked = this.backend.workflows.reloadBlockedResult();
1366
900
  if (blocked) {
@@ -1374,11 +908,7 @@ export class OwnedUiSessionPresenter {
1374
908
  ? await import("./share-operation.js")
1375
909
  : undefined;
1376
910
  const shareSurface = shareDialog === undefined ? undefined
1377
- : (this.#customViewport ? shareDialog.createPiShellShareOperationDialog : shareDialog.createPiShellOperationLoader)({
1378
- getColumns: () => this.#terminal.viewport().columns,
1379
- getRows: () => this.#terminal.viewport().rows,
1380
- requestRender: () => this.#terminal.requestRender(),
1381
- }, "Creating gist…");
911
+ : (this.#customViewport ? shareDialog.createPiShellShareOperationDialog : shareDialog.createPiShellOperationLoader)(componentRuntime(this.#terminal), "Creating gist…");
1382
912
  const operationSurface = shareSurface ?? (request.command === "reload" ? createPiShellReloadBox() : undefined);
1383
913
  const now = this.#reloadPresentation?.now ?? Date.now;
1384
914
  const shownAt = now();
@@ -1462,10 +992,7 @@ export class OwnedUiSessionPresenter {
1462
992
  }
1463
993
  showTrustSelector() {
1464
994
  const context = this.backend.catalog.pinnedProjectTrustContext();
1465
- const close = () => {
1466
- this.root.setInputSurface(null);
1467
- this.#terminal.requestRender();
1468
- };
995
+ const close = this.#dialogs.closeInputSurface;
1469
996
  const component = createPiShellTrustSelector({
1470
997
  ...context,
1471
998
  onSelect: selection => {
@@ -1529,21 +1056,13 @@ export class OwnedUiSessionPresenter {
1529
1056
  this.#copyIntentSequence += 1;
1530
1057
  this.#responseCopy?.dispose();
1531
1058
  this.#copyExecutor?.dispose();
1532
- this.#cancelWaitingImages();
1059
+ this.#submission.cancelWaiting();
1533
1060
  let pasteCleanup = Promise.resolve();
1534
1061
  let historyCleanup = Promise.resolve(true);
1535
1062
  attempt(() => { historyCleanup = this.#promptHistory?.close() ?? Promise.resolve(true); });
1536
1063
  attempt(() => { pasteCleanup = this.root.disposePendingPastes(); });
1537
1064
  attempt(() => this.root.clearViewportPointerState());
1538
1065
  attempt(() => this.#streamPresentation.dispose());
1539
- attempt(() => {
1540
- if (this.#startupRouteTimer !== undefined)
1541
- clearTimeout(this.#startupRouteTimer);
1542
- this.#startupRouteTimer = undefined;
1543
- if (this.#residentProgressTimer !== undefined)
1544
- clearTimeout(this.#residentProgressTimer);
1545
- this.#residentProgressTimer = undefined;
1546
- });
1547
1066
  attempt(() => this.#promptSuggestions?.dispose());
1548
1067
  attempt(() => this.#unsubscribePromptSuggestions());
1549
1068
  attempt(() => this.#unsubscribePromptImages());
@@ -1551,21 +1070,18 @@ export class OwnedUiSessionPresenter {
1551
1070
  attempt(() => this.#unsubscribeSettings());
1552
1071
  let exitText = "";
1553
1072
  attempt(() => {
1554
- const exitMode = this.backend.identity.disposed ? this.#fullscreenExitOutput : this.backend.settings.snapshot().fullscreenExitOutput;
1555
- const resume = this.backend.identity.currentSessionResumeMetadata();
1556
- const resumeHint = resume === null ? "" : `${dim("To resume this session:")} ${formatSessionResumeCommand(resume)}`;
1557
- // Invariant: bare A1 leaves only the hint behind; the pinned comparison profile still
1558
- // honors fullscreenExitOutput, including the styled transcript.
1559
- exitText = terminal.mode !== "fullscreen" ? ""
1560
- : this.#customViewport || exitMode === "resume-hint" ? resumeHint
1561
- : [this.root.exitTranscript(terminal.columns), resumeHint].filter(Boolean).join("\n\n");
1073
+ exitText = composeExitText({
1074
+ terminalMode: terminal.mode,
1075
+ customViewport: this.#customViewport,
1076
+ exitMode: this.backend.identity.disposed ? this.#fullscreenExitOutput : this.backend.settings.snapshot().fullscreenExitOutput,
1077
+ resume: this.backend.identity.currentSessionResumeMetadata(),
1078
+ transcript: () => this.root.exitTranscript(terminal.columns),
1079
+ });
1562
1080
  });
1563
1081
  attempt(() => this.#unbindClipboardWriter());
1564
- attempt(() => this.#unbindPiSettings());
1565
- attempt(() => this.#unbindTerminalSettings());
1566
- attempt(() => this.#unbindShutdownSettings());
1082
+ attempt(() => this.#unbindSessionSettings());
1567
1083
  attempt(() => this.backend.workflows.setWorkflowInteractionHost(DETACHED_WORKFLOW_INTERACTION));
1568
- attempt(() => this.#dialogHandle?.hide());
1084
+ this.#concerns.dispose(attempt);
1569
1085
  attempt(() => this.#extensionBridge.dispose());
1570
1086
  attempt(() => this.#programStatus.clear());
1571
1087
  // Invariant: the host settles a presenter only once the terminal is restored, so these never stall it.
@@ -1579,7 +1095,7 @@ export class OwnedUiSessionPresenter {
1579
1095
  }
1580
1096
  await historyCleanup.catch(() => false); // Security: background durability outcomes never enter terminal output.
1581
1097
  await boundedCleanup(() => pasteCleanup).catch(error => settled.push(error));
1582
- await boundedCleanup(() => this.#residentTabOptions?.gracefulStop?.(this.#currentSessionFile()) ?? Promise.resolve())
1098
+ await boundedCleanup(() => this.#resident.gracefulStop(this.#currentSessionFile()))
1583
1099
  .catch(error => settled.push(error));
1584
1100
  await boundedCleanup(() => this.backend.extensions.unbindExtensionUi()).catch(error => settled.push(error));
1585
1101
  return settled;
@@ -1587,14 +1103,6 @@ export class OwnedUiSessionPresenter {
1587
1103
  this.#released = { exitText, failures, settle };
1588
1104
  return this.#released;
1589
1105
  }
1590
- #settleStoppedLifecycle() {
1591
- void this.dispose().then(() => this.#resolveStoppedLifecycle(), () => this.#resolveStoppedLifecycle());
1592
- }
1593
- #resolveStoppedLifecycle() {
1594
- const resolve = this.#resolveStopped;
1595
- this.#resolveStopped = undefined;
1596
- resolve?.();
1597
- }
1598
1106
  // Invariant: incremental block updates notify listeners with the same complete view contract.
1599
1107
  #syncBlock(block) {
1600
1108
  this.root.applyTranscriptBlock(block);
@@ -1612,6 +1120,77 @@ export class OwnedUiSessionPresenter {
1612
1120
  listener(view);
1613
1121
  return view;
1614
1122
  }
1123
+ // Rationale: extensions reach the shell only through this bridge; every surface they draw lands in the root.
1124
+ #createExtensionBridge() {
1125
+ return createPiExtensionUiBridge({
1126
+ runtime: componentRuntime(this.#terminal),
1127
+ agentDir: this.backend.identity.agentDir,
1128
+ setInputSurface: component => this.root.setInputSurface(component, true, "opaque"),
1129
+ setProgramStatusBlocked: status => this.#programStatus.setBlocked("extension-dialog", status),
1130
+ showOverlay: (component, overlayOptions) => this.#terminal.showOverlay(component, overlayOptions),
1131
+ listenInput: handler => this.#terminal.addInputListener(handler),
1132
+ replaceWidget: (key, component, placement) => this.root.setExtensionWidget(key, component, placement),
1133
+ replaceHeader: component => this.root.setExtensionHeader(component),
1134
+ replaceFooter: component => this.root.setExtensionFooter(component),
1135
+ setStatus: (key, text) => this.root.setExtensionStatus(key, text),
1136
+ setWorking: (message, visible) => this.root.setExtensionWorking(message, visible),
1137
+ notify: (message, type) => this.root.addExtensionNotification(message, type),
1138
+ setTitle: title => this.#terminal.setTitle(title),
1139
+ getEditorText: () => this.root.editor.getText(),
1140
+ setEditorText: text => this.root.editor.setText(text),
1141
+ pasteToEditor: text => this.root.editor.insertText(text),
1142
+ addAutocompleteProvider: factory => this.root.editor.addAutocompleteProvider(factory),
1143
+ setCustomEditor: component => this.root.setInputSurface(component, true, "opaque"),
1144
+ getFooterData: () => this.root.extensionFooterData(),
1145
+ getToolsExpanded: () => this.root.toolsExpanded,
1146
+ setToolsExpanded: expanded => this.root.setToolsExpanded(expanded),
1147
+ });
1148
+ }
1149
+ #createSubmission(cwd) {
1150
+ return new PromptSubmission({
1151
+ backend: this.backend,
1152
+ root: this.root,
1153
+ terminal: this.#terminal,
1154
+ residentTab: this.#resident,
1155
+ cwd,
1156
+ promptHistory: () => this.#promptHistory,
1157
+ imageSidecar: () => this.#promptHistoryImageSidecar,
1158
+ disposed: () => this.#disposed,
1159
+ correlation: prefix => this.#correlation(prefix),
1160
+ lifecycle: () => this.view().lifecycle,
1161
+ currentSessionFile: () => this.#currentSessionFile(),
1162
+ slashCommand: text => this.#slashCommand(text),
1163
+ invalidateSuggestions: () => this.#promptSuggestions?.invalidate(),
1164
+ });
1165
+ }
1166
+ // Invariant: the order is the reset order on session replacement; copy intent before pastes before the input surface.
1167
+ #createConcerns() {
1168
+ return new SessionConcerns([
1169
+ { onSessionReplaced: () => { this.#copyIntentSequence += 1; this.#responseCopy?.reset(); } },
1170
+ this.#submission,
1171
+ { onSessionReplaced: () => this.root.resetPendingPastes() },
1172
+ { onSessionReplaced: () => this.#promptSuggestions?.invalidate() },
1173
+ { onSessionReplaced: () => { this.#sessionGeneration = this.backend.identity.sessionGeneration; this.#programStatus.reset(); } },
1174
+ { onSessionReplaced: () => this.#rebindTranscript() },
1175
+ this.#login,
1176
+ { onSessionReplaced: () => this.#extensionBridge.reset() },
1177
+ // Invariant: a replaced session takes its transient viewport and owned-route state with it.
1178
+ { onSessionReplaced: () => this.root.resetViewport() },
1179
+ this.#dialogs,
1180
+ { onSessionReplaced: () => this.root.setInputSurface(null) },
1181
+ { onSessionReplaced: () => this.root.resetExtensionUi() },
1182
+ { onSessionReplaced: () => this.root.resetWorkflowPresentation() },
1183
+ this.#resident,
1184
+ ]);
1185
+ }
1186
+ // Invariant: delivery recovery invalidates callbacks, not same-session local recall or its draft.
1187
+ #rebindTranscript() {
1188
+ if (this.#sessionBindingGeneration === this.backend.identity.sessionBindingGeneration)
1189
+ return;
1190
+ this.#sessionBindingGeneration = this.backend.identity.sessionBindingGeneration;
1191
+ this.root.resetTranscript();
1192
+ this.#promptHistory?.reset(this.view().transcript.flatMap(block => block.kind === "user" ? [block.text] : []));
1193
+ }
1615
1194
  #syncTerminalProgress(view) {
1616
1195
  if (!this.#terminal.running)
1617
1196
  return;
@@ -1631,34 +1210,10 @@ export class OwnedUiSessionPresenter {
1631
1210
  #syncView() {
1632
1211
  this.#streamPresentation.noteImmediatePresentation();
1633
1212
  const view = this.view();
1634
- if (this.backend.identity.sessionGeneration !== this.#sessionGeneration) {
1635
- this.#copyIntentSequence += 1;
1636
- this.#responseCopy?.reset();
1637
- this.#cancelWaitingImages();
1638
- this.root.resetPendingPastes();
1639
- this.#promptSuggestions?.invalidate();
1640
- this.#sessionGeneration = this.backend.identity.sessionGeneration;
1641
- this.#programStatus.reset();
1642
- // Invariant: delivery recovery invalidates callbacks, not same-session local recall or its draft.
1643
- if (this.#sessionBindingGeneration !== this.backend.identity.sessionBindingGeneration) {
1644
- this.#sessionBindingGeneration = this.backend.identity.sessionBindingGeneration;
1645
- this.root.resetTranscript();
1646
- this.#promptHistory?.reset(view.transcript.flatMap(block => block.kind === "user" ? [block.text] : []));
1647
- }
1648
- this.#activeLoginDialog = undefined;
1649
- this.#extensionBridge.reset();
1650
- // Invariant: a replaced session takes its transient viewport and owned-route state with it.
1651
- this.root.resetViewport();
1652
- this.#dialogHandle?.hide();
1653
- this.#dialogHandle = undefined;
1654
- this.#dialogId = undefined;
1655
- this.#dialogSource = undefined;
1656
- this.root.setInputSurface(null);
1657
- this.root.resetExtensionUi();
1658
- this.root.resetWorkflowPresentation();
1659
- }
1213
+ if (this.backend.identity.sessionGeneration !== this.#sessionGeneration)
1214
+ this.#concerns.replaceSession();
1660
1215
  this.root.update(view);
1661
- this.#syncDialog(view.dialog);
1216
+ this.#dialogs.syncBackend(view.dialog);
1662
1217
  this.#presentStartupTrustWarning(view);
1663
1218
  this.#terminal.requestRender();
1664
1219
  for (const listener of this.#listeners)
@@ -1676,119 +1231,34 @@ export class OwnedUiSessionPresenter {
1676
1231
  this.#startupTrustHandled = true;
1677
1232
  this.root.appendWorkflowMessage({ kind: "warning", message: diagnostic.message });
1678
1233
  }
1679
- #presentStartupRoute() {
1680
- const pending = this.#startupRoute;
1681
- if (pending === undefined || this.#disposed || !this.#terminal.running)
1682
- return;
1683
- if (this.#terminal.hasOverlay() || !this.root.usesDefaultInputSurface()) {
1684
- this.#startupRouteTimer = setTimeout(() => {
1685
- this.#startupRouteTimer = undefined;
1686
- this.#presentStartupRoute();
1687
- }, 25);
1688
- this.#startupRouteTimer.unref?.();
1689
- return;
1690
- }
1691
- this.#startupRoute = undefined;
1692
- this.#openOwnedRoute(pending.route, pending.input, pending.onClosed);
1693
- }
1694
- #openOwnedRoute(route, input, onClosed) {
1695
- const surface = this.#routeHost?.open(route, input) ?? null;
1696
- if (surface === null)
1697
- return { outcome: "failed", diagnostic: `route is unavailable: ${route}` };
1698
- if (!this.#terminal.running)
1699
- return { outcome: "failed", diagnostic: "runtime is not active" };
1700
- this.root.dismissNotice();
1701
- this.#dialogHandle?.hide();
1702
- this.#dialogSource = "route";
1703
- const handle = this.#terminal.openRoute(surface, {
1704
- exit: () => { void this.shutdown(); },
1705
- closed: rendered => {
1706
- if (this.#dialogHandle === handle) {
1707
- this.#dialogHandle = undefined;
1708
- this.#dialogId = undefined;
1709
- this.#dialogSource = undefined;
1710
- }
1711
- if (rendered) {
1712
- try {
1713
- void onClosed?.()?.catch(() => undefined);
1714
- }
1715
- catch { /* Rationale: a failed acknowledgement stays pending. */ }
1716
- this.#residentTabOptions?.closeOwnedRoute?.(route);
1717
- }
1718
- },
1719
- });
1720
- this.#dialogHandle = handle;
1721
- this.#dialogId = surface.id;
1722
- return { outcome: "completed", diagnostic: null };
1723
- }
1724
- #syncDialog(dialog) {
1725
- if (!this.#terminal.running)
1726
- return;
1727
- if (dialog === null) {
1728
- // Invariant: locally owned routes (notably /settings) are independent of backend
1729
- // lifecycle/status events and remain open while an agent is working.
1730
- if (this.#dialogSource !== "backend")
1731
- return;
1732
- this.#dialogHandle?.hide();
1733
- this.#dialogHandle = undefined;
1734
- this.#dialogId = undefined;
1735
- this.#dialogSource = undefined;
1736
- return;
1737
- }
1738
- if (this.#dialogSource === "backend" && this.#dialogId === dialog.id)
1739
- return;
1740
- this.#dialogHandle?.hide();
1741
- this.#dialogSource = "backend";
1742
- const component = createPiShellDialog(dialog, {
1743
- onSelect: () => {
1744
- this.#dialogHandle?.hide();
1745
- this.#dialogHandle = undefined;
1746
- this.#dialogId = undefined;
1747
- this.#dialogSource = undefined;
1748
- },
1749
- onCancel: () => {
1750
- this.#dialogHandle?.hide();
1751
- this.#dialogHandle = undefined;
1752
- this.#dialogId = undefined;
1753
- this.#dialogSource = undefined;
1754
- },
1755
- });
1756
- this.#dialogHandle = this.#terminal.showOverlay(component, {
1757
- width: "70%",
1758
- maxHeight: "80%",
1759
- anchor: "center",
1760
- inputCoordination: "owned",
1761
- });
1762
- this.#dialogId = dialog.id;
1763
- }
1764
1234
  openOwnedRoute(route) {
1765
1235
  if (this.#disposed)
1766
1236
  return;
1767
1237
  // Invariant: a route requested before the runtime runs (the resident Settings bridge route arrives
1768
1238
  // before start()) is retained, latest wins, and presented through the startup-route path once started.
1769
1239
  if (!this.#terminal.running) {
1770
- this.#startupRoute = { route };
1240
+ this.#dialogs.retainStartupRoute(route);
1771
1241
  return;
1772
1242
  }
1773
- const result = this.#openOwnedRoute(route);
1243
+ const result = this.#dialogs.openRoute(route);
1774
1244
  if (result.outcome !== "completed" && result.diagnostic !== null) {
1775
1245
  this.root.appendWorkflowMessage({ kind: "warning", message: result.diagnostic });
1776
1246
  }
1777
1247
  }
1778
1248
  hotkeysPresentation() {
1779
1249
  const presentation = this.root.hotkeysPresentation();
1780
- return this.#residentTabOptions === undefined ? presentation : { ...presentation, residentTabs: true };
1250
+ return this.#resident.active ? { ...presentation, residentTabs: true } : presentation;
1781
1251
  }
1782
1252
  async #slashCommand(text) {
1783
1253
  const body = text.slice(1).trim();
1784
1254
  const separator = body.search(/\s/);
1785
1255
  const name = separator < 0 ? body : body.slice(0, separator);
1786
1256
  const argument = separator < 0 ? "" : body.slice(separator + 1).trimStart();
1787
- if (name === "settings" && this.#residentTabOptions !== undefined) {
1257
+ if (name === "settings" && this.#resident.active) {
1788
1258
  return rejected("Settings is available from the global Settings tab.");
1789
1259
  }
1790
- if (this.#routeHost?.claims(name))
1791
- return this.#openOwnedRoute(name);
1260
+ if (this.#dialogs.claimsRoute(name))
1261
+ return this.#dialogs.openRoute(name);
1792
1262
  if (this.#isWorkflowRoute(name))
1793
1263
  return this.runWorkflow({ command: name, argument });
1794
1264
  if (this.#skillsCollapsed()) {
@@ -1796,17 +1266,10 @@ export class OwnedUiSessionPresenter {
1796
1266
  return this.#runSkillsCommand(text, argument);
1797
1267
  const rewritten = rewriteSkillsTunnelSubmission(text);
1798
1268
  if (rewritten !== text)
1799
- return this.#submitSkillPrompt(rewritten, text);
1269
+ return this.#submission.sendSlashPrompt(rewritten, text);
1800
1270
  }
1801
1271
  // Compatibility: unknown slash input, prompt templates, skills, and extension commands remain Pi prompt input.
1802
- this.#rememberInput(text, "slash");
1803
- this.root.resumeViewportFollowing();
1804
- return this.#execute({
1805
- type: this.view().lifecycle === "busy" ? "steer" : "prompt",
1806
- correlationId: this.#correlation("prompt-command"),
1807
- sessionId: this.backend.identity.sessionId,
1808
- text,
1809
- });
1272
+ return this.#submission.sendSlashPrompt(text);
1810
1273
  }
1811
1274
  #installAutocompleteCommands() {
1812
1275
  this.root.editor.setAutocompleteCommands(this.backend.workflows.workflowAutocompleteCommands());
@@ -1840,18 +1303,7 @@ export class OwnedUiSessionPresenter {
1840
1303
  this.#terminal.requestRender();
1841
1304
  return { outcome: "failed", diagnostic: message };
1842
1305
  }
1843
- return this.#submitSkillPrompt(skillPrompt(skill.name, separator < 0 ? "" : trimmed.slice(separator + 1)), text);
1844
- }
1845
- // Rationale: engine expands skill arguments; history retains typed input for recall.
1846
- #submitSkillPrompt(prompt, typed) {
1847
- this.#rememberInput(typed, "slash");
1848
- this.root.resumeViewportFollowing();
1849
- return this.#execute({
1850
- type: this.view().lifecycle === "busy" ? "steer" : "prompt",
1851
- correlationId: this.#correlation("prompt-command"),
1852
- sessionId: this.backend.identity.sessionId,
1853
- text: prompt,
1854
- });
1306
+ return this.#submission.sendSlashPrompt(skillPrompt(skill.name, separator < 0 ? "" : trimmed.slice(separator + 1)), text);
1855
1307
  }
1856
1308
  #treeSummary() {
1857
1309
  return new Promise(resolve => {
@@ -1892,139 +1344,6 @@ export class OwnedUiSessionPresenter {
1892
1344
  if (result.diagnostic === "Branch summarization cancelled")
1893
1345
  await this.showTreeSelector(entryId);
1894
1346
  }
1895
- #startWorkflowLogin(request) {
1896
- this.#finishWorkflowLogin();
1897
- const dialog = createPiShellLoginDialog({
1898
- getColumns: () => this.#terminal.viewport().columns,
1899
- getRows: () => this.#terminal.viewport().rows,
1900
- requestRender: () => this.#terminal.requestRender(),
1901
- }, request.providerId, success => {
1902
- if (!success)
1903
- this.#finishWorkflowLogin();
1904
- }, request.providerName);
1905
- this.#activeLoginDialog = dialog;
1906
- this.root.setInputSurface(dialog);
1907
- this.#programStatus.setBlocked("authentication", {
1908
- kind: "auth",
1909
- message: `Log in to ${request.providerName}`,
1910
- });
1911
- this.#syncTerminalProgress(this.view());
1912
- this.#terminal.requestRender();
1913
- }
1914
- #requestWorkflowInput(request) {
1915
- const dialog = this.#activeLoginDialog;
1916
- if (!dialog)
1917
- return Promise.resolve(null);
1918
- if (request.type === "select") {
1919
- return new Promise(resolve => {
1920
- const options = request.options ?? [];
1921
- const labels = options.map(option => option.label);
1922
- const restoreDialog = () => {
1923
- if (this.#activeLoginDialog === dialog)
1924
- this.root.setInputSurface(dialog);
1925
- this.#terminal.requestRender();
1926
- };
1927
- const selector = createPiShellExtensionSelector(request.message, labels, label => {
1928
- const id = options.find(option => option.label === label)?.id;
1929
- restoreDialog();
1930
- resolve(id ?? null);
1931
- }, () => {
1932
- restoreDialog();
1933
- resolve(null);
1934
- });
1935
- this.root.setInputSurface(selector, false);
1936
- this.#terminal.requestRender();
1937
- });
1938
- }
1939
- const response = request.type === "manual-code"
1940
- ? dialog.showManualInput(request.message)
1941
- : dialog.showPrompt(request.message, request.placeholder);
1942
- this.#terminal.requestRender();
1943
- return response.then(value => value, () => null);
1944
- }
1945
- #notifyWorkflowLogin(event) {
1946
- const dialog = this.#activeLoginDialog;
1947
- if (!dialog)
1948
- return;
1949
- if (event.type === "auth_url")
1950
- dialog.showAuth(event.url, event.instructions);
1951
- else if (event.type === "device_code") {
1952
- dialog.showDeviceCode(event);
1953
- dialog.showWaiting("Waiting for authentication...");
1954
- }
1955
- else if (event.type === "info")
1956
- dialog.showInfo(event.message, event.links);
1957
- else if (event.type === "waiting")
1958
- dialog.showWaiting(event.message);
1959
- else
1960
- dialog.showProgress(event.message);
1961
- this.#terminal.requestRender();
1962
- }
1963
- #finishWorkflowLogin() {
1964
- if (!this.#activeLoginDialog)
1965
- return;
1966
- this.#activeLoginDialog = undefined;
1967
- this.root.setInputSurface(null);
1968
- this.#programStatus.setBlocked("authentication", undefined);
1969
- this.#syncTerminalProgress(this.view());
1970
- this.#terminal.requestRender();
1971
- }
1972
- async #execute(command, draft) {
1973
- if (draft === undefined)
1974
- return this.backend.session.execute(command);
1975
- const revision = this.#editorRevision;
1976
- try {
1977
- assertOwnedUiCommand(command);
1978
- }
1979
- catch (error) {
1980
- return this.#recoverSubmission(draft, revision, error);
1981
- }
1982
- try {
1983
- const result = await this.backend.session.execute(command);
1984
- if (result.outcome === "rejected")
1985
- return this.#recoverSubmission(draft, revision);
1986
- return result;
1987
- }
1988
- catch {
1989
- // Security: dispatch might already have reached the provider. Never retry automatically.
1990
- this.root.editor.addToHistory(draft);
1991
- this.#promptHistory?.rememberRecovery(draft);
1992
- this.#reportSubmissionError(undefined, "Submission failed; delivery is uncertain. Check the conversation before retrying. Press Up to recover the draft.");
1993
- return { outcome: "failed", diagnostic: "submission delivery is uncertain" };
1994
- }
1995
- }
1996
- async #guardSubmission(draft, action) {
1997
- const revision = this.#editorRevision;
1998
- try {
1999
- return await action();
2000
- }
2001
- catch (error) {
2002
- if (error instanceof ImageAttachmentError && error.code === "image-canceled")
2003
- return rejected("image submission canceled");
2004
- return this.#recoverSubmission(draft, revision, error);
2005
- }
2006
- }
2007
- #recoverSubmission(draft, revision, error) {
2008
- this.root.editor.addToHistory(draft);
2009
- this.#promptHistory?.rememberRecovery(draft);
2010
- // Concurrency: never overwrite input typed (even typed and cleared) after this submission.
2011
- if (revision === this.#editorRevision && this.root.editor.getText().length === 0)
2012
- this.root.editor.setText(draft);
2013
- const message = error instanceof ImageAttachmentError ? error.message : "Submission rejected. Check the prompt and attachments.";
2014
- this.#reportSubmissionError(error, error instanceof ImageAttachmentError && error.code === "image-count"
2015
- ? message
2016
- : `${message} Press Up to recover the draft.`);
2017
- return rejected(message);
2018
- }
2019
- #reportSubmissionError(error, message) {
2020
- // Security: arbitrary provider/extension error messages can contain the entire request.
2021
- try {
2022
- this.root.appendWorkflowResult({ command: "debug", outcome: "failed", message: message
2023
- ?? (error instanceof ImageAttachmentError ? error.message : "Submission failed. Check the prompt and try again.") }, error instanceof ImageAttachmentError ? error.code : undefined);
2024
- this.#terminal.requestRender();
2025
- }
2026
- catch { /* Security: error presentation cannot create another rejected submission callback. */ }
2027
- }
2028
1347
  #simple(type) {
2029
1348
  return { type, correlationId: this.#correlation(type), sessionId: this.backend.identity.sessionId };
2030
1349
  }
@@ -2069,27 +1388,9 @@ function copyCharacters(snapshot) {
2069
1388
  }
2070
1389
  return hasContent ? count : undefined;
2071
1390
  }
2072
- export function formatSessionResumeCommand(metadata) {
2073
- const args = [PRODUCT_TEXT.commandName];
2074
- if (!metadata.usesDefaultSessionDir)
2075
- args.push("--session-dir", quoteCommandArgument(metadata.sessionDir));
2076
- args.push("--session", metadata.sessionId);
2077
- return args.join(" ");
2078
- }
2079
- export function quoteCommandArgument(value) {
2080
- if (value.length > 0 && !/[^a-zA-Z0-9_\-./~:@]/.test(value))
2081
- return value;
2082
- return `'${value.replaceAll("'", `'\\''`)}'`;
2083
- }
2084
- function dim(value) {
2085
- return `\u001b[2m${value}\u001b[22m`;
2086
- }
2087
1391
  function rejected(diagnostic) {
2088
1392
  return { outcome: "rejected", diagnostic };
2089
1393
  }
2090
- function modelKey(view) {
2091
- return view.activeModel === null ? "" : `${view.activeModel.providerId}/${view.activeModel.modelId}`;
2092
- }
2093
1394
  function workflowAdapterResult(result) {
2094
1395
  if (result.outcome === "completed")
2095
1396
  return { outcome: "completed", diagnostic: null };