@oh-my-pi/pi-tui 18.1.2 → 18.1.4
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 +6 -0
- package/dist/types/terminal-capabilities.d.ts +8 -2
- package/dist/types/terminal-multiplexer.d.ts +2 -0
- package/dist/types/terminal.d.ts +10 -2
- package/package.json +3 -3
- package/src/kitty-graphics.ts +2 -2
- package/src/terminal-capabilities.ts +11 -4
- package/src/terminal-multiplexer.ts +13 -1
- package/src/terminal.ts +14 -6
- package/src/tui.ts +8 -1
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.
|
|
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
|
-
*
|
|
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;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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.
|
|
4
|
+
"version": "18.1.4",
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "18.1.
|
|
40
|
+
"@oh-my-pi/pi-natives": "18.1.4",
|
|
41
|
+
"@oh-my-pi/pi-utils": "18.1.4"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"kitty-vt-wasm": "^0.2.0"
|
package/src/kitty-graphics.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|