@oh-my-pi/pi-tui 15.9.1 → 15.9.5

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,7 +2,57 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.9.5] - 2026-06-05
6
+
7
+ ### Changed
8
+
9
+ - Changed terminal resize handling so any width or height change always performs a clean reset + redraw: the renderer now unconditionally clears the viewport and native scrollback (`CSI 2 J` / `CSI 3 J`) and replays the full transcript at the new geometry, replacing the previous matrix of conditional viewport-repaint / history-rebuild / deferred-mutation branches. Multiplexer panes still repaint the visible window in place (pane scrollback cannot be erased), but a resize during active ED3-risk foreground streaming now performs the same clean rebuild rather than downgrading to a non-destructive viewport repaint: the terminal already re-wrapped its saved lines at the old width, so the rebuild must erase them (ED 3) instead of leaving the mis-wrapped history on screen. As a deliberate tradeoff this drops the prior no-overflow and confirmed-scrolled guards on resize: a reader scrolled into history snaps back to the bottom and preexisting shell scrollback above the UI is cleared.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed ED3-risk foreground streaming dropping the scrolled-off head of an append-only live block that alone overflows the viewport (a long streamed assistant reply). The live-region pin again committed native scrollback only up to the live-region start, so once the live block grew past the viewport its earlier rows scrolled above the viewport top but were committed nowhere and repainted nowhere — they vanished, leaving the reply looking like a ~viewport-tall circular buffer. The `NativeScrollbackLiveRegion` seam now also reports an optional append-only `getNativeScrollbackCommitSafeEnd`, and the pinned commit boundary is the deeper of the sealed start and that append-only end: rows in `[liveRegionStart, commitSafeEnd)` above the viewport top commit to scrollback, while volatile live blocks (tool previews that collapse) omit the boundary and keep their mutable rows deferred — preserving the pending-box-above-running-box fix.
14
+
15
+ ## [15.9.4] - 2026-06-05
16
+ ### Added
17
+
18
+ - Added `PI_TUI_SYNC_OUTPUT=0` and `PI_TUI_SYNC_OUTPUT=1` to explicitly disable or force-enable DEC 2026 synchronized-output mode, alongside `PI_FORCE_SYNC_OUTPUT=1` as a force-on alias
19
+ - Added `PI_TUI_ED3_SAFE=1` environment override to treat a terminal as non-ED3-risk for eager native scrollback rebuilds on unknown POSIX hosts
20
+
21
+ ### Changed
22
+
23
+ - Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe.
24
+ - Changed synchronized-output defaults to a conservative opt-in profile: DEC 2026 paint wrappers stay disabled for remote/multiplexer/VTE/unknown terminals unless explicitly forced, while the autowrap guards remain active.
25
+
26
+ ### Fixed
27
+
28
+ - Fixed ED3-risk unknown-viewport renders repainting offscreen structural edits over stale native scrollback, which could duplicate or shift rows when async blocks collapsed or middle rows were deleted.
29
+ - Fixed ED3-risk foreground streams committing mutable live-region rows into native scrollback, which could leave a stale `pending` tool box above the `running` box after the preview re-rendered.
30
+ - Fixed TUI shutdown leaving paint-time terminal state and Kitty image data behind by restoring synchronized-output/autowrap modes and purging all transmitted Kitty image ids on stop.
31
+ - Fixed stdin buffering splitting surrogate-pair text into UTF-16 halves and reduced timing sensitivity for incomplete escape sequences.
32
+ - Fixed terminal content not reflowing after a resize on terminals using DEC 2048 in-band resize (kitty/Ghostty/iTerm2/WezTerm). `ProcessTerminal.columns`/`rows` returned the last cached in-band report even after the OS already knew the new size, so a SIGWINCH whose in-band report was dropped or malformed (split past the stdin flush window, `:`-subparameter fields) re-rendered the whole transcript at the stale width. OS resize events now reconcile cached in-band geometry against the live `process.stdout` dimensions, dropping a stale cached value so the next render uses the true size; a valid in-band report still re-seeds pixel sizing.
33
+
34
+ ## [15.9.3] - 2026-06-05
35
+
36
+ ### Fixed
37
+
38
+ - Fixed ED3-risk foreground streaming erasing the head of any block that alone overflows the viewport (a tall tool result drawn in one frame, or a multi-line assistant reply growing past the viewport as it streams). The live-region pin committed native scrollback only up to the sealed-prefix boundary (`liveRegionStart`), so rows of the live block that had physically scrolled above the viewport top were neither pushed into scrollback nor kept in the repainted viewport — they vanished. The commit boundary is now the viewport top: every row above the viewport enters scrollback (only the tail still visible in the viewport stays transient and deferred to the checkpoint).
39
+ - Fixed the same ED3-risk live-region pin duplicating already-committed scrollback rows when a foreground stream's live region collapsed mid-turn (a tool preview shrinking to its compact result, an assistant block re-wrapping shorter, a late tool completion). Because growth commits every row above the viewport top to native scrollback, a subsequent shrink moved the bottom-anchored viewport back across those committed rows and the repaint re-drew them into the viewport — so they appeared twice on scroll-up, and with no prompt-submit checkpoint to reconcile (autonomous multi-turn runs, or the session ending into the welcome screen) the duplicate was baked permanently into terminal history. The pinned repaint now separates commit geometry from repaint geometry: a collapse clamps the repaint to the committed sealed boundary (`min(#scrollbackHighWater, liveRegionStart)`) instead of re-exposing those rows, leaving native scrollback un-duplicated without emitting ED3 under a possibly-scrolled reader; stale mutable live-region saved lines still reconcile at the next checkpoint.
40
+ - Fixed hiding overlays during ED3-risk foreground streaming on unknown-viewport terminals leaving the overlay's transient rows in native scrollback. Overlay visibility reductions now bypass the streaming deferral path and rebuild once, so hidden dialog/notification sentinels are scrubbed immediately.
41
+ - Fixed ED3-risk / unknown-viewport terminals (including WSL fronted by Windows Terminal) keeping the foreground-stream eager-rebuild mode active after the stream had already settled. A later scrolled content shrink or resize-with-append could then bypass the anti-yank deferral and repaint from stale geometry, jumping the viewport or replaying the wrong rows. The eager opt-in now drops immediately when no teardown render is pending, and the one-frame post-checkpoint suffix-suppression path no longer overrides geometry reflow handling.
42
+
43
+ ## [15.9.2] - 2026-06-05
44
+
45
+ ### Changed
46
+
47
+ - Changed foreground-stream rendering on ED3-risk terminals (Ghostty/kitty/Alacritty/VTE/iTerm2 on POSIX) to defer native-scrollback commits for unpinned transient frames: while a turn streams, generic frames repaint only the viewport and suppress `\r\n` scroll growth, so transient output (spinner ticks, partial lines, status rows) never pollutes terminal history. Components that report a `NativeScrollbackLiveRegion` still commit newly sealed prefix rows while keeping the active suffix dirty for checkpoint replay. Native scrollback is reconciled in a single ED3 (`CSI 3 J`) + re-emit at the next checkpoint (prompt submit) or on an explicit user-input/IME opt-in; an erase is never emitted mid-stream under a possibly-scrolled reader. Non-ED3-risk terminals keep their eager live rebuild. ([#1895](https://github.com/can1357/oh-my-pi/pull/1895))
48
+
49
+ ### Fixed
50
+
51
+ - Fixed ED3-risk foreground streaming dropping sealed transcript rows above the live block until the next prompt-submit checkpoint, which made scrollback beyond the viewport appear duplicated or out of order. The renderer restores native-scrollback live-region pinning so newly sealed rows are appended once while active live rows remain deferred.
52
+ - Fixed inline images (added in 15.9) rendering as a wall of empty PUA box glyphs and producing laggy scrolling on Kitty-protocol terminals that do not implement Unicode placeholders — most notably WezTerm (per upstream wezterm/wezterm#986, placeholder support is still unchecked) and the tmux/screen `getFallbackImageProtocol` path that forces Kitty mode even on non-supporting outer terminals (Terminal.app, etc.). `unicodePlaceholders` now defaults on only for `kitty` and `ghostty`; everything else falls back to direct `a=p,i=…,p=…` placement, which those paths already render correctly. `PI_NO_KITTY_PLACEHOLDERS=1` is still honored as a hard opt-out, and a new `PI_KITTY_PLACEHOLDERS=1` opts in on otherwise-unsupported terminals (e.g. a wezterm nightly that has merged placeholder support) ([#1877](https://github.com/can1357/oh-my-pi/issues/1877)).
53
+
5
54
  ## [15.9.1] - 2026-06-04
55
+
6
56
  ### Fixed
7
57
 
8
58
  - Fixed the OSC 11 appearance poll re-querying every 2s forever on terminals that support Mode 2031 but never change theme, whose repeated OSC 11/DA1 writes cleared the user's active text selection (breaking copy every 2 seconds). The poll now stops as soon as DECRQM confirms Mode 2031 support, since push notifications make polling redundant.
@@ -1022,4 +1072,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
1022
1072
 
1023
1073
  ### Fixed
1024
1074
 
1025
- - **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
1075
+ - **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
@@ -64,6 +64,8 @@ export declare class ImageBudget {
64
64
  endPass(): boolean;
65
65
  /** Image ids to delete from the terminal this frame; clears the pending set. */
66
66
  takePurgeIds(): readonly number[];
67
+ /** All image ids believed to be loaded in the terminal store; clears tracking. */
68
+ takeAllTransmittedIds(): readonly number[];
67
69
  /** Whether `imageId`'s data still needs to be transmitted to the terminal. */
68
70
  shouldTransmit(imageId: number): boolean;
69
71
  /**
@@ -9,6 +9,24 @@ export interface KittyGraphicsFeatures {
9
9
  /** How image data reaches the terminal: in-band base64 or a temp file. */
10
10
  transmissionMedium: KittyTransmissionMedium;
11
11
  }
12
+ /**
13
+ * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
14
+ * U+10EEEE with row/column diacritics).
15
+ *
16
+ * Only `kitty` (the protocol's origin) and `ghostty` ship a working
17
+ * implementation; WezTerm advertises Kitty graphics but treats placeholder
18
+ * cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support"
19
+ * still unchecked), and the tmux/screen fallback can land on any outer
20
+ * terminal. Enabling placeholders on those paths emits a `columns × rows`
21
+ * grid of U+10EEEE per image per frame; the cells render as boxed fallback
22
+ * glyphs and re-emit on every repaint, which is exactly the
23
+ * "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
24
+ *
25
+ * `PI_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a
26
+ * non-supporting outer terminal); `PI_KITTY_PLACEHOLDERS=1` forces on (e.g.
27
+ * for a wezterm nightly that has merged placeholder support).
28
+ */
29
+ export declare function detectKittyUnicodePlaceholdersSupport(terminalId: string, env?: NodeJS.ProcessEnv): boolean;
12
30
  export declare function getKittyGraphics(): Readonly<KittyGraphicsFeatures>;
13
31
  export declare function setKittyGraphics(partial: Partial<KittyGraphicsFeatures>): void;
14
32
  /**
@@ -19,8 +19,8 @@
19
19
  import { EventEmitter } from "events";
20
20
  export type StdinBufferOptions = {
21
21
  /**
22
- * Maximum time to wait for sequence completion (default: 10ms)
23
- * After this time, the buffer is flushed even if incomplete
22
+ * Maximum time to wait for sequence completion (default: 75ms).
23
+ * After this time, a genuinely incomplete escape is flushed.
24
24
  */
25
25
  timeout?: number;
26
26
  };
@@ -36,28 +36,23 @@ export declare function isNotificationSuppressed(): boolean;
36
36
  */
37
37
  export declare function isWindowsTerminalPreviewSixelSupported(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
38
38
  /**
39
- * Whether eager live-frame native scrollback rebuilds are unsafe when the
40
- * terminal viewport position is unobservable.
39
+ * Whether live-frame native scrollback rebuilds are unsafe when the terminal
40
+ * viewport position is unobservable.
41
41
  *
42
- * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
43
- * terminals below, ED3 can disturb a reader parked in native scrollback during
44
- * streaming: kitty/ghostty/alacritty/VTE clamp the scroll offset back to the
45
- * active tail when saved lines are erased. WezTerm, macOS Terminal.app, and
46
- * iTerm2 expose scrollback-only clears via ED3/terminfo E3; that still
47
- * invalidates a reader's scrollback position during live streaming.
42
+ * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). Many
43
+ * terminals either clamp a scrolled reader back to the active tail or erase host
44
+ * scrollback when ED3 lands. The important property is not the brand name — it
45
+ * is that an unknown viewport position cannot be proven safe. Environment
46
+ * markers are therefore only used to prove *risk* or a strongly-known profile;
47
+ * unknown POSIX/remote/multiplexer shapes default to risky for passive renders.
48
48
  *
49
- * Windows Terminal erases its host scrollback on ED3 and repositions the
50
- * viewport against the shortened buffer, so a scrolled-up reader is yanked.
51
- * Native win32 is excluded here because the renderer guards it with dedicated
52
- * platform checks (the viewport position is never observable on Windows — see
53
- * `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
54
- * platform means the outer host is Windows Terminal fronting a WSL distro (WT
55
- * propagates the variable into the Linux environment), where the same ED3
56
- * yank applies. See #1610.
57
- *
58
- * Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
49
+ * Native win32 is excluded here because the renderer has dedicated ConPTY
50
+ * deferral paths; a `WT_SESSION` sighting on POSIX means Windows Terminal is the
51
+ * outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
59
52
  */
60
53
  export declare function detectTerminalEagerEraseScrollbackRisk(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
54
+ /** Whether DEC 2026 synchronized-output wrappers should be enabled by default. */
55
+ export declare function shouldEnableSynchronizedOutputByDefault(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, terminalId?: TerminalId): boolean;
61
56
  /**
62
57
  * Whether the terminal applies Kitty-style DECCARA rectangular SGR changes
63
58
  * (`CSI Pt ; Pl ; Pb ; Pr ; <sgr> $ r`) extended to background color, so large
@@ -42,6 +42,28 @@ export interface Component {
42
42
  */
43
43
  invalidate(): void;
44
44
  }
45
+ /**
46
+ * Optional component seam for native-scrollback pinning. A component that
47
+ * renders a stable prefix followed by a live/transient suffix reports the local
48
+ * line index where that suffix begins after each render. TUI treats that suffix
49
+ * — and every root child rendered below it — as not yet safe to commit to native
50
+ * scrollback on ED3-risk terminals whose viewport position is unobservable.
51
+ *
52
+ * `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
53
+ * inside that live suffix: the line index up to which the live region is
54
+ * append-only (its earlier rows never re-layout, only new rows append at the
55
+ * bottom — a streaming assistant message). Rows in `[liveRegionStart,
56
+ * commitSafeEnd)` that scroll above the viewport are safe to commit to native
57
+ * scrollback even though they are technically live, because they will never
58
+ * change. Without this, a single live block that alone overflows the viewport
59
+ * loses its scrolled-off head (committed nowhere, repainted nowhere). Volatile
60
+ * live blocks (tool previews that collapse) omit it, so their mutable rows stay
61
+ * deferred. Defaults to `liveRegionStart` when absent.
62
+ */
63
+ export interface NativeScrollbackLiveRegion {
64
+ getNativeScrollbackLiveRegionStart(): number | undefined;
65
+ getNativeScrollbackCommitSafeEnd?(): number | undefined;
66
+ }
45
67
  /**
46
68
  * Interface for components that can receive focus and display a cursor.
47
69
  * When focused, the component should emit CURSOR_MARKER at the cursor position
@@ -76,9 +98,8 @@ export interface RenderRequestOptions {
76
98
  */
77
99
  allowUnknownViewportMutation?: boolean;
78
100
  }
79
- /** Options for deferred native scrollback rebuild checkpoints. */
101
+ /** Options for deferred native scrollback rebuild checkpoints. Reserved for API stability. */
80
102
  export interface NativeScrollbackRefreshOptions {
81
- /** Allow replay when the terminal cannot report viewport state. Use only for explicit user submit checkpoints. */
82
103
  allowUnknownViewport?: boolean;
83
104
  }
84
105
  /** Type guard to check if a component implements Focusable */
@@ -173,6 +194,7 @@ export declare class TUI extends Container {
173
194
  hidden: boolean;
174
195
  }[];
175
196
  constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions);
197
+ render(width: number): string[];
176
198
  get fullRedraws(): number;
177
199
  /** Shared budget that caps how many inline images render as live graphics. */
178
200
  get imageBudget(): ImageBudget;
@@ -193,8 +215,8 @@ export declare class TUI extends Container {
193
215
  setClearOnShrink(enabled: boolean): void;
194
216
  /**
195
217
  * Whether DEC 2026 synchronized-output wrappers are currently emitted around
196
- * paints. Starts from `PI_NO_SYNC_OUTPUT` and is force-disabled at runtime if
197
- * the terminal reports mode 2026 unsupported via DECRQM.
218
+ * paints. Starts from conservative terminal/env detection and is force-disabled
219
+ * at runtime if the terminal reports mode 2026 unsupported via DECRQM.
198
220
  */
199
221
  get synchronizedOutput(): boolean;
200
222
  /**
@@ -212,12 +234,13 @@ export declare class TUI extends Container {
212
234
  * (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint and
213
235
  * direct user-input rebuilds are unaffected.
214
236
  *
215
- * Disabling does not take effect until the next frame has been classified:
216
- * the event batch that ends a foreground stream both removes its UI rows
217
- * (loader/status teardown — a shrink) and clears this flag before the
218
- * throttled render timer fires. If the flag dropped immediately, that
219
- * teardown frame would hit the ED3-risk idle deferral and freeze on screen
220
- * (stale spinner) until the next keystroke.
237
+ * Disabling stays active through one already-requested frame: the event batch
238
+ * that ends a foreground stream both removes its UI rows (loader/status
239
+ * teardown — a shrink) and clears this flag before the throttled render timer
240
+ * fires. If the flag dropped immediately, that teardown frame would hit the
241
+ * ED3-risk idle deferral and freeze on screen (stale spinner) until the next
242
+ * keystroke. When no render is pending, disable immediately so a later
243
+ * unrelated content mutation does not inherit foreground-stream privileges.
221
244
  */
222
245
  setEagerNativeScrollbackRebuild(enabled: boolean): void;
223
246
  setFocus(component: Component | null): void;
@@ -240,6 +263,6 @@ export declare class TUI extends Container {
240
263
  * Callers should only invoke this at checkpoints where the user is expected to be
241
264
  * at the terminal bottom, such as after submitting a new prompt.
242
265
  */
243
- refreshNativeScrollbackIfDirty(options?: NativeScrollbackRefreshOptions): boolean;
266
+ refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean;
244
267
  requestRender(force?: boolean, options?: RenderRequestOptions): void;
245
268
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "15.9.1",
4
+ "version": "15.9.5",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "15.9.1",
41
- "@oh-my-pi/pi-utils": "15.9.1",
40
+ "@oh-my-pi/pi-natives": "15.9.5",
41
+ "@oh-my-pi/pi-utils": "15.9.5",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -163,6 +163,16 @@ export class ImageBudget {
163
163
  return ids;
164
164
  }
165
165
 
166
+ /** All image ids believed to be loaded in the terminal store; clears tracking. */
167
+ takeAllTransmittedIds(): readonly number[] {
168
+ if (this.#transmitted.size === 0) return EMPTY_IDS;
169
+ const ids = [...this.#transmitted];
170
+ this.#transmitted.clear();
171
+ this.#purgeIds = [];
172
+ this.#pendingTransmits = [];
173
+ return ids;
174
+ }
175
+
166
176
  /** Whether `imageId`'s data still needs to be transmitted to the terminal. */
167
177
  shouldTransmit(imageId: number): boolean {
168
178
  return !this.#transmitted.has(imageId);
@@ -17,7 +17,7 @@
17
17
  import * as fs from "node:fs";
18
18
  import * as os from "node:os";
19
19
  import * as path from "node:path";
20
- import { $env, $flag, logger } from "@oh-my-pi/pi-utils";
20
+ import { $env, logger } from "@oh-my-pi/pi-utils";
21
21
 
22
22
  /** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
23
23
  export const KITTY_PLACEHOLDER = "\u{10eeee}";
@@ -76,8 +76,36 @@ function transmissionOverride(): KittyTransmissionMedium | "auto" {
76
76
  return "auto";
77
77
  }
78
78
 
79
+ /**
80
+ * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
81
+ * U+10EEEE with row/column diacritics).
82
+ *
83
+ * Only `kitty` (the protocol's origin) and `ghostty` ship a working
84
+ * implementation; WezTerm advertises Kitty graphics but treats placeholder
85
+ * cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support"
86
+ * still unchecked), and the tmux/screen fallback can land on any outer
87
+ * terminal. Enabling placeholders on those paths emits a `columns × rows`
88
+ * grid of U+10EEEE per image per frame; the cells render as boxed fallback
89
+ * glyphs and re-emit on every repaint, which is exactly the
90
+ * "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
91
+ *
92
+ * `PI_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a
93
+ * non-supporting outer terminal); `PI_KITTY_PLACEHOLDERS=1` forces on (e.g.
94
+ * for a wezterm nightly that has merged placeholder support).
95
+ */
96
+ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: NodeJS.ProcessEnv = Bun.env): boolean {
97
+ const offRaw = env.PI_NO_KITTY_PLACEHOLDERS?.trim().toLowerCase();
98
+ if (offRaw === "1" || offRaw === "true" || offRaw === "on" || offRaw === "yes" || offRaw === "y") return false;
99
+ const force = env.PI_KITTY_PLACEHOLDERS?.trim().toLowerCase();
100
+ if (force === "1" || force === "true" || force === "on" || force === "yes" || force === "y") return true;
101
+ if (force === "0" || force === "false" || force === "off" || force === "no" || force === "n") return false;
102
+ return terminalId === "kitty" || terminalId === "ghostty";
103
+ }
104
+
79
105
  let features: KittyGraphicsFeatures = {
80
- unicodePlaceholders: !$flag("PI_NO_KITTY_PLACEHOLDERS"),
106
+ // Off until `terminal-capabilities` seeds it from the detected terminal id —
107
+ // the default-on path corrupts wezterm and tmux-passthrough sessions.
108
+ unicodePlaceholders: false,
81
109
  // Start direct; a successful probe (or explicit `temp-file` override) promotes.
82
110
  transmissionMedium: transmissionOverride() === "temp-file" ? "temp-file" : "direct",
83
111
  };
@@ -233,9 +233,10 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
233
233
  return { sequences, remainder: remaining };
234
234
  }
235
235
  } else {
236
- // Not an escape sequence - take a single character
237
- sequences.push(remaining[0]!);
238
- pos++;
236
+ // Not an escape sequence - take one Unicode scalar, not a UTF-16 code unit.
237
+ const char = Array.from(remaining)[0] ?? "";
238
+ sequences.push(char);
239
+ pos += char.length;
239
240
  }
240
241
  }
241
242
 
@@ -244,8 +245,8 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
244
245
 
245
246
  export type StdinBufferOptions = {
246
247
  /**
247
- * Maximum time to wait for sequence completion (default: 10ms)
248
- * After this time, the buffer is flushed even if incomplete
248
+ * Maximum time to wait for sequence completion (default: 75ms).
249
+ * After this time, a genuinely incomplete escape is flushed.
249
250
  */
250
251
  timeout?: number;
251
252
  };
@@ -269,7 +270,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
269
270
 
270
271
  constructor(options: StdinBufferOptions = {}) {
271
272
  super();
272
- this.#timeoutMs = options.timeout ?? 10;
273
+ this.#timeoutMs = options.timeout ?? 75;
273
274
  }
274
275
 
275
276
  process(data: string | Buffer): void {
@@ -1,12 +1,14 @@
1
1
  import { encodeSixel } from "@oh-my-pi/pi-natives";
2
2
  import { $env, isBunTestRuntime } from "@oh-my-pi/pi-utils";
3
3
  import {
4
+ detectKittyUnicodePlaceholdersSupport,
4
5
  encodeKittyTempFileTransmit,
5
6
  getKittyGraphics,
6
7
  isPngBase64,
7
8
  KITTY_PLACEHOLDER,
8
9
  kittyPlaceholdersFit,
9
10
  renderKittyPlaceholderLines,
11
+ setKittyGraphics,
10
12
  } from "./kitty-graphics";
11
13
 
12
14
  export enum ImageProtocol {
@@ -116,33 +118,44 @@ export function isWindowsTerminalPreviewSixelSupported(
116
118
  }
117
119
 
118
120
  /**
119
- * Whether eager live-frame native scrollback rebuilds are unsafe when the
120
- * terminal viewport position is unobservable.
121
+ * Whether live-frame native scrollback rebuilds are unsafe when the terminal
122
+ * viewport position is unobservable.
121
123
  *
122
- * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
123
- * terminals below, ED3 can disturb a reader parked in native scrollback during
124
- * streaming: kitty/ghostty/alacritty/VTE clamp the scroll offset back to the
125
- * active tail when saved lines are erased. WezTerm, macOS Terminal.app, and
126
- * iTerm2 expose scrollback-only clears via ED3/terminfo E3; that still
127
- * invalidates a reader's scrollback position during live streaming.
124
+ * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). Many
125
+ * terminals either clamp a scrolled reader back to the active tail or erase host
126
+ * scrollback when ED3 lands. The important property is not the brand name — it
127
+ * is that an unknown viewport position cannot be proven safe. Environment
128
+ * markers are therefore only used to prove *risk* or a strongly-known profile;
129
+ * unknown POSIX/remote/multiplexer shapes default to risky for passive renders.
128
130
  *
129
- * Windows Terminal erases its host scrollback on ED3 and repositions the
130
- * viewport against the shortened buffer, so a scrolled-up reader is yanked.
131
- * Native win32 is excluded here because the renderer guards it with dedicated
132
- * platform checks (the viewport position is never observable on Windows — see
133
- * `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
134
- * platform means the outer host is Windows Terminal fronting a WSL distro (WT
135
- * propagates the variable into the Linux environment), where the same ED3
136
- * yank applies. See #1610.
137
- *
138
- * Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
131
+ * Native win32 is excluded here because the renderer has dedicated ConPTY
132
+ * deferral paths; a `WT_SESSION` sighting on POSIX means Windows Terminal is the
133
+ * outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
139
134
  */
140
135
  export function detectTerminalEagerEraseScrollbackRisk(
141
136
  env: NodeJS.ProcessEnv = Bun.env,
142
137
  platform: NodeJS.Platform = process.platform,
143
138
  ): boolean {
144
139
  if (platform === "win32") return false;
140
+
141
+ const term = env.TERM?.toLowerCase() ?? "";
142
+ const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
143
+ const colorTerm = env.COLORTERM?.toLowerCase() ?? "";
144
+
145
+ if (env.PI_TUI_ED3_SAFE === "1") return false;
145
146
  if (env.WT_SESSION) return true;
147
+ if (
148
+ env.SSH_CONNECTION ||
149
+ env.SSH_CLIENT ||
150
+ env.SSH_TTY ||
151
+ env.TMUX ||
152
+ env.STY ||
153
+ env.ZELLIJ ||
154
+ term.startsWith("tmux") ||
155
+ term.startsWith("screen")
156
+ ) {
157
+ return true;
158
+ }
146
159
  if (
147
160
  env.WEZTERM_PANE ||
148
161
  env.KITTY_WINDOW_ID ||
@@ -153,17 +166,62 @@ export function detectTerminalEagerEraseScrollbackRisk(
153
166
  ) {
154
167
  return true;
155
168
  }
156
- switch (env.TERM_PROGRAM?.toLowerCase()) {
169
+ switch (termProgram) {
157
170
  case "alacritty":
158
171
  case "apple_terminal":
159
172
  case "ghostty":
173
+ case "gnome-terminal":
160
174
  case "iterm.app":
175
+ case "kgx":
161
176
  case "kitty":
177
+ case "ptyxis":
162
178
  case "wezterm":
179
+ case "xfce4-terminal":
163
180
  return true;
164
181
  default:
182
+ break;
183
+ }
184
+ if (platform === "linux" && (colorTerm === "truecolor" || colorTerm === "24bit")) return true;
185
+ // Unknown POSIX terminals have no scroll-position oracle. Treat them as risky
186
+ // for passive ED3 until a positive terminal-specific integration proves safe.
187
+ return true;
188
+ }
189
+
190
+ /** Whether DEC 2026 synchronized-output wrappers should be enabled by default. */
191
+ export function shouldEnableSynchronizedOutputByDefault(
192
+ env: NodeJS.ProcessEnv = Bun.env,
193
+ platform: NodeJS.Platform = process.platform,
194
+ terminalId: TerminalId = TERMINAL_ID,
195
+ ): boolean {
196
+ if (env.PI_NO_SYNC_OUTPUT || env.PI_TUI_SYNC_OUTPUT === "0") return false;
197
+ if (env.PI_FORCE_SYNC_OUTPUT === "1" || env.PI_TUI_SYNC_OUTPUT === "1") return true;
198
+ if (platform === "win32") return false;
199
+
200
+ const term = env.TERM?.toLowerCase() ?? "";
201
+ const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
202
+ if (
203
+ env.SSH_CONNECTION ||
204
+ env.SSH_CLIENT ||
205
+ env.SSH_TTY ||
206
+ env.TMUX ||
207
+ env.STY ||
208
+ env.ZELLIJ ||
209
+ term.startsWith("tmux") ||
210
+ term.startsWith("screen")
211
+ ) {
212
+ return false;
213
+ }
214
+ if (env.VTE_VERSION) return false;
215
+ switch (termProgram) {
216
+ case "gnome-terminal":
217
+ case "kgx":
218
+ case "ptyxis":
219
+ case "xfce4-terminal":
165
220
  return false;
221
+ default:
222
+ break;
166
223
  }
224
+ return terminalId === "kitty" || terminalId === "ghostty" || terminalId === "wezterm" || terminalId === "iterm2";
167
225
  }
168
226
 
169
227
  /**
@@ -321,6 +379,12 @@ export const TERMINAL = (() => {
321
379
  return resolved;
322
380
  })();
323
381
 
382
+ // Seed Kitty Unicode placeholder support from the resolved terminal id. Only
383
+ // kitty/ghostty are known to honor `U=1` placement; other Kitty-protocol paths
384
+ // (wezterm, tmux/screen fallback) treat the placeholder cells as literal PUA
385
+ // glyphs, which is the "ASCII artifact + laggy scrolling" reported in #1877.
386
+ setKittyGraphics({ unicodePlaceholders: detectKittyUnicodePlaceholdersSupport(TERMINAL.id, Bun.env) });
387
+
324
388
  type MutableTerminalInfo = {
325
389
  imageProtocol: ImageProtocol | null;
326
390
  deccara: boolean;
package/src/terminal.ts CHANGED
@@ -35,7 +35,9 @@ export function emergencyTerminalRestore(): void {
35
35
  // Blind restore only if we know a terminal was started but lost track of it
36
36
  // This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
37
37
  process.stdout.write(
38
- "\x1b[?2004l" + // Disable bracketed paste
38
+ "\x1b[?2026l" + // End synchronized output
39
+ "\x1b[?7h" + // Restore autowrap
40
+ "\x1b[?2004l" + // Disable bracketed paste
39
41
  "\x1b[?2031l" + // Disable Mode 2031 appearance notifications
40
42
  "\x1b[?2048l" + // Disable in-band resize notifications
41
43
  "\x1b[<u" + // Pop kitty keyboard protocol
@@ -173,6 +175,7 @@ export class ProcessTerminal implements Terminal {
173
175
  #wasRaw = false;
174
176
  #inputHandler?: (data: string) => void;
175
177
  #resizeHandler?: () => void;
178
+ #stdoutResizeListener?: () => void;
176
179
  #kittyProtocolActive = false;
177
180
  #modifyOtherKeysActive = false;
178
181
  #modifyOtherKeysTimeout?: Timer;
@@ -239,8 +242,14 @@ export class ProcessTerminal implements Terminal {
239
242
  // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
240
243
  this.#safeWrite("\x1b[?2004h");
241
244
 
242
- // Set up resize handler immediately
243
- process.stdout.on("resize", this.#resizeHandler);
245
+ // Set up resize handler immediately. The OS refreshes process.stdout
246
+ // dimensions before firing `resize`, so it is authoritative for geometry:
247
+ // reconcile any stale cached DEC 2048 report before notifying the renderer.
248
+ this.#stdoutResizeListener = () => {
249
+ this.#reconcileInBandGeometryOnResize();
250
+ this.#resizeHandler?.();
251
+ };
252
+ process.stdout.on("resize", this.#stdoutResizeListener);
244
253
 
245
254
  // Refresh terminal dimensions - they may be stale after suspend/resume
246
255
  // (SIGWINCH is lost while process is stopped). Unix only.
@@ -845,6 +854,31 @@ export class ProcessTerminal implements Terminal {
845
854
  }
846
855
  }
847
856
 
857
+ /**
858
+ * Reconcile cached in-band geometry with the OS on an OS-level resize.
859
+ *
860
+ * SIGWINCH (POSIX) and ConPTY (Windows) refresh `process.stdout.columns`/
861
+ * `rows` before the `resize` event fires, so they are authoritative for the
862
+ * new cell geometry. A cached DEC 2048 report can be stale: the matching
863
+ * post-resize report may be dropped (split across stdin reads past the flush
864
+ * window) or carry `:`-subparameters the parser skips, leaving the getters
865
+ * pinned to the old size — which freezes the rendered width because the
866
+ * renderer reflows against {@link columns}/{@link rows}, not the live OS
867
+ * value. Drop a cached dimension that disagrees with the live OS value; the
868
+ * terminal's next valid in-band report re-seeds pixel sizing.
869
+ */
870
+ #reconcileInBandGeometryOnResize(): void {
871
+ if (!this.#inBandResizeActive) return;
872
+ const osColumns = process.stdout.columns;
873
+ const osRows = process.stdout.rows;
874
+ if (this.#reportedColumns !== undefined && osColumns > 0 && this.#reportedColumns !== osColumns) {
875
+ this.#reportedColumns = undefined;
876
+ }
877
+ if (this.#reportedRows !== undefined && osRows > 0 && this.#reportedRows !== osRows) {
878
+ this.#reportedRows = undefined;
879
+ }
880
+ }
881
+
848
882
  async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
849
883
  if (this.#kittyProtocolActive) {
850
884
  // Disable Kitty keyboard protocol first so any late key releases
@@ -897,6 +931,10 @@ export class ProcessTerminal implements Terminal {
897
931
  this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
898
932
  }
899
933
 
934
+ // Leave paint-time terminal modes even if the process exits between the
935
+ // begin/end halves of a frame. Safe no-ops on terminals that ignored them.
936
+ this.#safeWrite("\x1b[?2026l\x1b[?7h");
937
+
900
938
  // Disable bracketed paste mode
901
939
  this.#safeWrite("\x1b[?2004l");
902
940
 
@@ -958,10 +996,11 @@ export class ProcessTerminal implements Terminal {
958
996
  }
959
997
  this.#inputHandler = undefined;
960
998
  this.#appearance = undefined;
961
- if (this.#resizeHandler) {
962
- process.stdout.removeListener("resize", this.#resizeHandler);
963
- this.#resizeHandler = undefined;
999
+ if (this.#stdoutResizeListener) {
1000
+ process.stdout.removeListener("resize", this.#stdoutResizeListener);
1001
+ this.#stdoutResizeListener = undefined;
964
1002
  }
1003
+ this.#resizeHandler = undefined;
965
1004
 
966
1005
  // Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
967
1006
  // re-interpreted after raw mode is disabled. This fixes a race condition