@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 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": "16.5.2",
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": "16.5.2",
41
- "@oh-my-pi/pi-utils": "16.5.2",
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
  },
@@ -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 && inlineHint) {
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;
@@ -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, and
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
- const targetWidthPx = Math.max(1, fit.columns * cellDims.widthPx);
956
- const targetHeightPx = Math.max(1, fit.rows * cellDims.heightPx);
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: fit.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
- // Wrap the probe under tmux so terminals behind `allow-passthrough on`
1059
- // can still respond (mirroring how `TerminalInfo.sendNotification`
1060
- // wraps notification deliveries). Without it the probe is swallowed
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 {