@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 +10 -0
- package/dist/types/symbols.d.ts +2 -0
- package/dist/types/terminal.d.ts +10 -0
- package/dist/types/tui.d.ts +6 -1
- package/package.json +3 -3
- package/src/components/editor.ts +4 -14
- package/src/components/markdown.ts +79 -3
- package/src/symbols.ts +2 -0
- package/src/terminal.ts +34 -1
- package/src/tui.ts +135 -26
- package/src/utils.ts +8 -20
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
|
package/dist/types/symbols.d.ts
CHANGED
package/dist/types/terminal.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.5.
|
|
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
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -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
|
-
|
|
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.#
|
|
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 ☃ 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 +=
|
|
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
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
|
-
|
|
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.#
|
|
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.#
|
|
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
|
|
1206
|
-
//
|
|
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)
|
|
1215
|
-
|
|
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)
|
|
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
|
|
1270
|
-
//
|
|
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 =
|
|
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 +=
|
|
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 =
|
|
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 +=
|
|
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 =
|
|
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 +=
|
|
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 =
|
|
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 +=
|
|
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 =
|
|
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 +=
|
|
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(
|
|
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:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
120
|
-
return str.length + tabLength;
|
|
121
|
-
}
|
|
122
|
-
return Bun.stringWidth(str) - jamoOvercount + tabLength;
|
|
110
|
+
return str.length;
|
|
123
111
|
}
|
|
124
112
|
|
|
125
113
|
/**
|