@oh-my-pi/pi-tui 16.5.1 → 17.0.0
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 +24 -0
- package/dist/types/components/editor.d.ts +2 -0
- package/dist/types/components/loader.d.ts +5 -0
- package/dist/types/kitty-graphics.d.ts +8 -11
- package/dist/types/terminal-capabilities.d.ts +1 -19
- package/dist/types/tmux.d.ts +6 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +12 -9
- package/src/components/editor.ts +35 -43
- package/src/components/loader.ts +40 -14
- package/src/kitty-graphics.ts +12 -12
- package/src/latex-block.ts +110 -2
- package/src/terminal-capabilities.ts +26 -34
- package/src/terminal.ts +14 -9
- package/src/tmux.ts +14 -0
- package/src/utils.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.0.0] - 2026-07-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Improved LaTeX rendering for \underbrace, \overbrace, \overset, \underset, and \stackrel to use drawn horizontal braces with centered labels and stacked annotations instead of flat inline glyphs.
|
|
10
|
+
- Improved LaTeX rendering of multi-letter subscripts and superscripts by displaying them as raised or lowered blocks instead of ragged per-character Unicode glyphs.
|
|
11
|
+
- Added an opt-in Editor.setImeSafeCursorLayout() method to protect macOS IME preedit while retaining the compact bordered layout by default.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Fixed SIXEL image rendering where images with cell heights not divisible by 6 would have their bottom portion overwritten by subsequent content.
|
|
16
|
+
- Fixed an issue where the Kitty OSC 99 desktop-notification capability probe would leak raw text into the terminal pane when running inside a multiplexer like tmux or screen.
|
|
17
|
+
|
|
18
|
+
## [16.5.2] - 2026-07-14
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Fixed animated Loader ANSI updates causing unnecessary text layout invalidation and re-wrapping on shimmer-only frames (#5230).
|
|
23
|
+
- Fixed Ctrl+W (delete word backward) stopping at underscores in snake_case identifiers, treating them as single words (#4776).
|
|
24
|
+
- Fixed automatic file completion incorrectly treating punctuation, trailing spaces, and slash-command text as paths, and improved autocomplete dismissal behavior (#5376).
|
|
25
|
+
- Fixed Kitty graphics rendering under tmux, ensuring images correctly follow pane scrolling and reflow (#5381).
|
|
26
|
+
- Fixed tmux sessions becoming unresponsive after terminal capability replies by falling back to legacy keyboard input mode when the Kitty protocol is unavailable (#5378).
|
|
27
|
+
- Fixed PageUp and PageDown keys on an empty prompt editor incorrectly navigating prompt history instead of scrolling the editor viewport (#4754).
|
|
28
|
+
|
|
5
29
|
## [16.5.1] - 2026-07-14
|
|
6
30
|
|
|
7
31
|
### Fixed
|
|
@@ -92,6 +92,8 @@ export declare class Editor implements Component, Focusable {
|
|
|
92
92
|
* Use the real terminal cursor instead of rendering a cursor glyph.
|
|
93
93
|
*/
|
|
94
94
|
setUseTerminalCursor(useTerminalCursor: boolean): void;
|
|
95
|
+
/** Render a dedicated bottom border so terminal-local IME preedit cannot shift editor chrome. */
|
|
96
|
+
setImeSafeCursorLayout(enabled: boolean): void;
|
|
95
97
|
getUseTerminalCursor(): boolean;
|
|
96
98
|
setMaxHeight(maxHeight: number | undefined): void;
|
|
97
99
|
setPaddingX(paddingX: number): void;
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import type { TUI } from "../tui.js";
|
|
2
2
|
import { Text } from "./text.js";
|
|
3
3
|
type ColorFn = (str: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* Styles Loader message fragments without changing their visible text or width.
|
|
6
|
+
* Set `animated` for colorizers whose ANSI output changes over time.
|
|
7
|
+
*/
|
|
4
8
|
export type LoaderMessageColorFn = ColorFn & {
|
|
5
9
|
readonly animated?: true;
|
|
6
10
|
};
|
|
11
|
+
/** Animates a spinner and colorized message while asynchronous work is pending. */
|
|
7
12
|
export declare class Loader extends Text {
|
|
8
13
|
#private;
|
|
9
14
|
private spinnerColorFn;
|
|
@@ -26,18 +26,15 @@ export interface KittyGraphicsFeatures {
|
|
|
26
26
|
* Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
|
|
27
27
|
* U+10EEEE with row/column diacritics).
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* glyphs and re-emit on every repaint, which is exactly the
|
|
36
|
-
* "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
|
|
29
|
+
* Kitty and Ghostty advertise placeholder support directly. A tmux session
|
|
30
|
+
* cannot use cursor-positioned placements because the outer terminal does not
|
|
31
|
+
* know pane scroll/reflow state, so an explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
|
|
32
|
+
* also opts into placeholders there — matching `timg -pk`. Automatic tmux
|
|
33
|
+
* fallback stays off because the unknown outer terminal may render U+10EEEE as
|
|
34
|
+
* literal PUA boxes (#1877).
|
|
37
35
|
*
|
|
38
|
-
* `PI_NO_KITTY_PLACEHOLDERS=1`
|
|
39
|
-
*
|
|
40
|
-
* for a wezterm nightly that has merged placeholder support).
|
|
36
|
+
* `PI_NO_KITTY_PLACEHOLDERS=1` and `PI_KITTY_PLACEHOLDERS=0` remain hard
|
|
37
|
+
* opt-outs; `PI_KITTY_PLACEHOLDERS=1` explicitly opts in anywhere else.
|
|
41
38
|
*/
|
|
42
39
|
export declare function detectKittyUnicodePlaceholdersSupport(terminalId: string, env?: NodeJS.ProcessEnv): boolean;
|
|
43
40
|
export declare function getKittyGraphics(): Readonly<KittyGraphicsFeatures>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { isInsideTmux, wrapTmuxPassthrough } from "./tmux.js";
|
|
1
2
|
export declare enum ImageProtocol {
|
|
2
3
|
Kitty = "\u001B_G",
|
|
3
4
|
Iterm2 = "\u001B]1337;File=",
|
|
@@ -34,12 +35,6 @@ export declare class TerminalInfo {
|
|
|
34
35
|
formatNotification(message: string | TerminalNotification): string;
|
|
35
36
|
sendNotification(message: string | TerminalNotification): void;
|
|
36
37
|
}
|
|
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
38
|
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
44
39
|
export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
|
|
45
40
|
/**
|
|
@@ -48,19 +43,6 @@ export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): bo
|
|
|
48
43
|
* is observed and tests can toggle `Bun.env.ZELLIJ` per case.
|
|
49
44
|
*/
|
|
50
45
|
export declare function isInsideZellij(env?: NodeJS.ProcessEnv): boolean;
|
|
51
|
-
/**
|
|
52
|
-
* Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
|
|
53
|
-
* ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips
|
|
54
|
-
* the envelope and forwards the unwrapped payload to the outer terminal only
|
|
55
|
-
* when the user opts in with `set -g allow-passthrough on`; otherwise tmux
|
|
56
|
-
* silently consumes the envelope, which is identical to the pre-wrap baseline
|
|
57
|
-
* (tmux already swallowed the bare OSC).
|
|
58
|
-
*
|
|
59
|
-
* Used by `TerminalInfo.sendNotification` and the OSC 99 capability probe in
|
|
60
|
-
* `terminal.ts` to keep notifications alive for terminals that understand
|
|
61
|
-
* OSC 9 / OSC 99 (kitty, ghostty, wezterm, iterm2) when running under tmux.
|
|
62
|
-
*/
|
|
63
|
-
export declare function wrapTmuxPassthrough(payload: string): string;
|
|
64
46
|
export declare function isNotificationSuppressed(): boolean;
|
|
65
47
|
/**
|
|
66
48
|
* Returns true when running in Windows Terminal with known SIXEL support.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Whether the process is running inside a tmux session. */
|
|
2
|
+
export declare function isInsideTmux(env?: NodeJS.ProcessEnv): boolean;
|
|
3
|
+
/** Wrap a control sequence in tmux's DCS passthrough envelope. */
|
|
4
|
+
export declare function wrapTmuxPassthrough(payload: string): string;
|
|
5
|
+
/** Pass a control sequence through tmux, leaving direct-terminal output unchanged. */
|
|
6
|
+
export declare function wrapTmuxPassthroughIfNeeded(payload: string, env?: NodeJS.ProcessEnv): string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "17.0.0",
|
|
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": "
|
|
41
|
-
"@oh-my-pi/pi-utils": "
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.0.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.0.0",
|
|
42
42
|
"lru-cache": "11.5.2",
|
|
43
43
|
"marked": "^18.0.6"
|
|
44
44
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -456,6 +456,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
456
456
|
prefix: isMidPromptSkillLookup ? commandText : textBeforeCursor,
|
|
457
457
|
};
|
|
458
458
|
}
|
|
459
|
+
if (!isMidPromptSkillLookup && slashStart === leadingSlashStart && !commandText.slice(1).includes("/")) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
459
463
|
// A slash token with no matching command may still be an absolute
|
|
460
464
|
// path (`/tmp/fo` at prompt start, `see /tmp` mid-prompt); fall
|
|
461
465
|
// through to file-path completion.
|
|
@@ -679,15 +683,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
679
683
|
return pathPrefix;
|
|
680
684
|
}
|
|
681
685
|
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
if (
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (pathPrefix === "" && text.endsWith(" ")) {
|
|
686
|
+
// Automatic updates complete only unambiguous path syntax. Bare relative
|
|
687
|
+
// tokens remain available through explicit Tab completion.
|
|
688
|
+
if (
|
|
689
|
+
pathPrefix.startsWith("/") ||
|
|
690
|
+
pathPrefix.startsWith("./") ||
|
|
691
|
+
pathPrefix.startsWith("../") ||
|
|
692
|
+
pathPrefix.startsWith("~/")
|
|
693
|
+
) {
|
|
691
694
|
return pathPrefix;
|
|
692
695
|
}
|
|
693
696
|
|
package/src/components/editor.ts
CHANGED
|
@@ -382,6 +382,7 @@ export class Editor implements Component, Focusable {
|
|
|
382
382
|
|
|
383
383
|
#theme: EditorTheme;
|
|
384
384
|
#useTerminalCursor = false;
|
|
385
|
+
#imeSafeCursorLayout = false;
|
|
385
386
|
|
|
386
387
|
/** When set, replaces the normal cursor glyph at end-of-text with this ANSI-styled string. */
|
|
387
388
|
cursorOverride: string | undefined;
|
|
@@ -539,6 +540,11 @@ export class Editor implements Component, Focusable {
|
|
|
539
540
|
this.#useTerminalCursor = useTerminalCursor;
|
|
540
541
|
}
|
|
541
542
|
|
|
543
|
+
/** Render a dedicated bottom border so terminal-local IME preedit cannot shift editor chrome. */
|
|
544
|
+
setImeSafeCursorLayout(enabled: boolean): void {
|
|
545
|
+
this.#imeSafeCursorLayout = enabled;
|
|
546
|
+
}
|
|
547
|
+
|
|
542
548
|
getUseTerminalCursor(): boolean {
|
|
543
549
|
return this.#useTerminalCursor;
|
|
544
550
|
}
|
|
@@ -866,6 +872,7 @@ export class Editor implements Component, Focusable {
|
|
|
866
872
|
let displayWidth = visibleWidth(layoutLine.text);
|
|
867
873
|
let cursorPaddingOverflow = 0;
|
|
868
874
|
let decorated = false;
|
|
875
|
+
let imeSafeCursorTail = false;
|
|
869
876
|
const showPromptGutter = promptGutter !== undefined && visibleIndex === 0;
|
|
870
877
|
const gutterText =
|
|
871
878
|
promptGutter === undefined ? "" : showPromptGutter ? promptGutter.firstLine : promptGutter.continuation;
|
|
@@ -922,7 +929,13 @@ export class Editor implements Component, Focusable {
|
|
|
922
929
|
if (marker) {
|
|
923
930
|
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
924
931
|
const after = displayText.slice(layoutLine.cursorPos);
|
|
925
|
-
if (after.length === 0 &&
|
|
932
|
+
if (this.#imeSafeCursorLayout && after.length === 0 && borderVisible) {
|
|
933
|
+
// Terminal frontends render IME marked text locally before committed bytes
|
|
934
|
+
// reach the application. Keep the end-of-input cursor row empty to its
|
|
935
|
+
// right so that insertion cannot shift box chrome onto the next row.
|
|
936
|
+
displayText = before + marker;
|
|
937
|
+
imeSafeCursorTail = true;
|
|
938
|
+
} else if (after.length === 0 && inlineHint) {
|
|
926
939
|
const availWidth = Math.max(0, lineContentWidth - displayWidth);
|
|
927
940
|
const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
|
|
928
941
|
displayText = before + marker + hintText;
|
|
@@ -1022,6 +1035,15 @@ export class Editor implements Component, Focusable {
|
|
|
1022
1035
|
// trailing `─`, but never the corner/vertical bar itself.
|
|
1023
1036
|
const isLastLine = visibleIndex === visibleLayoutLines.length - 1;
|
|
1024
1037
|
const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow);
|
|
1038
|
+
if (isLastLine && imeSafeCursorTail) {
|
|
1039
|
+
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
|
|
1040
|
+
const bottomBorder = this.borderColor(
|
|
1041
|
+
`${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`,
|
|
1042
|
+
);
|
|
1043
|
+
result.push(leftBorder + displayText);
|
|
1044
|
+
result.push(bottomBorder);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1025
1047
|
if (isLastLine) {
|
|
1026
1048
|
const rightPad = Math.max(0, rightChromeCells - 2);
|
|
1027
1049
|
const includeHorizontal = rightChromeCells >= 2;
|
|
@@ -1364,21 +1386,14 @@ export class Editor implements Component, Focusable {
|
|
|
1364
1386
|
} else if (kb.matches(data, "tui.editor.cursorLineEnd")) {
|
|
1365
1387
|
this.#moveToLineEnd();
|
|
1366
1388
|
}
|
|
1367
|
-
// Page navigation (PageUp/PageDown)
|
|
1389
|
+
// Page navigation (PageUp/PageDown): page the editor viewport only. On a
|
|
1390
|
+
// short draft this is a no-op — it never steps prompt history (that stays
|
|
1391
|
+
// on Up/Down), so an idle empty editor swallows the keys instead of
|
|
1392
|
+
// surprising the user by loading the previous prompt (#4754).
|
|
1368
1393
|
else if (kb.matches(data, "tui.editor.pageUp")) {
|
|
1369
|
-
|
|
1370
|
-
this.#navigateHistory(-1);
|
|
1371
|
-
} else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) {
|
|
1372
|
-
this.#navigateHistory(-1);
|
|
1373
|
-
} else {
|
|
1374
|
-
this.#pageScroll(-1);
|
|
1375
|
-
}
|
|
1394
|
+
this.#pageScroll(-1);
|
|
1376
1395
|
} else if (kb.matches(data, "tui.editor.pageDown")) {
|
|
1377
|
-
|
|
1378
|
-
this.#navigateHistory(1);
|
|
1379
|
-
} else {
|
|
1380
|
-
this.#pageScroll(1);
|
|
1381
|
-
}
|
|
1396
|
+
this.#pageScroll(1);
|
|
1382
1397
|
}
|
|
1383
1398
|
// Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
|
|
1384
1399
|
else if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
|
|
@@ -2077,15 +2092,13 @@ export class Editor implements Component, Focusable {
|
|
|
2077
2092
|
this.#resetKillSequence();
|
|
2078
2093
|
this.#recordUndoState();
|
|
2079
2094
|
|
|
2080
|
-
let
|
|
2095
|
+
let removedSlashTrigger = false;
|
|
2081
2096
|
|
|
2082
2097
|
if (this.#state.cursorCol > 0) {
|
|
2083
2098
|
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
2084
2099
|
const textBeforeCursor = line.slice(0, this.#state.cursorCol);
|
|
2085
2100
|
const trailingSlashStart = findTrailingSlashCommandStart(textBeforeCursor);
|
|
2086
|
-
|
|
2087
|
-
trailingSlashStart === this.#state.cursorCol - 1 &&
|
|
2088
|
-
(!this.#hasOnlyWhitespaceBeforeCursorLine() || textBeforeCursor.slice(0, trailingSlashStart).trim() !== "");
|
|
2101
|
+
removedSlashTrigger = trailingSlashStart === this.#state.cursorCol - 1;
|
|
2089
2102
|
// An atomic placeholder token (image/paste marker) deletes as a unit, so a single
|
|
2090
2103
|
// backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text.
|
|
2091
2104
|
const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1);
|
|
@@ -2125,7 +2138,7 @@ export class Editor implements Component, Focusable {
|
|
|
2125
2138
|
|
|
2126
2139
|
// Update or re-trigger autocomplete after backspace
|
|
2127
2140
|
if (this.#autocompleteState) {
|
|
2128
|
-
if (
|
|
2141
|
+
if (removedSlashTrigger) {
|
|
2129
2142
|
this.#cancelAutocomplete();
|
|
2130
2143
|
this.onAutocompleteUpdate?.();
|
|
2131
2144
|
} else {
|
|
@@ -3046,17 +3059,17 @@ export class Editor implements Component, Focusable {
|
|
|
3046
3059
|
} else if (this.#isInMidPromptSkillSlashContext()) {
|
|
3047
3060
|
await this.#handleSlashCommandCompletion();
|
|
3048
3061
|
if (!this.#autocompleteState) {
|
|
3049
|
-
await this.#forceFileAutocomplete(
|
|
3062
|
+
await this.#forceFileAutocomplete();
|
|
3050
3063
|
}
|
|
3051
3064
|
} else {
|
|
3052
|
-
await this.#forceFileAutocomplete(
|
|
3065
|
+
await this.#forceFileAutocomplete();
|
|
3053
3066
|
}
|
|
3054
3067
|
}
|
|
3055
3068
|
async #handleSlashCommandCompletion(): Promise<void> {
|
|
3056
3069
|
await this.#tryTriggerAutocomplete();
|
|
3057
3070
|
}
|
|
3058
3071
|
|
|
3059
|
-
async #forceFileAutocomplete(
|
|
3072
|
+
async #forceFileAutocomplete(): Promise<void> {
|
|
3060
3073
|
if (!this.#autocompleteProvider) return;
|
|
3061
3074
|
|
|
3062
3075
|
// File-aware providers expose getForceFileSuggestions; slash-only ones fall back to regular completion.
|
|
@@ -3076,27 +3089,6 @@ export class Editor implements Component, Focusable {
|
|
|
3076
3089
|
if (requestId !== this.#autocompleteRequestId) return;
|
|
3077
3090
|
|
|
3078
3091
|
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
3079
|
-
// If there's exactly one suggestion and this was an explicit Tab press, apply it immediately
|
|
3080
|
-
if (explicitTab && suggestions.items.length === 1) {
|
|
3081
|
-
const item = suggestions.items[0]!;
|
|
3082
|
-
const result = this.#autocompleteProvider.applyCompletion(
|
|
3083
|
-
this.#state.lines,
|
|
3084
|
-
this.#state.cursorLine,
|
|
3085
|
-
this.#state.cursorCol,
|
|
3086
|
-
item,
|
|
3087
|
-
suggestions.prefix,
|
|
3088
|
-
);
|
|
3089
|
-
|
|
3090
|
-
this.#state.lines = result.lines;
|
|
3091
|
-
this.#state.cursorLine = result.cursorLine;
|
|
3092
|
-
this.#setCursorCol(result.cursorCol);
|
|
3093
|
-
|
|
3094
|
-
if (this.onChange) {
|
|
3095
|
-
this.onChange(this.getText());
|
|
3096
|
-
}
|
|
3097
|
-
return;
|
|
3098
|
-
}
|
|
3099
|
-
|
|
3100
3092
|
this.#autocompletePrefix = suggestions.prefix;
|
|
3101
3093
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3102
3094
|
this.#autocompleteState = "force";
|
package/src/components/loader.ts
CHANGED
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
import type { TUI } from "../tui";
|
|
2
|
-
import { sliceByColumn, visibleWidth } from "../utils";
|
|
2
|
+
import { getPaddingX, sliceByColumn, visibleWidth } from "../utils";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Loader component. Spinner frames advance at `SPINNER_ADVANCE_MS`.
|
|
7
|
-
*
|
|
8
|
-
* Message colorizers that are time-dependent can opt into 30fps redraws by
|
|
9
|
-
* setting `animated` to `true` on the function object.
|
|
10
|
-
*/
|
|
11
5
|
const RENDER_INTERVAL_MS = 1000 / 30;
|
|
12
6
|
const SPINNER_ADVANCE_MS = 80;
|
|
13
7
|
|
|
14
8
|
type ColorFn = (str: string) => string;
|
|
15
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Styles Loader message fragments without changing their visible text or width.
|
|
12
|
+
* Set `animated` for colorizers whose ANSI output changes over time.
|
|
13
|
+
*/
|
|
16
14
|
export type LoaderMessageColorFn = ColorFn & {
|
|
17
15
|
readonly animated?: true;
|
|
18
16
|
};
|
|
19
17
|
|
|
18
|
+
/** Animates a spinner and colorized message while asynchronous work is pending. */
|
|
20
19
|
export class Loader extends Text {
|
|
21
20
|
#frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
22
21
|
#currentFrame = 0;
|
|
23
22
|
#intervalId?: NodeJS.Timeout;
|
|
24
23
|
#ui: TUI | null = null;
|
|
25
24
|
#lastSpinnerTick = 0;
|
|
25
|
+
#layoutSource?: readonly string[];
|
|
26
|
+
#layout?: readonly { leading: string; content: string; trailing: string }[];
|
|
26
27
|
|
|
27
28
|
constructor(
|
|
28
29
|
ui: TUI,
|
|
@@ -40,11 +41,36 @@ export class Loader extends Text {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
render(width: number): readonly string[] {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
const source = super.render(width);
|
|
45
|
+
if (source !== this.#layoutSource) {
|
|
46
|
+
const paddingX = getPaddingX(1);
|
|
47
|
+
this.#layoutSource = source;
|
|
48
|
+
this.#layout = source.map(line => {
|
|
49
|
+
const clamped = visibleWidth(line) > width ? sliceByColumn(line, 0, width, true) : line;
|
|
50
|
+
const body = clamped.slice(paddingX);
|
|
51
|
+
const content = body.trimEnd();
|
|
52
|
+
return {
|
|
53
|
+
leading: clamped.slice(0, paddingX),
|
|
54
|
+
content,
|
|
55
|
+
trailing: body.slice(content.length),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const frame = this.#frames[this.#currentFrame];
|
|
61
|
+
const lines = [""];
|
|
62
|
+
const layout = this.#layout ?? [];
|
|
63
|
+
for (let i = 0; i < layout.length; i++) {
|
|
64
|
+
const { leading, content, trailing } = layout[i];
|
|
65
|
+
if (i === 0 && content.startsWith(frame)) {
|
|
66
|
+
const remainder = content.slice(frame.length);
|
|
67
|
+
const separator = remainder.startsWith(" ") ? " " : "";
|
|
68
|
+
const message = remainder.slice(separator.length);
|
|
69
|
+
lines.push(
|
|
70
|
+
`${leading}${this.spinnerColorFn(frame)}${separator}${message ? this.messageColorFn(message) : ""}${trailing}`,
|
|
71
|
+
);
|
|
72
|
+
} else {
|
|
73
|
+
lines.push(`${leading}${content ? this.messageColorFn(content) : ""}${trailing}`);
|
|
48
74
|
}
|
|
49
75
|
}
|
|
50
76
|
return lines;
|
|
@@ -91,8 +117,8 @@ export class Loader extends Text {
|
|
|
91
117
|
|
|
92
118
|
#updateDisplay() {
|
|
93
119
|
const frame = this.#frames[this.#currentFrame];
|
|
94
|
-
const
|
|
95
|
-
if (this.
|
|
120
|
+
const textChanged = this.setText(`${frame} ${this.message}`);
|
|
121
|
+
if ((textChanged || this.messageColorFn.animated === true) && this.#ui) {
|
|
96
122
|
// Direct write: a loader tick changes only this component, so the TUI
|
|
97
123
|
// can update the already-positioned rows without driving the full
|
|
98
124
|
// compose/prepare/diff pipeline. Lightweight test stubs may not carry
|
package/src/kitty-graphics.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* forms. Protocol gating (`imageProtocol === Kitty`) lives in the caller.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { wrapTmuxPassthroughIfNeeded } from "./tmux";
|
|
19
|
+
|
|
18
20
|
/** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
|
|
19
21
|
export const KITTY_PLACEHOLDER = "\u{10eeee}";
|
|
20
22
|
|
|
@@ -60,18 +62,15 @@ export interface KittyGraphicsFeatures {
|
|
|
60
62
|
* Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
|
|
61
63
|
* U+10EEEE with row/column diacritics).
|
|
62
64
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* glyphs and re-emit on every repaint, which is exactly the
|
|
70
|
-
* "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
|
|
65
|
+
* Kitty and Ghostty advertise placeholder support directly. A tmux session
|
|
66
|
+
* cannot use cursor-positioned placements because the outer terminal does not
|
|
67
|
+
* know pane scroll/reflow state, so an explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
|
|
68
|
+
* also opts into placeholders there — matching `timg -pk`. Automatic tmux
|
|
69
|
+
* fallback stays off because the unknown outer terminal may render U+10EEEE as
|
|
70
|
+
* literal PUA boxes (#1877).
|
|
71
71
|
*
|
|
72
|
-
* `PI_NO_KITTY_PLACEHOLDERS=1`
|
|
73
|
-
*
|
|
74
|
-
* for a wezterm nightly that has merged placeholder support).
|
|
72
|
+
* `PI_NO_KITTY_PLACEHOLDERS=1` and `PI_KITTY_PLACEHOLDERS=0` remain hard
|
|
73
|
+
* opt-outs; `PI_KITTY_PLACEHOLDERS=1` explicitly opts in anywhere else.
|
|
75
74
|
*/
|
|
76
75
|
export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
77
76
|
const offRaw = env.PI_NO_KITTY_PLACEHOLDERS?.trim().toLowerCase();
|
|
@@ -79,6 +78,7 @@ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: N
|
|
|
79
78
|
const force = env.PI_KITTY_PLACEHOLDERS?.trim().toLowerCase();
|
|
80
79
|
if (force === "1" || force === "true" || force === "on" || force === "yes" || force === "y") return true;
|
|
81
80
|
if (force === "0" || force === "false" || force === "off" || force === "no" || force === "n") return false;
|
|
81
|
+
if (env.TMUX && env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase() === "kitty") return true;
|
|
82
82
|
return terminalId === "kitty" || terminalId === "ghostty";
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -120,7 +120,7 @@ export function encodeKittyVirtualPlacement(opts: {
|
|
|
120
120
|
const params = ["a=p", "U=1", "q=2", `i=${opts.imageId}`];
|
|
121
121
|
if (opts.placementId) params.push(`p=${opts.placementId}`);
|
|
122
122
|
params.push(`c=${opts.columns}`, `r=${opts.rows}`);
|
|
123
|
-
return `\x1b_G${params.join(",")}\x1b
|
|
123
|
+
return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/**
|
package/src/latex-block.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// fractions and `\binom`, stretch delimiters (`\left…\right`, tall bare parens,
|
|
12
12
|
// matrix brackets), render matrix/cases/array environments as baseline-aligned
|
|
13
13
|
// grids, place big-operator limits (`\sum`, `\lim`, `\int\limits`) above and
|
|
14
|
-
// below the symbol, draw radicals, raise/lower block scripts,
|
|
14
|
+
// below the symbol, draw radicals, raise/lower block scripts, draw labeled
|
|
15
|
+
// horizontal braces (`\underbrace{x}_{lbl}`), stack `\overset`/`\underset`, and
|
|
15
16
|
// align `&` columns in `align`-family environments. Flat runs — symbols, fonts,
|
|
16
17
|
// colors, inline scripts — are delegated to `latexToUnicode`.
|
|
17
18
|
//
|
|
@@ -126,6 +127,25 @@ const INTEGRAL_OPERATORS: Record<string, true> = {
|
|
|
126
127
|
smallint: true,
|
|
127
128
|
};
|
|
128
129
|
|
|
130
|
+
// Horizontal brace/bracket decorations drawn as a rule row beside the content,
|
|
131
|
+
// with an optional limits-style label beyond the rule (`\underbrace{x}_{lbl}`).
|
|
132
|
+
interface HBraceSpec {
|
|
133
|
+
left: string;
|
|
134
|
+
mid: string;
|
|
135
|
+
center: string;
|
|
136
|
+
right: string;
|
|
137
|
+
over: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const HBRACE_COMMANDS: Record<string, HBraceSpec> = {
|
|
141
|
+
overbrace: { left: "╭", mid: "─", center: "┴", right: "╮", over: true },
|
|
142
|
+
underbrace: { left: "╰", mid: "─", center: "┬", right: "╯", over: false },
|
|
143
|
+
overbracket: { left: "┌", mid: "─", center: "─", right: "┐", over: true },
|
|
144
|
+
underbracket: { left: "└", mid: "─", center: "─", right: "┘", over: false },
|
|
145
|
+
overparen: { left: "╭", mid: "─", center: "─", right: "╮", over: true },
|
|
146
|
+
underparen: { left: "╰", mid: "─", center: "─", right: "╯", over: false },
|
|
147
|
+
};
|
|
148
|
+
|
|
129
149
|
// Vertical delimiter piece characters: `only` for single-line content, then
|
|
130
150
|
// top/mid/bot columns for stretched forms; `axis` replaces `mid` at the
|
|
131
151
|
// baseline row (the brace point).
|
|
@@ -363,6 +383,31 @@ function limitsBox(glyph: Box, sub: Box | null, sup: Box | null): Box {
|
|
|
363
383
|
return { lines, baseline, width };
|
|
364
384
|
}
|
|
365
385
|
|
|
386
|
+
/**
|
|
387
|
+
* `\underbrace{content}_{label}` / `\overbrace{content}^{label}`: the content
|
|
388
|
+
* with a drawn horizontal brace beside it and the label centered beyond the
|
|
389
|
+
* brace. The baseline stays on the content so neighbors align with it.
|
|
390
|
+
*/
|
|
391
|
+
function hbraceBox(content: Box, spec: HBraceSpec, label: Box | null): Box {
|
|
392
|
+
const braceWidth = Math.max(content.width, 3);
|
|
393
|
+
const width = Math.max(braceWidth, label?.width ?? 0);
|
|
394
|
+
const lead = (braceWidth - 3) >> 1;
|
|
395
|
+
const brace = center(
|
|
396
|
+
spec.left + spec.mid.repeat(lead) + spec.center + spec.mid.repeat(braceWidth - 3 - lead) + spec.right,
|
|
397
|
+
width,
|
|
398
|
+
);
|
|
399
|
+
const contentLines = content.lines.map(line => center(line, width));
|
|
400
|
+
const labelLines = label === null ? [] : label.lines.map(line => center(line, width));
|
|
401
|
+
if (spec.over) {
|
|
402
|
+
return {
|
|
403
|
+
lines: [...labelLines, brace, ...contentLines],
|
|
404
|
+
baseline: labelLines.length + 1 + content.baseline,
|
|
405
|
+
width,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return { lines: [...contentLines, brace, ...labelLines], baseline: content.baseline, width };
|
|
409
|
+
}
|
|
410
|
+
|
|
366
411
|
/**
|
|
367
412
|
* Attach block scripts to `base` as one shared right-hand column: the
|
|
368
413
|
* superscript ends level with the base's top row (raised one row above a
|
|
@@ -878,6 +923,59 @@ function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box {
|
|
|
878
923
|
i = bottom.end;
|
|
879
924
|
continue;
|
|
880
925
|
}
|
|
926
|
+
if (name && HBRACE_COMMANDS[name]) {
|
|
927
|
+
flush();
|
|
928
|
+
const spec = HBRACE_COMMANDS[name];
|
|
929
|
+
const arg = readArg(src, j);
|
|
930
|
+
// Limits-style scripts: the brace-side script is the label; an
|
|
931
|
+
// opposite-side script attaches as a regular corner script.
|
|
932
|
+
let subText: string | null = null;
|
|
933
|
+
let supText: string | null = null;
|
|
934
|
+
let m = arg.end;
|
|
935
|
+
for (;;) {
|
|
936
|
+
let n = m;
|
|
937
|
+
while (src[n] === " ") n++;
|
|
938
|
+
if (src[n] === "_" && subText === null) {
|
|
939
|
+
const s = readArg(src, n + 1);
|
|
940
|
+
subText = s.text;
|
|
941
|
+
m = s.end;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
if (src[n] === "^" && supText === null) {
|
|
945
|
+
const s = readArg(src, n + 1);
|
|
946
|
+
supText = s.text;
|
|
947
|
+
m = s.end;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
const labelText = spec.over ? supText : subText;
|
|
953
|
+
const otherText = spec.over ? subText : supText;
|
|
954
|
+
let box = hbraceBox(
|
|
955
|
+
parseExpr(arg.text, inner()),
|
|
956
|
+
spec,
|
|
957
|
+
labelText === null ? null : parseExpr(labelText, inner()),
|
|
958
|
+
);
|
|
959
|
+
if (otherText !== null) {
|
|
960
|
+
const other = parseExpr(otherText, inner());
|
|
961
|
+
box = attachScripts(box, spec.over ? other : null, spec.over ? null : other);
|
|
962
|
+
}
|
|
963
|
+
boxes.push(paint(box));
|
|
964
|
+
i = m;
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (name === "overset" || name === "underset" || name === "stackrel") {
|
|
968
|
+
flush();
|
|
969
|
+
const anno = readArg(src, j);
|
|
970
|
+
const base = readArg(src, anno.end);
|
|
971
|
+
const annoBox = parseExpr(anno.text, inner());
|
|
972
|
+
const baseBox = parseExpr(base.text, inner());
|
|
973
|
+
boxes.push(
|
|
974
|
+
paint(limitsBox(baseBox, name === "underset" ? annoBox : null, name === "underset" ? null : annoBox)),
|
|
975
|
+
);
|
|
976
|
+
i = base.end;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
881
979
|
if (name === "sqrt") {
|
|
882
980
|
let k = j;
|
|
883
981
|
while (src[k] === " ") k++;
|
|
@@ -1108,8 +1206,18 @@ function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box {
|
|
|
1108
1206
|
const flat = latexToUnicode(raw);
|
|
1109
1207
|
return flat.startsWith("^") || flat.startsWith("_");
|
|
1110
1208
|
};
|
|
1209
|
+
// Multi-letter script words (`N_{turns}`) would convert per-char into
|
|
1210
|
+
// Unicode glyphs of uneven height and read ragged; box them too.
|
|
1211
|
+
// Commands are stripped: their output (`\prime` → ′) is not letters.
|
|
1212
|
+
const ragged = (raw: string | undefined): boolean => {
|
|
1213
|
+
if (raw === undefined) return false;
|
|
1214
|
+
const letters = scriptArgOf(raw)
|
|
1215
|
+
.replace(/\\[A-Za-z]+/g, "")
|
|
1216
|
+
.match(/[A-Za-z]/g);
|
|
1217
|
+
return letters !== null && letters.length >= 2;
|
|
1218
|
+
};
|
|
1111
1219
|
const tall = (supBox !== null && supBox.lines.length > 1) || (subBox !== null && subBox.lines.length > 1);
|
|
1112
|
-
if (tall || unconvertible(supText) || unconvertible(subText)) {
|
|
1220
|
+
if (tall || unconvertible(supText) || unconvertible(subText) || ragged(supText) || ragged(subText)) {
|
|
1113
1221
|
// Block script (`x^{\frac{1}{2}}`, `x^q`): raise/lower the boxes
|
|
1114
1222
|
// against the run or box they follow.
|
|
1115
1223
|
flush();
|
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
renderKittyPlaceholderLines,
|
|
10
10
|
setKittyGraphics,
|
|
11
11
|
} from "./kitty-graphics";
|
|
12
|
+
import { isInsideTmux, wrapTmuxPassthrough, wrapTmuxPassthroughIfNeeded } from "./tmux";
|
|
13
|
+
|
|
14
|
+
export { isInsideTmux, wrapTmuxPassthrough } from "./tmux";
|
|
12
15
|
|
|
13
16
|
export enum ImageProtocol {
|
|
14
17
|
Kitty = "\x1b_G",
|
|
@@ -142,15 +145,6 @@ export class TerminalInfo {
|
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
/**
|
|
146
|
-
* Whether the agent process is running inside a tmux session. Read fresh on
|
|
147
|
-
* each call so tests can toggle `Bun.env.TMUX` per case without re-importing
|
|
148
|
-
* the module and so a tmux session attached/detached mid-run is observed.
|
|
149
|
-
*/
|
|
150
|
-
export function isInsideTmux(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
151
|
-
return Boolean(env.TMUX);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
148
|
/** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
|
|
155
149
|
export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
156
150
|
// TMUX/STY/ZELLIJ/CMUX workspace+surface ids are authoritative session
|
|
@@ -172,22 +166,6 @@ export function isInsideZellij(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
|
172
166
|
return Boolean(env.ZELLIJ);
|
|
173
167
|
}
|
|
174
168
|
|
|
175
|
-
/**
|
|
176
|
-
* Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
|
|
177
|
-
* ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips
|
|
178
|
-
* the envelope and forwards the unwrapped payload to the outer terminal only
|
|
179
|
-
* when the user opts in with `set -g allow-passthrough on`; otherwise tmux
|
|
180
|
-
* silently consumes the envelope, which is identical to the pre-wrap baseline
|
|
181
|
-
* (tmux already swallowed the bare OSC).
|
|
182
|
-
*
|
|
183
|
-
* Used by `TerminalInfo.sendNotification` and the OSC 99 capability probe in
|
|
184
|
-
* `terminal.ts` to keep notifications alive for terminals that understand
|
|
185
|
-
* OSC 9 / OSC 99 (kitty, ghostty, wezterm, iterm2) when running under tmux.
|
|
186
|
-
*/
|
|
187
|
-
export function wrapTmuxPassthrough(payload: string): string {
|
|
188
|
-
return `\x1bPtmux;${payload.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
169
|
export function isNotificationSuppressed(): boolean {
|
|
192
170
|
const value = $env.PI_NOTIFICATIONS;
|
|
193
171
|
if (!value) return false;
|
|
@@ -628,7 +606,7 @@ export function setCellDimensions(dims: CellDimensions): void {
|
|
|
628
606
|
function chunkKittyApc(leadParams: string, base64Data: string): string {
|
|
629
607
|
const CHUNK_SIZE = 4096;
|
|
630
608
|
if (base64Data.length <= CHUNK_SIZE) {
|
|
631
|
-
return `\x1b_G${leadParams};${base64Data}\x1b
|
|
609
|
+
return wrapTmuxPassthroughIfNeeded(`\x1b_G${leadParams};${base64Data}\x1b\\`);
|
|
632
610
|
}
|
|
633
611
|
|
|
634
612
|
const chunks: string[] = [];
|
|
@@ -640,12 +618,12 @@ function chunkKittyApc(leadParams: string, base64Data: string): string {
|
|
|
640
618
|
const isLast = offset + CHUNK_SIZE >= base64Data.length;
|
|
641
619
|
|
|
642
620
|
if (isFirst) {
|
|
643
|
-
chunks.push(`\x1b_G${leadParams},m=1;${chunk}\x1b\\`);
|
|
621
|
+
chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_G${leadParams},m=1;${chunk}\x1b\\`));
|
|
644
622
|
isFirst = false;
|
|
645
623
|
} else if (isLast) {
|
|
646
|
-
chunks.push(`\
|
|
624
|
+
chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_Gq=2,m=0;${chunk}\x1b\\`));
|
|
647
625
|
} else {
|
|
648
|
-
chunks.push(`\
|
|
626
|
+
chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_Gq=2,m=1;${chunk}\x1b\\`));
|
|
649
627
|
}
|
|
650
628
|
|
|
651
629
|
offset += CHUNK_SIZE;
|
|
@@ -699,7 +677,7 @@ export function encodeKittyPlacement(options: {
|
|
|
699
677
|
if (options.placementId) params.push(`p=${options.placementId}`);
|
|
700
678
|
if (options.columns) params.push(`c=${options.columns}`);
|
|
701
679
|
if (options.rows) params.push(`r=${options.rows}`);
|
|
702
|
-
return `\x1b_G${params.join(",")}\x1b
|
|
680
|
+
return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`);
|
|
703
681
|
}
|
|
704
682
|
|
|
705
683
|
/**
|
|
@@ -710,7 +688,7 @@ export function encodeKittyPlacement(options: {
|
|
|
710
688
|
* this is the only way to actually purge a placed image.
|
|
711
689
|
*/
|
|
712
690
|
export function encodeKittyDeleteImage(imageId: number): string {
|
|
713
|
-
return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b
|
|
691
|
+
return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`);
|
|
714
692
|
}
|
|
715
693
|
|
|
716
694
|
export function encodeITerm2(
|
|
@@ -974,11 +952,25 @@ export function renderImage(
|
|
|
974
952
|
|
|
975
953
|
if (TERMINAL.imageProtocol === ImageProtocol.Sixel) {
|
|
976
954
|
try {
|
|
977
|
-
|
|
978
|
-
|
|
955
|
+
// SIXEL encodes in 6-pixel vertical bands. A height that is not a
|
|
956
|
+
// multiple of 6 is padded with transparent rows, but the terminal
|
|
957
|
+
// still allocates cell rows for the padded height. When the padded
|
|
958
|
+
// height crosses a cell boundary the terminal uses one more row
|
|
959
|
+
// than fit.rows, so the next line of content overwrites the bottom
|
|
960
|
+
// of the image — a visible slice stripped from the image. Round the
|
|
961
|
+
// encode height DOWN to the largest multiple of 6 that fits within
|
|
962
|
+
// the requested row budget, so the band boundary aligns without
|
|
963
|
+
// padding and the reserved row count never exceeds fit.rows. Scale
|
|
964
|
+
// the width by the same ratio so resize_exact preserves the aspect
|
|
965
|
+
// ratio instead of squashing the image vertically.
|
|
966
|
+
const rawHeightPx = Math.max(1, fit.rows * cellDims.heightPx);
|
|
967
|
+
const targetHeightPx = Math.max(6, Math.floor(rawHeightPx / 6) * 6);
|
|
968
|
+
const heightScale = targetHeightPx / rawHeightPx;
|
|
969
|
+
const targetWidthPx = Math.max(1, Math.round(fit.columns * cellDims.widthPx * heightScale));
|
|
970
|
+
const rows = Math.max(1, Math.ceil(targetHeightPx / cellDims.heightPx));
|
|
979
971
|
const decoded = new Uint8Array(Buffer.from(base64Data, "base64"));
|
|
980
972
|
const sequence = encodeSixel(decoded, targetWidthPx, targetHeightPx);
|
|
981
|
-
return { sequence, rows
|
|
973
|
+
return { sequence, rows };
|
|
982
974
|
} catch {
|
|
983
975
|
return null;
|
|
984
976
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -12,12 +12,12 @@ import {
|
|
|
12
12
|
import { setKittyProtocolActive } from "./keys";
|
|
13
13
|
import { StdinBuffer } from "./stdin-buffer";
|
|
14
14
|
import {
|
|
15
|
+
isInsideTerminalMultiplexer,
|
|
15
16
|
isInsideTmux,
|
|
16
17
|
NotifyProtocol,
|
|
17
18
|
setCellDimensions,
|
|
18
19
|
setOsc99Supported,
|
|
19
20
|
TERMINAL,
|
|
20
|
-
wrapTmuxPassthrough,
|
|
21
21
|
} from "./terminal-capabilities";
|
|
22
22
|
import { type HangulCompatibilityJamoWidth, setHangulCompatibilityJamoWidth } from "./utils";
|
|
23
23
|
|
|
@@ -45,6 +45,7 @@ export function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
function shouldEnableModifyOtherKeysFallback(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
48
|
+
if (isInsideTmux(env)) return false;
|
|
48
49
|
if (!env.SSH_CONNECTION && !env.SSH_TTY && !env.SSH_CLIENT) return true;
|
|
49
50
|
return TERMINAL.id !== "base" && TERMINAL.id !== "trueColor";
|
|
50
51
|
}
|
|
@@ -1041,6 +1042,15 @@ export class ProcessTerminal implements Terminal {
|
|
|
1041
1042
|
|
|
1042
1043
|
#shouldQueryOsc99Support(): boolean {
|
|
1043
1044
|
if (TERMINAL.notifyProtocol !== NotifyProtocol.Osc99) return false;
|
|
1045
|
+
// Never probe inside a terminal multiplexer. tmux/screen forward the
|
|
1046
|
+
// passthrough-wrapped `p=?` query to the outer terminal, but cannot route
|
|
1047
|
+
// the capability reply back to the pane that sent it (tmux/tmux#4386,
|
|
1048
|
+
// tmux/tmux#3964), so the reply leaks into the pane as literal text and
|
|
1049
|
+
// its bytes perturb input (issue #5582 — the notification sibling of the
|
|
1050
|
+
// graphics-probe leak #5381). Rich notifications fall back to the
|
|
1051
|
+
// single-line OSC 99 form until confirmation, and delivery still uses the
|
|
1052
|
+
// passthrough/BEL path (#3395).
|
|
1053
|
+
if (isInsideTerminalMultiplexer($env)) return false;
|
|
1044
1054
|
return !isBunTestRuntime() || $env.PI_TUI_OSC99_PROBE === "1";
|
|
1045
1055
|
}
|
|
1046
1056
|
|
|
@@ -1054,14 +1064,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
1054
1064
|
const id = `omp-probe-${nextOsc99ProbeId++}`;
|
|
1055
1065
|
this.#osc99PendingId = id;
|
|
1056
1066
|
this.#da1SentinelOwners.push({ kind: "osc99Probe", id });
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
|
|
1060
|
-
// inside tmux even when the outer terminal speaks OSC 99, and rich
|
|
1061
|
-
// notifications stay permanently downgraded to the single-line fallback.
|
|
1062
|
-
const probe = `\x1b]99;i=${id}:p=?;\x1b\\`;
|
|
1063
|
-
const sequence = isInsideTmux() ? wrapTmuxPassthrough(probe) : probe;
|
|
1064
|
-
this.#safeWrite(`${sequence}\x1b[c`);
|
|
1067
|
+
// The probe never runs under a multiplexer (see #shouldQueryOsc99Support),
|
|
1068
|
+
// so it is always sent directly to the terminal.
|
|
1069
|
+
this.#safeWrite(`\x1b]99;i=${id}:p=?;\x1b\\\x1b[c`);
|
|
1065
1070
|
}
|
|
1066
1071
|
|
|
1067
1072
|
#handleOsc99CapabilityResponse(metaRaw: string, payload: string): boolean {
|
package/src/tmux.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Whether the process is running inside a tmux session. */
|
|
2
|
+
export function isInsideTmux(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
3
|
+
return Boolean(env.TMUX);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Wrap a control sequence in tmux's DCS passthrough envelope. */
|
|
7
|
+
export function wrapTmuxPassthrough(payload: string): string {
|
|
8
|
+
return `\x1bPtmux;${payload.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Pass a control sequence through tmux, leaving direct-terminal output unchanged. */
|
|
12
|
+
export function wrapTmuxPassthroughIfNeeded(payload: string, env: NodeJS.ProcessEnv = Bun.env): string {
|
|
13
|
+
return isInsideTmux(env) ? wrapTmuxPassthrough(payload) : payload;
|
|
14
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -394,6 +394,7 @@ export function getWordNavKind(grapheme: string): WordNavKind {
|
|
|
394
394
|
const ch = firstCodePointChar(grapheme);
|
|
395
395
|
if (!ch) return "other";
|
|
396
396
|
if (WORD_NAV_RE_WHITESPACE.test(ch)) return "whitespace";
|
|
397
|
+
if (ch === "_") return "word";
|
|
397
398
|
if (WORD_NAV_RE_PUNCT.test(ch) || WORD_NAV_RE_SYMBOL.test(ch)) return "delimiter";
|
|
398
399
|
if (
|
|
399
400
|
WORD_NAV_RE_HAN.test(ch) ||
|
|
@@ -403,7 +404,7 @@ export function getWordNavKind(grapheme: string): WordNavKind {
|
|
|
403
404
|
) {
|
|
404
405
|
return "cjk";
|
|
405
406
|
}
|
|
406
|
-
if (
|
|
407
|
+
if (WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
|
|
407
408
|
return "other";
|
|
408
409
|
}
|
|
409
410
|
|