@oh-my-pi/pi-tui 18.1.2 → 18.1.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.3] - 2026-09-02
6
+
7
+ ### Fixed
8
+
9
+ - Fixed the TUI tearing in Herdr panes so the live viewport updates as one frame instead of leaving the top frozen while only the bottom refreshed. Pane identity vars (`HERDR_PANE_ID` / `HERDR_TAB_ID` / `HERDR_WORKSPACE_ID`) also count as inside Herdr, not only `HERDR_ENV=1`. A DECRPM “unrecognized” report keeps synchronized output on; a “permanently reset” report, or a custom terminal that omits the DECRPM status, still turns it off.
10
+
5
11
  ## [18.1.0] - 2026-09-01
6
12
 
7
13
  ### Fixed
@@ -85,9 +85,15 @@ export declare function synchronizedOutputUserOverride(env?: NodeJS.ProcessEnv):
85
85
  * 2. Positive `TERM_FEATURES` advertisement (`Sy`) — survives SSH/mux wrapping.
86
86
  * 3. Windows Terminal (1.24+) via `WT_SESSION`, on native win32 and the
87
87
  * WSL/SSH-fronted host alike.
88
- * 4. Known direct terminals with confirmed support. SSH does *not* disable
88
+ * 4. Herdr panes. Herdr is otherwise treated as a multiplexer so leaked
89
+ * kitty/ghostty identities cannot enable placeholder graphics, but its
90
+ * pane VTE is libghostty and already suppresses compositing while DEC 2026
91
+ * is set. Leaving sync off lets CUP-diff paints and split write(2) chunks
92
+ * composite as dirty-row patches — the live viewport tears, with the top
93
+ * frozen while only the bottom refreshes.
94
+ * 5. Known direct terminals with confirmed support. SSH does *not* disable —
89
95
  * DEC 2026 passes through SSH when the outer terminal honors it.
90
- * 5. Everything else starts off, including risky multiplexers; the runtime
96
+ * 6. Everything else starts off, including risky multiplexers; the runtime
91
97
  * DECRQM probe upgrades any of them when the terminal actually reports
92
98
  * `?2026` supported (current zellij, tmux master, foot, contour, mintty…).
93
99
  */
@@ -1,2 +1,4 @@
1
+ /** True when this process is running inside a Herdr pane. */
2
+ export declare function isInsideHerdr(env?: NodeJS.ProcessEnv): boolean;
1
3
  /** Detect whether a terminal multiplexer owns the current screen grid. */
2
4
  export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
@@ -99,6 +99,13 @@ export interface TerminalStartOptions {
99
99
  }
100
100
  /** Identity of an accepted explicit terminal appearance refresh request. */
101
101
  export type TerminalAppearanceRequestToken = number;
102
+ /**
103
+ * Fired once per DEC private mode when DECRQM support resolves.
104
+ * `confirmed` is false when only the DA1 sentinel arrived.
105
+ * `status` is the DECRPM value (0 unrecognized, 1/2 set/reset, 3 permanently
106
+ * set, 4 permanently reset) when the terminal answered DECRQM.
107
+ */
108
+ export type PrivateModeReportHandler = (mode: number, supported: boolean, confirmed?: boolean, status?: number) => void;
102
109
  export interface Terminal {
103
110
  start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void, options?: TerminalStartOptions): void;
104
111
  /**
@@ -177,8 +184,9 @@ export interface Terminal {
177
184
  * status resolves. `confirmed` is false when the terminal answered the DA1
178
185
  * sentinel without answering DECRQM, which proves only that querying support
179
186
  * is unavailable — not that the private mode itself is unsupported.
187
+ * `status` is the DECRPM value when the terminal answered DECRQM.
180
188
  */
181
- onPrivateModeReport?(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
189
+ onPrivateModeReport?(callback: PrivateModeReportHandler): void;
182
190
  }
183
191
  /**
184
192
  * True when stdout flows through a ConPTY pseudo-console (native win32, or
@@ -223,7 +231,7 @@ export declare class ProcessTerminal implements Terminal {
223
231
  * probes remain direct. Suppressed while inactive, headless, or after teardown.
224
232
  */
225
233
  refreshAppearance(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void;
226
- onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
234
+ onPrivateModeReport(callback: PrivateModeReportHandler): void;
227
235
  start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void, options?: TerminalStartOptions): void;
228
236
  enableInput(): void;
229
237
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.1.2",
4
+ "version": "18.1.3",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,8 +37,8 @@
37
37
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "18.1.2",
41
- "@oh-my-pi/pi-utils": "18.1.2"
40
+ "@oh-my-pi/pi-natives": "18.1.3",
41
+ "@oh-my-pi/pi-utils": "18.1.3"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"
@@ -15,7 +15,7 @@
15
15
  * forms. Protocol gating (`imageProtocol === Kitty`) lives in the caller.
16
16
  */
17
17
 
18
- import { isInsideTerminalMultiplexer } from "./terminal-multiplexer";
18
+ import { isInsideHerdr, isInsideTerminalMultiplexer } from "./terminal-multiplexer";
19
19
  import { wrapTmuxPassthroughIfNeeded } from "./tmux";
20
20
 
21
21
  /** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
@@ -81,7 +81,7 @@ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: N
81
81
  if (force === "0" || force === "false" || force === "off" || force === "no" || force === "n") return false;
82
82
  const insideMultiplexer = isInsideTerminalMultiplexer(env);
83
83
  if (insideMultiplexer && env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase() === "kitty") return true;
84
- if (env.HERDR_ENV === "1") return false;
84
+ if (isInsideHerdr(env)) return false;
85
85
  return terminalId === "kitty" || terminalId === "ghostty";
86
86
  }
87
87
 
@@ -9,7 +9,7 @@ import {
9
9
  renderKittyPlaceholderLines,
10
10
  setKittyGraphics,
11
11
  } from "./kitty-graphics";
12
- import { isInsideTerminalMultiplexer } from "./terminal-multiplexer";
12
+ import { isInsideHerdr, isInsideTerminalMultiplexer } from "./terminal-multiplexer";
13
13
  import { isInsideTmux, wrapTmuxPassthrough, wrapTmuxPassthroughIfNeeded } from "./tmux";
14
14
  import type { HangulCompatibilityJamoWidth } from "./utils";
15
15
 
@@ -289,9 +289,15 @@ function advertisesSynchronizedOutput(termFeatures: string | undefined): boolean
289
289
  * 2. Positive `TERM_FEATURES` advertisement (`Sy`) — survives SSH/mux wrapping.
290
290
  * 3. Windows Terminal (1.24+) via `WT_SESSION`, on native win32 and the
291
291
  * WSL/SSH-fronted host alike.
292
- * 4. Known direct terminals with confirmed support. SSH does *not* disable
292
+ * 4. Herdr panes. Herdr is otherwise treated as a multiplexer so leaked
293
+ * kitty/ghostty identities cannot enable placeholder graphics, but its
294
+ * pane VTE is libghostty and already suppresses compositing while DEC 2026
295
+ * is set. Leaving sync off lets CUP-diff paints and split write(2) chunks
296
+ * composite as dirty-row patches — the live viewport tears, with the top
297
+ * frozen while only the bottom refreshes.
298
+ * 5. Known direct terminals with confirmed support. SSH does *not* disable —
293
299
  * DEC 2026 passes through SSH when the outer terminal honors it.
294
- * 5. Everything else starts off, including risky multiplexers; the runtime
300
+ * 6. Everything else starts off, including risky multiplexers; the runtime
295
301
  * DECRQM probe upgrades any of them when the terminal actually reports
296
302
  * `?2026` supported (current zellij, tmux master, foot, contour, mintty…).
297
303
  */
@@ -304,6 +310,7 @@ export function shouldEnableSynchronizedOutputByDefault(
304
310
 
305
311
  if (advertisesSynchronizedOutput(env.TERM_FEATURES)) return true;
306
312
  if (env.WT_SESSION) return true;
313
+ if (isInsideHerdr(env)) return true;
307
314
 
308
315
  // Risky multiplexers start off even when an inner terminal id leaks through:
309
316
  // older tmux/screen synchronized-output handling is flaky and a mux may not
@@ -507,7 +514,7 @@ export function resolveImageProtocol(
507
514
  // Herdr owns the pane grid but does not expose whether the attached client
508
515
  // enabled its experimental Kitty renderer. Outer-terminal identity variables
509
516
  // can leak into the pane, so only the explicit protocol override is safe.
510
- if (imageProtocol !== null && env.HERDR_ENV === "1") {
517
+ if (imageProtocol !== null && isInsideHerdr(env)) {
511
518
  return null;
512
519
  }
513
520
  return imageProtocol;
@@ -1,3 +1,15 @@
1
+ /** True when this process is running inside a Herdr pane. */
2
+ export function isInsideHerdr(env: NodeJS.ProcessEnv = Bun.env): boolean {
3
+ // HERDR_ENV=1 is canonical. Identity vars survive env-sanitizing launchers
4
+ // that drop HERDR_ENV. Do not use HERDR_SOCKET_PATH, HERDR_BIN_PATH,
5
+ // HERDR_SESSION, HERDR_CONFIG_PATH, or HERDR_CLIENT_SOCKET_PATH here: they
6
+ // are client-side and can be set outside a Herdr pane, matching the
7
+ // CMUX_SOCKET_PATH warning below.
8
+ if (env.HERDR_ENV === "1") return true;
9
+ if (env.HERDR_PANE_ID || env.HERDR_TAB_ID || env.HERDR_WORKSPACE_ID) return true;
10
+ return false;
11
+ }
12
+
1
13
  /** Detect whether a terminal multiplexer owns the current screen grid. */
2
14
  export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
3
15
  // TMUX/STY/ZELLIJ, Herdr, and CMUX workspace/surface/remote-transport
@@ -5,7 +17,7 @@ export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): b
5
17
  // stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
6
18
  // use CMUX_SOCKET_PATH here: it is a CLI socket override and can be set
7
19
  // outside a CMUX terminal.
8
- if (env.TMUX || env.STY || env.ZELLIJ || env.HERDR_ENV === "1") return true;
20
+ if (env.TMUX || env.STY || env.ZELLIJ || isInsideHerdr(env)) return true;
9
21
  if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return true;
10
22
  const term = env.TERM?.toLowerCase() ?? "";
11
23
  return term.startsWith("tmux") || term.startsWith("screen");
package/src/terminal.ts CHANGED
@@ -454,6 +454,13 @@ export interface TerminalStartOptions {
454
454
  }
455
455
  /** Identity of an accepted explicit terminal appearance refresh request. */
456
456
  export type TerminalAppearanceRequestToken = number;
457
+ /**
458
+ * Fired once per DEC private mode when DECRQM support resolves.
459
+ * `confirmed` is false when only the DA1 sentinel arrived.
460
+ * `status` is the DECRPM value (0 unrecognized, 1/2 set/reset, 3 permanently
461
+ * set, 4 permanently reset) when the terminal answered DECRQM.
462
+ */
463
+ export type PrivateModeReportHandler = (mode: number, supported: boolean, confirmed?: boolean, status?: number) => void;
457
464
  export interface Terminal {
458
465
  // Start the terminal with input, resize, and host-disconnect handlers.
459
466
  start(
@@ -577,8 +584,9 @@ export interface Terminal {
577
584
  * status resolves. `confirmed` is false when the terminal answered the DA1
578
585
  * sentinel without answering DECRQM, which proves only that querying support
579
586
  * is unavailable — not that the private mode itself is unsupported.
587
+ * `status` is the DECRPM value when the terminal answered DECRQM.
580
588
  */
581
- onPrivateModeReport?(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
589
+ onPrivateModeReport?(callback: PrivateModeReportHandler): void;
582
590
  }
583
591
 
584
592
  /**
@@ -724,7 +732,7 @@ export class ProcessTerminal implements Terminal {
724
732
  #da1SentinelOwners: Da1SentinelOwner[] = [];
725
733
  /** Resolved DECRQM support per private mode (mode → supported). */
726
734
  #privateModeSupport = new Map<number, boolean>();
727
- #privateModeCallbacks: Array<(mode: number, supported: boolean, confirmed: boolean) => void> = [];
735
+ #privateModeCallbacks: PrivateModeReportHandler[] = [];
728
736
  /** Whether DEC 2048 in-band resize notifications are currently enabled. */
729
737
  #inBandResizeActive = false;
730
738
  /** Reassembly buffer for a DEC 2048 in-band resize report split across stdin reads. */
@@ -815,7 +823,7 @@ export class ProcessTerminal implements Terminal {
815
823
  return token;
816
824
  }
817
825
 
818
- onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void {
826
+ onPrivateModeReport(callback: PrivateModeReportHandler): void {
819
827
  this.#privateModeCallbacks.push(callback);
820
828
  }
821
829
 
@@ -1537,7 +1545,7 @@ export class ProcessTerminal implements Terminal {
1537
1545
  }
1538
1546
 
1539
1547
  #handlePrivateModeReport(mode: number, status: string): void {
1540
- this.#resolvePrivateMode(mode, isPrivateModeSupported(status), true);
1548
+ this.#resolvePrivateMode(mode, isPrivateModeSupported(status), true, Number.parseInt(status, 10));
1541
1549
  if (isXtermScrollToBottomMode(mode) && isPrivateModeSet(status)) {
1542
1550
  this.#disableXtermScrollToBottomMode(mode);
1543
1551
  }
@@ -1549,12 +1557,12 @@ export class ProcessTerminal implements Terminal {
1549
1557
  * unsupported response from an absent response followed by the DA1 sentinel.
1550
1558
  * Enables DEC 2048 in-band resize only after positive confirmation.
1551
1559
  */
1552
- #resolvePrivateMode(mode: number, supported: boolean, confirmed: boolean): void {
1560
+ #resolvePrivateMode(mode: number, supported: boolean, confirmed: boolean, status?: number): void {
1553
1561
  if (this.#privateModeSupport.has(mode)) return;
1554
1562
  this.#privateModeSupport.set(mode, supported);
1555
1563
  for (const cb of this.#privateModeCallbacks) {
1556
1564
  try {
1557
- cb(mode, supported, confirmed);
1565
+ cb(mode, supported, confirmed, status);
1558
1566
  } catch {
1559
1567
  // Ignore subscriber errors — capability reporting must not crash input.
1560
1568
  }
package/src/tui.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  encodeKittyPlacementLine,
27
27
  ImageProtocol,
28
28
  isImageProtocolForced,
29
+ isInsideHerdr,
29
30
  isInsideTerminalMultiplexer,
30
31
  parseKittyDirectPlacementLine,
31
32
  setCellDimensions,
@@ -1108,9 +1109,15 @@ export class TUI extends Container {
1108
1109
  // implementing DECRQM, so retain the statically detected default instead of
1109
1110
  // exposing destructive full paints. An explicit user opt-out/force still
1110
1111
  // wins, so skip every probe result in that case.
1111
- this.terminal.onPrivateModeReport?.((mode, supported, confirmed = true) => {
1112
+ this.terminal.onPrivateModeReport?.((mode, supported, confirmed = true, status) => {
1112
1113
  if (mode !== 2026 || !confirmed) return;
1113
1114
  if (synchronizedOutputUserOverride() !== null) return;
1115
+ // Herdr's Ghostty VTE honors DEC 2026 even when DECRQM is unanswered or
1116
+ // reports unrecognized (status 0). Other confirmed unsupported reports
1117
+ // still disable: status 4 is permanently reset, and a three-argument
1118
+ // callback (`status` omitted) is a definitive unsupported from a
1119
+ // custom Terminal that does not distinguish DECRPM codes.
1120
+ if (!supported && isInsideHerdr() && status === 0) return;
1114
1121
  this.#setSynchronizedOutput(supported);
1115
1122
  });
1116
1123
  this.terminal.start(