@oh-my-pi/pi-tui 17.1.0 → 17.1.2

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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.1.2] - 2026-07-24
6
+
7
+ ### Fixed
8
+
9
+ - Fixed the terminal flickering when leaving a fullscreen overlay (e.g. `/settings`, the models page) or resizing on terminals that re-report their size whenever the alternate screen buffer toggles: the alt-toggle size echo no longer arms a destructive full-screen (ED3) rebuild, and the in-place repaint path is now auto-detected for any such terminal instead of only Warp ([#6511](https://github.com/can1357/oh-my-pi/issues/6511)).
10
+
11
+ ## [17.1.1] - 2026-07-24
12
+
13
+ ### Fixed
14
+
15
+ - Fixed WarpTerminal wrapping streamed Compatibility Jamo at the platform-default width instead of its rendered one-cell width ([#6461](https://github.com/can1357/oh-my-pi/issues/6461)).
16
+
5
17
  ## [17.1.0] - 2026-07-24
6
18
 
7
19
  ### Added
@@ -1,3 +1,4 @@
1
+ import type { HangulCompatibilityJamoWidth } from "./utils.js";
1
2
  export { isInsideTmux, wrapTmuxPassthrough } from "./tmux.js";
2
3
  export declare enum ImageProtocol {
3
4
  Kitty = "\u001B_G",
@@ -21,9 +22,21 @@ export declare class TerminalInfo {
21
22
  readonly supportsScreenToScrollback: boolean;
22
23
  /** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
23
24
  readonly textSizing: boolean;
25
+ /**
26
+ * Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty follows
27
+ * UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
28
+ * (macOS narrow, otherwise UAX#11).
29
+ */
30
+ readonly hangulJamoWidth: HangulCompatibilityJamoWidth;
24
31
  constructor(id: TerminalId, imageProtocol: ImageProtocol | null, trueColor: boolean, hyperlinks: boolean, notifyProtocol?: NotifyProtocol, deccara?: boolean, supportsScreenToScrollback?: boolean,
25
32
  /** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
26
- textSizing?: boolean);
33
+ textSizing?: boolean,
34
+ /**
35
+ * Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty follows
36
+ * UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
37
+ * (macOS narrow, otherwise UAX#11).
38
+ */
39
+ hangulJamoWidth?: HangulCompatibilityJamoWidth);
27
40
  /**
28
41
  * Mutable clone for the {@link TERMINAL} singleton: copies every field and
29
42
  * keeps the prototype methods, so the builder and runtime setters flip
@@ -1,5 +1,3 @@
1
- import { type HangulCompatibilityJamoWidth } from "./utils.js";
2
- export declare function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(env?: NodeJS.ProcessEnv): HangulCompatibilityJamoWidth;
3
1
  /**
4
2
  * Split `data` into chunks whose encoded UTF-8 byte length is no greater than
5
3
  * `maxChunkBytes`, preferring a line boundary (`\n`) as the cut point so
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "17.1.0",
4
+ "version": "17.1.2",
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": "17.1.0",
41
- "@oh-my-pi/pi-utils": "17.1.0",
40
+ "@oh-my-pi/pi-natives": "17.1.2",
41
+ "@oh-my-pi/pi-utils": "17.1.2",
42
42
  "lru-cache": "11.5.2",
43
43
  "marked": "^18.0.6"
44
44
  },
@@ -10,6 +10,7 @@ import {
10
10
  setKittyGraphics,
11
11
  } from "./kitty-graphics";
12
12
  import { isInsideTmux, wrapTmuxPassthrough, wrapTmuxPassthroughIfNeeded } from "./tmux";
13
+ import type { HangulCompatibilityJamoWidth } from "./utils";
13
14
 
14
15
  export { isInsideTmux, wrapTmuxPassthrough } from "./tmux";
15
16
 
@@ -105,6 +106,12 @@ export class TerminalInfo {
105
106
  readonly supportsScreenToScrollback: boolean = false,
106
107
  /** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
107
108
  public readonly textSizing: boolean = false,
109
+ /**
110
+ * Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty follows
111
+ * UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
112
+ * (macOS narrow, otherwise UAX#11).
113
+ */
114
+ public readonly hangulJamoWidth: HangulCompatibilityJamoWidth = "platform",
108
115
  ) {}
109
116
 
110
117
  /**
@@ -447,7 +454,17 @@ export function resolveWarpImageProtocol(
447
454
  }
448
455
 
449
456
  function getWarpTerminalInfo(platform: NodeJS.Platform, env: NodeJS.ProcessEnv = Bun.env): TerminalInfo {
450
- return new TerminalInfo("warp", resolveWarpImageProtocol(platform, env), true, false, NotifyProtocol.Osc9);
457
+ return new TerminalInfo(
458
+ "warp",
459
+ resolveWarpImageProtocol(platform, env),
460
+ true,
461
+ false,
462
+ NotifyProtocol.Osc9,
463
+ false,
464
+ false,
465
+ false,
466
+ 1,
467
+ );
451
468
  }
452
469
  const KNOWN_TERMINALS = Object.freeze({
453
470
  // Fallback terminals
@@ -455,7 +472,7 @@ const KNOWN_TERMINALS = Object.freeze({
455
472
  trueColor: new TerminalInfo("trueColor", null, true, false, NotifyProtocol.Bell),
456
473
  // Recognized terminals
457
474
  kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99, true, true, true),
458
- ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9),
475
+ ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, false, false, false, 2),
459
476
  wezterm: new TerminalInfo("wezterm", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9),
460
477
  iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9),
461
478
  vscode: new TerminalInfo("vscode", null, true, true, NotifyProtocol.Bell),
@@ -465,7 +482,7 @@ const KNOWN_TERMINALS = Object.freeze({
465
482
  // detectKittyUnicodePlaceholdersSupport correctly excludes it). It does not
466
483
  // honor OSC 8 yet (the escape renders as visible text), so hyperlinks stay off,
467
484
  // but it does support OSC 9 notifications.
468
- warp: new TerminalInfo("warp", ImageProtocol.Kitty, true, false, NotifyProtocol.Osc9),
485
+ warp: new TerminalInfo("warp", ImageProtocol.Kitty, true, false, NotifyProtocol.Osc9, false, false, false, 1),
469
486
  });
470
487
 
471
488
  /** Resolve terminal identity from environment markers used by common emulators. */
package/src/terminal.ts CHANGED
@@ -19,31 +19,12 @@ import {
19
19
  TERMINAL,
20
20
  } from "./terminal-capabilities";
21
21
  import { isInsideTmux, wrapTmuxPassthrough } from "./tmux";
22
- import { type HangulCompatibilityJamoWidth, setHangulCompatibilityJamoWidth } from "./utils";
22
+ import { setHangulCompatibilityJamoWidth } from "./utils";
23
23
 
24
24
  const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
25
25
  const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
26
26
  const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
27
27
  const WINDOWS_TERMINAL_OSC11_POLL_MS = 30_000;
28
- // Hangul Compatibility Jamo (U+3131..=U+318E) render width is terminal-dependent:
29
- // Ghostty follows UAX#11 (2 cells); Terminal.app and iTerm2 render narrow (1),
30
- // matching the macOS platform default. Override only for terminals known to
31
- // disagree — the rest keep the platform default (macOS narrow, otherwise UAX#11),
32
- // so this is a no-op everywhere except Ghostty. A runtime DSR/CPR probe that
33
- // auto-detects the width on unknown terminals is tracked separately.
34
- export function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(
35
- env: NodeJS.ProcessEnv = Bun.env,
36
- ): HangulCompatibilityJamoWidth {
37
- if (
38
- env.GHOSTTY_RESOURCES_DIR ||
39
- env.TERM_PROGRAM?.toLowerCase() === "ghostty" ||
40
- env.TERM?.toLowerCase().includes("ghostty")
41
- ) {
42
- return 2;
43
- }
44
- return "platform";
45
- }
46
-
47
28
  function shouldEnableModifyOtherKeysFallback(env: NodeJS.ProcessEnv = Bun.env): boolean {
48
29
  if (!env.SSH_CONNECTION && !env.SSH_TTY && !env.SSH_CLIENT) return true;
49
30
  return TERMINAL.id !== "base" && TERMINAL.id !== "trueColor";
@@ -659,7 +640,7 @@ export class ProcessTerminal implements Terminal {
659
640
  // The query handler intercepts input temporarily, then installs the user's handler
660
641
  // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
661
642
  this.#queryAndEnableKittyProtocol();
662
- setHangulCompatibilityJamoWidth(resolveHangulCompatibilityJamoWidthFromTerminalIdentity());
643
+ setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
663
644
 
664
645
  // Query terminal background color via OSC 11 for dark/light detection.
665
646
  // Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
package/src/tui.ts CHANGED
@@ -1091,6 +1091,13 @@ export class TUI extends Container {
1091
1091
  // resize frames so width changes truncate the transient viewport instead of
1092
1092
  // pushing wrapped fragments into native scrollback.
1093
1093
  #resizeAltActive = false;
1094
+ // Latched once this terminal is observed re-reporting its size across an
1095
+ // alternate-screen toggle (a pure height change between alt-buffer enter and
1096
+ // exit). That is the Warp-class quirk {@link reportsSizeOnAltScreenToggle}
1097
+ // hardcodes: without it, leaving a fullscreen overlay flashes a destructive
1098
+ // ED3 full paint and the revert SIGWINCH flashes another (#6511). Once set,
1099
+ // {@link #resizeRepaintsInPlace} routes resizes through the in-place path.
1100
+ #altToggleResizesInPlace = false;
1094
1101
  #stopped = false;
1095
1102
  // Always-on event-loop lag probe. The high default threshold keeps it quiet;
1096
1103
  // it only logs `ui.loop-blocked` (with the current loop phase) when a frame
@@ -1590,8 +1597,26 @@ export class TUI extends Container {
1590
1597
  // window) into a single render once the pane is quiet —
1591
1598
  // `#resizeEventPending` is set first so the eventual render still
1592
1599
  // classifies as a resize.
1600
+ // A SIGWINCH while a fullscreen overlay covers the transcript is
1601
+ // either a genuine resize behind the overlay or the alt-toggle size
1602
+ // echo (a terminal re-reporting its size whenever the alternate
1603
+ // screen buffer toggles). The transcript is not visible either way,
1604
+ // so arming the drag/settle would only queue a destructive ED3
1605
+ // rebuild that fires as a flash when the overlay closes (#6511). A
1606
+ // pure height change is the alt-toggle-echo signature — latch the
1607
+ // in-place resize path — and just repaint the overlay at the new
1608
+ // size. #resizeEventPending carries to the overlay-exit render so it
1609
+ // still classifies as a resize.
1610
+ if (this.#altActive) {
1611
+ if (this.#altEnterWidth === this.terminal.columns && this.#altEnterHeight !== this.terminal.rows) {
1612
+ this.#altToggleResizesInPlace = true;
1613
+ }
1614
+ this.#resizeEventPending = true;
1615
+ this.requestRender();
1616
+ return;
1617
+ }
1593
1618
  this.#resizeEventPending = true;
1594
- if (!resizeRepaintsInPlace()) {
1619
+ if (!this.#resizeRepaintsInPlace()) {
1595
1620
  // Enter the viewport fast path and (re)arm the settle timer, then
1596
1621
  // request the cheap viewport-only paint. The authoritative full
1597
1622
  // replay fires from the settle timer once the drag goes quiet.
@@ -2815,9 +2840,15 @@ export class TUI extends Container {
2815
2840
  this.#altPreviousLines = [];
2816
2841
  // A resize while on the alt buffer reflowed the terminal's saved
2817
2842
  // normal screen; it no longer matches our accounting, so force the
2818
- // geometry rebuild path instead of a stale diff.
2843
+ // geometry rebuild path instead of a stale diff. A pure height change
2844
+ // across the alt-buffer boundary (width unchanged) is the signature of
2845
+ // a terminal that re-reports its size whenever the alternate screen
2846
+ // toggles — the Warp-class quirk. Latch the in-place resize path so
2847
+ // this exit and the revert SIGWINCH repaint without an ED3 scrollback
2848
+ // rewrap instead of flashing a destructive full paint (#6511).
2819
2849
  if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
2820
2850
  this.#resizeEventPending = true;
2851
+ if (width === this.#altEnterWidth) this.#altToggleResizesInPlace = true;
2821
2852
  }
2822
2853
  } else if (wantMouseTracking !== this.#altMouseTrackingActive) {
2823
2854
  this.terminal.write(wantMouseTracking ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
@@ -2870,7 +2901,7 @@ export class TUI extends Container {
2870
2901
  // count too: both enter the geometry rebuild path below.
2871
2902
  const replayFullHistory =
2872
2903
  this.#hasEverRendered &&
2873
- !resizeRepaintsInPlace() &&
2904
+ !this.#resizeRepaintsInPlace() &&
2874
2905
  (this.#clearScrollbackOnNextRender ||
2875
2906
  this.#resizeEventPending ||
2876
2907
  (this.#previousWidth > 0 && this.#previousWidth !== width) ||
@@ -3018,7 +3049,7 @@ export class TUI extends Container {
3018
3049
  // feedback loop), so committed history keeps its old wrap.
3019
3050
  const firstPaint = !this.#hasEverRendered;
3020
3051
  const replaceRequested = this.#clearScrollbackOnNextRender;
3021
- const geometryRebuild = geometryChanged && !resizeRepaintsInPlace();
3052
+ const geometryRebuild = geometryChanged && !this.#resizeRepaintsInPlace();
3022
3053
  // Committed history no longer matches the frame: a finalized block
3023
3054
  // replaced its scrolled-off live render, or the frame collapsed into
3024
3055
  // recorded rows. Native scrollback is a render cache, not a court
@@ -3175,7 +3206,8 @@ export class TUI extends Container {
3175
3206
  windowTop,
3176
3207
  prevWindowTop,
3177
3208
  prevHardwareCursorRow,
3178
- forceWindowRewrite: this.#forceViewportRepaintOnNextRender || (geometryChanged && resizeRepaintsInPlace()),
3209
+ forceWindowRewrite:
3210
+ this.#forceViewportRepaintOnNextRender || (geometryChanged && this.#resizeRepaintsInPlace()),
3179
3211
  repaintVirtualScrollInPlace: hasVisibleOverlay,
3180
3212
  cursorTrackingLineCount,
3181
3213
  });
@@ -3812,6 +3844,21 @@ export class TUI extends Container {
3812
3844
  return `${enhancementExit}${ALT_SCREEN_EXIT}`;
3813
3845
  }
3814
3846
 
3847
+ /**
3848
+ * Whether a resize repaints the visible window in place — no alternate-screen
3849
+ * borrow, no ED3 scrollback rewrap. Combines the static host detection
3850
+ * ({@link resizeRepaintsInPlace}) with the runtime {@link #altToggleResizesInPlace}
3851
+ * latch, so a terminal that re-reports its size on alt-screen toggles is
3852
+ * treated like Warp once observed, breaking the overlay-exit ED3 flash loop.
3853
+ * An explicit `PI_TUI_RESIZE_IN_PLACE=0|false` suppresses the runtime latch;
3854
+ * multiplexer handling remains authoritative through the static predicate.
3855
+ */
3856
+ #resizeRepaintsInPlace(): boolean {
3857
+ const override = Bun.env.PI_TUI_RESIZE_IN_PLACE;
3858
+ const allowAutoDetection = override !== "0" && override !== "false";
3859
+ return resizeRepaintsInPlace() || (allowAutoDetection && this.#altToggleResizesInPlace);
3860
+ }
3861
+
3815
3862
  /**
3816
3863
  * Emit a throwaway viewport repaint for the resize fast path as a per-row
3817
3864
  * overwrite. A width change can make the terminal's normal buffer reflow