@oh-my-pi/pi-tui 18.9.2 → 18.10.0

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.
@@ -1,5 +1,4 @@
1
- import { existsSync, unlinkSync } from "node:fs";
2
- import { createServer, type Server, type Socket } from "node:net";
1
+ import { type IpcConnection, IpcServer } from "@oh-my-pi/pi-natives";
3
2
  import { type Component, isFocusable, type OverlayOptions, type TUI } from "./tui";
4
3
  import { plainText } from "./native/spans";
5
4
 
@@ -209,72 +208,60 @@ function mouseSequence(x: number, y: number, action: string): string {
209
208
  export class TuiDebugServer {
210
209
  readonly #tui: TUI;
211
210
  readonly #path: string;
212
- #server: Server | undefined;
213
- #sockets = new Set<Socket>();
211
+ #server: IpcServer | undefined;
212
+ #listening: Promise<boolean> | undefined;
213
+ #stopped = false;
214
+ #connections = new Set<IpcConnection>();
214
215
 
215
216
  constructor(tui: TUI, path: string) {
216
217
  this.#tui = tui;
217
218
  this.#path = path;
218
219
  }
219
220
 
221
+ /** Resolves true once the socket accepts connections, false if listen failed or the server stopped first. */
222
+ get ready(): Promise<boolean> {
223
+ return this.#listening ?? Promise.resolve(false);
224
+ }
225
+
220
226
  start(): void {
221
- if (this.#server !== undefined) return;
222
- try {
223
- if (existsSync(this.#path)) unlinkSync(this.#path);
224
- } catch {
225
- // The listen error below is isolated from the host application.
226
- }
227
- const server = createServer(socket => this.#accept(socket));
228
- this.#server = server;
229
- server.on("error", error => {
230
- void error;
231
- });
232
- try {
233
- server.listen(this.#path);
234
- } catch {
235
- this.#server = undefined;
236
- server.close();
237
- }
227
+ if (this.#listening !== undefined) return;
228
+ this.#stopped = false;
229
+ this.#listening = IpcServer.listen(this.#path, { framing: "lines" }, connection => this.#accept(connection)).then(
230
+ server => {
231
+ if (this.#stopped) {
232
+ void server.close().catch(() => {});
233
+ return false;
234
+ }
235
+ this.#server = server;
236
+ return true;
237
+ },
238
+ () => false, // A live peer or unusable path disables the debug socket; the host application is unaffected.
239
+ );
238
240
  }
239
241
 
240
242
  stop(): void {
243
+ this.#stopped = true;
241
244
  const server = this.#server;
242
245
  this.#server = undefined;
243
- for (const socket of this.#sockets) socket.destroy();
244
- this.#sockets.clear();
245
- try {
246
- server?.close();
247
- } catch {
248
- // A failed or already-closed listener still needs its stale path removed.
249
- }
250
- try {
251
- if (existsSync(this.#path)) unlinkSync(this.#path);
252
- } catch {
253
- // Teardown must not affect the host application.
254
- }
246
+ this.#listening = undefined;
247
+ for (const connection of this.#connections) connection.destroy();
248
+ this.#connections.clear();
249
+ void server?.close().catch(() => {});
255
250
  }
256
251
 
257
- #accept(socket: Socket): void {
258
- this.#sockets.add(socket);
259
- let buffer = "";
260
- socket.setEncoding("utf8");
261
- socket.on("data", chunk => {
262
- buffer += chunk;
263
- let newline = buffer.indexOf("\n");
264
- while (newline !== -1) {
265
- const line = buffer.slice(0, newline).replace(/\r$/, "");
266
- buffer = buffer.slice(newline + 1);
267
- if (line.length > 0) this.#handleLine(socket, line);
268
- newline = buffer.indexOf("\n");
269
- }
270
- });
271
- socket.on("error", error => {
272
- void error;
273
- });
274
- socket.on("close", () => this.#sockets.delete(socket));
252
+ #accept(connection: IpcConnection): void {
253
+ if (this.#stopped) {
254
+ connection.destroy();
255
+ return;
256
+ }
257
+ this.#connections.add(connection);
258
+ connection.start(
259
+ line => void this.#handleLine(connection, line),
260
+ () => this.#connections.delete(connection),
261
+ );
275
262
  }
276
263
 
277
- #handleLine(socket: Socket, line: string): void {
264
+ async #handleLine(connection: IpcConnection, line: string): Promise<void> {
278
265
  let request: DebugRequest;
279
266
  try {
280
267
  const parsed: unknown = JSON.parse(line);
@@ -282,36 +269,31 @@ export class TuiDebugServer {
282
269
  throw new Error("request must be an object");
283
270
  request = parsed as DebugRequest;
284
271
  } catch (error) {
285
- this.#write(socket, { ok: false, error: `malformed JSON: ${errorMessage(error)}` });
272
+ this.#write(connection, { ok: false, error: `malformed JSON: ${errorMessage(error)}` });
286
273
  return;
287
274
  }
275
+ let response: DebugResponse;
288
276
  try {
289
- const response = this.#dispatch(request);
290
- if (request.op === "quit" && response.ok) {
291
- this.#write(socket, response, () => {
292
- this.#tui.stop();
293
- process.exit(0);
294
- });
295
- return;
296
- }
297
- this.#write(socket, response);
277
+ response = this.#dispatch(request);
298
278
  } catch (error) {
299
- this.#write(socket, { ok: false, error: errorMessage(error) });
279
+ response = { ok: false, error: errorMessage(error) };
280
+ }
281
+ this.#write(connection, response);
282
+ if (request.op === "quit" && response.ok) {
283
+ await connection.drained();
284
+ this.#tui.stop();
285
+ process.exit(0);
300
286
  }
301
287
  }
302
288
 
303
- #write(socket: Socket, response: DebugResponse, flushed?: () => void): void {
289
+ #write(connection: IpcConnection, response: DebugResponse): void {
304
290
  let line: string;
305
291
  try {
306
292
  line = JSON.stringify(response);
307
293
  } catch (error) {
308
294
  line = JSON.stringify({ ok: false, error: errorMessage(error) });
309
295
  }
310
- try {
311
- socket.write(`${line}\n`, flushed);
312
- } catch {
313
- // Broken clients cannot affect the TUI.
314
- }
296
+ connection.send(line);
315
297
  }
316
298
 
317
299
  #dispatch(request: DebugRequest): DebugResponse {
@@ -135,6 +135,7 @@ function hotkeyGroups(bindings: HotkeysMarkdownBindings): HotkeyGroup[] {
135
135
  { keys: [act("app.history.search")], action: "Search prompt history" },
136
136
  { keys: [act("app.tools.expand")], action: "Toggle tool output expansion" },
137
137
  { keys: [act("app.tools.toggleVisibility")], action: "Toggle tool activity visibility" },
138
+ { keys: [act("app.ask.hide")], action: "Hide/show a pending ask question" },
138
139
  { keys: [act("app.thinking.toggle")], action: "Toggle thinking block visibility" },
139
140
  { keys: [act("app.editor.external")], action: "Edit message in external editor" },
140
141
  { keys: [act("app.retry")], action: "Retry last failed assistant turn" },
@@ -81,7 +81,7 @@ import {
81
81
  import { optionMarker } from "../tools/ask";
82
82
  import { CountdownTimer } from "../chrome/countdown-timer";
83
83
  import { editorKey, editorKeys } from "../chrome/keybinding-hints";
84
- import { formatKeyHint, formatKeyHints } from "../app-keybindings";
84
+ import { formatKeyHint, formatKeyHints, formatTooltipKey } from "../app-keybindings";
85
85
  import { OverlayPanel, PanelDivider, PanelRows } from "../chrome/overlay-box";
86
86
  import { handleTabSwitchKey } from "../chrome/selector-helpers";
87
87
  import { col, node, span, text } from "../native/describe";
@@ -524,7 +524,6 @@ export function normalizeDialogQuestions(questions: ExtensionAskDialogQuestion[]
524
524
  }
525
525
 
526
526
  export class AskDialogComponent implements Component {
527
- readonly retireDisplacedTranscript = true;
528
527
  #states: QuestionState[];
529
528
  #activeTabIndex = 0;
530
529
  #submitScrollOffset = 0;
@@ -543,6 +542,8 @@ export class AskDialogComponent implements Component {
543
542
  #contentWidth = 76;
544
543
  #headerExpandable = false;
545
544
  #descExpandable = false;
545
+ /** Collapsed to one line (`app.ask.hide` / the Hide button) while the question stays pending. */
546
+ #hidden = false;
546
547
  /** Memoized native description; dropped whenever dialog state may have changed. */
547
548
  #native: NativeNode | undefined;
548
549
  /** Input-guard blocked state {@link #native} was described with (the guard flips outside this dialog). */
@@ -632,7 +633,7 @@ export class AskDialogComponent implements Component {
632
633
  }
633
634
 
634
635
  #canToggleExpansion(): boolean {
635
- if (this.#closed || this.#isSubmitTab()) return false;
636
+ if (this.#closed || this.#hidden || this.#isSubmitTab()) return false;
636
637
  const question = this.#questions[this.#currentQuestionIndex()];
637
638
  if (!question) return false;
638
639
  if (this.#expanded) return true;
@@ -652,6 +653,10 @@ export class AskDialogComponent implements Component {
652
653
  this.#finishCancel();
653
654
  return;
654
655
  }
656
+ if (getKeybindings().matches(keyData, "app.ask.hide")) {
657
+ this.#setHidden(!this.#hidden);
658
+ return;
659
+ }
655
660
  // Expand before the draft-input guard so Ctrl+O can reveal a truncated
656
661
  // question even while a pending prompt still owns typing.
657
662
  if (matchesAppToolsExpand(keyData)) {
@@ -664,6 +669,12 @@ export class AskDialogComponent implements Component {
664
669
  this.#requestRender();
665
670
  return;
666
671
  }
672
+ if (this.#hidden) {
673
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
674
+ this.#setHidden(false);
675
+ }
676
+ return;
677
+ }
667
678
  if (this.#hasSubmitTab() && handleTabSwitchKey(keyData, direction => this.#switchTab(direction))) {
668
679
  this.#requestRender();
669
680
  return;
@@ -680,6 +691,7 @@ export class AskDialogComponent implements Component {
680
691
  // renders as the next sibling in the same container, so this lands in the
681
692
  // same frame).
682
693
  this.options.inputGuard?.syncPresentation?.();
694
+ if (this.#hidden) return this.#renderHidden(width);
683
695
  const innerWidth = Math.max(1, width - 4);
684
696
  this.#contentWidth = innerWidth;
685
697
  // Fixed panel height: measured from the tallest tab at spawn and
@@ -725,6 +737,11 @@ export class AskDialogComponent implements Component {
725
737
  const ring = this.#countdown !== undefined && cx.supports("meter");
726
738
  if (ring) this.#startRingTick();
727
739
  if (this.#native && this.#nativeBlocked === blocked) return this.#native;
740
+ if (this.#hidden) {
741
+ this.#native = this.#describeHidden(ring);
742
+ this.#nativeBlocked = blocked;
743
+ return this.#native;
744
+ }
728
745
  const children: NativeChild[] = [];
729
746
  const head = this.#describeHead(ring);
730
747
  if (head) children.push(head);
@@ -752,31 +769,52 @@ export class AskDialogComponent implements Component {
752
769
  }
753
770
  if (this.#countdown) {
754
771
  children.push(node("spacer", { grow: 1 }));
755
- if (ring) {
756
- const total = Math.max(1, this.options.timeout ?? 1);
757
- const left = Math.max(0, this.#countdownDeadline - Date.now());
758
- children.push(
759
- node(
760
- "meter",
761
- {
762
- value: Math.round((left / total) * 1000) / 1000,
763
- style: "ring",
764
- size: "sm",
765
- label: `${Math.ceil(left / 1000)}s`,
766
- title: "Answers the recommended option when the time runs out",
767
- },
768
- undefined,
769
- "timer",
770
- ),
771
- );
772
- } else {
773
- children.push(this.#countdown.describe());
774
- }
772
+ children.push(this.#describeTimer(this.#countdown, ring));
775
773
  }
776
774
  if (children.length === 0) return undefined;
777
775
  return node("row", { role: "omp.ask.head", gap: "sm", align: "center" }, children, "head");
778
776
  }
779
777
 
778
+ /** The countdown: a ring when the terminal draws meters, else a clocked `elapsed`. */
779
+ #describeTimer(countdown: CountdownTimer, ring: boolean): NativeNode {
780
+ if (!ring) return countdown.describe();
781
+ const total = Math.max(1, this.options.timeout ?? 1);
782
+ const left = Math.max(0, this.#countdownDeadline - Date.now());
783
+ return node(
784
+ "meter",
785
+ {
786
+ value: Math.round((left / total) * 1000) / 1000,
787
+ style: "ring",
788
+ size: "sm",
789
+ label: `${Math.ceil(left / 1000)}s`,
790
+ title: "Answers the recommended option when the time runs out",
791
+ },
792
+ undefined,
793
+ "timer",
794
+ );
795
+ }
796
+
797
+ /** Hidden: one action row in the composer's place — the question clipped to a line, the countdown, Show and Skip. */
798
+ #describeHidden(ring: boolean): NativeNode {
799
+ const question = this.#questions[this.#currentQuestionIndex()];
800
+ const more = this.#questions.length > 1 ? ` (+${this.#questions.length - 1} more)` : "";
801
+ const summary = text(
802
+ [
803
+ span("Ask hidden · ", "muted"),
804
+ span(`${normalizedInlineInput(sanitizeCarriageReturns(question?.question ?? ""))}${more}`),
805
+ ],
806
+ { truncate: "end" },
807
+ );
808
+ const hideKey = getKeybindings().getKeys("app.ask.hide")[0];
809
+ const show = actionButton("Show", "show", { tone: "accent", ...(hideKey ? { keys: hideKey } : {}) });
810
+ const cancelKey = getKeybindings().getKeys("tui.select.cancel")[0];
811
+ const skip = actionButton("Skip", "cancel", cancelKey ? { keys: cancelKey } : {});
812
+ const items: (NativeNode | null)[] = [summary, null];
813
+ if (this.#countdown) items.push(this.#describeTimer(this.#countdown, ring));
814
+ items.push(show, skip);
815
+ return col([actionBar(items)], { role: "omp.editor", gap: "md" });
816
+ }
817
+
780
818
  /** Question tab: the question as markdown and its answers as radio/check rows (label, description, badge). */
781
819
  #describeQuestionBody(children: NativeChild[]): void {
782
820
  const active = this.#activeQuestionState();
@@ -870,14 +908,20 @@ export class AskDialogComponent implements Component {
870
908
  /**
871
909
  * The action bar: Submit ⏎ (Enter's action on this tab; `Next` while more
872
910
  * questions follow), Note n, Skip esc. Keyboard-only keys live in the
873
- * buttons' tooltips; a draft that owns input shows its hint instead.
911
+ * buttons' tooltips; a draft that owns input shows its hint instead. Hide
912
+ * (left-aligned, apart from the answer actions) collapses the dialog.
874
913
  */
875
914
  #describeActions(blocked: boolean): NativeNode {
876
915
  const cancelKey = getKeybindings().getKeys("tui.select.cancel")[0];
877
916
  const skip = actionButton("Skip", "cancel", cancelKey ? { keys: cancelKey } : {});
917
+ const hideKey = getKeybindings().getKeys("app.ask.hide")[0];
918
+ const hide = actionButton("Hide", "hide", {
919
+ ...(hideKey ? { keys: hideKey } : {}),
920
+ title: `Hide the question until you are ready to answer${hideKey ? ` ${formatTooltipKey(hideKey)}` : ""}`,
921
+ });
878
922
  const inputGuard = this.options.inputGuard;
879
923
  if (blocked && inputGuard) {
880
- return actionBar([text([span(plainText(inputGuard.hint), "muted")], { truncate: "end" }), null, skip]);
924
+ return actionBar([text([span(plainText(inputGuard.hint), "muted")], { truncate: "end" }), null, hide, skip]);
881
925
  }
882
926
  const moves = ["up/down move"];
883
927
  if (this.#hasSubmitTab()) moves.push("tab switches question");
@@ -887,7 +931,7 @@ export class AskDialogComponent implements Component {
887
931
  tone: "accent",
888
932
  title: "Submit answers enter",
889
933
  });
890
- return actionBar([null, skip, submit]);
934
+ return actionBar([hide, null, skip, submit]);
891
935
  }
892
936
  const question = this.#questions[this.#currentQuestionIndex()];
893
937
  const last = this.#questions.length === 1;
@@ -899,14 +943,15 @@ export class AskDialogComponent implements Component {
899
943
  title: `${enterLabel} enter · ${tips.join(" · ")}`,
900
944
  });
901
945
  const note = actionButton("Note", "note", { keys: "n", title: `Add a note to the highlighted answer n` });
902
- return actionBar([null, note, skip, submit]);
946
+ return actionBar([hide, null, note, skip, submit]);
903
947
  }
904
948
 
905
949
  /**
906
950
  * Pointer actions: a tab click switches to it; a click on an answer row is
907
951
  * Enter on it (Space for a multi-select option, so it toggles; its
908
952
  * double-click `activate` is Enter); the action bar's Submit, Note and Skip
909
- * run exactly what Enter, `n` and Esc run on the highlighted row.
953
+ * run exactly what Enter, `n` and Esc run on the highlighted row; Hide/Show
954
+ * collapse and restore the dialog.
910
955
  */
911
956
  handleNativeEvent(event: NativeUiEvent): void {
912
957
  if (this.#closed || this.#promptActive) return;
@@ -915,13 +960,17 @@ export class AskDialogComponent implements Component {
915
960
  this.#finishCancel();
916
961
  return;
917
962
  }
918
- if (this.options.inputGuard?.isBlocked()) return;
963
+ if (event.act === "hide" || event.act === "show") {
964
+ this.#setHidden(event.act === "hide");
965
+ return;
966
+ }
967
+ if (this.#hidden || this.options.inputGuard?.isBlocked()) return;
919
968
  if (event.act === "submit") this.#pressEnter();
920
969
  else if (event.act === "note") this.#pressNote();
921
970
  return;
922
971
  }
923
972
  if (event.type !== "select" && event.type !== "activate") return;
924
- if (this.options.inputGuard?.isBlocked()) return;
973
+ if (this.#hidden || this.options.inputGuard?.isBlocked()) return;
925
974
  const key = leafKey(event.key);
926
975
  if (key === "tabs") {
927
976
  if (!this.#hasSubmitTab()) return;
@@ -1049,6 +1098,35 @@ export class AskDialogComponent implements Component {
1049
1098
  return this.#remainingSeconds === undefined ? "Ask" : `Ask (${this.#remainingSeconds}s)`;
1050
1099
  }
1051
1100
 
1101
+ #setHidden(hidden: boolean): void {
1102
+ if (this.#hidden === hidden) return;
1103
+ this.#hidden = hidden;
1104
+ this.#countdown?.reset();
1105
+ this.#requestRender();
1106
+ }
1107
+
1108
+ /** Hidden: one line in the composer's place — title (with countdown), the question clipped to fit, and how to bring it back. */
1109
+ #renderHidden(width: number): readonly string[] {
1110
+ const hideKey = editorKey("app.ask.hide");
1111
+ const enter = formatKeyHint("enter");
1112
+ const inputGuard = this.options.inputGuard;
1113
+ const blocked = inputGuard?.isBlocked() ?? false;
1114
+ // While a draft owns input, Enter goes to the draft, so only the hide key restores.
1115
+ const show = blocked ? hideKey : hideKey ? `${hideKey}/${enter}` : enter;
1116
+ const hints: string[] = [];
1117
+ if (blocked && inputGuard) hints.push(inputGuard.hint);
1118
+ if (show) hints.push(`${show} show`);
1119
+ hints.push(`${editorKey("tui.select.cancel")} cancel`);
1120
+ const hint = theme.fg("dim", ` · ${hints.join(" · ")}`);
1121
+ const label = theme.fg("accent", `${this.#titleText()} hidden:`);
1122
+ const question = this.#questions[this.#currentQuestionIndex()];
1123
+ const more = this.#questions.length > 1 ? theme.fg("dim", ` (+${this.#questions.length - 1} more)`) : "";
1124
+ const summary = normalizedInlineInput(sanitizeCarriageReturns(question?.question ?? ""));
1125
+ const summaryWidth = Math.max(1, width - visibleWidth(label) - visibleWidth(more) - visibleWidth(hint) - 1);
1126
+ const clipped = truncateToWidth(summary, summaryWidth, Ellipsis.Unicode);
1127
+ return [truncateToWidth(`${label} ${theme.fg("muted", clipped)}${more}${hint}`, width, Ellipsis.Unicode)];
1128
+ }
1129
+
1052
1130
  #hasSubmitTab(): boolean {
1053
1131
  // Multi questions confirm on the Submit tab (Enter toggles, never
1054
1132
  // submits), so any multi question forces the tab even when there is
@@ -1114,7 +1192,9 @@ export class AskDialogComponent implements Component {
1114
1192
  }
1115
1193
 
1116
1194
  #footerHintText(indicator: string): string {
1117
- const cancel = `${editorKey("tui.select.cancel")} cancel`;
1195
+ // Hide rides along with cancel: both leave the question view without answering.
1196
+ const hideKey = editorKey("app.ask.hide");
1197
+ const cancel = `${editorKey("tui.select.cancel")} cancel${hideKey ? ` · ${hideKey} hide` : ""}`;
1118
1198
  const enter = formatKeyHint("enter");
1119
1199
  const upDown = editorKeys("tui.select.up", "tui.select.down");
1120
1200
  const inputGuard = this.options.inputGuard;
@@ -86,18 +86,8 @@ export interface ComposerOptions {
86
86
  readonly now?: () => number;
87
87
  }
88
88
 
89
- /** How {@link Composer.setRuntimeChildren} bills below-transcript roots against transcript retirement. */
89
+ /** How {@link Composer.setRuntimeChildren} lays out the roots below the transcript. */
90
90
  export interface RuntimeChildrenOptions {
91
- /**
92
- * Below-transcript roots whose growth is momentary (inline dialogs, a tall
93
- * multi-line editor). They are billed at their smallest height so expansion
94
- * clips the live tail instead of retiring rows a later shrink could not
95
- * reclaim (#11007). Every other root is billed at its current height, so
96
- * settled rows it displaces retire to native scrollback. Decision panels
97
- * declaring `retireDisplacedTranscript` opt out of this floor even when
98
- * mounted as a child of a transient editor container.
99
- */
100
- readonly transient?: readonly Component[];
101
91
  /**
102
92
  * Dock order on a TSP terminal (no status strip; the composer carries its
103
93
  * facts): the native dock stacks HUD pills, the working row and queued messages over
@@ -257,19 +247,17 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
257
247
  #retiredHeaderStart = 0;
258
248
  #resizeRetiredHeaderStart: number | undefined;
259
249
  #lastNormalRows = 0;
260
- // Roots from `RuntimeChildrenOptions.transient`, and the smallest height
261
- // they have rendered at since mount. Retirement bills transient roots at
262
- // this floor, never their peak, so a transient dialog or tall editor that later
263
- // shrinks never leaves committed transcript rows the live viewport cannot
264
- // reclaim (#11007). Persistent roots (loader, todo/subagent HUDs) bill at
265
- // their current height: they stay up for a whole turn, and billing them at
266
- // an idle floor hid the settled rows they displaced until the turn ended.
267
- // The floor is terminal-height independent — editor and dialog heights do
268
- // not scale with rows — so it is retained across resizes rather than
269
- // rediscovered from whatever chrome is expanded when the height changes.
270
- #transientChrome: ReadonlySet<Component> = new Set();
271
- #transientChromeFloor: number | undefined;
272
- #anchorAfterInlineRetirement = false;
250
+ // The smallest height the chrome below the transcript has rendered at since
251
+ // mount. Retirement bills that chrome at this floor, never its current
252
+ // height: a dialog, report, HUD or tall draft that grows clips the live tail
253
+ // instead of committing rows it displaces, so when it shrinks those rows
254
+ // refill the screen rather than leaving a band no frame can fill (#11007).
255
+ // Displaced rows reach scrollback once newer output pushes them past the
256
+ // floor, or when the chrome shrinks back and they scroll off normally.
257
+ // The floor is terminal-height independent — chrome heights do not scale
258
+ // with rows — so it is retained across resizes rather than rediscovered
259
+ // from whatever chrome is expanded when the height changes.
260
+ #chromeFloor: number | undefined;
273
261
  /** Rows the chrome below each below-transcript root took in the last frame (see {@link rowsBelow}). */
274
262
  #rowsBelow = new Map<Component, number>();
275
263
  #lastInterruptAt = 0;
@@ -349,18 +337,6 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
349
337
  return this.#rowsBelow.get(root);
350
338
  }
351
339
 
352
- /**
353
- * Keep the input on the bottom row while the live rows cannot fill the
354
- * screen, as after an inline decision panel closes. A tall block that just
355
- * left the chrome above the editor (a command report) may have scrolled
356
- * rows into native history that cannot be pulled back; without the pin the
357
- * editor would jump up to where the shorter frame now ends. The pin lifts
358
- * once live rows fill the screen again.
359
- */
360
- pinInputToBottom(): void {
361
- this.#anchorAfterInlineRetirement = true;
362
- }
363
-
364
340
  /** Compose the bounded mutable viewport and the next ordered history append. */
365
341
  renderFrame(viewport: ViewportSize): TerminalFramePlan {
366
342
  if (!this.#started || this.#stopped) return { viewport: [] };
@@ -384,55 +360,32 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
384
360
  const afterRoots = roots.slice(transcriptIndex + 1);
385
361
  const after: string[] = [];
386
362
  const afterSpans: ViewportClickSpan[] = [];
387
- let transientRows = 0;
388
- let displacingRows = 0;
389
- let decisionPanelOpen = false;
390
363
  const ends: { root: Component; end: number }[] = [];
391
364
  for (const root of afterRoots) {
392
- const chrome: Component = root;
393
- const start = after.length;
394
365
  this.#renderBelowRoot(root, width, after, afterSpans);
395
366
  ends.push({ root, end: after.length });
396
- if (this.#transientChrome.has(root)) transientRows += after.length - start;
397
- if (
398
- chrome.retireDisplacedTranscript ||
399
- (root instanceof Container && root.children.some(child => child.retireDisplacedTranscript))
400
- ) {
401
- if (this.#transientChrome.has(root)) displacingRows += after.length - start;
402
- decisionPanelOpen = true;
403
- }
404
367
  }
405
368
  this.#rowsBelow = new Map(ends.map(({ root, end }) => [root, after.length - end]));
406
369
  // Offer history under capacity pressure only: blocks stay live (and keep
407
370
  // reflowing to the current width) while the screen has room. A batch
408
371
  // leaves the mutable viewport in the same frame it is appended, so its
409
372
  // rows are never painted twice.
410
- //
411
- // Retirement bills ordinary transient roots at their floor, not their
412
- // peak: a confirmation dialog or a tall multi-line editor clips the live tail for
413
- // its lifetime, but must not permanently commit transcript rows to native
414
- // history — otherwise a later shrink cannot refill the freed rows and the
415
- // editor drifts up above a band of blank rows (#11007).
416
- this.#transientChromeFloor = Math.min(this.#transientChromeFloor ?? transientRows, transientRows);
417
- // An ask panel remains open while the user reads the preceding response.
418
- // Its displaced settled rows must be reachable in scrollback now, rather
419
- // than clipped until the panel closes. Ordinary drafts retain their floor.
420
- const belowFloor = after.length - transientRows + Math.max(this.#transientChromeFloor, displacingRows);
373
+ this.#chromeFloor = Math.min(this.#chromeFloor ?? after.length, after.length);
374
+ const belowFloor = this.#chromeFloor;
421
375
  const now = performance.now();
422
376
  const frame: AnimationFrame = { now, tick: Math.floor(now / 80) };
423
377
  // Retirement measures the same live blocks the viewport lays out below;
424
378
  // one open frame renders each of them once for both.
425
379
  transcript.beginFrame(frame);
426
380
  const history = this.#offerHistory(transcript, width, rows, preRoots.length + belowFloor);
427
- if (decisionPanelOpen && history !== undefined) this.#anchorAfterInlineRetirement = true;
428
381
  const headerVisible = !this.#headerRetired && this.#offeredHistory?.source !== "header";
429
382
  const headerRows = headerVisible ? this.#header.render(width) : [];
430
383
  const before = [...headerRows, ...preRoots];
431
384
  // The live tail is laid out against the same baseline retirement is
432
385
  // billed against, so its compaction allocator (one row per block, no
433
386
  // inter-block blanks) engages only when a block genuinely cannot retire.
434
- // Rows the transient chrome peak displaces are clipped from the top by
435
- // the `drop` slice below, which is what scrollback would have done.
387
+ // Rows the chrome above its floor displaces are clipped from the top by
388
+ // the `drop` slice below; they stay live until the chrome shrinks.
436
389
  const active = transcript.renderViewport(width, Math.max(0, rows - before.length - belowFloor), frame);
437
390
  const activeSpans: ViewportClickSpan[] = [];
438
391
  for (const span of transcript.getLastViewportSpans()) {
@@ -442,12 +395,24 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
442
395
  }
443
396
  const drop = Math.max(0, before.length + active.length + after.length - rows);
444
397
  const mutable = [...before, ...active, ...after].slice(drop);
445
- // Once live rows fill the screen again, the retired gap is gone.
446
- if (!decisionPanelOpen && mutable.length >= rows) this.#anchorAfterInlineRetirement = false;
447
- // Rows retired during a decision panel cannot be pulled back from native
448
- // history when it closes. Keep the input pinned to the bottom without
449
- // replaying those rows (which would duplicate them) or clearing history.
450
- const topPadding = this.#anchorAfterInlineRetirement ? Math.max(0, rows - mutable.length) : 0;
398
+ // Once a paint reaches the bottom row over committed history, rows above
399
+ // it may already be in native scrollback, which no later frame can pull
400
+ // back down. Keep the input on the bottom row instead of stranding a
401
+ // band of blank rows under it whenever the frame shrinks — a closing
402
+ // dialog, report or HUD, or a live block collapsing. The band sits
403
+ // between the history still on screen and the live rows; replaying
404
+ // those rows would duplicate them, clearing history would lose them.
405
+ // Replays position themselves.
406
+ // Without native history the live viewport is the whole document and
407
+ // shrinks in place.
408
+ const filledHistoryRows =
409
+ history?.kind === "replay" || !(history !== undefined || this.ui.hasProviderHistory())
410
+ ? undefined
411
+ : this.ui.getFilledHistoryRows();
412
+ const topPadding =
413
+ filledHistoryRows === undefined
414
+ ? 0
415
+ : Math.max(0, rows - Math.min(rows, filledHistoryRows + (history?.rows.length ?? 0)) - mutable.length);
451
416
  if (topPadding > 0) mutable.unshift(...Array<string>(topPadding).fill(""));
452
417
  const viewportLength = mutable.length;
453
418
  const spans: ViewportClickSpan[] = [];
@@ -924,9 +889,7 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
924
889
  /** Mount or replace session-aware root children while preserving the header and status hosts. */
925
890
  setRuntimeChildren(children: readonly Component[], options: RuntimeChildrenOptions = {}): void {
926
891
  if (this.#stopped) return;
927
- this.#transientChrome = new Set(options.transient);
928
- this.#transientChromeFloor = undefined;
929
- this.#anchorAfterInlineRetirement = false;
892
+ this.#chromeFloor = undefined;
930
893
  this.#nativeDock = options.nativeDock;
931
894
  this.ui.removeChild(this.#statusHost);
932
895
  if (this.#runtimeMounted) {