@oh-my-pi/pi-tui 18.4.8 → 18.4.9

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 (40) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/types/apps/debug/log-viewer.d.ts +17 -0
  3. package/dist/types/apps/debug/raw-sse.d.ts +14 -0
  4. package/dist/types/native/backend.d.ts +8 -0
  5. package/dist/types/native/encode.d.ts +5 -1
  6. package/dist/types/native/node.d.ts +14 -1
  7. package/dist/types/native/reconcile.d.ts +5 -0
  8. package/dist/types/overlays/extensions/extension-dashboard.d.ts +31 -0
  9. package/dist/types/overlays/extensions/extension-list.d.ts +37 -0
  10. package/dist/types/overlays/extensions/inspector-panel.d.ts +8 -0
  11. package/dist/types/overlays/jobs-panel.d.ts +35 -9
  12. package/dist/types/prompt/word-completion.d.ts +12 -0
  13. package/dist/types/terminal.d.ts +7 -0
  14. package/dist/types/tools/bash-interactive.d.ts +15 -0
  15. package/dist/types/tools/output-meta.d.ts +8 -2
  16. package/dist/types/tools/streaming-output.d.ts +15 -10
  17. package/dist/types/tui.d.ts +5 -0
  18. package/dist/types/windows-input-mode.d.ts +26 -0
  19. package/package.json +9 -9
  20. package/src/apps/debug/log-viewer.ts +236 -2
  21. package/src/apps/debug/protocol-probe.ts +10 -1
  22. package/src/apps/debug/raw-sse.ts +210 -28
  23. package/src/native/apply.ts +2 -1
  24. package/src/native/backend.ts +22 -0
  25. package/src/native/encode.ts +7 -2
  26. package/src/native/node.ts +15 -1
  27. package/src/native/reconcile.ts +28 -1
  28. package/src/overlays/extensions/extension-dashboard.ts +275 -23
  29. package/src/overlays/extensions/extension-list.ts +274 -14
  30. package/src/overlays/extensions/inspector-panel.ts +368 -0
  31. package/src/overlays/hook-selector.ts +1 -1
  32. package/src/overlays/jobs-panel.ts +222 -49
  33. package/src/prompt/composer-cache.ts +26 -3
  34. package/src/prompt/word-completion.ts +31 -13
  35. package/src/terminal.ts +37 -1
  36. package/src/tools/bash-interactive.ts +85 -16
  37. package/src/tools/output-meta.ts +13 -5
  38. package/src/tools/streaming-output.ts +48 -23
  39. package/src/tui.ts +52 -14
  40. package/src/windows-input-mode.ts +206 -0
@@ -1,6 +1,11 @@
1
1
  import type * as XtermModule from "@oh-my-pi/pi-utils/vterm";
2
2
  import type { Terminal as XtermTerminalType } from "@oh-my-pi/pi-utils/vterm";
3
+ import type { TspSpan } from "@oh-my-pi/pi-wire";
3
4
  import type { Component } from "../tui";
5
+ import { ansi, col, row, span, text } from "../native/describe";
6
+ import { Memo } from "../native/memo";
7
+ import type { DescribeContext, NativeNode, NativeUiEvent } from "../native/node";
8
+ import { actionBar, actionButton } from "../native/overlay";
4
9
  import { extractPrintableText, matchesKey, parseKey, parseKittySequence } from "../keys";
5
10
  import { padding, truncateToWidth, visibleWidth } from "../utils";
6
11
  import type { Theme } from "../theme/theme";
@@ -15,6 +20,10 @@ export interface BashInteractiveTerminalBackend {
15
20
 
16
21
  // The capture sink owns final output; this caps only the live display backlog.
17
22
  const MAX_LIVE_WRITE_QUEUE_CHUNKS = 512;
23
+ // Cells the native full sheet's insets take from the pane width.
24
+ const NATIVE_SHEET_INSET_COLS = 8;
25
+ // Action id of the native Force kill button (mirrors Esc).
26
+ const KILL_ACTION = "kill";
18
27
 
19
28
  function normalizeInputForPty(data: string, applicationCursorKeysMode: boolean): string {
20
29
  const kitty = parseKittySequence(data);
@@ -78,6 +87,8 @@ function normalizeInputForPty(data: string, applicationCursorKeysMode: boolean):
78
87
  }
79
88
  /** Interactive terminal overlay driven by an external PTY controller. */
80
89
  export class BashInteractiveOverlayComponent implements Component {
90
+ /** Native sheet: a full-pane glass overlay titled Console. */
91
+ readonly nativeOverlay = { role: "omp.overlay.console", size: "full", head: "Console" } as const;
81
92
  #terminal: XtermTerminalType;
82
93
  #state: "running" | "complete" | "timed_out" | "killed" = "running";
83
94
  #exitCode: number | undefined;
@@ -94,6 +105,9 @@ export class BashInteractiveOverlayComponent implements Component {
94
105
  #writeOffset = 0;
95
106
  #flushResolvers: Array<() => void> = [];
96
107
  #writing = false;
108
+ /** Bumped whenever the virtual terminal processed a chunk, so `describe` knows the screen changed. */
109
+ #screenVersion = 0;
110
+ readonly #native = new Memo();
97
111
 
98
112
  constructor(
99
113
  command: string,
@@ -160,6 +174,7 @@ export class BashInteractiveOverlayComponent implements Component {
160
174
  const data = this.#writeQueue[this.#writeOffset]!;
161
175
  this.#terminal.write(data, () => {
162
176
  this.#writing = false;
177
+ this.#screenVersion += 1;
163
178
  this.#writeOffset += 1;
164
179
  if (this.#writeOffset >= this.#writeQueue.length) {
165
180
  this.#writeQueue = [];
@@ -206,7 +221,7 @@ export class BashInteractiveOverlayComponent implements Component {
206
221
 
207
222
  handleInput(data: string): void {
208
223
  if (this.#state === "running" && (matchesKey(data, "escape") || matchesKey(data, "esc"))) {
209
- this.#onDismiss();
224
+ this.#forceKill();
210
225
  return;
211
226
  }
212
227
  if (this.#state !== "running") {
@@ -218,6 +233,73 @@ export class BashInteractiveOverlayComponent implements Component {
218
233
  }
219
234
  this.#onInput(normalizedInput);
220
235
  }
236
+
237
+ /** The Force kill button runs the Esc path. */
238
+ handleNativeEvent(event: NativeUiEvent): void {
239
+ if (event.type === "action" && event.act === KILL_ACTION) this.#forceKill();
240
+ }
241
+
242
+ #forceKill(): void {
243
+ if (this.#state === "running") this.#onDismiss();
244
+ }
245
+
246
+ #syncPtySize(cols: number, rows: number): void {
247
+ if (cols === this.#lastCols && rows === this.#lastRows) return;
248
+ this.#lastCols = cols;
249
+ this.#lastRows = rows;
250
+ try {
251
+ this.#backend.resize(cols, rows);
252
+ } catch {
253
+ // Session may have ended
254
+ }
255
+ }
256
+
257
+ #maxContentRows(): number {
258
+ const maxOverlayRows = Math.max(5, Math.floor(this.#getTerminalRows() * 0.8));
259
+ return Math.max(1, maxOverlayRows - 4);
260
+ }
261
+
262
+ #stateSpan(): TspSpan {
263
+ if (this.#state === "running") return span("running", "warning");
264
+ if (this.#state === "timed_out") return span("timed out", "warning");
265
+ if (this.#state === "killed") return span("killed", "warning");
266
+ if (this.#exitCode === 0) return span("exit 0", "success");
267
+ if (this.#exitCode === undefined) return span("exited", "warning");
268
+ return span(`exit ${this.#exitCode}`, "error");
269
+ }
270
+
271
+ /**
272
+ * Native view: the command with its state badge, the PTY screen as an
273
+ * `ansi` node (SGR kept, fixed to the PTY grid), and a footer with the
274
+ * Force kill action while running.
275
+ */
276
+ describe(cx: DescribeContext): NativeNode {
277
+ const cols = Math.max(20, cx.cols - NATIVE_SHEET_INSET_COLS);
278
+ const rows = this.#maxContentRows();
279
+ this.#syncPtySize(cols, rows);
280
+ this.#terminal.resize(cols, rows);
281
+ const deps = [this.#screenVersion, cols, rows, this.#state, this.#exitCode];
282
+ return this.#native.get(deps, () => {
283
+ const viewportY = this.#terminal.buffer.active.viewportY;
284
+ const screen = readTerminalRows(this.#terminal, viewportY, rows).map(line => styleTerminalRow(line, ""));
285
+ const head = row(
286
+ [
287
+ text([span(replaceTabs(this.#command), "muted")], { wrap: "none", truncate: "end" }),
288
+ text([span("[", "dim"), this.#stateSpan(), span("]", "dim")]),
289
+ ],
290
+ { gap: "sm", align: "baseline", justify: "between" },
291
+ );
292
+ const footer =
293
+ this.#state === "running"
294
+ ? actionBar([
295
+ text([span("Input forwarded to PTY", "dim")]),
296
+ null,
297
+ actionButton("Force kill", KILL_ACTION, { keys: "escape", tone: "error" }),
298
+ ])
299
+ : actionBar([text([span("Session finished", "dim")])]);
300
+ return col([head, ansi(screen.join("\n"), { cols }), footer], { gap: "sm" });
301
+ });
302
+ }
221
303
  #stateText(): string {
222
304
  if (this.#state === "running") return this.#uiTheme.fg("warning", "running");
223
305
  if (this.#state === "timed_out") return this.#uiTheme.fg("warning", "timed out");
@@ -237,21 +319,8 @@ export class BashInteractiveOverlayComponent implements Component {
237
319
  render(width: number): readonly string[] {
238
320
  const safeWidth = Math.max(20, width);
239
321
  const innerWidth = Math.max(1, safeWidth - 2);
240
- const maxOverlayRows = Math.max(5, Math.floor(this.#getTerminalRows() * 0.8));
241
- const chromeRows = 4;
242
- const maxContentRows = Math.max(1, maxOverlayRows - chromeRows);
243
- // Propagate terminal resize to PTY session
244
- const currentCols = innerWidth;
245
- const currentRows = maxContentRows;
246
- if (currentCols !== this.#lastCols || currentRows !== this.#lastRows) {
247
- this.#lastCols = currentCols;
248
- this.#lastRows = currentRows;
249
- try {
250
- this.#backend.resize(currentCols, currentRows);
251
- } catch {
252
- // Session may have ended
253
- }
254
- }
322
+ const maxContentRows = this.#maxContentRows();
323
+ this.#syncPtySize(innerWidth, maxContentRows);
255
324
  const statusIcon =
256
325
  this.#state === "running"
257
326
  ? formatStatusIcon("running", this.#uiTheme)
@@ -25,6 +25,8 @@ export interface TruncationMeta {
25
25
  elidedLines?: number;
26
26
  /** Artifact ID if full output was saved */
27
27
  artifactId?: string;
28
+ /** Bytes the artifact cap dropped from the saved file's middle; the artifact is then a head/tail sample. */
29
+ artifactElidedBytes?: number;
28
30
  /** Next offset for pagination (head truncation only) */
29
31
  nextOffset?: number;
30
32
  /**
@@ -62,7 +64,7 @@ export interface LimitsMeta {
62
64
  resultLimit?: { reached: number; suggestion?: number };
63
65
  headLimit?: { reached: number; suggestion: number };
64
66
  /** `unit` may be absent in sessions persisted before it was recorded. */
65
- columnTruncated?: { maxColumn: number; unit?: "bytes" | "chars"; artifactId?: string };
67
+ columnTruncated?: { maxColumn: number; unit?: "bytes" | "chars"; artifactId?: string; artifactElidedBytes?: number };
66
68
  }
67
69
 
68
70
  /**
@@ -130,8 +132,14 @@ export function formatGroupedDiagnosticMessages(messages: string[]): string {
130
132
  return lines.join("\n");
131
133
  }
132
134
 
133
- /** Format a recoverable output artifact link. */
134
- export function formatFullOutputReference(artifactId: string): string {
135
+ /**
136
+ * Format a recoverable output artifact link. An artifact the size cap cut
137
+ * (`artifactElidedBytes > 0`) is labeled as the head/tail sample it holds.
138
+ */
139
+ export function formatFullOutputReference(artifactId: string, artifactElidedBytes?: number): string {
140
+ if (artifactElidedBytes !== undefined && artifactElidedBytes > 0) {
141
+ return `Read artifact://${artifactId} for a head/tail sample of the output; ${formatBytes(artifactElidedBytes)} from its middle was not saved`;
142
+ }
135
143
  return `Read artifact://${artifactId} for full output`;
136
144
  }
137
145
 
@@ -206,7 +214,7 @@ export function formatTruncationMetaNotice(truncation: TruncationMeta, source?:
206
214
  ? undefined
207
215
  : source?.type === "report"
208
216
  ? `Read artifact://${truncation.artifactId} for full report (${source.value})`
209
- : formatFullOutputReference(truncation.artifactId);
217
+ : formatFullOutputReference(truncation.artifactId, truncation.artifactElidedBytes);
210
218
 
211
219
  if (truncation.direction === "middle") {
212
220
  const head = truncation.headRange;
@@ -322,7 +330,7 @@ export function formatOutputNotice(meta: OutputMeta | undefined): string {
322
330
  // stops matching the persisted "… 768 chars" text.
323
331
  let columnNotice = `Some lines truncated to ${c.maxColumn} ${c.unit ?? "chars"}`;
324
332
  if (c.artifactId != null) {
325
- columnNotice += `. ${formatFullOutputReference(c.artifactId)}`;
333
+ columnNotice += `. ${formatFullOutputReference(c.artifactId, c.artifactElidedBytes)}`;
326
334
  }
327
335
  parts.push(columnNotice);
328
336
  }
@@ -14,14 +14,15 @@ export const DEFAULT_MAX_BYTES = 50 * 1024;
14
14
  export const DEFAULT_MAX_COLUMN = 512;
15
15
 
16
16
  /**
17
- * Default artifact-on-disk cap for {@link OutputSink}.
18
- *
19
- * `0` means unbounded: by default, `artifact://<id>` references preserve the
20
- * complete raw stream instead of a capped head/tail sample.
17
+ * Default artifact-on-disk cap for {@link OutputSink}: 16 MiB, split into a
18
+ * {@link ARTIFACT_DEFAULT_HEAD_BYTES} head and a rolling tail. `0` means
19
+ * unbounded (the complete raw stream is preserved).
21
20
  */
22
- export const ARTIFACT_DEFAULT_MAX_BYTES = 0;
21
+ export const ARTIFACT_DEFAULT_MAX_BYTES = 16 * 1024 * 1024; // 16 MiB
23
22
  /** Default head budget; the remainder becomes the rolling tail window. */
24
23
  export const ARTIFACT_DEFAULT_HEAD_BYTES = 3 * 1024 * 1024; // 3 MiB
24
+ /** Upper bound on how far the rolling artifact tail may outgrow its budget between trims. */
25
+ const ARTIFACT_TAIL_MAX_SLACK_BYTES = 4 * 1024 * 1024;
25
26
 
26
27
  const NL = "\n";
27
28
  const CR = "\r";
@@ -54,6 +55,11 @@ export interface OutputSummary {
54
55
  columnMax?: number;
55
56
  /** Artifact ID for internal URL access (artifact://<id>) when truncated */
56
57
  artifactId?: string;
58
+ /**
59
+ * Bytes the artifact cap dropped from the middle of the saved file. When
60
+ * set, `artifact://<id>` holds a head/tail sample, not the full output.
61
+ */
62
+ artifactElidedBytes?: number;
57
63
  /** Full raw output was not completely saved; artifactId is unavailable. */
58
64
  artifactError?: OutputArtifactError;
59
65
  }
@@ -86,17 +92,18 @@ export interface OutputSinkOptions {
86
92
  /** Minimum ms between onChunk calls. 0 = every chunk (default). */
87
93
  chunkThrottleMs?: number;
88
94
  /**
89
- * Optional cap on bytes written to the artifact-on-disk file. When the cap
90
- * is hit, the head window is preserved verbatim and subsequent output feeds
91
- * a rolling tail window; on close, the sink writes a single
95
+ * Cap on bytes written to the artifact-on-disk file. When the cap is hit,
96
+ * the head window is preserved verbatim and subsequent output feeds a
97
+ * rolling tail window; on close, the sink writes a single
92
98
  * `[ARTIFACT TRUNCATED: …]` notice between them. Default
93
- * {@link ARTIFACT_DEFAULT_MAX_BYTES} (unbounded).
99
+ * {@link ARTIFACT_DEFAULT_MAX_BYTES} (16 MiB); `0` = unbounded.
94
100
  */
95
101
  artifactMaxBytes?: number;
96
102
  /**
97
103
  * Bytes reserved for the head window of the capped artifact file. The
98
104
  * tail window receives `artifactMaxBytes - artifactHeadBytes`. Default
99
- * {@link ARTIFACT_DEFAULT_HEAD_BYTES}; clamped to `[0, artifactMaxBytes]`.
105
+ * {@link ARTIFACT_DEFAULT_HEAD_BYTES}, but at most half of
106
+ * `artifactMaxBytes`; clamped to `[0, artifactMaxBytes]`.
100
107
  */
101
108
  artifactHeadBytes?: number;
102
109
  }
@@ -912,20 +919,24 @@ export class OutputSink {
912
919
  readonly #chunkThrottleMs: number;
913
920
  readonly #maxColumns: number;
914
921
 
915
- // Optional artifact-on-disk cap. When `#artifactMaxBytes > 0` the file sink
916
- // owns a head budget + a rolling tail buffer; once the head is closed,
917
- // subsequent chunks are diverted into `#artifactTailRing` (bounded by
918
- // `#artifactTailBudget`). On `dump()` the tail is flushed back to the sink
919
- // behind a `[ARTIFACT TRUNCATED: …]` notice. The default cap is disabled so
920
- // advertised `artifact://<id>` captures are lossless.
922
+ // Artifact-on-disk cap. When `#artifactMaxBytes > 0` the file sink owns a
923
+ // head budget + a rolling tail buffer; once the head is closed, subsequent
924
+ // chunks are diverted into `#artifactTailRing` (trimmed back to
925
+ // `#artifactTailBudget` whenever it outgrows the budget by
926
+ // `#artifactTailSlack`). On `dump()` the tail is flushed back to the sink
927
+ // behind a `[ARTIFACT TRUNCATED: …]` notice. `0` disables the cap.
921
928
  readonly #artifactMaxBytes: number;
922
929
  readonly #artifactHeadBudget: number;
923
930
  readonly #artifactTailBudget: number;
931
+ /** Overshoot tolerated before trimming, so trimming costs amortized O(1) per byte instead of O(budget) per chunk. */
932
+ readonly #artifactTailSlack: number;
924
933
  #artifactHeadBytesWritten = 0;
925
934
  #artifactHeadClosed = false;
926
935
  #artifactTailRing = "";
927
936
  #artifactTailRingBytes = 0;
928
937
  #artifactTailIncomingBytes = 0;
938
+ /** Bytes the cap dropped from the artifact middle, recorded when the tail is flushed. */
939
+ #artifactElidedBytes = 0;
929
940
  constructor(options?: OutputSinkOptions) {
930
941
  const {
931
942
  artifactPath,
@@ -936,7 +947,7 @@ export class OutputSink {
936
947
  onChunk,
937
948
  chunkThrottleMs = 0,
938
949
  artifactMaxBytes = ARTIFACT_DEFAULT_MAX_BYTES,
939
- artifactHeadBytes = ARTIFACT_DEFAULT_HEAD_BYTES,
950
+ artifactHeadBytes,
940
951
  } = options ?? {};
941
952
  this.#artifactPath = artifactPath;
942
953
  this.#artifactId = artifactId;
@@ -946,8 +957,13 @@ export class OutputSink {
946
957
  this.#onChunk = onChunk;
947
958
  this.#chunkThrottleMs = chunkThrottleMs;
948
959
  this.#artifactMaxBytes = Math.max(0, artifactMaxBytes);
949
- this.#artifactHeadBudget = Math.max(0, Math.min(artifactHeadBytes, this.#artifactMaxBytes));
960
+ // The default head yields to the rolling tail on small caps: it takes at
961
+ // most half, so the most recent output is always kept.
962
+ const headBytesRequested =
963
+ artifactHeadBytes ?? Math.min(ARTIFACT_DEFAULT_HEAD_BYTES, Math.floor(this.#artifactMaxBytes / 2));
964
+ this.#artifactHeadBudget = Math.max(0, Math.min(headBytesRequested, this.#artifactMaxBytes));
950
965
  this.#artifactTailBudget = Math.max(0, this.#artifactMaxBytes - this.#artifactHeadBudget);
966
+ this.#artifactTailSlack = Math.min(this.#artifactTailBudget, ARTIFACT_TAIL_MAX_SLACK_BYTES);
951
967
  }
952
968
 
953
969
  /**
@@ -1251,11 +1267,14 @@ export class OutputSink {
1251
1267
  }
1252
1268
  this.#artifactTailRing += chunk;
1253
1269
  this.#artifactTailRingBytes += chunkBytes;
1254
- if (this.#artifactTailRingBytes > budget) {
1255
- const { text, bytes } = truncateTailBytes(this.#artifactTailRing, budget);
1256
- this.#artifactTailRing = text;
1257
- this.#artifactTailRingBytes = bytes;
1258
- }
1270
+ if (this.#artifactTailRingBytes > budget + this.#artifactTailSlack) this.#trimArtifactTail();
1271
+ }
1272
+
1273
+ #trimArtifactTail(): void {
1274
+ if (this.#artifactTailRingBytes <= this.#artifactTailBudget) return;
1275
+ const { text, bytes } = truncateTailBytes(this.#artifactTailRing, this.#artifactTailBudget);
1276
+ this.#artifactTailRing = text;
1277
+ this.#artifactTailRingBytes = bytes;
1259
1278
  }
1260
1279
 
1261
1280
  #recordArtifactError(operation: OutputArtifactError): void {
@@ -1413,11 +1432,13 @@ export class OutputSink {
1413
1432
  #flushArtifactTailIfCapped(): void {
1414
1433
  if (!this.#file || this.#artifactError) return;
1415
1434
  if (this.#artifactMaxBytes === 0) return;
1435
+ this.#trimArtifactTail();
1416
1436
  const tailBytes = this.#artifactTailRingBytes;
1417
1437
  const droppedBytes = Math.max(0, this.#artifactTailIncomingBytes - tailBytes);
1418
1438
  if (tailBytes === 0 && droppedBytes === 0) return;
1419
1439
 
1420
1440
  if (droppedBytes > 0) {
1441
+ this.#artifactElidedBytes = droppedBytes;
1421
1442
  const headWritten = this.#artifactHeadBytesWritten;
1422
1443
  const totalCapped = headWritten + this.#artifactTailIncomingBytes;
1423
1444
  const headSep = headWritten > 0 ? "\n" : "";
@@ -1505,6 +1526,10 @@ export class OutputSink {
1505
1526
  columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined,
1506
1527
  columnMax: this.#columnTruncatedLines > 0 ? this.#maxColumns : undefined,
1507
1528
  artifactId: this.#artifactError ? undefined : this.#file?.artifactId,
1529
+ artifactElidedBytes:
1530
+ this.#artifactError || !this.#file?.artifactId || this.#artifactElidedBytes === 0
1531
+ ? undefined
1532
+ : this.#artifactElidedBytes,
1508
1533
  artifactError: this.#artifactError,
1509
1534
  };
1510
1535
  }
package/src/tui.ts CHANGED
@@ -1002,6 +1002,11 @@ export class TUI extends Container {
1002
1002
  options?: OverlayOptions;
1003
1003
  preFocus: Component | null;
1004
1004
  hidden: boolean;
1005
+ /**
1006
+ * A sheet the user clicked away from (see {@link TUI.#focusFromPointer}):
1007
+ * it stays up but no longer holds the keys, until focus moves back into it.
1008
+ */
1009
+ released: boolean;
1005
1010
  }[] = [];
1006
1011
 
1007
1012
  constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions) {
@@ -1120,12 +1125,14 @@ export class TUI extends Container {
1120
1125
  }
1121
1126
 
1122
1127
  setFocus(component: Component | null): void {
1123
- const topVisibleOverlay = this.#getTopmostVisibleOverlay();
1124
- if (topVisibleOverlay && !isOverlayFocusTarget(topVisibleOverlay.component, component)) {
1128
+ const holder = this.#getKeyHolderOverlay();
1129
+ if (holder && !isOverlayFocusTarget(holder.component, component)) {
1125
1130
  const currentFocus = this.#focusedComponent;
1126
- component = isOverlayFocusTarget(topVisibleOverlay.component, currentFocus)
1127
- ? currentFocus
1128
- : topVisibleOverlay.component;
1131
+ component = isOverlayFocusTarget(holder.component, currentFocus) ? currentFocus : holder.component;
1132
+ }
1133
+ // Focus moving back into a released sheet makes it hold the keys again.
1134
+ for (const entry of this.overlayStack) {
1135
+ if (entry.released && isOverlayFocusTarget(entry.component, component)) entry.released = false;
1129
1136
  }
1130
1137
 
1131
1138
  const previousFocusedComponent = this.#focusedComponent;
@@ -1176,7 +1183,7 @@ export class TUI extends Container {
1176
1183
  */
1177
1184
  showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
1178
1185
  component.setIgnoreTight?.(true);
1179
- const entry = { component, options, preFocus: this.#focusedComponent, hidden: false };
1186
+ const entry = { component, options, preFocus: this.#focusedComponent, hidden: false, released: false };
1180
1187
  this.overlayStack.push(entry);
1181
1188
  // Only focus if overlay is actually visible
1182
1189
  if (this.#isOverlayVisible(entry)) {
@@ -1194,8 +1201,7 @@ export class TUI extends Container {
1194
1201
  this.overlayStack.splice(index, 1);
1195
1202
  // Restore focus if this overlay or one of its owned targets had focus
1196
1203
  if (isOverlayFocusTarget(component, this.#focusedComponent)) {
1197
- const topVisible = this.#getTopmostVisibleOverlay();
1198
- this.setFocus(topVisible?.component ?? entry.preFocus);
1204
+ this.setFocus(this.#getKeyHolderOverlay()?.component ?? entry.preFocus);
1199
1205
  }
1200
1206
  if (this.overlayStack.length === 0) {
1201
1207
  this.terminal.hideCursor();
@@ -1209,10 +1215,9 @@ export class TUI extends Container {
1209
1215
  entry.hidden = hidden;
1210
1216
  // Update focus when hiding/showing
1211
1217
  if (hidden) {
1212
- // If this overlay or one of its owned targets had focus, move focus to next visible or preFocus
1218
+ // If this overlay or one of its owned targets had focus, move focus to the next one holding keys or preFocus
1213
1219
  if (isOverlayFocusTarget(component, this.#focusedComponent)) {
1214
- const topVisible = this.#getTopmostVisibleOverlay();
1215
- this.setFocus(topVisible?.component ?? entry.preFocus);
1220
+ this.setFocus(this.#getKeyHolderOverlay()?.component ?? entry.preFocus);
1216
1221
  }
1217
1222
  } else {
1218
1223
  // Restore focus to this overlay when showing (if it's actually visible)
@@ -1230,9 +1235,8 @@ export class TUI extends Container {
1230
1235
  hideOverlay(): void {
1231
1236
  const overlay = this.overlayStack.pop();
1232
1237
  if (!overlay) return;
1233
- // Find topmost visible overlay, or fall back to preFocus
1234
- const topVisible = this.#getTopmostVisibleOverlay();
1235
- this.setFocus(topVisible?.component ?? overlay.preFocus);
1238
+ // Find the topmost visible overlay holding keys, or fall back to preFocus
1239
+ this.setFocus(this.#getKeyHolderOverlay()?.component ?? overlay.preFocus);
1236
1240
  if (this.overlayStack.length === 0) {
1237
1241
  this.terminal.hideCursor();
1238
1242
  this.#recordHardwareCursorHidden();
@@ -1312,6 +1316,39 @@ export class TUI extends Container {
1312
1316
  return undefined;
1313
1317
  }
1314
1318
 
1319
+ /** The topmost visible overlay that holds the keys: not a sheet the user clicked away from. */
1320
+ #getKeyHolderOverlay(): (typeof this.overlayStack)[number] | undefined {
1321
+ for (let i = this.overlayStack.length - 1; i >= 0; i--) {
1322
+ const entry = this.overlayStack[i];
1323
+ if (!entry.released && this.#isOverlayVisible(entry)) return entry;
1324
+ }
1325
+ return undefined;
1326
+ }
1327
+
1328
+ /**
1329
+ * Moves keyboard focus where the user clicked (the terminal's `focus` event,
1330
+ * see {@link NativeHost.focusFromPointer}). A click inside an overlay gives it
1331
+ * the keys unless they are already inside it. A click on `field` under the
1332
+ * overlays focuses it only when every visible overlay is a `sheet` (a panel
1333
+ * beside the content, like `/settings` docked in Tern): those stay up but
1334
+ * stop holding the keys until focus moves back into one of them.
1335
+ */
1336
+ #focusFromPointer(
1337
+ owners: readonly Component[],
1338
+ field: Component | null,
1339
+ sheet: (overlay: Component) => boolean,
1340
+ ): void {
1341
+ const visible = this.overlayStack.filter(entry => this.#isOverlayVisible(entry));
1342
+ const overlay = visible.findLast(entry => owners.some(owner => isOverlayFocusTarget(entry.component, owner)));
1343
+ if (overlay) {
1344
+ if (!isOverlayFocusTarget(overlay.component, this.#focusedComponent)) this.setFocus(overlay.component);
1345
+ return;
1346
+ }
1347
+ if (!field || field === this.#focusedComponent || !visible.every(entry => sheet(entry.component))) return;
1348
+ for (const entry of visible) entry.released = true;
1349
+ this.setFocus(field);
1350
+ }
1351
+
1315
1352
  override invalidate(): void {
1316
1353
  super.invalidate();
1317
1354
  for (const overlay of this.overlayStack) overlay.component.invalidate?.();
@@ -1616,6 +1653,7 @@ export class TUI extends Container {
1616
1653
  return visible;
1617
1654
  },
1618
1655
  focused: () => this.#focusedComponent,
1656
+ focusFromPointer: (owners, field, sheet) => this.#focusFromPointer(owners, field, sheet),
1619
1657
  requestRender: () => this.requestRender(),
1620
1658
  appearanceChanged: () => {
1621
1659
  this.terminal.refreshAppearance?.();