@oh-my-pi/pi-tui 16.1.16 → 16.1.17
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 +14 -0
- package/dist/types/terminal-capabilities.d.ts +19 -0
- package/dist/types/terminal.d.ts +2 -0
- package/dist/types/utils.d.ts +4 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +31 -20
- package/src/components/settings-list.ts +1 -1
- package/src/terminal-capabilities.ts +40 -1
- package/src/terminal.ts +45 -49
- package/src/utils.ts +83 -2
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.
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -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
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "16.1.17",
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.1.17",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.1.17",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
(isAtPrefix &&
|
|
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 (
|
|
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 (
|
|
711
|
+
} else if (expandedPrefix.endsWith("/")) {
|
|
704
712
|
// If prefix ends with /, show contents of that directory
|
|
705
|
-
if (
|
|
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 (
|
|
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 (
|
|
761
|
-
// Absolute path
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
531
|
-
// (
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
// terminals
|
|
536
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
|
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;
|