@oh-my-pi/pi-tui 16.5.2 → 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 +13 -0
- package/dist/types/components/editor.d.ts +2 -0
- package/package.json +3 -3
- package/src/components/editor.ts +23 -1
- package/src/latex-block.ts +110 -2
- package/src/terminal-capabilities.ts +17 -3
- package/src/terminal.ts +13 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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
|
+
|
|
5
18
|
## [16.5.2] - 2026-07-14
|
|
6
19
|
|
|
7
20
|
### 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;
|
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/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;
|
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();
|
|
@@ -952,11 +952,25 @@ export function renderImage(
|
|
|
952
952
|
|
|
953
953
|
if (TERMINAL.imageProtocol === ImageProtocol.Sixel) {
|
|
954
954
|
try {
|
|
955
|
-
|
|
956
|
-
|
|
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));
|
|
957
971
|
const decoded = new Uint8Array(Buffer.from(base64Data, "base64"));
|
|
958
972
|
const sequence = encodeSixel(decoded, targetWidthPx, targetHeightPx);
|
|
959
|
-
return { sequence, rows
|
|
973
|
+
return { sequence, rows };
|
|
960
974
|
} catch {
|
|
961
975
|
return null;
|
|
962
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
|
|
|
@@ -1042,6 +1042,15 @@ export class ProcessTerminal implements Terminal {
|
|
|
1042
1042
|
|
|
1043
1043
|
#shouldQueryOsc99Support(): boolean {
|
|
1044
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;
|
|
1045
1054
|
return !isBunTestRuntime() || $env.PI_TUI_OSC99_PROBE === "1";
|
|
1046
1055
|
}
|
|
1047
1056
|
|
|
@@ -1055,14 +1064,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
1055
1064
|
const id = `omp-probe-${nextOsc99ProbeId++}`;
|
|
1056
1065
|
this.#osc99PendingId = id;
|
|
1057
1066
|
this.#da1SentinelOwners.push({ kind: "osc99Probe", id });
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
|
|
1061
|
-
// inside tmux even when the outer terminal speaks OSC 99, and rich
|
|
1062
|
-
// notifications stay permanently downgraded to the single-line fallback.
|
|
1063
|
-
const probe = `\x1b]99;i=${id}:p=?;\x1b\\`;
|
|
1064
|
-
const sequence = isInsideTmux() ? wrapTmuxPassthrough(probe) : probe;
|
|
1065
|
-
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`);
|
|
1066
1070
|
}
|
|
1067
1071
|
|
|
1068
1072
|
#handleOsc99CapabilityResponse(metaRaw: string, payload: string): boolean {
|