@oh-my-pi/pi-tui 16.0.2 → 16.0.3

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,5 @@
1
1
  import { encodeSixel } from "@oh-my-pi/pi-natives";
2
- import { $env, isBunTestRuntime } from "@oh-my-pi/pi-utils";
2
+ import { $env, isBunTestRuntime, isTerminalHeadless } from "@oh-my-pi/pi-utils";
3
3
  import {
4
4
  detectKittyUnicodePlaceholdersSupport,
5
5
  getKittyGraphics,
@@ -97,7 +97,7 @@ export class TerminalInfo {
97
97
  }
98
98
 
99
99
  sendNotification(message: string | TerminalNotification): void {
100
- if (isNotificationSuppressed()) return;
100
+ if (isNotificationSuppressed() || isTerminalHeadless()) return;
101
101
  process.stdout.write(this.formatNotification(message));
102
102
  }
103
103
  }
package/src/terminal.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi";
2
2
  import * as fs from "node:fs";
3
- import { $env, isBunTestRuntime, logger } from "@oh-my-pi/pi-utils";
3
+ import { $env, isBunTestRuntime, isTerminalHeadless, logger } from "@oh-my-pi/pi-utils";
4
4
  import { setKittyProtocolActive } from "./keys";
5
5
  import { StdinBuffer } from "./stdin-buffer";
6
6
  import { NotifyProtocol, setCellDimensions, setOsc99Supported, TERMINAL } from "./terminal-capabilities";
@@ -249,7 +249,7 @@ export function emergencyTerminalRestore(): void {
249
249
  altScreenActive = false;
250
250
  }
251
251
  terminal.showCursor();
252
- } else if (terminalEverStarted) {
252
+ } else if (terminalEverStarted && !isTerminalHeadless()) {
253
253
  // Blind restore only if we know a terminal was started but lost track of it
254
254
  // This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
255
255
  process.stdout.write(
@@ -404,6 +404,10 @@ export class ProcessTerminal implements Terminal {
404
404
  #stdinBuffer?: StdinBuffer;
405
405
  #stdinDataHandler?: (data: string) => void;
406
406
  #dead = false;
407
+ // Captured at construction and re-read at start(): when true, every real
408
+ // terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
409
+ // suppressed. Defaults on under `bun test` — see isTerminalHeadless().
410
+ #headless = isTerminalHeadless();
407
411
  #writeLogPath = $env.PI_TUI_WRITE_LOG || "";
408
412
  #stdoutErrorCleanup?: () => void;
409
413
  #stdoutErrorHandler = (err: Error) => {
@@ -459,6 +463,13 @@ export class ProcessTerminal implements Terminal {
459
463
  this.#inputHandler = onInput;
460
464
  this.#resizeHandler = onResize;
461
465
 
466
+ // Headless (tests): suppress every real-terminal side effect. Skip raw
467
+ // mode, stdin listeners, capability probes, SIGWINCH, and emergency-restore
468
+ // ownership; #safeWrite is also a no-op, so frame paints and teardown
469
+ // escapes never reach the developer's terminal during `bun test`.
470
+ this.#headless = isTerminalHeadless();
471
+ if (this.#headless) return;
472
+
462
473
  // Register for emergency cleanup
463
474
  activeTerminal = this;
464
475
  terminalEverStarted = true;
@@ -1134,6 +1145,7 @@ export class ProcessTerminal implements Terminal {
1134
1145
  }
1135
1146
 
1136
1147
  async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
1148
+ if (this.#headless) return;
1137
1149
  if (this.#kittyProtocolActive) {
1138
1150
  // Disable Kitty keyboard protocol first so any late key releases
1139
1151
  // do not generate new Kitty escape sequences.
@@ -1176,6 +1188,7 @@ export class ProcessTerminal implements Terminal {
1176
1188
  }
1177
1189
 
1178
1190
  stop(): void {
1191
+ if (this.#headless) return;
1179
1192
  // Unregister from emergency cleanup
1180
1193
  if (activeTerminal === this) {
1181
1194
  activeTerminal = null;
@@ -1303,6 +1316,7 @@ export class ProcessTerminal implements Terminal {
1303
1316
  }
1304
1317
 
1305
1318
  #safeWrite(data: string): void {
1319
+ if (this.#headless) return;
1306
1320
  if (this.#dead) return;
1307
1321
  // Skip control sequences when stdout isn't a TTY (piped output, tests, log
1308
1322
  // files). They serve no purpose there and would surface as visible noise.
@@ -1385,6 +1399,7 @@ export class ProcessTerminal implements Terminal {
1385
1399
  }
1386
1400
 
1387
1401
  setProgress(active: boolean): void {
1402
+ if (this.#headless) return;
1388
1403
  if (active) {
1389
1404
  this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
1390
1405
  if (!this.#progressTimer) {
package/src/tui.ts CHANGED
@@ -171,6 +171,12 @@ export interface Component {
171
171
  dispose?(): void;
172
172
  }
173
173
 
174
+ /** Lets an overlay root delegate keyboard focus to components it owns. */
175
+ export interface OverlayFocusOwner {
176
+ /** Returns true when `component` is a focus target inside this overlay. */
177
+ ownsOverlayFocusTarget(component: Component): boolean;
178
+ }
179
+
174
180
  /**
175
181
  * Component seam for append-only native-scrollback commits. A component that
176
182
  * renders a finalized prefix followed by a live/mutating suffix reports the
@@ -221,6 +227,13 @@ function setNativeScrollbackCommittedRows(component: Component, rows: number): v
221
227
  (component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
222
228
  }
223
229
 
230
+ function isOverlayFocusTarget(owner: Component, component: Component | null): boolean {
231
+ if (component === owner) return true;
232
+ if (!component) return false;
233
+ const candidate = owner as Component & Partial<OverlayFocusOwner>;
234
+ return candidate.ownsOverlayFocusTarget?.(component) === true;
235
+ }
236
+
224
237
  function getNativeScrollbackLiveRegionStart(component: Component): number | undefined {
225
238
  return (component as Component & Partial<NativeScrollbackLiveRegion>).getNativeScrollbackLiveRegionStart?.();
226
239
  }
@@ -1171,6 +1184,14 @@ export class TUI extends Container {
1171
1184
  }
1172
1185
 
1173
1186
  setFocus(component: Component | null): void {
1187
+ const topVisibleOverlay = this.#getTopmostVisibleOverlay();
1188
+ if (topVisibleOverlay && !isOverlayFocusTarget(topVisibleOverlay.component, component)) {
1189
+ const currentFocus = this.#focusedComponent;
1190
+ component = isOverlayFocusTarget(topVisibleOverlay.component, currentFocus)
1191
+ ? currentFocus
1192
+ : topVisibleOverlay.component;
1193
+ }
1194
+
1174
1195
  const previousFocusedComponent = this.#focusedComponent;
1175
1196
  // Clear focused flag on old component
1176
1197
  if (isFocusable(previousFocusedComponent)) {
@@ -1213,8 +1234,8 @@ export class TUI extends Container {
1213
1234
  const index = this.overlayStack.indexOf(entry);
1214
1235
  if (index !== -1) {
1215
1236
  this.overlayStack.splice(index, 1);
1216
- // Restore focus if this overlay had focus
1217
- if (this.#focusedComponent === component) {
1237
+ // Restore focus if this overlay or one of its owned targets had focus
1238
+ if (isOverlayFocusTarget(component, this.#focusedComponent)) {
1218
1239
  const topVisible = this.#getTopmostVisibleOverlay();
1219
1240
  this.setFocus(topVisible?.component ?? entry.preFocus);
1220
1241
  }
@@ -1230,8 +1251,8 @@ export class TUI extends Container {
1230
1251
  entry.hidden = hidden;
1231
1252
  // Update focus when hiding/showing
1232
1253
  if (hidden) {
1233
- // If this overlay had focus, move focus to next visible or preFocus
1234
- if (this.#focusedComponent === component) {
1254
+ // If this overlay or one of its owned targets had focus, move focus to next visible or preFocus
1255
+ if (isOverlayFocusTarget(component, this.#focusedComponent)) {
1235
1256
  const topVisible = this.#getTopmostVisibleOverlay();
1236
1257
  this.setFocus(topVisible?.component ?? entry.preFocus);
1237
1258
  }