@oh-my-pi/pi-tui 15.5.13 → 15.5.15

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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.14] - 2026-05-29
6
+
7
+ ### Added
8
+
9
+ - `Markdown` now renders a small color-chip swatch, painted with the referenced color, in front of CSS hex colors mentioned in prose, thinking traces, lists, tables, and blockquotes (e.g. `#C5FFD6` or `` `#C5FFD6` ``). The chip glyph comes from the theme's symbol set so it degrades across tiers (Nerd Font / Unicode `■` → ASCII `[]`) and is overridable via the `md.colorSwatch` symbol. Truecolor terminals get an exact 24-bit chip; others fall back to the nearest 256-color cell. Bare prose requires a hex letter for 3/4-digit forms so short issue/PR references (`#123`, `#1011`) don't sprout swatches; backticked codes are always treated as colors.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed the terminal hardware cursor disappearing in Ghostty. `resolveHardwareCursorPreference` force-hid the hardware cursor whenever it detected a Ghostty session (to fight bar-cursor afterimage "trails"), but the editor was simultaneously kept in terminal-cursor (marker-only) mode via `getUseTerminalCursorMarker()`, which renders no glyph and relies on the now-hidden hardware cursor — so Ghostty users had no visible caret at all, regardless of `PI_HARDWARE_CURSOR`. The Ghostty/`PI_FORCE_HARDWARE_CURSOR` override and the redundant `useTerminalCursorMarker` state are removed: `showHardwareCursor` is honored as-requested again (hardware cursor on by default), and disabling it cleanly falls back to the steady software-cursor glyph. The per-paint anti-trail mitigations (hide-cursor + autowrap-off inside the synchronized-output block) are retained, which is the actual trail fix.
14
+
5
15
  ## [15.5.12] - 2026-05-29
6
16
 
7
17
  ### Fixed
@@ -19,5 +19,7 @@ export interface SymbolTheme {
19
19
  table: BoxSymbols;
20
20
  quoteBorder: string;
21
21
  hrChar: string;
22
+ /** Chip glyph drawn (painted with the referenced color) before inline hex colors. */
23
+ colorSwatch?: string;
22
24
  spinnerFrames: string[];
23
25
  }
@@ -27,6 +27,11 @@ export interface Terminal {
27
27
  clearScreen(): void;
28
28
  setTitle(title: string): void;
29
29
  setProgress(active: boolean): void;
30
+ /**
31
+ * Returns whether the native terminal viewport is at the scrollback tail when
32
+ * the host exposes that state. `undefined` means the terminal cannot report it.
33
+ */
34
+ isNativeViewportAtBottom?(): boolean | undefined;
30
35
  /**
31
36
  * Register a callback for terminal appearance (dark/light) changes.
32
37
  * Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
@@ -45,6 +50,11 @@ export declare class ProcessTerminal implements Terminal {
45
50
  get appearance(): TerminalAppearance | undefined;
46
51
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
47
52
  start(onInput: (data: string) => void, onResize: () => void): void;
53
+ /**
54
+ * Returns true when Windows' active console viewport is at the scrollback tail.
55
+ * POSIX terminals do not expose native scrollback position through a standard API.
56
+ */
57
+ isNativeViewportAtBottom(): boolean | undefined;
48
58
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
49
59
  stop(): void;
50
60
  write(data: string): void;
@@ -45,6 +45,11 @@ export interface RenderRequestOptions {
45
45
  /** Clear terminal scrollback for intentional transcript replacement. */
46
46
  clearScrollback?: boolean;
47
47
  }
48
+ /** Options for deferred native scrollback rebuild checkpoints. */
49
+ export interface NativeScrollbackRefreshOptions {
50
+ /** Allow replay when the terminal cannot report viewport state. Use only for explicit user submit checkpoints. */
51
+ allowUnknownViewport?: boolean;
52
+ }
48
53
  /** Type guard to check if a component implements Focusable */
49
54
  export declare function isFocusable(component: Component | null): component is Component & Focusable;
50
55
  /**
@@ -167,6 +172,6 @@ export declare class TUI extends Container {
167
172
  * Callers should only invoke this at checkpoints where the user is expected to be
168
173
  * at the terminal bottom, such as after submitting a new prompt.
169
174
  */
170
- refreshNativeScrollbackIfDirty(): boolean;
175
+ refreshNativeScrollbackIfDirty(options?: NativeScrollbackRefreshOptions): boolean;
171
176
  requestRender(force?: boolean, options?: RenderRequestOptions): void;
172
177
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "15.5.13",
4
+ "version": "15.5.15",
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": "15.5.13",
41
- "@oh-my-pi/pi-utils": "15.5.13",
40
+ "@oh-my-pi/pi-natives": "15.5.15",
41
+ "@oh-my-pi/pi-utils": "15.5.15",
42
42
  "lru-cache": "11.3.6",
43
43
  "marked": "^18.0.3"
44
44
  },
@@ -596,7 +596,9 @@ export class Editor implements Component, Focusable {
596
596
 
597
597
  #getStyledInputCursor(): { text: string; width: number } {
598
598
  const cursorChar = this.#theme.symbols.inputCursor;
599
- return { text: `\x1b[5m${cursorChar}\x1b[0m`, width: visibleWidth(cursorChar) };
599
+ // Keep the software cursor steady. Ghostty/cmux can leave visual
600
+ // afterimages for SGR blink cells during rapid input-row repaints.
601
+ return { text: cursorChar, width: visibleWidth(cursorChar) };
600
602
  }
601
603
 
602
604
  #renderEndOfLineCursorAtWidthLimit(
@@ -1485,19 +1487,7 @@ export class Editor implements Component, Focusable {
1485
1487
  /** Insert text at the current cursor position */
1486
1488
  insertText(text: string): void {
1487
1489
  this.#exitHistoryForEditing();
1488
- this.#resetKillSequence();
1489
- this.#recordUndoState();
1490
-
1491
- const line = this.#state.lines[this.#state.cursorLine] || "";
1492
- const before = line.slice(0, this.#state.cursorCol);
1493
- const after = line.slice(this.#state.cursorCol);
1494
-
1495
- this.#state.lines[this.#state.cursorLine] = before + text + after;
1496
- this.#setCursorCol(this.#state.cursorCol + text.length);
1497
-
1498
- if (this.onChange) {
1499
- this.onChange(this.getText());
1500
- }
1490
+ this.#insertTextAtCursor(text);
1501
1491
  }
1502
1492
 
1503
1493
  // All the editor methods from before...
@@ -130,6 +130,80 @@ function formatHyperlink(text: string, target: string): string {
130
130
  return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`;
131
131
  }
132
132
 
133
+ // ---------------------------------------------------------------------------
134
+ // Inline hex-color swatches
135
+ // ---------------------------------------------------------------------------
136
+ // When prose/thinking mentions a CSS hex color (e.g. #C5FFD6 or `#C5FFD6`),
137
+ // render a small chip painted with that color just before the code. The chip
138
+ // glyph comes from the theme's symbol set (ASCII → Unicode → Nerd Font), so it
139
+ // degrades gracefully; the color itself is exact 24-bit on truecolor terminals
140
+ // and the nearest 256-color cell otherwise (Bun.color quantizes for us).
141
+
142
+ /** Fallback chip when the theme supplies no `colorSwatch` symbol (Unicode default). */
143
+ const DEFAULT_COLOR_SWATCH_GLYPH = "■";
144
+
145
+ // `#` + 3-8 hex digits, not glued to a surrounding word/`#`/`&` (avoids HTML
146
+ // entities like &#9731; and paths like foo#fff) and not trailed by more hex
147
+ // (so over-long runs never produce a misleading swatch). Length/letter rules
148
+ // are enforced in classifyHexColor since the alternation can't express "exactly
149
+ // 3, 4, 6, or 8".
150
+ const HEX_COLOR_REGEX = /(?<![\w#&])#([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g;
151
+ const HEX_COLOR_EXACT_REGEX = /^#([0-9a-fA-F]{3,8})$/;
152
+
153
+ /**
154
+ * Decide whether a run of hex digits denotes a renderable CSS color.
155
+ *
156
+ * Only the canonical CSS lengths (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) qualify. In
157
+ * `strict` mode (bare prose) a 3/4-digit run must contain a hex letter, so the
158
+ * far more common short issue/PR references (#123, #1011) don't sprout swatches.
159
+ * Codespans opt out of strictness — the backticks already signal "this is a color".
160
+ */
161
+ function classifyHexColor(hex: string, strict: boolean): boolean {
162
+ const n = hex.length;
163
+ if (n !== 3 && n !== 4 && n !== 6 && n !== 8) return false;
164
+ if (strict && n <= 4 && !/[a-fA-F]/.test(hex)) return false;
165
+ return true;
166
+ }
167
+
168
+ /** ANSI-painted `glyph` for `#${hex}`, or "" when the color can't be encoded. */
169
+ function colorSwatch(hex: string, glyph: string): string {
170
+ const ansi = Bun.color(`#${hex}`, TERMINAL.trueColor ? "ansi-16m" : "ansi-256");
171
+ // Reset only the foreground (\x1b[39m) so an enclosing background/decoration
172
+ // applied later by the line renderer survives across the swatch.
173
+ return ansi ? `${ansi}${glyph}\x1b[39m ` : "";
174
+ }
175
+
176
+ /**
177
+ * Style a plain-text run, inserting a color swatch before each hex color it
178
+ * mentions. Non-color text (including the matched `#hex` itself) is routed
179
+ * through `applySegment` so the caller's base styling is preserved verbatim.
180
+ */
181
+ function renderTextWithSwatches(text: string, applySegment: (t: string) => string, glyph: string): string {
182
+ HEX_COLOR_REGEX.lastIndex = 0;
183
+ let result = "";
184
+ let last = 0;
185
+ for (;;) {
186
+ const match = HEX_COLOR_REGEX.exec(text);
187
+ if (match === null) break;
188
+ if (!classifyHexColor(match[1], true)) continue;
189
+ const swatch = colorSwatch(match[1], glyph);
190
+ if (!swatch) continue;
191
+ if (match.index > last) result += applySegment(text.slice(last, match.index));
192
+ result += swatch + applySegment(match[0]);
193
+ last = match.index + match[0].length;
194
+ }
195
+ if (last === 0) return applySegment(text);
196
+ if (last < text.length) result += applySegment(text.slice(last));
197
+ return result;
198
+ }
199
+
200
+ /** Swatch for a codespan whose entire content is a single hex color, else "". */
201
+ function codespanSwatch(code: string, glyph: string): string {
202
+ const match = HEX_COLOR_EXACT_REGEX.exec(code.trim());
203
+ if (!match || !classifyHexColor(match[1], false)) return "";
204
+ return colorSwatch(match[1], glyph);
205
+ }
206
+
133
207
  export class Markdown implements Component {
134
208
  #text: string;
135
209
  #paddingX: number; // Left/right padding
@@ -543,6 +617,7 @@ export class Markdown implements Component {
543
617
  const segments: string[] = text.split("\n");
544
618
  return segments.map((segment: string) => applyText(segment)).join("\n");
545
619
  };
620
+ const swatchGlyph = this.#theme.symbols.colorSwatch || DEFAULT_COLOR_SWATCH_GLYPH;
546
621
 
547
622
  for (const token of tokens) {
548
623
  switch (token.type) {
@@ -551,7 +626,7 @@ export class Markdown implements Component {
551
626
  if (token.tokens && token.tokens.length > 0) {
552
627
  result += this.#renderInlineTokens(token.tokens, resolvedStyleContext);
553
628
  } else {
554
- result += applyTextWithNewlines(token.text);
629
+ result += renderTextWithSwatches(token.text, applyTextWithNewlines, swatchGlyph);
555
630
  }
556
631
  break;
557
632
 
@@ -572,9 +647,10 @@ export class Markdown implements Component {
572
647
  break;
573
648
  }
574
649
 
575
- case "codespan":
576
- result += this.#theme.code(token.text) + stylePrefix;
650
+ case "codespan": {
651
+ result += codespanSwatch(token.text, swatchGlyph) + this.#theme.code(token.text) + stylePrefix;
577
652
  break;
653
+ }
578
654
 
579
655
  case "link": {
580
656
  const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
package/src/symbols.ts CHANGED
@@ -20,5 +20,7 @@ export interface SymbolTheme {
20
20
  table: BoxSymbols;
21
21
  quoteBorder: string;
22
22
  hrChar: string;
23
+ /** Chip glyph drawn (painted with the referenced color) before inline hex colors. */
24
+ colorSwatch?: string;
23
25
  spinnerFrames: string[];
24
26
  }
package/src/terminal.ts CHANGED
@@ -18,6 +18,7 @@ let activeTerminal: ProcessTerminal | null = null;
18
18
  let terminalEverStarted = false;
19
19
 
20
20
  const STD_INPUT_HANDLE = -10;
21
+ const STD_OUTPUT_HANDLE = -11;
21
22
  const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
22
23
  /**
23
24
  * Emergency terminal restore - call this from signal/crash handlers
@@ -92,13 +93,18 @@ export interface Terminal {
92
93
  // Progress indicator (OSC 9;4)
93
94
  setProgress(active: boolean): void;
94
95
 
96
+ /**
97
+ * Returns whether the native terminal viewport is at the scrollback tail when
98
+ * the host exposes that state. `undefined` means the terminal cannot report it.
99
+ */
100
+ isNativeViewportAtBottom?(): boolean | undefined;
101
+
95
102
  /**
96
103
  * Register a callback for terminal appearance (dark/light) changes.
97
104
  * Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
98
105
  * Fires when the detected appearance changes, including the initial detection.
99
106
  */
100
107
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
101
-
102
108
  /** The last detected terminal appearance, or undefined if not yet known. */
103
109
  get appearance(): TerminalAppearance | undefined;
104
110
  }
@@ -205,6 +211,33 @@ export class ProcessTerminal implements Terminal {
205
211
  }
206
212
  }
207
213
 
214
+ /**
215
+ * Returns true when Windows' active console viewport is at the scrollback tail.
216
+ * POSIX terminals do not expose native scrollback position through a standard API.
217
+ */
218
+ isNativeViewportAtBottom(): boolean | undefined {
219
+ if (process.platform !== "win32") return undefined;
220
+ try {
221
+ const kernel32 = dlopen("kernel32.dll", {
222
+ GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
223
+ GetConsoleScreenBufferInfo: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
224
+ });
225
+ try {
226
+ const handle = kernel32.symbols.GetStdHandle(STD_OUTPUT_HANDLE);
227
+ const info = new Uint8Array(22);
228
+ const infoPtr = ptr(info);
229
+ if (!infoPtr || !kernel32.symbols.GetConsoleScreenBufferInfo(handle, infoPtr)) return undefined;
230
+ const viewBottom = new DataView(info.buffer, info.byteOffset, info.byteLength).getInt16(16, true);
231
+ const bufferHeight = new DataView(info.buffer, info.byteOffset, info.byteLength).getInt16(2, true);
232
+ return viewBottom >= bufferHeight - 1;
233
+ } finally {
234
+ kernel32.close();
235
+ }
236
+ } catch {
237
+ return undefined;
238
+ }
239
+ }
240
+
208
241
  /**
209
242
  * On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT to the stdin console mode
210
243
  * so modified keys (for example Shift+Tab) arrive as VT escape sequences.
package/src/tui.ts CHANGED
@@ -26,6 +26,16 @@ const SEGMENT_RESET = "\x1b[0m";
26
26
  * diffing so `#previousLines` mirrors what was actually written.
27
27
  */
28
28
  const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
29
+ // Hide the hardware cursor before each paint/move write. Ghostty-style bar
30
+ // cursors can otherwise leave visual afterimages while the TUI repaints the
31
+ // row under a visible cursor. Paint writes also disable terminal autowrap:
32
+ // several terminals keep a "pending wrap" flag after an exact-width row, so a
33
+ // following cursor move can first wrap to the next row and produce staircase
34
+ // trails. The TUI emits explicit CRLFs and restores autowrap before leaving
35
+ // synchronized output mode.
36
+ const HIDE_CURSOR = "\x1b[?25l";
37
+ const PAINT_BEGIN = `${HIDE_CURSOR}\x1b[?2026h\x1b[?7l`;
38
+ const PAINT_END = "\x1b[?7h\x1b[?2026l";
29
39
 
30
40
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
31
41
  type InputListener = (data: string) => InputListenerResult;
@@ -76,6 +86,11 @@ export interface RenderRequestOptions {
76
86
  clearScrollback?: boolean;
77
87
  }
78
88
 
89
+ /** Options for deferred native scrollback rebuild checkpoints. */
90
+ export interface NativeScrollbackRefreshOptions {
91
+ /** Allow replay when the terminal cannot report viewport state. Use only for explicit user submit checkpoints. */
92
+ allowUnknownViewport?: boolean;
93
+ }
79
94
  /** Type guard to check if a component implements Focusable */
80
95
  export function isFocusable(component: Component | null): component is Component & Focusable {
81
96
  return component !== null && "focused" in component;
@@ -139,6 +154,15 @@ function isMultiplexerSession(): boolean {
139
154
  return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
140
155
  }
141
156
 
157
+ function requiresNativeViewportProofForReplay(): boolean {
158
+ return (
159
+ process.platform === "win32" ||
160
+ (process.platform === "linux" &&
161
+ Boolean(Bun.env.WT_SESSION) &&
162
+ Boolean(Bun.env.WSL_DISTRO_NAME || Bun.env.WSL_INTEROP))
163
+ );
164
+ }
165
+
142
166
  /**
143
167
  * Options for overlay positioning and sizing.
144
168
  * Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -242,6 +266,11 @@ export class Container implements Component {
242
266
  * - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
243
267
  * is set, emit those tail rows as scrollback growth first so streaming
244
268
  * output reaches terminal history before the corrected viewport is drawn.
269
+ * - `deferredShrink`: pure content shrink would re-expose rows already in
270
+ * native history. Keep row indices stable with blank tail padding, repaint
271
+ * only the viewport, and defer the real shorter replay to a checkpoint.
272
+ * - `deferredMutation`: a row-inserting edit would reindex native scrollback
273
+ * while the user is scrolled. Defer all bytes until a safe rebuild checkpoint.
245
274
  * - `shrink`: trailing rows were dropped — clear extras inline.
246
275
  * - `diff`: differential repaint of visible rows / append new rows below.
247
276
  */
@@ -251,6 +280,8 @@ type RenderIntent =
251
280
  | { kind: "sessionReplace" }
252
281
  | { kind: "historyRebuild" }
253
282
  | { kind: "viewportRepaint"; appendFrom?: number }
283
+ | { kind: "deferredShrink"; paddedLength: number }
284
+ | { kind: "deferredMutation" }
254
285
  | { kind: "shrink" }
255
286
  | { kind: "diff"; firstChanged: number; lastChanged: number; appendedLines: boolean };
256
287
 
@@ -308,9 +339,7 @@ export class TUI extends Container {
308
339
  constructor(terminal: Terminal, showHardwareCursor?: boolean) {
309
340
  super();
310
341
  this.terminal = terminal;
311
- if (showHardwareCursor !== undefined) {
312
- this.#showHardwareCursor = showHardwareCursor;
313
- }
342
+ this.#showHardwareCursor = showHardwareCursor === undefined ? this.#showHardwareCursor : showHardwareCursor;
314
343
  }
315
344
 
316
345
  get fullRedraws(): number {
@@ -634,8 +663,14 @@ export class TUI extends Container {
634
663
  * Callers should only invoke this at checkpoints where the user is expected to be
635
664
  * at the terminal bottom, such as after submitting a new prompt.
636
665
  */
637
- refreshNativeScrollbackIfDirty(): boolean {
666
+ refreshNativeScrollbackIfDirty(options?: NativeScrollbackRefreshOptions): boolean {
638
667
  if (!this.#nativeScrollbackDirty || this.#stopped) return false;
668
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
669
+ if (
670
+ !this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom, options?.allowUnknownViewport === true)
671
+ ) {
672
+ return false;
673
+ }
639
674
  this.#prepareForcedRender(true);
640
675
  this.#renderRequested = false;
641
676
  this.#lastRenderAt = performance.now();
@@ -1131,14 +1166,14 @@ export class TUI extends Container {
1131
1166
  return;
1132
1167
  case "sessionReplace":
1133
1168
  this.#clearScrollbackOnNextRender = false;
1134
- this.#nativeScrollbackDirty = false;
1169
+ this.#clearNativeScrollbackDirty();
1135
1170
  this.#emitFullPaint(lines, width, height, cursorPos, {
1136
1171
  clearViewport: true,
1137
1172
  clearScrollback: !isMultiplexerSession(),
1138
1173
  });
1139
1174
  return;
1140
1175
  case "historyRebuild":
1141
- this.#nativeScrollbackDirty = false;
1176
+ this.#clearNativeScrollbackDirty();
1142
1177
  this.#emitFullPaint(lines, width, height, cursorPos, {
1143
1178
  clearViewport: true,
1144
1179
  clearScrollback: !isMultiplexerSession(),
@@ -1150,6 +1185,16 @@ export class TUI extends Container {
1150
1185
  }
1151
1186
  this.#emitViewportRepaint(lines, width, height, cursorPos);
1152
1187
  return;
1188
+ case "deferredMutation":
1189
+ return;
1190
+ case "deferredShrink":
1191
+ this.#emitViewportRepaint(
1192
+ this.#padDeferredShrinkLines(lines, intent.paddedLength),
1193
+ width,
1194
+ height,
1195
+ cursorPos,
1196
+ );
1197
+ return;
1153
1198
  case "shrink":
1154
1199
  this.#emitShrink(lines, width, height, cursorPos, prevHardwareCursorRow, prevViewportTop);
1155
1200
  return;
@@ -1196,14 +1241,19 @@ export class TUI extends Container {
1196
1241
  // lines were dropped, so no diff is possible. Repaint visible rows only
1197
1242
  // — emitting the transcript here would duplicate it into scrollback.
1198
1243
  if (this.#previousLines.length === 0) return { kind: "viewportRepaint" };
1244
+ if (this.#nativeScrollbackDirty && this.#nativeViewportIsAtBottom(this.#readNativeViewportAtBottom())) {
1245
+ return { kind: "historyRebuild" };
1246
+ }
1199
1247
 
1200
1248
  const diff = this.#diffLines(newLines);
1201
-
1202
1249
  // Shrink across the viewport boundary: the new transcript would re-expose
1203
1250
  // rows already committed to native scrollback. A real resize already
1204
1251
  // reflowed history, so rebuild it now; a pure content shrink (e.g. a
1205
- // streaming tail cell collapsing) defers the clear+replay so a user
1206
- // scrolled into history is not yanked to the bottom mid-stream.
1252
+ // streaming tail cell collapsing) defers the clear+replay. When the terminal
1253
+ // can report that the user is scrolled into history, the live repaint keeps
1254
+ // the previous row count with blank tail padding; otherwise cursor-home
1255
+ // repainting rewrites old buffer rows with newly bottom-anchored content,
1256
+ // which looks like a jump upward.
1207
1257
  const naturalViewportTop = Math.max(0, newLines.length - height);
1208
1258
  if (
1209
1259
  diff.firstChanged !== -1 &&
@@ -1211,8 +1261,17 @@ export class TUI extends Container {
1211
1261
  naturalViewportTop < this.#scrollbackHighWater &&
1212
1262
  !isMultiplexerSession()
1213
1263
  ) {
1214
- if (widthChanged || heightChanged) return { kind: "historyRebuild" };
1215
- this.#nativeScrollbackDirty = true;
1264
+ if (widthChanged || heightChanged) {
1265
+ if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) {
1266
+ this.#markNativeScrollbackDirty();
1267
+ return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1268
+ }
1269
+ return { kind: "historyRebuild" };
1270
+ }
1271
+ this.#markNativeScrollbackDirty();
1272
+ if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) {
1273
+ return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1274
+ }
1216
1275
  return { kind: "viewportRepaint" };
1217
1276
  }
1218
1277
 
@@ -1241,12 +1300,26 @@ export class TUI extends Container {
1241
1300
  // reflowed and the user is at the terminal to resize. Pure appends fall
1242
1301
  // through to the diff path so the append handler scrolls them into history.
1243
1302
  if (widthChanged) {
1244
- if (diff.firstChanged < prevViewportTop) return { kind: "historyRebuild" };
1303
+ if (diff.firstChanged < prevViewportTop) {
1304
+ if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) {
1305
+ this.#markNativeScrollbackDirty();
1306
+ return { kind: "viewportRepaint" };
1307
+ }
1308
+ return { kind: "historyRebuild" };
1309
+ }
1245
1310
  const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
1246
1311
  if (!pureAppend) return { kind: "viewportRepaint" };
1247
1312
  }
1248
1313
 
1249
1314
  const contentGrew = newLines.length > this.#previousLines.length;
1315
+ const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
1316
+ const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
1317
+ if (!pureAppend && structuralMutation && !isMultiplexerSession()) {
1318
+ if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) {
1319
+ this.#markNativeScrollbackDirty();
1320
+ return { kind: "deferredMutation" };
1321
+ }
1322
+ }
1250
1323
 
1251
1324
  // Height changes shift the visible window. Repaint when content didn't
1252
1325
  // grow, but skip in Termux (software keyboard toggles height) and inside
@@ -1266,9 +1339,8 @@ export class TUI extends Container {
1266
1339
  return { kind: "shrink" };
1267
1340
  }
1268
1341
 
1269
- // Offscreen edit: viewport repaint corrects the shifted rows. If new
1270
- // rows also appended in the same frame, emit them as scrollback growth
1271
- // first so streaming output is not lost from terminal history.
1342
+ // Offscreen edit: viewport repaint corrects shifted rows when the native
1343
+ // viewport is at the tail. Scrolled native-history cases are deferred above.
1272
1344
  if (diff.firstChanged < prevViewportTop) {
1273
1345
  const appendFrom = diff.appendedLines ? this.#findAppendedTailStart(newLines) : undefined;
1274
1346
  return { kind: "viewportRepaint", appendFrom };
@@ -1337,6 +1409,43 @@ export class TUI extends Container {
1337
1409
  return bestEnd === -1 ? newLines.length : bestEnd + 1;
1338
1410
  }
1339
1411
 
1412
+ #markNativeScrollbackDirty(): void {
1413
+ this.#nativeScrollbackDirty = true;
1414
+ }
1415
+
1416
+ #clearNativeScrollbackDirty(): void {
1417
+ this.#nativeScrollbackDirty = false;
1418
+ }
1419
+
1420
+ #readNativeViewportAtBottom(): boolean | undefined {
1421
+ return this.terminal.isNativeViewportAtBottom?.();
1422
+ }
1423
+
1424
+ #nativeViewportIsScrolled(nativeViewportAtBottom: boolean | undefined): boolean {
1425
+ return (
1426
+ nativeViewportAtBottom === false ||
1427
+ (nativeViewportAtBottom === undefined && requiresNativeViewportProofForReplay())
1428
+ );
1429
+ }
1430
+
1431
+ #nativeViewportIsAtBottom(nativeViewportAtBottom: boolean | undefined): boolean {
1432
+ return nativeViewportAtBottom === true;
1433
+ }
1434
+
1435
+ #canReplayNativeScrollbackAtCheckpoint(
1436
+ nativeViewportAtBottom: boolean | undefined,
1437
+ allowUnknownViewport: boolean,
1438
+ ): boolean {
1439
+ return (
1440
+ nativeViewportAtBottom === true ||
1441
+ (nativeViewportAtBottom === undefined && (allowUnknownViewport || !requiresNativeViewportProofForReplay()))
1442
+ );
1443
+ }
1444
+
1445
+ #padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
1446
+ if (lines.length >= paddedLength) return lines;
1447
+ return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
1448
+ }
1340
1449
  /**
1341
1450
  * Truncate a line to the visible viewport width. Image lines are left
1342
1451
  * alone, narrow lines pass through unchanged. Truncation re-appends the
@@ -1376,7 +1485,7 @@ export class TUI extends Container {
1376
1485
  options: { clearViewport: boolean; clearScrollback: boolean },
1377
1486
  ): void {
1378
1487
  this.#fullRedrawCount += 1;
1379
- let buffer = "\x1b[?2026h";
1488
+ let buffer = PAINT_BEGIN;
1380
1489
  if (options.clearViewport) {
1381
1490
  buffer += options.clearScrollback ? "\x1b[2J\x1b[H\x1b[3J" : "\x1b[2J\x1b[H";
1382
1491
  }
@@ -1387,7 +1496,7 @@ export class TUI extends Container {
1387
1496
  const finalRow = Math.max(0, lines.length - 1);
1388
1497
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
1389
1498
  buffer += seq;
1390
- buffer += "\x1b[?2026l";
1499
+ buffer += PAINT_END;
1391
1500
  this.terminal.write(buffer);
1392
1501
 
1393
1502
  this.#maxLinesRendered = options.clearViewport ? lines.length : Math.max(this.#maxLinesRendered, lines.length);
@@ -1414,7 +1523,7 @@ export class TUI extends Container {
1414
1523
  ): void {
1415
1524
  this.#fullRedrawCount += 1;
1416
1525
  const viewportTop = Math.max(0, lines.length - height);
1417
- let buffer = "\x1b[?2026h\x1b[H";
1526
+ let buffer = `${PAINT_BEGIN}\x1b[H`;
1418
1527
  for (let screenRow = 0; screenRow < height; screenRow++) {
1419
1528
  if (screenRow > 0) buffer += "\r\n";
1420
1529
  buffer += "\x1b[2K";
@@ -1431,7 +1540,7 @@ export class TUI extends Container {
1431
1540
  const finalRow = viewportTop + height - 1;
1432
1541
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
1433
1542
  buffer += seq;
1434
- buffer += "\x1b[?2026l";
1543
+ buffer += PAINT_END;
1435
1544
  this.terminal.write(buffer);
1436
1545
 
1437
1546
  this.#maxLinesRendered = lines.length;
@@ -1452,7 +1561,7 @@ export class TUI extends Container {
1452
1561
  prevHardwareCursorRow: number,
1453
1562
  ): void {
1454
1563
  if (start >= lines.length) return;
1455
- let buffer = "\x1b[?2026h";
1564
+ let buffer = PAINT_BEGIN;
1456
1565
  // Clamp tracked cursor to the visible viewport bottom — terminals clamp
1457
1566
  // on resize, so a prior frame may have committed a row that no longer
1458
1567
  // exists. Without this the scroll math points outside the viewport.
@@ -1464,7 +1573,7 @@ export class TUI extends Container {
1464
1573
  buffer += "\r\n";
1465
1574
  buffer += lines[i];
1466
1575
  }
1467
- buffer += "\x1b[?2026l";
1576
+ buffer += PAINT_END;
1468
1577
  this.terminal.write(buffer);
1469
1578
  const pushedNow = Math.max(0, lines.length - height);
1470
1579
  if (pushedNow > this.#scrollbackHighWater) {
@@ -1500,7 +1609,7 @@ export class TUI extends Container {
1500
1609
  const viewportTop = Math.max(0, this.#maxLinesRendered - height);
1501
1610
  const targetRow = Math.max(0, lines.length - 1);
1502
1611
 
1503
- let buffer = "\x1b[?2026h";
1612
+ let buffer = PAINT_BEGIN;
1504
1613
 
1505
1614
  const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
1506
1615
  const currentScreenRow = clampedCursor - prevViewportTop;
@@ -1525,7 +1634,7 @@ export class TUI extends Container {
1525
1634
 
1526
1635
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, targetRow);
1527
1636
  buffer += seq;
1528
- buffer += "\x1b[?2026l";
1637
+ buffer += PAINT_END;
1529
1638
  this.terminal.write(buffer);
1530
1639
 
1531
1640
  this.#maxLinesRendered = lines.length;
@@ -1559,7 +1668,7 @@ export class TUI extends Container {
1559
1668
  const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
1560
1669
  const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
1561
1670
 
1562
- let buffer = "\x1b[?2026h";
1671
+ let buffer = PAINT_BEGIN;
1563
1672
 
1564
1673
  // Scroll-down branch: target row is past the bottom of the previous
1565
1674
  // viewport (a pure append). Emit `\r\n`s so the terminal pushes the
@@ -1610,7 +1719,7 @@ export class TUI extends Container {
1610
1719
 
1611
1720
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalCursorRow);
1612
1721
  buffer += seq;
1613
- buffer += "\x1b[?2026l";
1722
+ buffer += PAINT_END;
1614
1723
 
1615
1724
  this.#writeDiffDebug(
1616
1725
  lines,
@@ -1740,6 +1849,6 @@ export class TUI extends Container {
1740
1849
  }
1741
1850
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
1742
1851
  this.#hardwareCursorRow = toRow;
1743
- this.terminal.write(`\x1b[?2026h${seq}\x1b[?2026l`);
1852
+ this.terminal.write(`${HIDE_CURSOR}\x1b[?2026h${seq}\x1b[?2026l`);
1744
1853
  }
1745
1854
  }
package/src/utils.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  extractSegments as nativeExtractSegments,
5
5
  sliceWithWidth as nativeSliceWithWidth,
6
6
  truncateToWidth as nativeTruncateToWidth,
7
+ visibleWidth as nativeVisibleWidth,
7
8
  wrapTextWithAnsi as nativeWrapTextWithAnsi,
8
9
  type SliceResult,
9
10
  } from "@oh-my-pi/pi-natives";
@@ -96,30 +97,17 @@ export function visibleWidthRaw(str: string): number {
96
97
  return 0;
97
98
  }
98
99
 
99
- // Fast path: pure ASCII printable
100
- let tabLength = 0;
101
- const tabWidth = getDefaultTabWidth();
102
- let isPureAscii = true;
103
- let jamoOvercount = 0;
104
- const isMacOS = process.platform === "darwin";
100
+ // Fast path: printable ASCII has one cell per code unit. Defer every
101
+ // control/non-ASCII case (tabs, ANSI/OSC, combining marks, CJK) to the
102
+ // native text engine so all width/slice/wrap helpers share one Unicode
103
+ // model instead of mixing Bun.stringWidth quirks with Rust truncation.
105
104
  for (let i = 0; i < str.length; i++) {
106
105
  const code = str.charCodeAt(i);
107
- if (code === 9) {
108
- tabLength += tabWidth;
109
- } else if (code < 0x20 || code > 0x7e) {
110
- isPureAscii = false;
111
- // Hangul Compatibility Jamo (U+3131..U+318E) is EAW=W per UAX#11,
112
- // but macOS terminals render them as 1 cell. WezTerm and others
113
- // follow UAX#11 at 2 cells. Only correct on macOS.
114
- if (isMacOS && code >= 0x3131 && code <= 0x318e) {
115
- jamoOvercount++;
116
- }
106
+ if (code < 0x20 || code > 0x7e) {
107
+ return nativeVisibleWidth(str, getDefaultTabWidth());
117
108
  }
118
109
  }
119
- if (isPureAscii) {
120
- return str.length + tabLength;
121
- }
122
- return Bun.stringWidth(str) - jamoOvercount + tabLength;
110
+ return str.length;
123
111
  }
124
112
 
125
113
  /**