@oh-my-pi/pi-tui 16.1.16 → 16.1.18

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,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.17] - 2026-06-24
6
+
7
+ ### Added
8
+
9
+ - Added runtime resolution of the Hangul Compatibility Jamo (U+3131..U+318E) display width for terminals known to disagree with the platform default (e.g. Ghostty, which renders these at 2 cells). Fixes doubled/ghosted jamo during Korean IME composition; the resolved width is pushed into the native width engine before the first paint. Other terminals keep the platform default (macOS narrow, otherwise UAX#11), so the override is a no-op outside Ghostty. A runtime DSR/CPR probe for unknown terminals is tracked separately.
10
+ - Added `setHangulCompatibilityJamoWidth` / `getHangulCompatibilityJamoWidth` to set the jamo width profile (`"platform" | "unicode" | 1 | 2`); the profile is mirrored into the native `setHangulCompatJamoWidthOverride`.
11
+
12
+ ### Fixed
13
+
14
+ - Removed the 30-second OSC 11 background-color poll that ran on terminals without DEC Mode 2031 support (macOS Terminal.app, Warp, VS Code's built-in terminal, older Alacritty/WezTerm). Each poll's OSC 11 + DA1 write wiped the user's active text selection on several of those terminals, causing intermittent "can't copy" failures whenever a poll fired mid-drag — most visibly during the Ask tool dialog when the user wants to quote text back from the conversation ([#3297](https://github.com/can1357/oh-my-pi/issues/3297)). Theme detection now relies on the initial startup probe plus Mode 2031 push notifications; affected terminals pick up OS-theme changes on next launch.
15
+ - Fixed `@`-path autocomplete failing on Windows for paths outside the cwd. Windows absolute paths (e.g. `C:\\Users\\...`) were not detected as absolute — only `/` was checked — so they were incorrectly joined with the base directory, producing invalid search paths and empty suggestions. Path-join calls also introduced backslashes into suggestion values, breaking round-trip insertion. Absolute path detection now uses `path.isAbsolute()` (handles drive letters) and suggestion paths are normalized to forward slashes (valid on all platforms).
16
+ - Fixed settings rows crashing native text truncation when a malformed config value reaches the renderer as a non-string ([#3338](https://github.com/can1357/oh-my-pi/issues/3338)).
17
+ - Fixed desktop notifications being silently lost under tmux on the common stack of tmux + kitty/ghostty/wezterm/iTerm2. `TERMINAL_ID` resolves to the inner terminal (whose markers leak into the tmux session env), which maps to `NotifyProtocol.Osc9` / `NotifyProtocol.Osc99`, and `sendNotification()` wrote that raw OSC straight to stdout — tmux dropped it on the floor and `monitor-bell` / `monitor-activity` never fired, so a backgrounded omp pane had no way to flag completion or `ask` blockage. Under `TMUX`, OSC-protocol notifications are now wrapped in tmux's `\x1bPtmux;…\x1b\\` DCS passthrough envelope (so users with `set -g allow-passthrough on` still get the real toast on the outer terminal) and followed by a `\x07` BEL (so `set -g monitor-bell on` reliably flags the window otherwise). The OSC 99 capability probe in `terminal.ts` is wrapped the same way so rich notifications keep working across tmux. `NotifyProtocol.Bell` paths are unchanged. ([#3395](https://github.com/can1357/oh-my-pi/issues/3395))
18
+
5
19
  ## [16.1.10] - 2026-06-21
6
20
 
7
21
  ### Fixed
@@ -34,6 +34,25 @@ export declare class TerminalInfo {
34
34
  formatNotification(message: string | TerminalNotification): string;
35
35
  sendNotification(message: string | TerminalNotification): void;
36
36
  }
37
+ /**
38
+ * Whether the agent process is running inside a tmux session. Read fresh on
39
+ * each call so tests can toggle `Bun.env.TMUX` per case without re-importing
40
+ * the module and so a tmux session attached/detached mid-run is observed.
41
+ */
42
+ export declare function isInsideTmux(env?: NodeJS.ProcessEnv): boolean;
43
+ /**
44
+ * Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
45
+ * ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips
46
+ * the envelope and forwards the unwrapped payload to the outer terminal only
47
+ * when the user opts in with `set -g allow-passthrough on`; otherwise tmux
48
+ * silently consumes the envelope, which is identical to the pre-wrap baseline
49
+ * (tmux already swallowed the bare OSC).
50
+ *
51
+ * Used by `TerminalInfo.sendNotification` and the OSC 99 capability probe in
52
+ * `terminal.ts` to keep notifications alive for terminals that understand
53
+ * OSC 9 / OSC 99 (kitty, ghostty, wezterm, iterm2) when running under tmux.
54
+ */
55
+ export declare function wrapTmuxPassthrough(payload: string): string;
37
56
  export declare function isNotificationSuppressed(): boolean;
38
57
  /**
39
58
  * Returns true when running in Windows Terminal with known SIXEL support.
@@ -1,3 +1,5 @@
1
+ import { type HangulCompatibilityJamoWidth } from "./utils";
2
+ export declare function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(env?: NodeJS.ProcessEnv): HangulCompatibilityJamoWidth;
1
3
  /**
2
4
  * Split `data` into chunks whose encoded UTF-8 byte length is no greater than
3
5
  * `maxChunkBytes`, preferring a line boundary (`\n`) as the cut point so
@@ -1,6 +1,10 @@
1
1
  import { Ellipsis, type ExtractSegmentsResult, type SliceResult } from "@oh-my-pi/pi-natives";
2
2
  export { Ellipsis } from "@oh-my-pi/pi-natives";
3
3
  export { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils";
4
+ export type HangulCompatibilityJamoWidth = "platform" | "unicode" | 1 | 2;
5
+ export declare function getHangulCompatibilityJamoWidth(): HangulCompatibilityJamoWidth;
6
+ export declare function setHangulCompatibilityJamoWidth(width: HangulCompatibilityJamoWidth): boolean;
7
+ export declare function resetHangulCompatibilityJamoWidthForTests(): void;
4
8
  export type TextSizingScale = 1 | 2 | 3;
5
9
  export type TextSizingVerticalAlign = "top" | "bottom" | "center";
6
10
  export type TextSizingHorizontalAlign = "left" | "right" | "center";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "16.1.16",
4
+ "version": "16.1.18",
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": "16.1.16",
41
- "@oh-my-pi/pi-utils": "16.1.16",
40
+ "@oh-my-pi/pi-natives": "16.1.18",
41
+ "@oh-my-pi/pi-utils": "16.1.18",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -678,31 +678,39 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
678
678
  const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix);
679
679
  let expandedPrefix = rawPrefix;
680
680
 
681
+ // Normalize backslashes to forward slashes so Windows native paths
682
+ // (C:\tmp\foo) work with the /-based splitting/joining below.
683
+ expandedPrefix = expandedPrefix.replace(/\\/g, "/");
684
+
685
+ // Capture the pre-expansion prefix so root checks can still
686
+ // detect bare "~" and "~/" after #expandHomePath rewrites them.
687
+ const preExpand = expandedPrefix;
688
+
681
689
  // Handle home directory expansion
682
690
  if (expandedPrefix.startsWith("~")) {
683
691
  expandedPrefix = this.#expandHomePath(expandedPrefix);
684
692
  }
685
693
 
686
694
  const isRootPrefix =
687
- rawPrefix === "" ||
688
- rawPrefix === "./" ||
689
- rawPrefix === "../" ||
690
- rawPrefix === "~" ||
691
- rawPrefix === "~/" ||
692
- rawPrefix === "/" ||
693
- (isAtPrefix && rawPrefix === "");
695
+ preExpand === "" ||
696
+ preExpand === "./" ||
697
+ preExpand === "../" ||
698
+ preExpand === "~" ||
699
+ preExpand === "~/" ||
700
+ preExpand === "/" ||
701
+ (isAtPrefix && preExpand === "");
694
702
 
695
703
  if (isRootPrefix) {
696
704
  // Complete from specified position
697
- if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
705
+ if (expandedPrefix.startsWith("~") || path.isAbsolute(expandedPrefix)) {
698
706
  searchDir = expandedPrefix;
699
707
  } else {
700
708
  searchDir = path.join(this.#basePath, expandedPrefix);
701
709
  }
702
710
  searchPrefix = "";
703
- } else if (rawPrefix.endsWith("/")) {
711
+ } else if (expandedPrefix.endsWith("/")) {
704
712
  // If prefix ends with /, show contents of that directory
705
- if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
713
+ if (expandedPrefix.startsWith("~") || path.isAbsolute(expandedPrefix)) {
706
714
  searchDir = expandedPrefix;
707
715
  } else {
708
716
  searchDir = path.join(this.#basePath, expandedPrefix);
@@ -712,7 +720,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
712
720
  // Split into directory and file prefix
713
721
  const dir = path.dirname(expandedPrefix);
714
722
  const file = path.basename(expandedPrefix);
715
- if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
723
+ if (expandedPrefix.startsWith("~") || path.isAbsolute(expandedPrefix)) {
716
724
  searchDir = dir;
717
725
  } else {
718
726
  searchDir = path.join(this.#basePath, dir);
@@ -746,7 +754,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
746
754
 
747
755
  let relativePath: string;
748
756
  const name = entry.name;
749
- const displayPrefix = rawPrefix;
757
+ const displayPrefix = rawPrefix.replace(/\\/g, "/");
750
758
 
751
759
  if (displayPrefix.endsWith("/")) {
752
760
  // If prefix ends with /, append entry to the prefix
@@ -757,14 +765,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
757
765
  const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
758
766
  const dir = path.dirname(homeRelativeDir);
759
767
  relativePath = `~/${dir === "." ? name : path.join(dir, name)}`;
760
- } else if (displayPrefix.startsWith("/")) {
761
- // Absolute path - construct properly
762
- const dir = path.dirname(displayPrefix);
763
- if (dir === "/") {
764
- relativePath = `/${name}`;
765
- } else {
766
- relativePath = `${dir}/${name}`;
767
- }
768
+ } else if (path.isAbsolute(displayPrefix)) {
769
+ // Absolute path covers both /unix/paths and Windows C:/drive/paths.
770
+ // Use string concat with / instead of path.join (which uses platform-native
771
+ // separators and produces drive-relative results like "C:alpha" when
772
+ // dirname returns "C:" without a trailing slash).
773
+ const dir = displayPrefix.slice(0, displayPrefix.lastIndexOf("/"));
774
+ relativePath = dir === "" || dir === "/" ? `/${name}` : `${dir}/${name}`;
768
775
  } else {
769
776
  relativePath = path.join(path.dirname(displayPrefix), name);
770
777
  if (displayPrefix.startsWith("./") && !relativePath.startsWith("./")) {
@@ -780,6 +787,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
780
787
  }
781
788
  }
782
789
 
790
+ // Normalize backslashes to forward slashes so suggestions are consistent
791
+ // with the user's input (which uses / on all platforms) and work correctly
792
+ // when inserted back into the editor. Forward slashes are valid on Windows.
793
+ relativePath = relativePath.replace(/\\/g, "/");
783
794
  const pathValue = isDirectory ? `${relativePath}/` : relativePath;
784
795
  const value = buildCompletionValue(pathValue, {
785
796
  isDirectory,
@@ -498,7 +498,7 @@ export class SettingsList implements Component {
498
498
  const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
499
499
  const separator = " ";
500
500
  const valueMaxWidth = rowWidth - prefixWidth - maxLabelWidth - visibleWidth(separator) - 2;
501
- const valuePlain = truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit);
501
+ const valuePlain = truncateToWidth(String(item.currentValue ?? ""), valueMaxWidth, Ellipsis.Omit);
502
502
  const hovered = !isSelected && this.#theme.hovered !== undefined && item.id === this.#hoveredItemId;
503
503
  // De-emphasized rows (outside the active section) render as plain text
504
504
  // under one dim wash so inner label/value colors don't fight it.
@@ -98,10 +98,49 @@ export class TerminalInfo {
98
98
 
99
99
  sendNotification(message: string | TerminalNotification): void {
100
100
  if (isNotificationSuppressed() || isTerminalHeadless()) return;
101
- process.stdout.write(this.formatNotification(message));
101
+ const formatted = this.formatNotification(message);
102
+ // Under tmux, terminals whose notify protocol is OSC 9 / OSC 99 would
103
+ // otherwise lose the notification entirely: tmux does not forward bare
104
+ // OSC 9/99 to the outer terminal, and the bare sequence does not flag
105
+ // tmux's own `monitor-bell` / `monitor-activity`. Wrap the OSC in tmux's
106
+ // DCS passthrough envelope so users with `allow-passthrough on` still
107
+ // get the desktop toast, then append a BEL so `monitor-bell` flags the
108
+ // pane/window for everyone else — the only signal a backgrounded pane
109
+ // has that the agent finished or is waiting for input. `Bell` protocol
110
+ // already self-flags via tmux's bell monitoring, so leave it alone.
111
+ if (this.notifyProtocol !== NotifyProtocol.Bell && isInsideTmux()) {
112
+ process.stdout.write(`${wrapTmuxPassthrough(formatted)}\x07`);
113
+ return;
114
+ }
115
+ process.stdout.write(formatted);
102
116
  }
103
117
  }
104
118
 
119
+ /**
120
+ * Whether the agent process is running inside a tmux session. Read fresh on
121
+ * each call so tests can toggle `Bun.env.TMUX` per case without re-importing
122
+ * the module and so a tmux session attached/detached mid-run is observed.
123
+ */
124
+ export function isInsideTmux(env: NodeJS.ProcessEnv = Bun.env): boolean {
125
+ return Boolean(env.TMUX);
126
+ }
127
+
128
+ /**
129
+ * Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
130
+ * ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips
131
+ * the envelope and forwards the unwrapped payload to the outer terminal only
132
+ * when the user opts in with `set -g allow-passthrough on`; otherwise tmux
133
+ * silently consumes the envelope, which is identical to the pre-wrap baseline
134
+ * (tmux already swallowed the bare OSC).
135
+ *
136
+ * Used by `TerminalInfo.sendNotification` and the OSC 99 capability probe in
137
+ * `terminal.ts` to keep notifications alive for terminals that understand
138
+ * OSC 9 / OSC 99 (kitty, ghostty, wezterm, iterm2) when running under tmux.
139
+ */
140
+ export function wrapTmuxPassthrough(payload: string): string {
141
+ return `\x1bPtmux;${payload.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
142
+ }
143
+
105
144
  export function isNotificationSuppressed(): boolean {
106
145
  const value = $env.PI_NOTIFICATIONS;
107
146
  if (!value) return false;
package/src/terminal.ts CHANGED
@@ -3,11 +3,37 @@ import * as fs from "node:fs";
3
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
- import { NotifyProtocol, setCellDimensions, setOsc99Supported, TERMINAL } from "./terminal-capabilities";
6
+ import {
7
+ isInsideTmux,
8
+ NotifyProtocol,
9
+ setCellDimensions,
10
+ setOsc99Supported,
11
+ TERMINAL,
12
+ wrapTmuxPassthrough,
13
+ } from "./terminal-capabilities";
14
+ import { type HangulCompatibilityJamoWidth, setHangulCompatibilityJamoWidth } from "./utils";
7
15
 
8
16
  const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
9
17
  const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
10
18
  const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
19
+ // Hangul Compatibility Jamo (U+3131..=U+318E) render width is terminal-dependent:
20
+ // Ghostty follows UAX#11 (2 cells); Terminal.app and iTerm2 render narrow (1),
21
+ // matching the macOS platform default. Override only for terminals known to
22
+ // disagree — the rest keep the platform default (macOS narrow, otherwise UAX#11),
23
+ // so this is a no-op everywhere except Ghostty. A runtime DSR/CPR probe that
24
+ // auto-detects the width on unknown terminals is tracked separately.
25
+ export function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(
26
+ env: NodeJS.ProcessEnv = Bun.env,
27
+ ): HangulCompatibilityJamoWidth {
28
+ if (
29
+ env.GHOSTTY_RESOURCES_DIR ||
30
+ env.TERM_PROGRAM?.toLowerCase() === "ghostty" ||
31
+ env.TERM?.toLowerCase().includes("ghostty")
32
+ ) {
33
+ return 2;
34
+ }
35
+ return "platform";
36
+ }
11
37
 
12
38
  /**
13
39
  * Maximum encoded UTF-8 bytes per `process.stdout.write` call on Windows.
@@ -435,7 +461,6 @@ export class ProcessTerminal implements Terminal {
435
461
  #inBandResizeBuffer = "";
436
462
  #reportedColumns?: number;
437
463
  #reportedRows?: number;
438
- #osc11PollTimer?: Timer;
439
464
  #mode2031DebounceTimer?: Timer;
440
465
  #progressTimer?: Timer;
441
466
 
@@ -509,6 +534,7 @@ export class ProcessTerminal implements Terminal {
509
534
  // The query handler intercepts input temporarily, then installs the user's handler
510
535
  // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
511
536
  this.#queryAndEnableKittyProtocol();
537
+ setHangulCompatibilityJamoWidth(resolveHangulCompatibilityJamoWidthFromTerminalIdentity());
512
538
 
513
539
  // Query terminal background color via OSC 11 for dark/light detection.
514
540
  // Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
@@ -527,24 +553,18 @@ export class ProcessTerminal implements Terminal {
527
553
  // actual background color (following Neovim convention) with 100ms debounce.
528
554
  this.#safeWrite("\x1b[?2031h");
529
555
 
530
- // Start periodic OSC 11 re-query for terminals without Mode 2031
531
- // (Warp, Alacritty, older WezTerm). Stops once Mode 2031 support is
532
- // confirmed via DECRQM (probed below) or a Mode 2031 change notification
533
- // fires push notifications supersede polling, and the poll's repeated
534
- // OSC 11/DA1 writes clear the user's active text selection on some
535
- // terminals (copy breaks every 2s).
536
- // Windows Terminal under WSL has been observed to close the hosting tab
537
- // after repeated OSC 11/DA1 probes. Keep the initial/event-driven probes,
538
- // but avoid background polling there.
539
- const isWSL = process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
540
- if (!isWSL) {
541
- this.#startOsc11Poll();
542
- }
556
+ // Theme detection relies on (1) the startup OSC 11 probe above and
557
+ // (2) DEC Mode 2031 push notifications. Terminals without Mode 2031
558
+ // (macOS Terminal.app, Warp, VS Code's built-in, older Alacritty/
559
+ // WezTerm) detect the appearance once at startup and pick up later OS
560
+ // theme changes on next launch. Earlier builds polled OSC 11 every 30 s
561
+ // here for those terminals, but each poll's OSC 11/DA1 write wiped the
562
+ // user's active text selection on several of them (#3297).
543
563
 
544
564
  // Probe DEC private-mode support via DECRQM. 2026 (synchronized output)
545
565
  // gates the renderer's begin/end markers; 2048 (in-band resize) is enabled
546
566
  // only after the terminal confirms support; 2031 (appearance change
547
- // notifications) stops the OSC 11 poll once confirmed. Xterm ?1010/?1011
567
+ // notifications) drives mid-session theme tracking. Xterm ?1010/?1011
548
568
  // are disabled while OMP owns the TTY so typing in the editor does not
549
569
  // force a reader scrolled into native history back to the tail. Each probe
550
570
  // rides the shared DA1 sentinel, so terminals that ignore DECRQM resolve as
@@ -869,7 +889,6 @@ export class ProcessTerminal implements Terminal {
869
889
  // (Neovim convention — coalesces rapid notifications during transitions)
870
890
  const appearanceMatch = sequence.match(appearanceDsrPattern);
871
891
  if (appearanceMatch) {
872
- this.#stopOsc11Poll();
873
892
  if (this.#mode2031DebounceTimer) clearTimeout(this.#mode2031DebounceTimer);
874
893
  this.#mode2031DebounceTimer = setTimeout(() => {
875
894
  this.#mode2031DebounceTimer = undefined;
@@ -936,7 +955,14 @@ export class ProcessTerminal implements Terminal {
936
955
  const id = `omp-probe-${nextOsc99ProbeId++}`;
937
956
  this.#osc99PendingId = id;
938
957
  this.#da1SentinelOwners.push({ kind: "osc99Probe", id });
939
- this.#safeWrite(`\x1b]99;i=${id}:p=?;\x1b\\\x1b[c`);
958
+ // Wrap the probe under tmux so terminals behind `allow-passthrough on`
959
+ // can still respond (mirroring how `TerminalInfo.sendNotification`
960
+ // wraps notification deliveries). Without it the probe is swallowed
961
+ // inside tmux even when the outer terminal speaks OSC 99, and rich
962
+ // notifications stay permanently downgraded to the single-line fallback.
963
+ const probe = `\x1b]99;i=${id}:p=?;\x1b\\`;
964
+ const sequence = isInsideTmux() ? wrapTmuxPassthrough(probe) : probe;
965
+ this.#safeWrite(`${sequence}\x1b[c`);
940
966
  }
941
967
 
942
968
  #handleOsc99CapabilityResponse(metaRaw: string, payload: string): boolean {
@@ -984,32 +1010,6 @@ export class ProcessTerminal implements Terminal {
984
1010
  }
985
1011
  }
986
1012
 
987
- /**
988
- * Start periodic OSC 11 re-queries for terminals without Mode 2031 (Warp, Alacritty, WezTerm).
989
- * Self-disables once Mode 2031 fires (push-based is better than polling).
990
- * The interval is deliberately long: each poll's OSC 11 + DA1 write clears
991
- * an active text selection on several terminals, so polling exists only to
992
- * eventually notice a rare OS theme switch, not to track it promptly.
993
- */
994
- #startOsc11Poll(): void {
995
- this.#stopOsc11Poll();
996
- this.#osc11PollTimer = setInterval(() => {
997
- if (this.#dead) {
998
- this.#stopOsc11Poll();
999
- return;
1000
- }
1001
- this.#queryBackgroundColor();
1002
- }, 30_000);
1003
- this.#osc11PollTimer.unref();
1004
- }
1005
-
1006
- #stopOsc11Poll(): void {
1007
- if (this.#osc11PollTimer) {
1008
- clearInterval(this.#osc11PollTimer);
1009
- this.#osc11PollTimer = undefined;
1010
- }
1011
- }
1012
-
1013
1013
  /**
1014
1014
  * Query terminal for Kitty keyboard protocol support and enable if available.
1015
1015
  *
@@ -1060,9 +1060,7 @@ export class ProcessTerminal implements Terminal {
1060
1060
  /**
1061
1061
  * Record DECRQM support for a private mode (idempotent — first result wins)
1062
1062
  * and notify subscribers. Enables DEC 2048 in-band resize when 2048 resolves
1063
- * supported, and stops the OSC 11 poll when 2031 resolves supported (Mode 2031
1064
- * push notifications make periodic re-querying redundant — and the poll's
1065
- * OSC 11/DA1 writes clobber active text selections on some terminals).
1063
+ * supported.
1066
1064
  */
1067
1065
  #resolvePrivateMode(mode: number, supported: boolean): void {
1068
1066
  if (this.#privateModeSupport.has(mode)) return;
@@ -1075,7 +1073,6 @@ export class ProcessTerminal implements Terminal {
1075
1073
  }
1076
1074
  }
1077
1075
  if (mode === 2048 && supported) this.#enableInBandResize();
1078
- if (mode === 2031 && supported) this.#stopOsc11Poll();
1079
1076
  }
1080
1077
 
1081
1078
  #disableXtermScrollToBottomMode(mode: number): void {
@@ -1224,7 +1221,6 @@ export class ProcessTerminal implements Terminal {
1224
1221
  this.#safeWrite("\x1b[?2048l");
1225
1222
  this.#inBandResizeActive = false;
1226
1223
  }
1227
- this.#stopOsc11Poll();
1228
1224
  if (this.#mode2031DebounceTimer) {
1229
1225
  clearTimeout(this.#mode2031DebounceTimer);
1230
1226
  this.#mode2031DebounceTimer = undefined;
package/src/utils.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  Ellipsis,
3
3
  type ExtractSegmentsResult,
4
4
  extractSegments as nativeExtractSegments,
5
+ setHangulCompatJamoWidthOverride as nativeSetHangulCompatJamoWidthOverride,
5
6
  sliceWithWidth as nativeSliceWithWidth,
6
7
  truncateToWidth as nativeTruncateToWidth,
7
8
  wrapTextWithAnsi as nativeWrapTextWithAnsi,
@@ -13,6 +14,34 @@ export { Ellipsis } from "@oh-my-pi/pi-natives";
13
14
 
14
15
  export { DEFAULT_TAB_WIDTH } from "@oh-my-pi/pi-utils";
15
16
 
17
+ export type HangulCompatibilityJamoWidth = "platform" | "unicode" | 1 | 2;
18
+
19
+ let hangulCompatibilityJamoWidth: HangulCompatibilityJamoWidth = "platform";
20
+
21
+ // Wire encoding for the native override (see crates/pi-natives text.rs):
22
+ // 0 = platform default, 1 = narrow, 2 = wide, 3 = unicode (no correction).
23
+ function nativeHangulCompatibilityJamoOverride(width: HangulCompatibilityJamoWidth): number {
24
+ if (width === "unicode") return 3;
25
+ if (typeof width === "number") return width;
26
+ return 0;
27
+ }
28
+
29
+ export function getHangulCompatibilityJamoWidth(): HangulCompatibilityJamoWidth {
30
+ return hangulCompatibilityJamoWidth;
31
+ }
32
+
33
+ export function setHangulCompatibilityJamoWidth(width: HangulCompatibilityJamoWidth): boolean {
34
+ const changed = hangulCompatibilityJamoWidth !== width;
35
+ hangulCompatibilityJamoWidth = width;
36
+ nativeSetHangulCompatJamoWidthOverride(nativeHangulCompatibilityJamoOverride(width));
37
+ return changed;
38
+ }
39
+
40
+ export function resetHangulCompatibilityJamoWidthForTests(): void {
41
+ hangulCompatibilityJamoWidth = "platform";
42
+ nativeSetHangulCompatJamoWidthOverride(0);
43
+ }
44
+
16
45
  export type TextSizingScale = 1 | 2 | 3;
17
46
  export type TextSizingVerticalAlign = "top" | "bottom" | "center";
18
47
  export type TextSizingHorizontalAlign = "left" | "right" | "center";
@@ -157,6 +186,58 @@ const LONG_WIDTH_FAST_PATH_MIN = 128;
157
186
  // non-CJK tables that back truncate/slice/wrap. Hoisted so no per-call alloc.
158
187
  const STRING_WIDTH_OPTS = { countAnsiEscapeCodes: false, ambiguousIsNarrow: true } as const;
159
188
 
189
+ // Hangul Compatibility Jamo (U+3131..=U+318E). `Bun.stringWidth` follows UAX#11
190
+ // and reports these at 2 cells (the U+3164 HANGUL FILLER at 0), but the actual
191
+ // rendered width is decided by the *client* terminal (1 cell on Terminal.app /
192
+ // iTerm2, 2 on Ghostty and most Linux terminals). The width is resolved from
193
+ // the terminal identity and pushed into the native engine through
194
+ // `setHangulCompatibilityJamoWidth`; mirror the same correction here so the TS
195
+ // width stays in parity with the native truncate/slice/wrap model — and so the
196
+ // hardware cursor column lands on the actual glyph during Korean IME input.
197
+ const HANGUL_COMPAT_JAMO_REGEX = /[\u3131-\u318e]/;
198
+ const HANGUL_COMPAT_JAMO_GLOBAL_REGEX = /[\u3131-\u318e]/g;
199
+ const HANGUL_FILLER_CODE_POINT = 0x3164;
200
+ // `Bun.stringWidth` counts every code point in the Compatibility Jamo block as
201
+ // 2 cells (even the U+3164 filler that `unicode-width` treats as zero-width).
202
+ const HANGUL_COMPAT_JAMO_BUN_WIDTH = 2;
203
+
204
+ // Effective target cell width for Compatibility Jamo, or `null` to follow the
205
+ // Unicode width (no correction). Mirrors `hangul_compat_jamo_target_width` in
206
+ // crates/pi-natives/src/text.rs.
207
+ function hangulCompatibilityJamoTargetWidth(): 1 | 2 | null {
208
+ switch (hangulCompatibilityJamoWidth) {
209
+ case 1:
210
+ return 1;
211
+ case 2:
212
+ return 2;
213
+ case "unicode":
214
+ return null;
215
+ default:
216
+ // "platform": macOS terminals historically render these narrow.
217
+ return process.platform === "darwin" ? 1 : null;
218
+ }
219
+ }
220
+
221
+ // Reconcile the `Bun.stringWidth` count for Compatibility Jamo to the native
222
+ // width engine: subtract Bun's per-jamo cell count and add back the effective
223
+ // width — the runtime target when one is active, otherwise the `unicode-width`
224
+ // value. Mirrors `char_width_corrected` / `apply_hangul_compat_jamo_delta` in
225
+ // crates/pi-natives/src/text.rs, including the rule that the zero-width filler
226
+ // (U+3164) is never widened past the narrow correction (a wide terminal still
227
+ // renders it at its Unicode width of 0).
228
+ function correctHangulCompatibilityJamoWidth(width: number, str: string): number {
229
+ if (!HANGUL_COMPAT_JAMO_REGEX.test(str)) return width;
230
+ const target = hangulCompatibilityJamoTargetWidth();
231
+ let corrected = width;
232
+ HANGUL_COMPAT_JAMO_GLOBAL_REGEX.lastIndex = 0;
233
+ for (let m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str); m !== null; m = HANGUL_COMPAT_JAMO_GLOBAL_REGEX.exec(str)) {
234
+ const unicodeWidth = m[0].codePointAt(0) === HANGUL_FILLER_CODE_POINT ? 0 : 2;
235
+ const finalWidth = target === null || (unicodeWidth === 0 && target > 1) ? unicodeWidth : target;
236
+ corrected += finalWidth - HANGUL_COMPAT_JAMO_BUN_WIDTH;
237
+ }
238
+ return corrected;
239
+ }
240
+
160
241
  /**
161
242
  * Visible width of a string in terminal columns, excluding ANSI/OSC escapes.
162
243
  *
@@ -177,7 +258,7 @@ export function visibleWidth(str: string): number {
177
258
  tabCount++;
178
259
  }
179
260
  if (tabCount > 0) width += tabCount * DEFAULT_TAB_WIDTH;
180
- return width;
261
+ return correctHangulCompatibilityJamoWidth(width, str);
181
262
  }
182
263
 
183
264
  let tabCount = 0;
@@ -238,7 +319,7 @@ export function visibleWidth(str: string): number {
238
319
  }
239
320
  }
240
321
 
241
- return width;
322
+ return correctHangulCompatibilityJamoWidth(width, str);
242
323
  }
243
324
 
244
325
  const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;