@timurproko/a1 0.2.7-dev.806 → 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 (105) 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 +209 -897
  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 +26 -4
  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 +6 -25
  44. package/dist/composition/owned-ui.js +48 -242
  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/settings-route-host.d.ts +6 -1
  52. package/dist/composition/settings-route-host.js +7 -6
  53. package/dist/composition/startup-route.d.ts +32 -0
  54. package/dist/composition/startup-route.js +72 -0
  55. package/dist/features/owned-ui/settings-app.d.ts +4 -1
  56. package/dist/features/owned-ui/settings-app.js +9 -3
  57. package/dist/features/resident-tab/shortcut-declarations.js +1 -1
  58. package/dist/features/resident-tab/status-deriver.d.ts +4 -3
  59. package/dist/features/resident-tab/status-deriver.js +2 -5
  60. package/dist/foundation/release/bootstrap.js +2 -2
  61. package/dist/foundation/resident-tabs/bridge-client.d.ts +1 -1
  62. package/dist/foundation/resident-tabs/bridge-client.js +2 -1
  63. package/dist/foundation/resident-tabs/fixture-control.d.ts +7 -0
  64. package/dist/foundation/resident-tabs/fixture-control.js +35 -0
  65. package/dist/foundation/resident-tabs/progress-observation.d.ts +7 -0
  66. package/dist/foundation/resident-tabs/progress-observation.js +119 -0
  67. package/dist/foundation/terminal-cleanup/index.d.ts +1 -1
  68. package/dist/foundation/terminal-cleanup/index.js +1 -1
  69. package/dist/foundation/terminal-cleanup/terminal-reset.d.ts +5 -0
  70. package/dist/foundation/terminal-cleanup/terminal-reset.js +5 -0
  71. package/dist/integrations/pi/engine/adapter.d.ts +4 -77
  72. package/dist/integrations/pi/engine/adapter.js +85 -393
  73. package/dist/integrations/pi/engine/command-dispatch.d.ts +13 -0
  74. package/dist/integrations/pi/engine/command-dispatch.js +57 -0
  75. package/dist/integrations/pi/engine/host.js +2 -2
  76. package/dist/integrations/pi/engine/ports.d.ts +56 -0
  77. package/dist/integrations/pi/engine/ports.js +89 -0
  78. package/dist/integrations/pi/engine/session-runtime.js +5 -2
  79. package/dist/integrations/pi/engine/view-state.d.ts +42 -0
  80. package/dist/integrations/pi/engine/view-state.js +126 -0
  81. package/dist/integrations/pi/tui-runtime/adapter.d.ts +6 -0
  82. package/dist/integrations/pi/tui-runtime/adapter.js +28 -1
  83. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +11 -0
  84. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +30 -1
  85. package/dist/native/darwin-arm64/manifest.json +1 -1
  86. package/dist/native/darwin-arm64/terminal-host.manifest.json +3 -3
  87. package/dist/native/darwin-arm64/terminal-host.node +0 -0
  88. package/dist/native/darwin-x64/manifest.json +1 -1
  89. package/dist/native/darwin-x64/terminal-host.manifest.json +3 -3
  90. package/dist/native/darwin-x64/terminal-host.node +0 -0
  91. package/dist/native/linux-x64/manifest.json +1 -1
  92. package/dist/native/linux-x64/terminal-host.manifest.json +3 -3
  93. package/dist/native/linux-x64/terminal-host.node +0 -0
  94. package/dist/native/win32-x64/manifest.json +2 -2
  95. package/dist/native/win32-x64/process-guardian.node +0 -0
  96. package/dist/native/win32-x64/terminal-host.manifest.json +3 -3
  97. package/dist/native/win32-x64/terminal-host.node +0 -0
  98. package/dist/product-identity.d.ts +2 -2
  99. package/dist/product-identity.js +2 -2
  100. package/dist/product-identity.json +6 -0
  101. package/dist/ui/settings/manager.d.ts +6 -0
  102. package/dist/ui/settings/manager.js +25 -0
  103. package/docs/architecture/boundaries.md +14 -0
  104. package/docs/architecture/resident-tabs.md +23 -18
  105. package/package.json +2 -1
@@ -1,35 +1,31 @@
1
- import { acceptsTranscriptUpdate, ImageAttachmentError } from "../../contracts/owned-ui/index.js";
2
- import { caretCell } from "../../ui/components/line-input.js";
3
- import { PromptInput, promptArrow } from "../../ui/components/prompt-input.js";
4
- import { composeSubmittedPromptRows, formatSubmittedPromptTime, submittedPromptLayout, } from "../../ui/components/submitted-prompt.js";
5
- import { backgroundSgrSpan, heldNativeHyperlinkStyle, hyperlinkSgrSpan, nativeHyperlinkStyle, overlaySpan, } from "../../ui/components/spans.js";
1
+ import { ImageAttachmentError } from "../../contracts/owned-ui/index.js";
2
+ import { promptArrow } from "../../ui/components/prompt-input.js";
3
+ import { composeSubmittedPromptRows, submittedPromptLayout, } from "../../ui/components/submitted-prompt.js";
4
+ import { backgroundSgrSpan, nativeHyperlinkStyle, } from "../../ui/components/spans.js";
6
5
  import { progressStatusFrame, progressStatusText } from "../../ui/components/progress-status.js";
7
- import { displayWidth, faint, stripAnsi } from "../../ui/components/text.js";
8
- import { createPiShellEditor } from "../../integrations/pi/components/shell-editor-autocomplete.js";
9
6
  import { createPiQueuedInputStatus, createPiShellFooter, createPiShellHeader, createPiShellLoadedResources, createPiShellStatus, } from "../../integrations/pi/components/shell-footer-status.js";
10
- import { createPiShellArmin, createPiShellEarendilAnnouncement, } from "../../integrations/pi/components/shell-selectors-dialogs.js";
11
- import { createPiShellChangelog, createPiShellCollapsedChangelog, createPiShellHotkeys, createPiShellSessionInfo, renderPiShellCommandMessage, renderPiShellPackageUpdateNotice, renderPiShellReleaseUpdateBanner, renderPiShellReleaseUpdateNotice, renderPiShellStatusText, } from "../../integrations/pi/components/shell-presenters-info.js";
12
- import { createPiShellTranscriptComponent, isPiPromptStyleCompaction, paintPiSubmittedPromptTimestamp, renderPiShellStartupDiagnostic, renderPiShellTranscriptBlock, } from "../../integrations/pi/components/shell-presenters-transcript.js";
13
- import { onPiThemeChange, PINNED_PI_LAYOUT, piTheme } from "../../integrations/pi/components/upstream/theme/theme.js";
14
- import { piShellHyperlink, piShellTruncateToWidth, piShellVisibleWidth, } from "../../integrations/pi/components/shell-shared-facade.js";
15
- import { classifyPiTuiInput, } from "../../integrations/pi/tui-runtime/input-presentation-coordinator.js";
7
+ import { createPiShellTranscriptComponent, } from "../../integrations/pi/components/shell-presenters-transcript.js";
8
+ import { onPiThemeChange, piTheme } from "../../integrations/pi/components/upstream/theme/theme.js";
9
+ import {} from "../../integrations/pi/components/shell-shared-facade.js";
10
+ import {} from "../../integrations/pi/tui-runtime/input-presentation-coordinator.js";
16
11
  import { PromptChipStore } from "./prompt-chips.js";
17
- import { EditorHyperlinkBudget } from "./editor-hyperlink-budget.js";
18
12
  import { SessionViewportController } from "./session-viewport-controller.js";
19
- import { canPreparePasteInline } from "./paste-text-preparation.js";
13
+ import { createShellEditor } from "./shell-editor-factory.js";
14
+ import { ExtensionSurface } from "./extension-surface.js";
15
+ import { DockChrome } from "./dock-chrome.js";
16
+ import { WorkflowOutput } from "./workflow-output.js";
17
+ import { InputSurfaceSwitch } from "./input-surface-switch.js";
18
+ import { PinnedLayout } from "./shell-layout.js";
19
+ import { ViewportLayout } from "./viewport-layout.js";
20
+ import { nativeTranscriptLinkColor, TranscriptDocument } from "./transcript-document.js";
20
21
  /** Composes backend session state into the owned transcript, viewport, editor, and dock presentation. */
21
22
  export class OwnedUiSessionShellRoot {
22
23
  editor;
23
24
  header;
24
25
  resources;
25
26
  #cwd;
26
- #transcript = new Map();
27
- #blocksById = new Map();
28
- #renderedRows = new Map();
29
- // Performance: document layouts are shared by wheel, rail, jump, and selection frames.
30
- #documentLayouts = new Map();
27
+ #document;
31
28
  #themeUnsubscribe;
32
- #transcriptOrder = [];
33
29
  #view;
34
30
  #status;
35
31
  #footer;
@@ -37,15 +33,10 @@ export class OwnedUiSessionShellRoot {
37
33
  #extensionRenderers;
38
34
  #imageAssets;
39
35
  #promptChips;
40
- #editorHyperlinks = new EditorHyperlinkBudget();
41
36
  #pasteDiagnosticId = 0;
42
- #terminalPasteDiagnosticRequest;
43
37
  #customViewport;
44
38
  #submittedPromptComposer;
45
39
  #viewportController;
46
- #viewportTheme;
47
- #onViewportFrame;
48
- #onInputSurfaceChanged;
49
40
  #onPromptSuggestionEligibilityChanged;
50
41
  #componentRuntime;
51
42
  #toolsExpanded = false;
@@ -55,33 +46,11 @@ export class OwnedUiSessionShellRoot {
55
46
  #showImages = true;
56
47
  #imageWidthCells = 80;
57
48
  #outputPad = 1;
58
- #workflowTranscriptSequence = 0;
59
- #workflowStatusAnchors = new Map();
60
- #workflowStatusMessages = new Map();
61
- #lastWorkflowStatusId;
62
- // Invariant: the notice is dock chrome, never transcript content; the custom viewport alone uses it.
63
- #dockNotice;
64
- // Invariant: bare A1 docks the release notice above the live status; closing it lasts for this session only.
65
- #releaseNoticeDismissed = false;
66
- #releaseCloseTarget;
67
- #copyAcknowledgement;
68
- #copyAcknowledgementTimer;
69
- #inputSurface;
70
- #inputSurfaceCoordination = "editor";
71
- #dockInputReuseEnabled;
72
- #dockInputCandidate = false;
73
- // Invariant: receipt-time eligibility is evidence, never authorization for composition-time reuse.
74
- #dockInputRevision;
75
- #visibleViewportSnapshot;
76
- #fullViewportCompositions = 0;
77
- #dockOnlyViewportCompositions = 0;
78
- #transcriptBlockRenders = 0;
79
- #extensionHeader = null;
80
- #extensionFooter = null;
81
- #extensionWidgets = new Map();
82
- #extensionStatuses = new Map();
83
- #extensionWorkingMessage;
84
- #extensionWorkingVisible = true;
49
+ #workflowOutput;
50
+ #input;
51
+ #layout;
52
+ #extensions = new ExtensionSurface();
53
+ #dockChrome;
85
54
  #imageSubmissionsSending = 0;
86
55
  constructor(view, cwd, handlers, startup = {}, agentDir, extensionRenderers = {
87
56
  getMessageRenderer: () => undefined,
@@ -94,20 +63,13 @@ export class OwnedUiSessionShellRoot {
94
63
  ...(handlers.pasteDiagnostics === undefined ? {} : { onEvent: handlers.pasteDiagnostics }),
95
64
  ...(handlers.pastePreparation === undefined ? {} : { preparation: handlers.pastePreparation }),
96
65
  ...(handlers.promptImageLimit === undefined ? {} : { imageLimit: handlers.promptImageLimit }) });
97
- this.#submittedPromptComposer = this.#customViewport
98
- ? {
99
- layout: (width, source) => submittedPromptLayout(width, source, this.#viewportController.config.timeFormat ?? "8:00 PM"),
100
- compose: (rows, width, source, style) => composeSubmittedPromptRows(rows, width, source, { ...style, prefix: text => promptArrow(text, piTheme()) }, this.#viewportController.config.timeFormat ?? "8:00 PM").map(row => nativeHyperlinkStyle(row, nativeTranscriptLinkColor)),
101
- }
102
- : undefined;
66
+ this.#submittedPromptComposer = this.#customViewport ? this.#createSubmittedPromptComposer() : undefined;
103
67
  this.#cwd = cwd;
68
+ this.#document = this.#createDocument(cwd);
104
69
  this.#extensionRenderers = extensionRenderers;
105
70
  this.#imageAssets = imageAssets;
106
71
  this.#componentRuntime = handlers;
107
- this.#onViewportFrame = handlers.onViewportFrame;
108
- this.#onInputSurfaceChanged = handlers.onInputSurfaceChanged;
109
72
  this.#onPromptSuggestionEligibilityChanged = handlers.onPromptSuggestionEligibilityChanged;
110
- this.#dockInputReuseEnabled = handlers.enableDockInputReuse ?? true;
111
73
  this.header = createPiShellHeader({
112
74
  ...startup,
113
75
  ...(this.#customViewport ? { getKeybindings: () => this.editor.keybindingConfig() } : {}),
@@ -118,81 +80,16 @@ export class OwnedUiSessionShellRoot {
118
80
  frame: progressStatusFrame,
119
81
  }, handlers);
120
82
  this.#status.setProgressPresentation(this.#customViewport ? "custom-viewport" : "pinned");
121
- this.#footer = createPiShellFooter(this.#viewWithExtensionStatuses(view), cwd, this.#customViewport ? "a1" : "pi", () => this.#footerLevel, handlers.footerSessionName ?? true);
122
- this.editor = createPiShellEditor({
123
- ...handlers,
124
- keybindingProfile: this.#customViewport ? "a1" : "pi",
125
- ...(handlers.keybindings === undefined ? {} : { keybindings: handlers.keybindings }),
126
- paintEditorSelection: (line, from, to, atomic) => backgroundSgrSpan(line, from, to, atomic ? "\u001b[7m" : "\u001b[27m\u001b[48;2;38;79;120m", atomic ? "\u001b[27m" : "\u001b[49m", piShellVisibleWidth),
127
- transformPastedContent: content => {
128
- try {
129
- return this.#promptChips.transformPastedContent(content, this.editor.getText());
130
- }
131
- catch (error) {
132
- handlers.onPasteRejected?.(error);
133
- return "";
134
- }
135
- },
136
- ...(this.#customViewport ? {
137
- beginClipboardPaste: () => this.#promptChips.beginPaste(this.editor.getText(), handlers.captureClipboardPaste?.() ?? { kind: "provided", read: signal => handlers.readClipboardContent?.(signal) ?? Promise.resolve(null) }, error => handlers.onPasteRejected?.(error), () => handlers.requestRender()),
138
- onPasteInput: (bytes, phase) => {
139
- try {
140
- if (phase === "framing")
141
- this.#terminalPasteDiagnosticRequest = --this.#pasteDiagnosticId;
142
- const request = this.#terminalPasteDiagnosticRequest ?? --this.#pasteDiagnosticId;
143
- handlers.pasteDiagnostics?.({ request, phase, bytes, atMs: performance.now(), pending: 0, transport: "terminal",
144
- ...(phase === "settled" ? { outcome: "ready" } : {}) });
145
- if (phase === "settled")
146
- this.#terminalPasteDiagnosticRequest = undefined;
147
- }
148
- catch { /* Invariant: payload-free diagnostics cannot interfere with the input path. */ }
149
- },
150
- deferTextPaste: text => !canPreparePasteInline(text),
151
- beginTextPaste: text => this.#promptChips.beginPaste(this.editor.getText(), { kind: "text", text }, error => handlers.onPasteRejected?.(error)),
152
- } : {}),
153
- editorAtomicRanges: line => this.#promptChips.atomicRanges(line),
154
- editorHiddenRanges: line => this.#promptChips.hiddenRanges(line),
155
- decorateEditorRow: (row, width, rowIndex) => {
156
- if (rowIndex === 0)
157
- this.#editorHyperlinks.reset();
158
- const plain = stripAnsi(row);
159
- const dimmed = this.#promptChips.unsentRanges(plain).reduce((result, range) => backgroundSgrSpan(result, piShellVisibleWidth(plain.slice(0, range.start)), piShellVisibleWidth(plain.slice(0, range.end)), "\u001b[2m", "\u001b[22m", piShellVisibleWidth), row);
160
- const ranges = this.#promptChips.hyperlinkRanges(plain);
161
- if (ranges.length === 0 || !this.#editorHyperlinks.takeCleanup())
162
- return dimmed;
163
- const linkResetAndTail = `\u001b]8;;\u001b\\\u001b[24m${" ".repeat(Math.max(0, width - piShellVisibleWidth(dimmed)))}`;
164
- // Platform: VS15 is zero-column and default-ignorable. It breaks Windows Terminal's
165
- // plain-text URL detector only in the held-button paint; semantic text stays exact.
166
- const paintRow = this.#viewportController.editorPointerSelecting
167
- ? dimmed.replaceAll("https://", "https:\uFE0E//").replaceAll("http://", "http:\uFE0E//")
168
- : dimmed;
169
- const decorated = this.#viewportController.editorPointerSelecting
170
- ? ranges.reduce((result, range) => backgroundSgrSpan(result, piShellVisibleWidth(plain.slice(0, range.start)), piShellVisibleWidth(plain.slice(0, range.end)), "\u001b[4:4m", "\u001b[24m", piShellVisibleWidth), paintRow)
171
- : ranges.filter(range => this.#editorHyperlinks.take(range.target)).reduce((result, range) => hyperlinkSgrSpan(result, piShellVisibleWidth(plain.slice(0, range.start)), piShellVisibleWidth(plain.slice(0, range.end)), range.target, piShellVisibleWidth), dimmed);
172
- // Platform: Windows Terminal can retain stale native dotted-link cells when a mutable
173
- // prompt replaces a longer URL. Explicit non-link spaces overwrite that tail.
174
- return `${decorated}${linkResetAndTail}`;
175
- },
176
- expandCopiedEditorText: text => this.#promptChips.expandCopiedText(text),
83
+ this.#footer = createPiShellFooter(this.#extensions.withStatuses(view), cwd, this.#customViewport ? "a1" : "pi", () => this.#footerLevel, handlers.footerSessionName ?? true);
84
+ this.editor = createShellEditor({
85
+ handlers,
86
+ customViewport: this.#customViewport,
177
87
  cwd,
178
88
  ...(agentDir === undefined ? {} : { agentDir }),
179
- onToolsExpand: () => this.#setToolsExpanded(!this.#toolsExpanded),
180
- ...(this.#customViewport ? {
181
- promptPresentation: {
182
- input: new PromptInput({ fg: (token, text) => piTheme().fg(token, text) }, {
183
- measure: piShellVisibleWidth,
184
- truncate: piShellTruncateToWidth,
185
- }),
186
- styleSuggestion: faint,
187
- styleSuggestionCaret: caretCell,
188
- },
189
- } : {}),
190
- onChange: text => {
191
- handlers.onEditorChange?.(text);
192
- // Concurrency: Pi clears before onSubmit; capture waiting references before pruning removed chips.
193
- queueMicrotask(() => this.#promptChips.reconcileDraft(this.editor.getText()));
194
- },
195
- ...(handlers.onPromptSuggestionAccepted === undefined ? {} : { onPromptSuggestionAccepted: handlers.onPromptSuggestionAccepted }),
89
+ promptChips: this.#promptChips,
90
+ editorPointerSelecting: () => this.#viewportController.editorPointerSelecting,
91
+ nextPasteDiagnosticRequest: () => --this.#pasteDiagnosticId,
92
+ toggleToolsExpanded: () => this.#setToolsExpanded(!this.#toolsExpanded),
196
93
  });
197
94
  this.#queued = createPiQueuedInputStatus(view.editor.queuedSubmissions, this.#customViewport ? "custom-viewport" : "pinned", this.#customViewport ? () => this.editor.keybindingConfig() : undefined);
198
95
  this.#viewportController = new SessionViewportController({
@@ -204,23 +101,33 @@ export class OwnedUiSessionShellRoot {
204
101
  ...(handlers.pasteDiagnostics === undefined ? {} : { pasteDiagnostics: handlers.pasteDiagnostics }),
205
102
  nextPasteDiagnosticRequest: () => --this.#pasteDiagnosticId,
206
103
  });
207
- // Performance: stable painter identities let the neutral viewport retain row-level
208
- // selection variants while each callback still resolves the live Pi theme.
209
- this.#viewportTheme = {
210
- track: text => `${SCROLLBAR_CELL_RESET}${piTheme().fg("dim", text)}`,
211
- thumb: text => `${SCROLLBAR_CELL_RESET}${piTheme().fg("accent", text)}`,
212
- sticky: (text, hovered) => piTheme().bg(hovered ? "selectedBg" : "toolPendingBg", piTheme().fg("text", withoutTerminalBackground(text))),
213
- quietSticky: text => `\u001b[2m${text.replace(/\u001b\[(?:0|22)m/g, "$&\u001b[2m")}\u001b[22m`,
214
- bottomControl: (text, hovered) => piTheme().bg(hovered ? "selectedBg" : "toolPendingBg", piTheme().fg("text", text)),
215
- selection: (line, from, to) => backgroundSgrSpan(line, from, to, "\u001b[48;2;38;79;120m", "\u001b[49m"),
216
- };
104
+ this.#dockChrome = new DockChrome({
105
+ invalidateChrome: () => this.#invalidateChrome(),
106
+ requestRender: () => this.#componentRuntime.requestRender(),
107
+ viewport: this.#viewportController,
108
+ });
109
+ this.#workflowOutput = new WorkflowOutput({
110
+ customViewport: this.#customViewport,
111
+ document: this.#document,
112
+ dock: this.#dockChrome,
113
+ transcriptLength: () => this.#view.transcript.length,
114
+ outputPad: () => this.#outputPad,
115
+ hotkeysPresentation: () => this.hotkeysPresentation(),
116
+ componentRuntime: this.#componentRuntime,
117
+ invalidate: () => this.invalidate(),
118
+ });
217
119
  this.editor.setThinkingLevel(view.thinkingLevel);
218
- this.#inputSurface = this.editor;
120
+ this.#input = new InputSurfaceSwitch(this.editor, {
121
+ dockInputReuse: this.#customViewport && (handlers.enableDockInputReuse ?? true),
122
+ viewport: this.#viewportController,
123
+ ...(handlers.onInputSurfaceChanged === undefined ? {} : { onChanged: handlers.onInputSurfaceChanged }),
124
+ });
125
+ this.#layout = this.#createLayout(handlers.onViewportFrame);
219
126
  for (const block of view.transcript) {
220
127
  if (block.kind === "user")
221
128
  this.editor.addToHistory(block.text);
222
129
  }
223
- this.#syncTranscript(view.transcript);
130
+ this.#document.sync(view.transcript);
224
131
  // Invariant: colours come from the active theme, so rendered rows outlive their revision only
225
132
  // until the theme under them changes.
226
133
  this.#themeUnsubscribe = onPiThemeChange(() => {
@@ -228,9 +135,51 @@ export class OwnedUiSessionShellRoot {
228
135
  this.#componentRuntime.requestRender(true);
229
136
  });
230
137
  }
138
+ // Invariant: the document reads the shell's chrome lazily, so it may be built before the header and viewport.
139
+ #createDocument(cwd) {
140
+ return new TranscriptDocument({
141
+ customViewport: this.#customViewport,
142
+ cwd,
143
+ mount: block => this.#mountTranscript(block),
144
+ chrome: {
145
+ diagnostics: () => this.#view.diagnostics,
146
+ headerRows: width => this.#extensions.header !== null ? this.#extensions.header.render(width)
147
+ : this.#customViewport ? [] : this.header.render(width),
148
+ resourceRows: width => this.#customViewport ? [] : this.resources.render(width),
149
+ hasExtensionHeader: () => this.#extensions.header !== null,
150
+ thinkingVisible: () => this.#thinkingVisible,
151
+ timeFormat: () => this.#viewportController.config.timeFormat,
152
+ },
153
+ });
154
+ }
155
+ // Invariant: one layout per root; the render path never consults the layout mode again.
156
+ #createLayout(onFrame) {
157
+ const ports = {
158
+ viewport: this.#viewportController,
159
+ input: this.#input,
160
+ editor: this.editor,
161
+ promptChips: this.#promptChips,
162
+ dockChrome: this.#dockChrome,
163
+ theme: VIEWPORT_THEME,
164
+ ...(onFrame === undefined ? {} : { onFrame }),
165
+ rows: () => this.#componentRuntime.getRows(),
166
+ dock: width => this.#renderDockLayout(width),
167
+ document: width => this.#document.layout(width),
168
+ queued: width => this.#renderQueued(width),
169
+ liveStatus: width => this.#renderStatus(width),
170
+ diagnostics: () => this.#view.diagnostics,
171
+ };
172
+ return this.#customViewport ? new ViewportLayout(ports) : new PinnedLayout(ports);
173
+ }
174
+ #createSubmittedPromptComposer() {
175
+ return {
176
+ layout: (width, source) => submittedPromptLayout(width, source, this.#viewportController.config.timeFormat ?? "8:00 PM"),
177
+ compose: (rows, width, source, style) => composeSubmittedPromptRows(rows, width, source, { ...style, prefix: text => promptArrow(text, piTheme()) }, this.#viewportController.config.timeFormat ?? "8:00 PM").map(row => nativeHyperlinkStyle(row, nativeTranscriptLinkColor)),
178
+ };
179
+ }
231
180
  setEditorPaddingX(padding) {
232
181
  this.editor.setPaddingX(padding);
233
- this.#documentLayouts.clear();
182
+ this.#document.invalidate("editor-padding");
234
183
  }
235
184
  setAutocompleteMaxVisible(maxVisible) {
236
185
  this.editor.setAutocompleteMaxVisible(maxVisible);
@@ -240,7 +189,7 @@ export class OwnedUiSessionShellRoot {
240
189
  return;
241
190
  this.#outputPad = padding;
242
191
  this.#status.setOutputPad(padding);
243
- for (const component of this.#transcript.values())
192
+ for (const component of this.#document.components())
244
193
  component.setOutputPad(padding);
245
194
  this.#invalidateTranscriptPresentation();
246
195
  }
@@ -248,7 +197,7 @@ export class OwnedUiSessionShellRoot {
248
197
  if (this.#thinkingVisible === !hidden)
249
198
  return;
250
199
  this.#thinkingVisible = !hidden;
251
- for (const component of this.#transcript.values())
200
+ for (const component of this.#document.components())
252
201
  component.setHideThinkingBlock(hidden);
253
202
  this.#invalidateTranscriptPresentation();
254
203
  }
@@ -256,7 +205,7 @@ export class OwnedUiSessionShellRoot {
256
205
  if (this.#mermaidRenderingMode === mode)
257
206
  return;
258
207
  this.#mermaidRenderingMode = mode;
259
- for (const component of this.#transcript.values())
208
+ for (const component of this.#document.components())
260
209
  component.setMermaidRenderingMode(mode);
261
210
  this.#invalidateTranscriptPresentation();
262
211
  }
@@ -265,7 +214,7 @@ export class OwnedUiSessionShellRoot {
265
214
  return;
266
215
  this.#showImages = showImages;
267
216
  this.#imageWidthCells = imageWidthCells;
268
- for (const component of this.#transcript.values())
217
+ for (const component of this.#document.components())
269
218
  component.setImagePresentation(showImages, imageWidthCells);
270
219
  this.#invalidateTranscriptPresentation();
271
220
  }
@@ -285,7 +234,7 @@ export class OwnedUiSessionShellRoot {
285
234
  return this.#promptChips.prepareSubmission(text);
286
235
  }
287
236
  reconcilePromptImageLimitNotice(text = this.editor.getText()) {
288
- if (this.#dockNotice?.errorCode !== "image-count" || !this.#promptChips.imageLimitState(text).corrected)
237
+ if (this.#dockChrome.notice?.errorCode !== "image-count" || !this.#promptChips.imageLimitState(text).corrected)
289
238
  return false;
290
239
  this.dismissNotice();
291
240
  return true;
@@ -331,22 +280,22 @@ export class OwnedUiSessionShellRoot {
331
280
  const becameReady = this.#view.lifecycle !== "ready" && view.lifecycle === "ready";
332
281
  if (view.diagnostics.length !== this.#view.diagnostics.length
333
282
  || view.diagnostics.some((diagnostic, index) => diagnostic.sequence !== this.#view.diagnostics[index]?.sequence)) {
334
- this.#documentLayouts.clear();
283
+ this.#document.invalidate("diagnostics");
335
284
  }
336
285
  const promptAccepted = this.#view.lifecycle !== "busy" && view.lifecycle === "busy";
337
286
  this.#view = view;
338
287
  // Invariant: the idle new-session confirmation yields to the first truthful live status;
339
288
  // ordinary dock notices deliberately remain visible below Working during a run.
340
- if (promptAccepted && this.#dockNotice?.kind === "new-session")
341
- this.#dockNotice = undefined;
289
+ if (promptAccepted && this.#dockChrome.notice?.kind === "new-session")
290
+ this.#dockChrome.forgetNotice();
342
291
  if (promptAccepted && this.#imageSubmissionsSending > 0) {
343
292
  this.#imageSubmissionsSending = 0;
344
293
  this.#syncWorkingOverride();
345
294
  }
346
295
  this.#status.update(view);
347
- this.#footer.update(this.#viewWithExtensionStatuses(view));
296
+ this.#footer.update(this.#extensions.withStatuses(view));
348
297
  this.#queued.update(view.editor.queuedSubmissions);
349
- this.#syncTranscript(view.transcript);
298
+ this.#document.sync(view.transcript);
350
299
  this.editor.setSubmitEnabled(view.lifecycle !== "stopping" && view.lifecycle !== "stopped" && view.lifecycle !== "failed");
351
300
  this.editor.setThinkingLevel(view.thinkingLevel);
352
301
  // Performance: semantic updates already invalidate affected transcript blocks. Chrome is a separate authority.
@@ -360,24 +309,7 @@ export class OwnedUiSessionShellRoot {
360
309
  * walk of the whole transcript.
361
310
  */
362
311
  applyTranscriptBlock(block) {
363
- const current = this.#blocksById.get(block.id);
364
- // Invariant: a full-view/final presentation may preempt queued partials. Never revive an older revision.
365
- if (current !== undefined && !acceptsTranscriptUpdate(current, block))
366
- return;
367
- this.#blocksById.set(block.id, block);
368
- const component = this.#transcript.get(block.id);
369
- if (component === undefined) {
370
- const created = this.#mountTranscript(block);
371
- this.#transcript.set(block.id, created);
372
- this.#transcriptOrder.push(block.id);
373
- }
374
- else if (component.revision !== block.revision) {
375
- component.update(block);
376
- }
377
- // Performance: one chunk touches one block, and the updated component tracks its own dirtiness.
378
- // Invalidating the whole shell here would re-wrap the entire transcript per chunk.
379
- this.#renderedRows.delete(block.id);
380
- this.#documentLayouts.clear();
312
+ this.#document.apply(block);
381
313
  }
382
314
  #mountTranscript(block) {
383
315
  // Invariant: the notice answers the reader's last command, so only their next prompt or
@@ -385,148 +317,18 @@ export class OwnedUiSessionShellRoot {
385
317
  if (block.kind === "user" || block.kind === "bash")
386
318
  this.dismissNotice();
387
319
  const created = createPiShellTranscriptComponent(block, this.#cwd, this.#extensionRenderers, this.#submittedPromptComposer, this.#outputPad, !this.#thinkingVisible, this.#mermaidRenderingMode, this.#showImages, this.#imageWidthCells, this.#imageAssets, { ...this.#componentRuntime, changed: () => {
388
- if (this.#transcript.get(block.id) !== created)
320
+ if (this.#document.component(block.id) !== created)
389
321
  return;
390
- this.#renderedRows.delete(block.id);
391
- this.#documentLayouts.clear();
392
- this.#visibleViewportSnapshot = undefined;
393
- this.#dockInputCandidate = false;
394
- this.#dockInputRevision = undefined;
322
+ this.#document.blockChanged(block.id);
323
+ this.#layout.invalidate();
324
+ this.#input.clearDockInputCandidate();
395
325
  this.#componentRuntime.requestRender();
396
326
  } });
397
327
  created.setExpanded(this.#toolsExpanded);
398
328
  return created;
399
329
  }
400
330
  render(width) {
401
- if (!this.#customViewport) {
402
- this.#viewportController.setEditorPointerFrame(undefined);
403
- return [...this.#renderDocument(width), ...this.#renderDock(width)];
404
- }
405
- const height = Math.max(0, this.#componentRuntime.getRows());
406
- const dock = this.#renderDockLayout(width);
407
- const copyAcknowledgement = this.#renderCopyAcknowledgement(width);
408
- // Invariant: auto/always own one stable final-column gutter. Every scrollable
409
- // surface ends before it; the dock remains full-width and hidden mode returns it.
410
- const documentWidth = this.#viewportController.config.scrollbarAppearance === "hidden"
411
- ? width
412
- : Math.max(1, width - 1);
413
- const document = this.#renderDocumentLayout(documentWidth);
414
- const steeringRows = this.#renderQueued(documentWidth);
415
- const releaseBanner = this.#renderReleaseBanner(documentWidth);
416
- // Rationale: the release notice rides the bottom-aligned tail directly above Working, so it
417
- // never displaces transcript content and scrolls away with the live status.
418
- // Invariant: when idle the banner's last row sits on the line Working would occupy.
419
- const statusRows = [...releaseBanner?.rows ?? [], ...this.#renderStatus(documentWidth)];
420
- const transientSignature = transientRowsSignature(steeringRows, statusRows);
421
- const snapshot = this.#visibleViewportSnapshot;
422
- // Invariant: paint-only feedback may cover either viewport or dock cells, so
423
- // ordinary dock-only reuse cannot safely omit its overlay composition.
424
- const dockInputCandidate = this.#dockInputCandidate && copyAcknowledgement === undefined;
425
- // Concurrency: input may change after a keyboard receipt but before its paint.
426
- // Capture only the live inputs this composition will actually represent.
427
- const inputSurface = this.#inputSurface;
428
- const dockRows = dock.rows;
429
- const selectableDocumentRowCount = document.rows.length;
430
- const dockStartRow = height - dockRows.length + 1;
431
- const editorOffset = dock.editorOffset;
432
- // Rationale: rendered chip tags are lossy presentation (truncated labels), so a frame drag over
433
- // a draft holding chips stays visual-only; editor-owned word/line selection still copies the
434
- // expanded chip text through its semantic route.
435
- const draft = this.usesDefaultInputSurface() ? this.editor.getText() : "";
436
- const promptCopyRows = draft.length > 0
437
- && !draft.split("\n").some(line => this.#promptChips.atomicRanges(line).length > 0)
438
- ? Math.max(0, dock.inputRows - 2)
439
- : 0;
440
- const copyableDockRows = [
441
- ...dock.copyableMessageRows,
442
- ...Array.from({ length: promptCopyRows }, (_value, index) => ({
443
- row: editorOffset + index + 1,
444
- prompt: index === 0 ? "first" : "continuation",
445
- })),
446
- ...Array.from({ length: dock.footerRows }, (_value, index) => ({
447
- row: dockRows.length - dock.footerRows + index,
448
- })),
449
- ];
450
- this.#viewportController.setInputSurfaceFrame(this.usesDefaultInputSurface() ? undefined : {
451
- component: this.#inputSurface,
452
- columnStart: 1,
453
- columnEnd: width,
454
- rowStart: Math.max(1, dockStartRow + editorOffset),
455
- rowEnd: Math.min(height, dockStartRow + editorOffset + dock.inputRows - 1),
456
- });
457
- this.#viewportController.setEditorPointerFrame(this.usesDefaultInputSurface()
458
- ? {
459
- rowStart: dockStartRow + editorOffset,
460
- rowEnd: dockStartRow + editorOffset + dock.inputRows - 1,
461
- }
462
- : undefined);
463
- const viewportRevision = this.#viewportController.presentationRevision;
464
- const selectionRevision = this.#viewportController.selectionRevision;
465
- const pointerPosition = this.#viewportController.pointerPosition;
466
- this.#dockInputCandidate = false;
467
- let frame = dockInputCandidate
468
- && snapshot !== undefined
469
- && snapshot.width === width
470
- && snapshot.height === height
471
- && snapshot.documentRows === document.rows
472
- && snapshot.transientSignature === transientSignature
473
- && snapshot.promptAnchors === document.promptAnchors
474
- && snapshot.dockLength === dockRows.length
475
- && snapshot.inputSurface === inputSurface
476
- && snapshot.viewportRevision === viewportRevision
477
- && snapshot.selectionRevision === selectionRevision
478
- ? this.#viewportController.composeDockOnly(dockRows, width, height, copyableDockRows)
479
- : null;
480
- if (frame === null) {
481
- frame = this.#viewportController.compose({
482
- // Performance: do not copy the complete document on unchanged dock-only input.
483
- // Steering and Working retain their one non-persistent viewport-tail ownership.
484
- documentRows: [...document.rows, ...steeringRows, ...statusRows],
485
- ...(this.#viewportController.transcriptPointerSelecting
486
- ? { paintDocumentRow: heldNativeHyperlinkStyle }
487
- : {}),
488
- // Invariant: this boundary tracks persistent transcript rows for streaming and
489
- // geometry; transient Steering and Working rows remain complete-frame selectable.
490
- selectableDocumentRowCount,
491
- ...(document.liveTailStartRow === undefined ? {} : { liveTailStartRow: document.liveTailStartRow }),
492
- bottomAlignedTailRowCount: steeringRows.length + statusRows.length,
493
- dockRows,
494
- copyableDockRows,
495
- promptAnchors: document.promptAnchors,
496
- width,
497
- height,
498
- // Invariant: the control belongs immediately above the complete dock and
499
- // floats over transient viewport content rather than consuming a dock row.
500
- bottomControlRow: Math.max(0, height - Math.min(height, dockRows.length) - 1),
501
- ...(copyAcknowledgement === undefined || height <= 0 ? {} : {
502
- frameOverlay: {
503
- row: Math.max(1, Math.min(height, dockStartRow + dock.inputOffset - 1)),
504
- text: copyAcknowledgement,
505
- },
506
- }),
507
- theme: this.#viewportTheme,
508
- });
509
- this.#fullViewportCompositions += 1;
510
- }
511
- else
512
- this.#dockOnlyViewportCompositions += 1;
513
- this.#locateReleaseClose(frame, releaseBanner, document.rows.length + steeringRows.length + statusRows.length, statusRows.length);
514
- this.#visibleViewportSnapshot = {
515
- width,
516
- height,
517
- documentRows: document.rows,
518
- transientSignature,
519
- promptAnchors: document.promptAnchors,
520
- dockLength: dockRows.length,
521
- inputSurface,
522
- // Invariant: a callback during composition cannot bless older rows with its
523
- // newer revision. Its ordinary pending render will publish the newer state.
524
- viewportRevision,
525
- selectionRevision: frame.descriptor.selectionRevision,
526
- pointerPosition,
527
- };
528
- this.#onViewportFrame?.(frame);
529
- return frame.rows;
331
+ return this.#layout.render(width);
530
332
  }
531
333
  viewportFrameDescriptor() {
532
334
  return this.#viewportController.frame?.descriptor ?? null;
@@ -534,10 +336,10 @@ export class OwnedUiSessionShellRoot {
534
336
  /** Read-only, payload-free evidence; observing a pending frame must never render or repair it. */
535
337
  viewportPresentationEvidence() {
536
338
  const frame = this.#viewportController.frame;
537
- const snapshot = this.#visibleViewportSnapshot;
339
+ const snapshot = this.#layout.composed();
538
340
  return {
539
- candidate: this.#dockInputCandidate,
540
- candidateRevision: this.#dockInputCandidate ? this.#dockInputRevision ?? null : null,
341
+ candidate: this.#input.dockInputCandidate,
342
+ candidateRevision: this.#input.dockInputCandidate ? this.#input.dockInputRevision ?? null : null,
541
343
  currentRevision: this.#viewportController.presentationRevision,
542
344
  composedRevision: snapshot?.viewportRevision ?? null,
543
345
  currentPointer: this.#viewportController.pointerPosition ?? null,
@@ -559,29 +361,15 @@ export class OwnedUiSessionShellRoot {
559
361
  }
560
362
  setViewportConfig(config) {
561
363
  if (this.#viewportController.setConfig(config)) {
562
- this.#renderedRows.clear();
563
- this.#documentLayouts.clear();
564
- this.#visibleViewportSnapshot = undefined;
364
+ this.#document.invalidate("viewport-config");
365
+ this.#layout.invalidate();
565
366
  }
566
367
  }
567
368
  setFullscreenCopyOnSelect(enabled) {
568
369
  this.#viewportController.setCopyOnSelect(enabled);
569
370
  }
570
371
  showCopyAcknowledgement(message) {
571
- this.#clearCopyAcknowledgement(false);
572
- this.#copyAcknowledgement = message;
573
- const timer = setTimeout(() => {
574
- if (this.#copyAcknowledgementTimer !== timer)
575
- return;
576
- this.#copyAcknowledgementTimer = undefined;
577
- this.#copyAcknowledgement = undefined;
578
- this.#invalidateChrome();
579
- this.#componentRuntime.requestRender();
580
- }, COPY_ACKNOWLEDGEMENT_DURATION_MS);
581
- timer.unref?.();
582
- this.#copyAcknowledgementTimer = timer;
583
- this.#invalidateChrome();
584
- this.#componentRuntime.requestRender();
372
+ this.#dockChrome.showCopyAcknowledgement(message);
585
373
  }
586
374
  resumeViewportFollowing() {
587
375
  this.#viewportController.resumeFollowing();
@@ -591,13 +379,7 @@ export class OwnedUiSessionShellRoot {
591
379
  }
592
380
  /** A new session binding owns new mounts even when it reuses semantic invocation ids. */
593
381
  resetTranscript() {
594
- for (const component of this.#transcript.values())
595
- component.dispose?.();
596
- this.#transcript.clear();
597
- this.#blocksById.clear();
598
- this.#transcriptOrder = [];
599
- this.#renderedRows.clear();
600
- this.#documentLayouts.clear();
382
+ this.#document.reset();
601
383
  }
602
384
  resetViewport() {
603
385
  this.#viewportController.reset();
@@ -615,20 +397,17 @@ export class OwnedUiSessionShellRoot {
615
397
  handleViewportPreInput(data, allowWheel = true, now = Date.now(), editorActive = this.usesDefaultInputSurface()) {
616
398
  return this.#viewportController.handlePreInput(data, allowWheel, now, editorActive);
617
399
  }
618
- #renderDock(width) {
619
- return this.#renderDockLayout(width).rows;
620
- }
621
400
  #renderDockLayout(width) {
622
401
  const queued = this.#customViewport ? [] : this.#renderQueued(width);
623
402
  const statusRows = this.#customViewport ? this.#status.renderDock(width) : this.#renderStatus(width);
624
- const noticeRows = this.#renderDockNotice(width);
403
+ const noticeRows = this.#dockChrome.renderNotice(width, this.#outputPad);
625
404
  const transientRows = [...queued, ...statusRows, ...noticeRows];
626
405
  const messageStart = queued.length;
627
406
  const copyableMessageRows = Array.from({ length: statusRows.length + noticeRows.length }, (_value, index) => ({ row: messageStart + index }));
628
- const aboveWidgets = this.#renderWidgets("aboveEditor", width);
629
- const input = this.#inputSurface.render(width);
407
+ const aboveWidgets = this.#extensions.renderWidgets("aboveEditor", width);
408
+ const input = this.#input.surface.render(width);
630
409
  const body = this.usesDefaultInputSurface() ? this.editor.bodyGeometry?.() : undefined;
631
- const belowWidgets = this.#renderWidgets("belowEditor", width);
410
+ const belowWidgets = this.#extensions.renderWidgets("belowEditor", width);
632
411
  const footer = this.#renderFooter(width);
633
412
  const rowsWithoutTransient = [...aboveWidgets, ...input, ...belowWidgets, ...footer];
634
413
  const inputOffset = transientRows.length + aboveWidgets.length;
@@ -641,66 +420,8 @@ export class OwnedUiSessionShellRoot {
641
420
  copyableMessageRows,
642
421
  };
643
422
  }
644
- #renderCopyAcknowledgement(width) {
645
- if (this.#copyAcknowledgement === undefined || width <= 0)
646
- return undefined;
647
- return piTheme().fg("accent", piShellTruncateToWidth(this.#copyAcknowledgement, width));
648
- }
649
- #clearCopyAcknowledgement(requestRender) {
650
- if (this.#copyAcknowledgementTimer !== undefined)
651
- clearTimeout(this.#copyAcknowledgementTimer);
652
- this.#copyAcknowledgementTimer = undefined;
653
- if (this.#copyAcknowledgement === undefined)
654
- return;
655
- this.#copyAcknowledgement = undefined;
656
- this.#invalidateChrome();
657
- if (requestRender)
658
- this.#componentRuntime.requestRender();
659
- }
660
- #renderReleaseBanner(width) {
661
- if (this.#releaseNoticeDismissed)
662
- return undefined;
663
- const diagnostic = this.#view.diagnostics.findLast(item => item.code === "release-update");
664
- const release = diagnostic === undefined ? null : parseReleaseUpdateDiagnostic(diagnostic.message);
665
- if (release === null)
666
- return undefined;
667
- return renderPiShellReleaseUpdateBanner(release, width, this.#viewportController.tailControlHovered(this.#releaseCloseTarget));
668
- }
669
- #locateReleaseClose(frame, banner, tailEnd, tailLength) {
670
- // Invariant: the tail is the document suffix, so the control's document row is fixed relative to its
671
- // end; alignment gap rows precede it and the viewport scroll decides whether it is on screen.
672
- const bannerStart = tailEnd + frame.descriptor.transientAlignmentGapRows - tailLength - frame.scrollTop + 1;
673
- const rowStart = banner === undefined ? 0 : Math.max(1, bannerStart + banner.close.rowStart);
674
- const rowEnd = banner === undefined ? 0 : Math.min(frame.hits.viewportHeight, bannerStart + banner.close.rowEnd);
675
- this.#releaseCloseTarget = banner !== undefined && rowStart <= rowEnd
676
- ? { rowStart, rowEnd, columnStart: banner.close.columnStart, columnEnd: banner.close.columnEnd }
677
- : undefined;
678
- this.#viewportController.setTailControl(this.#releaseCloseTarget === undefined ? undefined : {
679
- ...this.#releaseCloseTarget,
680
- activate: () => {
681
- this.#releaseNoticeDismissed = true;
682
- this.#releaseCloseTarget = undefined;
683
- },
684
- });
685
- }
686
- #renderDockNotice(width) {
687
- const notice = this.#dockNotice;
688
- if (notice === undefined)
689
- return [];
690
- if (notice.kind === "status") {
691
- // Compatibility: Pi pads its status text by one cell regardless of the output pad setting.
692
- return ["", ...renderPiShellStatusText(notice.message, width, PINNED_PI_LAYOUT.outputPad)];
693
- }
694
- return renderPiShellCommandMessage({
695
- kind: notice.kind === "new-session" ? "accent" : notice.kind,
696
- message: notice.message,
697
- }, width, this.#outputPad);
698
- }
699
423
  dismissNotice() {
700
- if (this.#dockNotice === undefined)
701
- return;
702
- this.#dockNotice = undefined;
703
- this.#invalidateChrome();
424
+ this.#dockChrome.dismissNotice();
704
425
  }
705
426
  #renderQueued(width) {
706
427
  return this.#view.editor.queuedSubmissions.length === 0 ? [] : this.#queued.render(width);
@@ -710,287 +431,30 @@ export class OwnedUiSessionShellRoot {
710
431
  return {
711
432
  document: layoutPort(width => this.#renderDocument(width), () => this.invalidate()),
712
433
  queued: layoutPort(width => this.#view.editor.queuedSubmissions.length === 0 ? [] : this.#queued.render(width), () => this.#queued.invalidate()),
713
- aboveWidgets: layoutPort(width => this.#renderWidgets("aboveEditor", width), () => this.#invalidateExtensions()),
434
+ aboveWidgets: layoutPort(width => this.#extensions.renderWidgets("aboveEditor", width), () => this.#extensions.invalidate()),
714
435
  status: layoutPort(width => this.#renderStatus(width), () => this.#status.invalidate()),
715
- editor: layoutPort(width => this.#inputSurface.render(width), () => this.#inputSurface.invalidate(), data => this.#inputSurface.handleInput?.(data)),
716
- belowWidgets: layoutPort(width => this.#renderWidgets("belowEditor", width), () => this.#invalidateExtensions()),
717
- footer: layoutPort(width => this.#renderFooter(width), () => (this.#extensionFooter ?? this.#footer).invalidate()),
436
+ editor: layoutPort(width => this.#input.surface.render(width), () => this.#input.surface.invalidate(), data => this.#input.surface.handleInput?.(data)),
437
+ belowWidgets: layoutPort(width => this.#extensions.renderWidgets("belowEditor", width), () => this.#extensions.invalidate()),
438
+ footer: layoutPort(width => this.#renderFooter(width), () => (this.#extensions.footer ?? this.#footer).invalidate()),
718
439
  };
719
440
  }
720
441
  #renderDocument(width) {
721
- return this.#renderDocumentLayout(width).rows;
722
- }
723
- #renderDocumentLayout(width) {
724
- // Performance: extension headers may animate independently of transcript revisions, so
725
- // only the stable built-in document participates in this frame cache.
726
- const cached = this.#extensionHeader === null ? this.#documentLayouts.get(width) : undefined;
727
- if (cached !== undefined)
728
- return cached;
729
- const diagnostics = this.#view.diagnostics;
730
- const startupRows = diagnostics
731
- // Compatibility: the pinned route keeps trust failures with Pi-style startup diagnostics;
732
- // bare A1 presents them in its prompt-adjacent notice dock instead.
733
- .filter(diagnostic => diagnostic.code === "engine-startup"
734
- || !this.#customViewport && diagnostic.code === "project-trust")
735
- .flatMap(diagnostic => renderPiShellStartupDiagnostic(diagnostic, width));
736
- const resourceRows = this.#customViewport ? [] : [...this.resources.render(width)];
737
- if (resourceRows.at(-1) === "")
738
- resourceRows.pop();
739
- const headerRows = this.#extensionHeader !== null
740
- ? this.#extensionHeader.render(width)
741
- : this.#customViewport ? [] : this.header.render(width);
742
- const rows = [
743
- ...startupRows,
744
- ...headerRows,
745
- ...resourceRows,
746
- ];
747
- const promptAnchors = [];
748
- // Rationale: a block that is still streaming re-renders every row it owns, so the frame
749
- // reports where its rows begin. Damage-aware painting uses that to tell a live block's
750
- // own reflow apart from a repaint that reaches settled rows.
751
- let liveTailStartRow;
752
- for (let index = 0; index < this.#transcriptOrder.length; index += 1) {
753
- const id = this.#transcriptOrder[index];
754
- const block = this.#blocksById.get(id);
755
- if (!this.#thinkingVisible && block?.kind === "thinking")
756
- continue;
757
- const promptLike = block !== undefined
758
- && (block.kind === "user" || this.#customViewport && isPiPromptStyleCompaction(block));
759
- // Invariant: the custom viewport has already removed its rail gutter from this width.
760
- // Prompt-like and ordinary blocks therefore share one right content edge.
761
- const blockWidth = width;
762
- const blockRows = this.#blockRows(id, block, blockWidth);
763
- if (promptLike) {
764
- // Compatibility: the first natural prompt gets one breathing row at the document top.
765
- // Once scrolling advances, the prompt itself reaches row zero and then
766
- // becomes sticky there, so the spacer is never pinned with it.
767
- if (this.#customViewport && index === 0)
768
- rows.push("");
769
- else if (index > 0)
770
- rows.push("");
771
- }
772
- const firstRow = rows.length;
773
- if (liveTailStartRow === undefined && block !== undefined && block.status !== "finalized") {
774
- liveTailStartRow = firstRow;
775
- }
776
- rows.push(...blockRows);
777
- if (promptLike && blockRows[0] !== undefined) {
778
- const source = pinnedPromptSource(block, blockRows[0], blockWidth, this.#viewportController.config.timeFormat);
779
- promptAnchors.push({
780
- id: block.id,
781
- firstRow,
782
- lastRow: Math.max(firstRow, rows.length - 1),
783
- sourceRow: source.row,
784
- copySuffixWidth: source.copySuffixWidth,
785
- prominentSourceRow: blockRows[0],
786
- });
787
- }
788
- }
789
- // Invariant: the A1 release notice precedes the extension-package notice, matching pinned Pi's order.
790
- // Bare A1 docks it in the viewport tail instead of the document.
791
- rows.push(...diagnostics
792
- .filter(diagnostic => !this.#customViewport && diagnostic.code === "release-update")
793
- .flatMap(diagnostic => {
794
- const release = parseReleaseUpdateDiagnostic(diagnostic.message);
795
- return release === null ? [] : renderPiShellReleaseUpdateNotice(release, width);
796
- }));
797
- rows.push(...diagnostics
798
- .filter(diagnostic => diagnostic.code === "package-updates")
799
- .flatMap(diagnostic => renderPiShellPackageUpdateNotice(diagnostic.message.split("\n").filter(line => line.startsWith("- ")).map(line => line.slice(2)), width)));
800
- rows.push(...diagnostics
801
- .filter(diagnostic => diagnostic.code === "changelog-collapsed" || diagnostic.code === "changelog-expanded")
802
- // Rationale: bare A1 translates either startup diagnostic into transient dock chrome;
803
- // the pinned layout retains Pi's collapsed hint or expanded document.
804
- .flatMap(diagnostic => this.#customViewport
805
- ? []
806
- : diagnostic.code === "changelog-collapsed"
807
- ? createPiShellCollapsedChangelog().render(width)
808
- : createPiShellChangelog(diagnostic.message).render(width)));
809
- rows.push(...diagnostics
810
- .filter(diagnostic => diagnostic.code !== "engine-startup" && diagnostic.code !== "project-trust"
811
- && diagnostic.code !== "release-update"
812
- && diagnostic.code !== "package-updates" && diagnostic.code !== "changelog-collapsed"
813
- && diagnostic.code !== "changelog-expanded")
814
- .slice(-3)
815
- .flatMap(diagnostic => renderPiShellTranscriptBlock({
816
- id: `diagnostic-${diagnostic.sequence}`,
817
- kind: diagnostic.severity === "error" ? "error" : "system",
818
- status: "finalized",
819
- revision: diagnostic.sequence,
820
- title: diagnostic.code,
821
- text: diagnostic.message,
822
- payload: {},
823
- }, width, this.#cwd)));
824
- const layout = { rows: Object.freeze(rows), promptAnchors: Object.freeze(promptAnchors), liveTailStartRow };
825
- if (this.#extensionHeader === null) {
826
- this.#documentLayouts.set(width, layout);
827
- // Performance: the custom viewport commonly probes full width and reserved-rail width.
828
- while (this.#documentLayouts.size > 2)
829
- this.#documentLayouts.delete(this.#documentLayouts.keys().next().value);
830
- }
831
- return layout;
832
- }
833
- // Performance: finalized blocks cache by revision and width; live blocks always render.
834
- #blockRows(id, block, width) {
835
- const component = this.#transcript.get(id);
836
- if (component === undefined)
837
- return [];
838
- const render = () => {
839
- this.#transcriptBlockRenders += 1;
840
- const rows = component.render(width);
841
- if (!this.#customViewport || block?.kind === "user")
842
- return rows;
843
- return rows.map(row => nativeHyperlinkStyle(row, nativeTranscriptLinkColor));
844
- };
845
- if (block === undefined || block.status !== "finalized")
846
- return render();
847
- let byWidth = this.#renderedRows.get(id);
848
- const cached = byWidth?.get(width);
849
- const presentationRevision = component.presentationRevision ?? 0;
850
- if (cached?.revision === block.revision && cached.presentationRevision === presentationRevision)
851
- return cached.rows;
852
- const rows = render();
853
- if (byWidth === undefined) {
854
- byWidth = new Map();
855
- this.#renderedRows.set(id, byWidth);
856
- }
857
- byWidth.set(width, { revision: block.revision, presentationRevision, rows });
858
- // Invariant: bare A1 probes full width before reserving the overflowing rail column;
859
- // retain both widths without turning resize history into an unbounded cache.
860
- while (byWidth.size > 2)
861
- byWidth.delete(byWidth.keys().next().value);
862
- return rows;
442
+ return this.#document.layout(width).rows;
863
443
  }
864
444
  transcriptComponent(id) {
865
- return this.#transcript.get(id);
445
+ return this.#document.component(id);
866
446
  }
867
447
  exitTranscript(width) {
868
- const rows = [];
869
- for (const id of this.#transcriptOrder) {
870
- const block = this.#blocksById.get(id);
871
- if (block === undefined || (!this.#thinkingVisible && block.kind === "thinking"))
872
- continue;
873
- if (rows.length > 0 && (block.kind === "user" || this.#customViewport && isPiPromptStyleCompaction(block)))
874
- rows.push("");
875
- rows.push(...this.#blockRows(id, block, width).map(sanitizeExitTranscriptRow));
876
- }
877
- while (rows.at(-1) === "")
878
- rows.pop();
879
- return rows.join("\n");
448
+ return this.#document.exitTranscript(width);
880
449
  }
881
450
  appendWorkflowStatus(message) {
882
- // Rationale: bare A1 acknowledges commands in one dock notice above the editor, where the
883
- // reader's eye already is, instead of a transcript row that opens an empty session at the
884
- // top-left or sinks into a long feed. The pinned route keeps Pi's chat placement.
885
- if (this.#customViewport) {
886
- this.#dockNotice = { kind: "status", message };
887
- this.#invalidateChrome();
888
- return;
889
- }
890
- const previousId = this.#lastWorkflowStatusId;
891
- if (previousId !== undefined
892
- && this.#transcriptOrder.at(-1) === previousId
893
- && this.#workflowStatusAnchors.get(previousId) === this.#view.transcript.length) {
894
- this.#workflowStatusMessages.set(previousId, message);
895
- this.#documentLayouts.clear();
896
- this.invalidate();
897
- return;
898
- }
899
- const id = this.#appendAnchoredWorkflowComponent(width => [
900
- "",
901
- ...renderPiShellStatusText(this.#workflowStatusMessages.get(id) ?? message, width),
902
- ]);
903
- this.#workflowStatusMessages.set(id, message);
904
- this.#lastWorkflowStatusId = id;
451
+ this.#workflowOutput.appendStatus(message);
905
452
  }
906
453
  appendWorkflowMessage(message, errorCode) {
907
- if (message.kind === "status") {
908
- this.appendWorkflowStatus(message.message);
909
- return;
910
- }
911
- if (this.#customViewport && (message.kind === "warning" || message.kind === "error")) {
912
- this.#dockNotice = {
913
- kind: errorCode === "image-count" ? "warning" : message.kind,
914
- message: message.message,
915
- ...(errorCode === undefined ? {} : { errorCode }),
916
- };
917
- this.#invalidateChrome();
918
- return;
919
- }
920
- this.#lastWorkflowStatusId = undefined;
921
- const presentation = { kind: message.kind, message: message.message };
922
- this.#appendAnchoredWorkflowComponent(width => renderPiShellCommandMessage(presentation, width, this.#outputPad));
454
+ this.#workflowOutput.appendMessage(message, errorCode);
923
455
  }
924
456
  appendWorkflowResult(result, errorCode) {
925
- if (result.messages !== undefined) {
926
- for (const message of result.messages)
927
- this.appendWorkflowMessage(message, errorCode);
928
- return;
929
- }
930
- if (result.messageKind === "silent" || (result.outcome === "cancelled" && result.messageKind === undefined))
931
- return;
932
- if (result.command === "reload" && result.outcome === "completed") {
933
- this.appendWorkflowStatus(result.message);
934
- return;
935
- }
936
- if (result.command === "session" && result.outcome === "completed" && result.presentation?.kind === "session-info") {
937
- this.#lastWorkflowStatusId = undefined;
938
- const sessionInfo = createPiShellSessionInfo(result.presentation);
939
- this.#appendAnchoredWorkflowComponent(width => sessionInfo.render(width), () => sessionInfo.dispose?.());
940
- return;
941
- }
942
- if (result.command === "hotkeys" && result.outcome === "completed") {
943
- this.#lastWorkflowStatusId = undefined;
944
- const presentation = this.hotkeysPresentation();
945
- const hotkeys = createPiShellHotkeys(presentation.bindings, presentation.getShortcuts, presentation.profile, presentation.residentTabs);
946
- this.#appendAnchoredWorkflowComponent(width => hotkeys.render(width), () => hotkeys.dispose?.());
947
- return;
948
- }
949
- if (result.command === "changelog" && result.outcome === "completed") {
950
- this.#lastWorkflowStatusId = undefined;
951
- const changelog = createPiShellChangelog(result.detail ?? "No changelog entries found.");
952
- this.#appendAnchoredWorkflowComponent(width => changelog.render(width), () => changelog.dispose?.());
953
- return;
954
- }
955
- if (result.outcome === "failed") {
956
- this.appendWorkflowMessage({
957
- kind: result.messageKind === "warning" ? "warning" : "error",
958
- message: result.message,
959
- }, errorCode);
960
- return;
961
- }
962
- if (result.outcome === "completed" && (result.command === "quit" || result.command === "compact"))
963
- return;
964
- if (result.outcome === "completed" && result.command === "new" && this.#customViewport) {
965
- // Rationale: bare A1 keeps the replacement session visually empty while preserving Pi's accent notice shape.
966
- this.#dockNotice = { kind: "new-session", message: result.message };
967
- this.#invalidateChrome();
968
- return;
969
- }
970
- if (result.outcome === "completed" && (result.command === "new" || result.command === "name" || result.command === "debug")) {
971
- this.#lastWorkflowStatusId = undefined;
972
- const presentation = { kind: result.command, message: result.message, ...(result.detail === undefined ? {} : { detail: result.detail }) };
973
- this.#appendAnchoredWorkflowComponent(width => renderPiShellCommandMessage(presentation, width, this.#outputPad));
974
- return;
975
- }
976
- if (result.command === "arminsayshi" && result.outcome === "completed") {
977
- this.#lastWorkflowStatusId = undefined;
978
- const armin = createPiShellArmin(this.#componentRuntime);
979
- this.#appendAnchoredWorkflowComponent(width => ["", ...armin.render(width)], () => armin.dispose?.());
980
- return;
981
- }
982
- if (result.command === "dementedelves" && result.outcome === "completed") {
983
- this.#lastWorkflowStatusId = undefined;
984
- const announcement = createPiShellEarendilAnnouncement();
985
- this.#appendAnchoredWorkflowComponent(width => ["", ...announcement.render(width)], () => announcement.dispose?.());
986
- return;
987
- }
988
- // Rationale: the pinned engine links both share URLs; the workflow result carries the plain URLs.
989
- const message = result.command !== "share" ? result.message
990
- : result.detail ? `${linkShareUrl(result.message)}\nGist: ${piShellHyperlink(result.detail)}`
991
- // Protocol: Radius shares carry only the artifact URL.
992
- : linkShareUrl(result.message);
993
- this.appendWorkflowStatus(message);
457
+ this.#workflowOutput.appendResult(result, errorCode);
994
458
  }
995
459
  toggleThinkingVisibility() {
996
460
  this.setHideThinkingBlock(this.#thinkingVisible);
@@ -1002,39 +466,25 @@ export class OwnedUiSessionShellRoot {
1002
466
  this.#setToolsExpanded(expanded);
1003
467
  }
1004
468
  setExtensionWidget(key, component, placement) {
1005
- const previous = this.#extensionWidgets.get(key)?.component;
1006
- if (component === null)
1007
- this.#extensionWidgets.delete(key);
1008
- else
1009
- this.#extensionWidgets.set(key, { component, placement });
1010
- if (previous !== undefined && previous !== component)
1011
- previous.dispose?.();
469
+ this.#extensions.placeWidget(key, component, placement);
1012
470
  this.invalidate();
1013
471
  }
1014
472
  setExtensionHeader(component) {
1015
- if (this.#extensionHeader !== component)
1016
- this.#extensionHeader?.dispose?.();
1017
- this.#extensionHeader = component;
1018
- this.#documentLayouts.clear();
473
+ this.#extensions.replaceHeader(component);
474
+ this.#document.invalidate("extension-header");
1019
475
  this.invalidate();
1020
476
  }
1021
477
  setExtensionFooter(component) {
1022
- if (this.#extensionFooter !== component)
1023
- this.#extensionFooter?.dispose?.();
1024
- this.#extensionFooter = component;
478
+ this.#extensions.replaceFooter(component);
1025
479
  this.invalidate();
1026
480
  }
1027
481
  setExtensionStatus(key, text) {
1028
- if (text === undefined)
1029
- this.#extensionStatuses.delete(key);
1030
- else
1031
- this.#extensionStatuses.set(key, text);
1032
- this.#footer.update(this.#viewWithExtensionStatuses(this.#view));
482
+ this.#extensions.setStatus(key, text);
483
+ this.#footer.update(this.#extensions.withStatuses(this.#view));
1033
484
  this.invalidate();
1034
485
  }
1035
- setExtensionWorking(message, visible = this.#extensionWorkingVisible) {
1036
- this.#extensionWorkingMessage = message;
1037
- this.#extensionWorkingVisible = visible;
486
+ setExtensionWorking(message, visible = this.#extensions.working.visible) {
487
+ this.#extensions.setWorking(message, visible);
1038
488
  this.#syncWorkingOverride();
1039
489
  this.invalidate();
1040
490
  }
@@ -1056,7 +506,7 @@ export class OwnedUiSessionShellRoot {
1056
506
  const sending = this.#imageSubmissionsSending > 0;
1057
507
  this.#status.setWorkingOverride(sending
1058
508
  ? "Sending…"
1059
- : this.#extensionWorkingVisible ? this.#extensionWorkingMessage : undefined, sending);
509
+ : this.#extensions.working.visible ? this.#extensions.working.message : undefined, sending);
1060
510
  }
1061
511
  hotkeysPresentation() {
1062
512
  return {
@@ -1066,34 +516,17 @@ export class OwnedUiSessionShellRoot {
1066
516
  };
1067
517
  }
1068
518
  resetWorkflowPresentation() {
1069
- this.#dockNotice = undefined;
1070
- this.#clearCopyAcknowledgement(false);
1071
- for (const id of this.#workflowStatusAnchors.keys()) {
1072
- this.#transcript.get(id)?.dispose?.();
1073
- this.#transcript.delete(id);
1074
- }
1075
- this.#workflowStatusAnchors.clear();
1076
- this.#workflowStatusMessages.clear();
1077
- this.#transcriptOrder = this.#transcriptOrder.filter(id => !id.startsWith("workflow-status-"));
1078
- this.#lastWorkflowStatusId = undefined;
1079
- this.#documentLayouts.clear();
519
+ this.#dockChrome.forgetNotice();
520
+ this.#dockChrome.clearCopyAcknowledgement(false);
521
+ this.#workflowOutput.reset();
1080
522
  this.invalidate();
1081
523
  }
1082
524
  resetExtensionUi() {
1083
- this.#extensionHeader?.dispose?.();
1084
- this.#extensionFooter?.dispose?.();
1085
- for (const { component } of this.#extensionWidgets.values())
1086
- component.dispose?.();
1087
- this.#extensionHeader = null;
1088
- this.#extensionFooter = null;
1089
- this.#extensionWidgets.clear();
1090
- this.#extensionStatuses.clear();
1091
- this.#extensionWorkingMessage = undefined;
525
+ this.#extensions.reset();
1092
526
  this.#syncWorkingOverride();
1093
- this.#footer.update(this.#viewWithExtensionStatuses(this.#view));
527
+ this.#footer.update(this.#extensions.withStatuses(this.#view));
1094
528
  // Invariant: an extension renderer may have drawn transcript blocks that are now unrendered by it.
1095
- this.#renderedRows.clear();
1096
- this.#documentLayouts.clear();
529
+ this.#document.invalidate("extension-renderers");
1097
530
  this.invalidate();
1098
531
  }
1099
532
  addExtensionNotification(message, type) {
@@ -1106,268 +539,93 @@ export class OwnedUiSessionShellRoot {
1106
539
  extensionFooterData() {
1107
540
  return {
1108
541
  getGitBranch: () => this.#view.status.footer?.branch ?? null,
1109
- getExtensionStatuses: () => new Map(this.#extensionStatuses),
542
+ getExtensionStatuses: () => this.#extensions.statuses(),
1110
543
  getAvailableProviderCount: () => this.#view.status.footer?.availableProviderCount ?? 1,
1111
544
  onBranchChange: () => () => { },
1112
545
  };
1113
546
  }
1114
547
  usesDefaultInputSurface() {
1115
- return this.#inputSurface === this.editor;
548
+ return this.#input.usesEditor;
1116
549
  }
1117
550
  inputCoordinationSurface() {
1118
- return this.#inputSurfaceCoordination;
551
+ return this.#input.coordination;
1119
552
  }
1120
553
  viewportCompositionEvidence() {
1121
- return { full: this.#fullViewportCompositions, dockOnly: this.#dockOnlyViewportCompositions };
554
+ return this.#layout.compositions();
1122
555
  }
1123
556
  transcriptRenderCount() {
1124
- return this.#transcriptBlockRenders;
557
+ return this.#document.renderCount;
1125
558
  }
1126
559
  setFooterLevel(visible) { this.#footerLevel = visible; }
1127
560
  setInputSurface(component, disposePrevious = true, coordination = "owned") {
1128
- const next = component ?? this.editor;
1129
- const nextCoordination = next === this.editor ? "editor" : coordination;
1130
- if (next === this.#inputSurface) {
1131
- this.#inputSurfaceCoordination = nextCoordination;
1132
- return;
1133
- }
1134
- this.#dockInputCandidate = false;
1135
- this.#viewportController.invalidateInputSurface();
1136
- this.#inputSurface.setFocused?.(false);
1137
- if (disposePrevious && this.#inputSurface !== this.editor)
1138
- this.#inputSurface.dispose?.();
1139
- this.#inputSurface = next;
1140
- this.#inputSurfaceCoordination = nextCoordination;
1141
- this.#onInputSurfaceChanged?.();
1142
- this.#inputSurface.setFocused?.(true);
1143
- this.invalidate();
561
+ if (this.#input.set(component, disposePrevious, coordination))
562
+ this.invalidate();
1144
563
  }
1145
564
  handleInput(data) {
1146
- this.#dockInputCandidate = this.#customViewport && this.#dockInputReuseEnabled
1147
- && classifyPiTuiInput(data, this.#inputSurfaceCoordination) === "safe";
1148
- this.#dockInputRevision = this.#dockInputCandidate ? this.#viewportController.presentationRevision : undefined;
1149
- try {
1150
- this.#inputSurface.handleInput?.(data);
1151
- }
1152
- catch (error) {
1153
- this.#dockInputCandidate = false;
1154
- throw error;
1155
- }
565
+ this.#input.handleInput(data);
1156
566
  }
1157
567
  invalidate() {
1158
- this.#renderedRows.clear();
1159
- this.#documentLayouts.clear();
1160
- for (const component of this.#transcript.values())
1161
- component.invalidate();
568
+ this.#document.invalidate("repaint");
1162
569
  this.#invalidateChrome();
1163
570
  }
1164
571
  #invalidateChrome() {
1165
572
  this.header.invalidate();
1166
573
  this.resources.invalidate();
1167
574
  this.editor.invalidate();
1168
- if (this.#inputSurface !== this.editor)
1169
- this.#inputSurface.invalidate();
1170
- this.#invalidateExtensions();
575
+ if (!this.#input.usesEditor)
576
+ this.#input.surface.invalidate();
577
+ this.#extensions.invalidate();
1171
578
  this.#status.invalidate();
1172
579
  this.#footer.invalidate();
1173
580
  this.#queued.invalidate();
1174
581
  }
1175
582
  setFocused(focused) {
1176
- this.#inputSurface.setFocused?.(focused);
583
+ this.#input.surface.setFocused?.(focused);
1177
584
  if (focused)
1178
585
  this.#onPromptSuggestionEligibilityChanged?.();
1179
586
  }
1180
587
  dispose() {
1181
- this.#clearCopyAcknowledgement(false);
588
+ this.#dockChrome.clearCopyAcknowledgement(false);
1182
589
  this.clearViewportPointerState();
1183
590
  this.#themeUnsubscribe();
1184
591
  this.header.dispose?.();
1185
592
  this.resources.dispose?.();
1186
- for (const component of this.#transcript.values())
1187
- component.dispose?.();
1188
- this.#transcript.clear();
1189
- this.#renderedRows.clear();
1190
- this.#documentLayouts.clear();
1191
- this.#visibleViewportSnapshot = undefined;
1192
- this.#dockInputCandidate = false;
1193
- this.#dockInputRevision = undefined;
1194
- if (this.#inputSurface !== this.editor)
1195
- this.#inputSurface.dispose?.();
1196
- this.#extensionHeader?.dispose?.();
1197
- this.#extensionFooter?.dispose?.();
1198
- for (const { component } of this.#extensionWidgets.values())
1199
- component.dispose?.();
1200
- this.#extensionWidgets.clear();
593
+ this.#document.dispose();
594
+ this.#layout.invalidate();
595
+ this.#input.dispose();
596
+ this.#extensions.dispose();
1201
597
  this.editor.dispose?.();
1202
598
  this.#status.dispose?.();
1203
599
  this.#footer.dispose?.();
1204
600
  this.#queued.dispose?.();
1205
601
  }
1206
602
  #invalidateTranscriptPresentation() {
1207
- this.#renderedRows.clear();
1208
- this.#documentLayouts.clear();
603
+ this.#document.invalidate("block-presentation");
1209
604
  this.invalidate();
1210
605
  }
1211
- #syncTranscript(blocks) {
1212
- const previousIds = this.#transcriptOrder.filter(id => !id.startsWith("workflow-status-"));
1213
- const transcriptChanged = previousIds.length !== blocks.length || blocks.some((block, index) => {
1214
- const previous = this.#blocksById.get(block.id);
1215
- return previousIds[index] !== block.id || previous?.revision !== block.revision;
1216
- });
1217
- if (transcriptChanged)
1218
- this.#documentLayouts.clear();
1219
- this.#blocksById.clear();
1220
- for (const block of blocks)
1221
- this.#blocksById.set(block.id, block);
1222
- const nextIds = new Set(blocks.map(block => block.id));
1223
- for (const [id, component] of this.#transcript) {
1224
- if (id.startsWith("workflow-status-") || nextIds.has(id))
1225
- continue;
1226
- component.dispose?.();
1227
- this.#transcript.delete(id);
1228
- this.#renderedRows.delete(id);
1229
- }
1230
- for (const block of blocks) {
1231
- const component = this.#transcript.get(block.id);
1232
- if (component === undefined) {
1233
- const created = this.#mountTranscript(block);
1234
- this.#transcript.set(block.id, created);
1235
- }
1236
- else if (component.revision !== block.revision) {
1237
- component.update(block);
1238
- }
1239
- }
1240
- const statusIds = [...this.#workflowStatusAnchors.keys()];
1241
- const order = [];
1242
- for (let index = 0; index <= blocks.length; index += 1) {
1243
- for (const statusId of statusIds) {
1244
- if (this.#workflowStatusAnchors.get(statusId) === index)
1245
- order.push(statusId);
1246
- }
1247
- const block = blocks[index];
1248
- if (block !== undefined)
1249
- order.push(block.id);
1250
- }
1251
- const placed = new Set(order);
1252
- for (const statusId of statusIds) {
1253
- if (!placed.has(statusId))
1254
- order.push(statusId);
1255
- }
1256
- this.#transcriptOrder = order;
1257
- }
1258
- #appendAnchoredWorkflowComponent(render, dispose) {
1259
- // Invariant: transcript-bound workflow output supersedes a pending acknowledgement.
1260
- this.dismissNotice();
1261
- this.#workflowTranscriptSequence += 1;
1262
- const id = `workflow-status-${this.#workflowTranscriptSequence}`;
1263
- const component = {
1264
- id,
1265
- revision: 1,
1266
- render,
1267
- handleInput() { },
1268
- invalidate() { },
1269
- update() { },
1270
- setExpanded() { },
1271
- setOutputPad() { },
1272
- setHideThinkingBlock() { },
1273
- setMermaidRenderingMode() { },
1274
- setImagePresentation() { },
1275
- ...(dispose === undefined ? {} : { dispose }),
1276
- };
1277
- this.#transcript.set(id, component);
1278
- this.#workflowStatusAnchors.set(id, this.#view.transcript.length);
1279
- this.#transcriptOrder.push(id);
1280
- this.#documentLayouts.clear();
1281
- this.invalidate();
1282
- return id;
1283
- }
1284
- #renderWidgets(placement, width) {
1285
- const rows = [...this.#extensionWidgets.values()]
1286
- .filter(widget => widget.placement === placement)
1287
- .flatMap(widget => widget.component.render(width));
1288
- return placement === "aboveEditor" ? ["", ...rows] : rows;
1289
- }
1290
606
  #renderStatus(width) {
1291
607
  return this.#customViewport ? this.#status.renderLive(width) : this.#status.render(width);
1292
608
  }
1293
609
  #renderFooter(width) {
1294
- return (this.#extensionFooter ?? this.#footer).render(width);
1295
- }
1296
- #viewWithExtensionStatuses(view) {
1297
- const footer = view.status.footer;
1298
- const statuses = new Map(footer?.extensionStatuses ?? []);
1299
- for (const [key, text] of this.#extensionStatuses)
1300
- statuses.set(key, text);
1301
- return {
1302
- ...view,
1303
- status: {
1304
- ...view.status,
1305
- footer: {
1306
- ...footer,
1307
- branch: footer?.branch ?? null,
1308
- sessionName: footer?.sessionName ?? null,
1309
- availableProviderCount: footer?.availableProviderCount ?? 1,
1310
- extensionStatuses: [...statuses],
1311
- },
1312
- },
1313
- };
1314
- }
1315
- #invalidateExtensions() {
1316
- this.#extensionHeader?.invalidate();
1317
- this.#extensionFooter?.invalidate();
1318
- for (const { component } of this.#extensionWidgets.values())
1319
- component.invalidate();
610
+ return (this.#extensions.footer ?? this.#footer).render(width);
1320
611
  }
1321
612
  #setToolsExpanded(expanded) {
1322
613
  this.#toolsExpanded = expanded;
1323
614
  this.header.setExpanded(expanded);
1324
615
  this.resources.setExpanded(expanded);
1325
- for (const component of this.#transcript.values())
616
+ for (const component of this.#document.components())
1326
617
  component.setExpanded(expanded);
1327
618
  // Invariant: expansion changes what a block draws without changing its revision.
1328
- this.#renderedRows.clear();
1329
- this.#documentLayouts.clear();
619
+ this.#document.invalidate("tools-expanded");
1330
620
  this.invalidate();
1331
621
  }
1332
622
  }
1333
- const COPY_ACKNOWLEDGEMENT_DURATION_MS = 1_000;
1334
623
  const SCROLLBAR_CELL_RESET = "\u001b[22;23;24;25;27;28;29;39;54;55m";
1335
- const KITTY_IMAGE_CONTROL = /\u001b_G[\s\S]*?\u001b\\/g;
1336
- const ITERM_IMAGE_CONTROL = /\u001b]1337;File=[^\u0007]*(?:\u0007|\u001b\\)/g;
1337
- /** Keep semantic SGR/OSC styling while excluding non-replayable image payloads. */
1338
- function sanitizeExitTranscriptRow(row) {
1339
- return row.replace(KITTY_IMAGE_CONTROL, "").replace(ITERM_IMAGE_CONTROL, "");
1340
- }
1341
624
  const TERMINAL_BACKGROUND = /\u001b\[(?:4[0-9]|10[0-7]|48(?:[;:][0-9;:]*)?)m/g;
1342
- /** Web URLs use link blue; file targets retain A1's cyan interactive accent. */
1343
- function nativeTranscriptLinkColor(text, target) {
1344
- return piTheme().fg(/^file:/iu.test(target) ? "accent" : "mdLink", text);
1345
- }
1346
625
  /** Repaint a source prompt with viewport chrome while preserving text, links, and foreground roles. */
1347
626
  function withoutTerminalBackground(text) {
1348
627
  return text.replace(TERMINAL_BACKGROUND, "");
1349
628
  }
1350
- /** Preserve the stable metadata-grey foreground for every pinned timestamp state. */
1351
- function pinnedPromptSource(block, sourceRow, width, timeFormat) {
1352
- const unchanged = { row: sourceRow, copySuffixWidth: 0 };
1353
- if (typeof block.payload !== "object" || block.payload === null)
1354
- return unchanged;
1355
- const value = block.payload.timestamp;
1356
- if (typeof value !== "number" || !Number.isFinite(value))
1357
- return unchanged;
1358
- const format = timeFormat ?? "8:00 PM";
1359
- const timestamp = formatSubmittedPromptTime(value, format);
1360
- if (timestamp === null || submittedPromptLayout(width, value, format).timestamp === null)
1361
- return unchanged;
1362
- const rowWidth = displayWidth(sourceRow);
1363
- const timestampWidth = displayWidth(timestamp);
1364
- if (rowWidth < timestampWidth)
1365
- return unchanged;
1366
- return {
1367
- row: overlaySpan(sourceRow, rowWidth - timestampWidth, rowWidth, paintPiSubmittedPromptTimestamp(timestamp)),
1368
- copySuffixWidth: timestampWidth,
1369
- };
1370
- }
1371
629
  /**
1372
630
  * The pinned-profile screen: a followed transcript scroll above the dock. The runtime mounts one such layout,
1373
631
  * so its single `transcript` scroll view is shared by whichever session the parts currently resolve to.
@@ -1577,20 +835,13 @@ function isPackageExtensionSource(sourceInfo) {
1577
835
  const source = sourceInfo?.source ?? "";
1578
836
  return source.startsWith("npm:") || source.startsWith("git:");
1579
837
  }
1580
- function transientRowsSignature(steeringRows, statusRows) {
1581
- return `${steeringRows.length}\u0000${steeringRows.join("\u0000")}\u0001${statusRows.length}\u0000${statusRows.join("\u0000")}`;
1582
- }
1583
- /** The release, command, and optional changelog carried by the adapter's `release-update` diagnostic. */
1584
- function parseReleaseUpdateDiagnostic(message) {
1585
- const [instruction, changelog] = message.split("\n");
1586
- const match = /^New version (\S+) is available\. Run (.+)$/.exec(instruction ?? "");
1587
- if (match === null)
1588
- return null;
1589
- const url = changelog === undefined ? null : /^Changelog: (\S+)$/.exec(changelog)?.[1] ?? null;
1590
- return { version: match[1], command: match[2], changelogUrl: url };
1591
- }
1592
- /** "Share URL: <url>" with the URL made clickable; other share wordings pass through unchanged. */
1593
- function linkShareUrl(message) {
1594
- const match = /^Share URL: (\S+)$/.exec(message);
1595
- return match === null ? message : `Share URL: ${piShellHyperlink(match[1])}`;
1596
- }
838
+ // Performance: stable painter identities let the neutral viewport retain row-level
839
+ // selection variants while each callback still resolves the live Pi theme.
840
+ const VIEWPORT_THEME = {
841
+ track: text => `${SCROLLBAR_CELL_RESET}${piTheme().fg("dim", text)}`,
842
+ thumb: text => `${SCROLLBAR_CELL_RESET}${piTheme().fg("accent", text)}`,
843
+ sticky: (text, hovered) => piTheme().bg(hovered ? "selectedBg" : "toolPendingBg", piTheme().fg("text", withoutTerminalBackground(text))),
844
+ quietSticky: text => `\u001b[2m${text.replace(/\u001b\[(?:0|22)m/g, "$&\u001b[2m")}\u001b[22m`,
845
+ bottomControl: (text, hovered) => piTheme().bg(hovered ? "selectedBg" : "toolPendingBg", piTheme().fg("text", text)),
846
+ selection: (line, from, to) => backgroundSgrSpan(line, from, to, "\u001b[48;2;38;79;120m", "\u001b[49m"),
847
+ };