@oh-my-pi/pi-tui 15.5.13 → 15.6.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,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.6.0] - 2026-05-30
6
+ ### Added
7
+
8
+ - Added autocomplete triggering for internal URL scheme tokens such as `local://` and `skill://` while typing in the editor
9
+
10
+ ### Fixed
11
+
12
+ - Fixed streaming output staying invisible in Windows Terminal + WSL2 until the window was minimized + restored. The 15.5.14 WSL branch of `requiresNativeViewportProofForReplay` treated an unknown native viewport state as "scrolled into history" — but `ProcessTerminal.isNativeViewportAtBottom` can only return a real answer through `kernel32.dll` FFI, which a Linux user-space process inside WSL cannot load, so the probe was permanently `undefined`. Every row-inserting structural mutation (each new streaming token row above the bottom-anchored prompt) was therefore classified as `deferredMutation` and emitted zero bytes. Any geometry change (resize/minimize/restore) bypassed the gate via a different render intent, which is why the output became visible only on window resize. The WSL clause is removed; on platforms where the probe cannot answer, unknown is treated as at-bottom (the pre-15.5.14 behaviour) so the live render path runs again. Native Win32 keeps the conservative "assume scrolled when unknown" heuristic since `kernel32` FFI does succeed there and unknown means the probe transiently failed. ([#1534](https://github.com/can1357/oh-my-pi/issues/1534))
13
+
14
+ ## [15.5.14] - 2026-05-29
15
+
16
+ ### Added
17
+
18
+ - `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.
19
+
20
+ ### Fixed
21
+
22
+ - 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.
23
+
5
24
  ## [15.5.12] - 2026-05-29
6
25
 
7
26
  ### 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.6.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,10 +37,10 @@
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",
42
- "lru-cache": "11.3.6",
43
- "marked": "^18.0.3"
40
+ "@oh-my-pi/pi-natives": "15.6.0",
41
+ "@oh-my-pi/pi-utils": "15.6.0",
42
+ "lru-cache": "11.5.1",
43
+ "marked": "^18.0.4"
44
44
  },
45
45
  "devDependencies": {
46
46
  "chalk": "^5.6.2",
@@ -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...
@@ -1581,6 +1571,10 @@ export class Editor implements Component, Focusable {
1581
1571
  else if (textBeforeCursor.match(/(?:^|[\s([{>]):[a-zA-Z0-9_+-]*$/)) {
1582
1572
  this.#tryTriggerAutocomplete();
1583
1573
  }
1574
+ // Check if we're typing an internal URL scheme (e.g. local://, skill://)
1575
+ else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
1576
+ this.#tryTriggerAutocomplete();
1577
+ }
1584
1578
  }
1585
1579
  } else {
1586
1580
  this.#debouncedUpdateAutocomplete();
@@ -1772,6 +1766,10 @@ export class Editor implements Component, Focusable {
1772
1766
  else if (textBeforeCursor.match(/#[^\s#]*$/)) {
1773
1767
  this.#tryTriggerAutocomplete();
1774
1768
  }
1769
+ // internal URL scheme context (e.g. local://, skill://)
1770
+ else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
1771
+ this.#tryTriggerAutocomplete();
1772
+ }
1775
1773
  }
1776
1774
  }
1777
1775
 
@@ -1923,6 +1921,8 @@ export class Editor implements Component, Focusable {
1923
1921
  this.#tryTriggerAutocomplete();
1924
1922
  } else if (textBeforeCursor.match(/#[^\s#]*$/)) {
1925
1923
  this.#tryTriggerAutocomplete();
1924
+ } else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
1925
+ this.#tryTriggerAutocomplete();
1926
1926
  }
1927
1927
  }
1928
1928
  }
@@ -2242,6 +2242,10 @@ export class Editor implements Component, Focusable {
2242
2242
  else if (textBeforeCursor.match(/#[^\s#]*$/)) {
2243
2243
  this.#tryTriggerAutocomplete();
2244
2244
  }
2245
+ // internal URL scheme context (e.g. local://, skill://)
2246
+ else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
2247
+ this.#tryTriggerAutocomplete();
2248
+ }
2245
2249
  }
2246
2250
  }
2247
2251
 
@@ -2473,6 +2477,17 @@ export class Editor implements Component, Focusable {
2473
2477
  }
2474
2478
 
2475
2479
  // Autocomplete methods
2480
+ /**
2481
+ * Whether the text ending at the cursor looks like a `scheme://` URL token.
2482
+ * Generic by design: any scheme triggers a suggestion fetch and the active
2483
+ * provider decides whether it has candidates (returning none is a no-op).
2484
+ * MUST stay in sync with the token grammar in coding-agent's
2485
+ * `internal-url-autocomplete.ts`.
2486
+ */
2487
+ #textTriggersUrlAutocomplete(textBeforeCursor: string): boolean {
2488
+ return /(?:^|[\s"'`(<=])[a-z][a-z0-9+.-]*:\/{1,2}[^\s"'`()<>]*$/i.test(textBeforeCursor);
2489
+ }
2490
+
2476
2491
  async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
2477
2492
  if (!this.#autocompleteProvider) return;
2478
2493
  // Check if we should trigger file completion on Tab
@@ -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;
@@ -242,6 +257,11 @@ export class Container implements Component {
242
257
  * - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
243
258
  * is set, emit those tail rows as scrollback growth first so streaming
244
259
  * output reaches terminal history before the corrected viewport is drawn.
260
+ * - `deferredShrink`: pure content shrink would re-expose rows already in
261
+ * native history. Keep row indices stable with blank tail padding, repaint
262
+ * only the viewport, and defer the real shorter replay to a checkpoint.
263
+ * - `deferredMutation`: a row-inserting edit would reindex native scrollback
264
+ * while the user is scrolled. Defer all bytes until a safe rebuild checkpoint.
245
265
  * - `shrink`: trailing rows were dropped — clear extras inline.
246
266
  * - `diff`: differential repaint of visible rows / append new rows below.
247
267
  */
@@ -251,6 +271,8 @@ type RenderIntent =
251
271
  | { kind: "sessionReplace" }
252
272
  | { kind: "historyRebuild" }
253
273
  | { kind: "viewportRepaint"; appendFrom?: number }
274
+ | { kind: "deferredShrink"; paddedLength: number }
275
+ | { kind: "deferredMutation" }
254
276
  | { kind: "shrink" }
255
277
  | { kind: "diff"; firstChanged: number; lastChanged: number; appendedLines: boolean };
256
278
 
@@ -308,9 +330,7 @@ export class TUI extends Container {
308
330
  constructor(terminal: Terminal, showHardwareCursor?: boolean) {
309
331
  super();
310
332
  this.terminal = terminal;
311
- if (showHardwareCursor !== undefined) {
312
- this.#showHardwareCursor = showHardwareCursor;
313
- }
333
+ this.#showHardwareCursor = showHardwareCursor === undefined ? this.#showHardwareCursor : showHardwareCursor;
314
334
  }
315
335
 
316
336
  get fullRedraws(): number {
@@ -634,8 +654,14 @@ export class TUI extends Container {
634
654
  * Callers should only invoke this at checkpoints where the user is expected to be
635
655
  * at the terminal bottom, such as after submitting a new prompt.
636
656
  */
637
- refreshNativeScrollbackIfDirty(): boolean {
657
+ refreshNativeScrollbackIfDirty(options?: NativeScrollbackRefreshOptions): boolean {
638
658
  if (!this.#nativeScrollbackDirty || this.#stopped) return false;
659
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
660
+ if (
661
+ !this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom, options?.allowUnknownViewport === true)
662
+ ) {
663
+ return false;
664
+ }
639
665
  this.#prepareForcedRender(true);
640
666
  this.#renderRequested = false;
641
667
  this.#lastRenderAt = performance.now();
@@ -1131,14 +1157,14 @@ export class TUI extends Container {
1131
1157
  return;
1132
1158
  case "sessionReplace":
1133
1159
  this.#clearScrollbackOnNextRender = false;
1134
- this.#nativeScrollbackDirty = false;
1160
+ this.#clearNativeScrollbackDirty();
1135
1161
  this.#emitFullPaint(lines, width, height, cursorPos, {
1136
1162
  clearViewport: true,
1137
1163
  clearScrollback: !isMultiplexerSession(),
1138
1164
  });
1139
1165
  return;
1140
1166
  case "historyRebuild":
1141
- this.#nativeScrollbackDirty = false;
1167
+ this.#clearNativeScrollbackDirty();
1142
1168
  this.#emitFullPaint(lines, width, height, cursorPos, {
1143
1169
  clearViewport: true,
1144
1170
  clearScrollback: !isMultiplexerSession(),
@@ -1150,6 +1176,16 @@ export class TUI extends Container {
1150
1176
  }
1151
1177
  this.#emitViewportRepaint(lines, width, height, cursorPos);
1152
1178
  return;
1179
+ case "deferredMutation":
1180
+ return;
1181
+ case "deferredShrink":
1182
+ this.#emitViewportRepaint(
1183
+ this.#padDeferredShrinkLines(lines, intent.paddedLength),
1184
+ width,
1185
+ height,
1186
+ cursorPos,
1187
+ );
1188
+ return;
1153
1189
  case "shrink":
1154
1190
  this.#emitShrink(lines, width, height, cursorPos, prevHardwareCursorRow, prevViewportTop);
1155
1191
  return;
@@ -1196,14 +1232,19 @@ export class TUI extends Container {
1196
1232
  // lines were dropped, so no diff is possible. Repaint visible rows only
1197
1233
  // — emitting the transcript here would duplicate it into scrollback.
1198
1234
  if (this.#previousLines.length === 0) return { kind: "viewportRepaint" };
1235
+ if (this.#nativeScrollbackDirty && this.#nativeViewportIsAtBottom(this.#readNativeViewportAtBottom())) {
1236
+ return { kind: "historyRebuild" };
1237
+ }
1199
1238
 
1200
1239
  const diff = this.#diffLines(newLines);
1201
-
1202
1240
  // Shrink across the viewport boundary: the new transcript would re-expose
1203
1241
  // rows already committed to native scrollback. A real resize already
1204
1242
  // 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.
1243
+ // streaming tail cell collapsing) defers the clear+replay. When the terminal
1244
+ // can report that the user is scrolled into history, the live repaint keeps
1245
+ // the previous row count with blank tail padding; otherwise cursor-home
1246
+ // repainting rewrites old buffer rows with newly bottom-anchored content,
1247
+ // which looks like a jump upward.
1207
1248
  const naturalViewportTop = Math.max(0, newLines.length - height);
1208
1249
  if (
1209
1250
  diff.firstChanged !== -1 &&
@@ -1211,8 +1252,17 @@ export class TUI extends Container {
1211
1252
  naturalViewportTop < this.#scrollbackHighWater &&
1212
1253
  !isMultiplexerSession()
1213
1254
  ) {
1214
- if (widthChanged || heightChanged) return { kind: "historyRebuild" };
1215
- this.#nativeScrollbackDirty = true;
1255
+ if (widthChanged || heightChanged) {
1256
+ if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) {
1257
+ this.#markNativeScrollbackDirty();
1258
+ return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1259
+ }
1260
+ return { kind: "historyRebuild" };
1261
+ }
1262
+ this.#markNativeScrollbackDirty();
1263
+ if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) {
1264
+ return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
1265
+ }
1216
1266
  return { kind: "viewportRepaint" };
1217
1267
  }
1218
1268
 
@@ -1241,12 +1291,44 @@ export class TUI extends Container {
1241
1291
  // reflowed and the user is at the terminal to resize. Pure appends fall
1242
1292
  // through to the diff path so the append handler scrolls them into history.
1243
1293
  if (widthChanged) {
1244
- if (diff.firstChanged < prevViewportTop) return { kind: "historyRebuild" };
1294
+ if (diff.firstChanged < prevViewportTop) {
1295
+ if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) {
1296
+ this.#markNativeScrollbackDirty();
1297
+ return { kind: "viewportRepaint" };
1298
+ }
1299
+ return { kind: "historyRebuild" };
1300
+ }
1245
1301
  const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
1246
1302
  if (!pureAppend) return { kind: "viewportRepaint" };
1247
1303
  }
1248
1304
 
1249
1305
  const contentGrew = newLines.length > this.#previousLines.length;
1306
+ const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
1307
+ const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop;
1308
+ if (!pureAppend && structuralMutation && !isMultiplexerSession()) {
1309
+ const nativeViewportAtBottom = this.#readNativeViewportAtBottom();
1310
+ if (this.#nativeViewportIsScrolled(nativeViewportAtBottom)) {
1311
+ this.#markNativeScrollbackDirty();
1312
+ return { kind: "deferredMutation" };
1313
+ }
1314
+ // Expanding a collapsed offscreen cell inserts rows before an unchanged
1315
+ // suffix. A viewport-only repaint makes the live bottom look correct but
1316
+ // leaves native scrollback holding the old collapsed rows; scrolling up then
1317
+ // shows a splice of stale history and the new tail. Pure tail appends with an
1318
+ // offscreen status/header tick are still handled by the append-tail path.
1319
+ if (
1320
+ contentGrew &&
1321
+ diff.firstChanged < prevViewportTop &&
1322
+ this.#canReplayNativeScrollbackAtCheckpoint(nativeViewportAtBottom, false)
1323
+ ) {
1324
+ const appendedTailStart = diff.appendedLines ? this.#findAppendedTailStart(newLines) : newLines.length;
1325
+ const tailAppendCount = newLines.length - appendedTailStart;
1326
+ const addedCount = newLines.length - this.#previousLines.length;
1327
+ if (addedCount > tailAppendCount) {
1328
+ return { kind: "historyRebuild" };
1329
+ }
1330
+ }
1331
+ }
1250
1332
 
1251
1333
  // Height changes shift the visible window. Repaint when content didn't
1252
1334
  // grow, but skip in Termux (software keyboard toggles height) and inside
@@ -1266,9 +1348,8 @@ export class TUI extends Container {
1266
1348
  return { kind: "shrink" };
1267
1349
  }
1268
1350
 
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.
1351
+ // Offscreen edit: viewport repaint corrects shifted rows when the native
1352
+ // viewport is at the tail. Scrolled native-history cases are deferred above.
1272
1353
  if (diff.firstChanged < prevViewportTop) {
1273
1354
  const appendFrom = diff.appendedLines ? this.#findAppendedTailStart(newLines) : undefined;
1274
1355
  return { kind: "viewportRepaint", appendFrom };
@@ -1337,6 +1418,40 @@ export class TUI extends Container {
1337
1418
  return bestEnd === -1 ? newLines.length : bestEnd + 1;
1338
1419
  }
1339
1420
 
1421
+ #markNativeScrollbackDirty(): void {
1422
+ this.#nativeScrollbackDirty = true;
1423
+ }
1424
+
1425
+ #clearNativeScrollbackDirty(): void {
1426
+ this.#nativeScrollbackDirty = false;
1427
+ }
1428
+
1429
+ #readNativeViewportAtBottom(): boolean | undefined {
1430
+ return this.terminal.isNativeViewportAtBottom?.();
1431
+ }
1432
+
1433
+ #nativeViewportIsScrolled(nativeViewportAtBottom: boolean | undefined): boolean {
1434
+ return nativeViewportAtBottom === false || (nativeViewportAtBottom === undefined && process.platform === "win32");
1435
+ }
1436
+
1437
+ #nativeViewportIsAtBottom(nativeViewportAtBottom: boolean | undefined): boolean {
1438
+ return nativeViewportAtBottom === true;
1439
+ }
1440
+
1441
+ #canReplayNativeScrollbackAtCheckpoint(
1442
+ nativeViewportAtBottom: boolean | undefined,
1443
+ allowUnknownViewport: boolean,
1444
+ ): boolean {
1445
+ return (
1446
+ nativeViewportAtBottom === true ||
1447
+ (nativeViewportAtBottom === undefined && (allowUnknownViewport || process.platform !== "win32"))
1448
+ );
1449
+ }
1450
+
1451
+ #padDeferredShrinkLines(lines: string[], paddedLength: number): string[] {
1452
+ if (lines.length >= paddedLength) return lines;
1453
+ return [...lines, ...new Array<string>(paddedLength - lines.length).fill("")];
1454
+ }
1340
1455
  /**
1341
1456
  * Truncate a line to the visible viewport width. Image lines are left
1342
1457
  * alone, narrow lines pass through unchanged. Truncation re-appends the
@@ -1376,7 +1491,7 @@ export class TUI extends Container {
1376
1491
  options: { clearViewport: boolean; clearScrollback: boolean },
1377
1492
  ): void {
1378
1493
  this.#fullRedrawCount += 1;
1379
- let buffer = "\x1b[?2026h";
1494
+ let buffer = PAINT_BEGIN;
1380
1495
  if (options.clearViewport) {
1381
1496
  buffer += options.clearScrollback ? "\x1b[2J\x1b[H\x1b[3J" : "\x1b[2J\x1b[H";
1382
1497
  }
@@ -1387,7 +1502,7 @@ export class TUI extends Container {
1387
1502
  const finalRow = Math.max(0, lines.length - 1);
1388
1503
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
1389
1504
  buffer += seq;
1390
- buffer += "\x1b[?2026l";
1505
+ buffer += PAINT_END;
1391
1506
  this.terminal.write(buffer);
1392
1507
 
1393
1508
  this.#maxLinesRendered = options.clearViewport ? lines.length : Math.max(this.#maxLinesRendered, lines.length);
@@ -1414,7 +1529,7 @@ export class TUI extends Container {
1414
1529
  ): void {
1415
1530
  this.#fullRedrawCount += 1;
1416
1531
  const viewportTop = Math.max(0, lines.length - height);
1417
- let buffer = "\x1b[?2026h\x1b[H";
1532
+ let buffer = `${PAINT_BEGIN}\x1b[H`;
1418
1533
  for (let screenRow = 0; screenRow < height; screenRow++) {
1419
1534
  if (screenRow > 0) buffer += "\r\n";
1420
1535
  buffer += "\x1b[2K";
@@ -1431,7 +1546,7 @@ export class TUI extends Container {
1431
1546
  const finalRow = viewportTop + height - 1;
1432
1547
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
1433
1548
  buffer += seq;
1434
- buffer += "\x1b[?2026l";
1549
+ buffer += PAINT_END;
1435
1550
  this.terminal.write(buffer);
1436
1551
 
1437
1552
  this.#maxLinesRendered = lines.length;
@@ -1452,7 +1567,7 @@ export class TUI extends Container {
1452
1567
  prevHardwareCursorRow: number,
1453
1568
  ): void {
1454
1569
  if (start >= lines.length) return;
1455
- let buffer = "\x1b[?2026h";
1570
+ let buffer = PAINT_BEGIN;
1456
1571
  // Clamp tracked cursor to the visible viewport bottom — terminals clamp
1457
1572
  // on resize, so a prior frame may have committed a row that no longer
1458
1573
  // exists. Without this the scroll math points outside the viewport.
@@ -1464,7 +1579,7 @@ export class TUI extends Container {
1464
1579
  buffer += "\r\n";
1465
1580
  buffer += lines[i];
1466
1581
  }
1467
- buffer += "\x1b[?2026l";
1582
+ buffer += PAINT_END;
1468
1583
  this.terminal.write(buffer);
1469
1584
  const pushedNow = Math.max(0, lines.length - height);
1470
1585
  if (pushedNow > this.#scrollbackHighWater) {
@@ -1500,7 +1615,7 @@ export class TUI extends Container {
1500
1615
  const viewportTop = Math.max(0, this.#maxLinesRendered - height);
1501
1616
  const targetRow = Math.max(0, lines.length - 1);
1502
1617
 
1503
- let buffer = "\x1b[?2026h";
1618
+ let buffer = PAINT_BEGIN;
1504
1619
 
1505
1620
  const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
1506
1621
  const currentScreenRow = clampedCursor - prevViewportTop;
@@ -1525,7 +1640,7 @@ export class TUI extends Container {
1525
1640
 
1526
1641
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, targetRow);
1527
1642
  buffer += seq;
1528
- buffer += "\x1b[?2026l";
1643
+ buffer += PAINT_END;
1529
1644
  this.terminal.write(buffer);
1530
1645
 
1531
1646
  this.#maxLinesRendered = lines.length;
@@ -1559,7 +1674,7 @@ export class TUI extends Container {
1559
1674
  const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
1560
1675
  const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
1561
1676
 
1562
- let buffer = "\x1b[?2026h";
1677
+ let buffer = PAINT_BEGIN;
1563
1678
 
1564
1679
  // Scroll-down branch: target row is past the bottom of the previous
1565
1680
  // viewport (a pure append). Emit `\r\n`s so the terminal pushes the
@@ -1610,7 +1725,7 @@ export class TUI extends Container {
1610
1725
 
1611
1726
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalCursorRow);
1612
1727
  buffer += seq;
1613
- buffer += "\x1b[?2026l";
1728
+ buffer += PAINT_END;
1614
1729
 
1615
1730
  this.#writeDiffDebug(
1616
1731
  lines,
@@ -1740,6 +1855,6 @@ export class TUI extends Container {
1740
1855
  }
1741
1856
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
1742
1857
  this.#hardwareCursorRow = toRow;
1743
- this.terminal.write(`\x1b[?2026h${seq}\x1b[?2026l`);
1858
+ this.terminal.write(`${HIDE_CURSOR}\x1b[?2026h${seq}\x1b[?2026l`);
1744
1859
  }
1745
1860
  }
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";
@@ -84,6 +85,44 @@ export function padding(n: number): string {
84
85
  // Grapheme segmenter (shared instance)
85
86
  const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
86
87
 
88
+ const EXTENDED_PICTOGRAPHIC_REGEX = /\p{Extended_Pictographic}/u;
89
+
90
+ // Matches CSI (`\x1b[…`) and OSC (`\x1b]…` terminated by BEL/ST) escape
91
+ // sequences. Mirrors the standard ansi-regex coverage so visible-span
92
+ // segmentation lines up with the native ANSI scanner.
93
+ const ANSI_ESCAPE_REGEX =
94
+ /[\u001b\u009b][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
95
+
96
+ function pictographicSpanWidth(span: string): number {
97
+ let width = 0;
98
+ for (const { segment } of segmenter.segment(span)) {
99
+ width += EXTENDED_PICTOGRAPHIC_REGEX.test(segment) ? 2 : nativeVisibleWidth(segment, getDefaultTabWidth());
100
+ }
101
+ return width;
102
+ }
103
+
104
+ // Width fallback for strings that mix ANSI styling with ZWJ pictographic
105
+ // emoji. `Intl.Segmenter` would split an escape sequence into individual
106
+ // graphemes, so the native scanner (which only skips ANSI when handed the
107
+ // complete sequence) double-counts the printable SGR bytes. Excise the ANSI
108
+ // spans first — they contribute zero cells — and apply the pictographic
109
+ // grapheme override only to the visible spans, then sum.
110
+ function visibleWidthByGrapheme(str: string): number {
111
+ let width = 0;
112
+ let lastIndex = 0;
113
+ ANSI_ESCAPE_REGEX.lastIndex = 0;
114
+ for (let match = ANSI_ESCAPE_REGEX.exec(str); match !== null; match = ANSI_ESCAPE_REGEX.exec(str)) {
115
+ if (match.index > lastIndex) {
116
+ width += pictographicSpanWidth(str.slice(lastIndex, match.index));
117
+ }
118
+ lastIndex = ANSI_ESCAPE_REGEX.lastIndex;
119
+ }
120
+ if (lastIndex < str.length) {
121
+ width += lastIndex === 0 ? pictographicSpanWidth(str) : pictographicSpanWidth(str.slice(lastIndex));
122
+ }
123
+ return width;
124
+ }
125
+
87
126
  /**
88
127
  * Get the shared grapheme segmenter instance.
89
128
  */
@@ -96,30 +135,17 @@ export function visibleWidthRaw(str: string): number {
96
135
  return 0;
97
136
  }
98
137
 
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";
138
+ // Fast path: printable ASCII has one cell per code unit. Defer every
139
+ // control/non-ASCII case (tabs, ANSI/OSC, combining marks, CJK) to the
140
+ // native text engine so all width/slice/wrap helpers share one Unicode
141
+ // model instead of mixing Bun.stringWidth quirks with Rust truncation.
105
142
  for (let i = 0; i < str.length; i++) {
106
143
  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
- }
144
+ if (code < 0x20 || code > 0x7e) {
145
+ return str.includes("\u200d") ? visibleWidthByGrapheme(str) : nativeVisibleWidth(str, getDefaultTabWidth());
117
146
  }
118
147
  }
119
- if (isPureAscii) {
120
- return str.length + tabLength;
121
- }
122
- return Bun.stringWidth(str) - jamoOvercount + tabLength;
148
+ return str.length;
123
149
  }
124
150
 
125
151
  /**