@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 +19 -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 +5 -5
- package/src/components/editor.ts +29 -14
- package/src/components/markdown.ts +79 -3
- package/src/symbols.ts +2 -0
- package/src/terminal.ts +34 -1
- package/src/tui.ts +141 -26
- package/src/utils.ts +46 -20
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
|
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.
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.
|
|
42
|
-
"lru-cache": "11.
|
|
43
|
-
"marked": "^18.0.
|
|
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",
|
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...
|
|
@@ -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 ☃ 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;
|
|
@@ -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
|
-
|
|
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.#
|
|
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.#
|
|
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
|
|
1206
|
-
//
|
|
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)
|
|
1215
|
-
|
|
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)
|
|
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
|
|
1270
|
-
//
|
|
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 =
|
|
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 +=
|
|
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 =
|
|
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 +=
|
|
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 =
|
|
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 +=
|
|
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 =
|
|
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 +=
|
|
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 =
|
|
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 +=
|
|
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(
|
|
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:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
}
|
|
144
|
+
if (code < 0x20 || code > 0x7e) {
|
|
145
|
+
return str.includes("\u200d") ? visibleWidthByGrapheme(str) : nativeVisibleWidth(str, getDefaultTabWidth());
|
|
117
146
|
}
|
|
118
147
|
}
|
|
119
|
-
|
|
120
|
-
return str.length + tabLength;
|
|
121
|
-
}
|
|
122
|
-
return Bun.stringWidth(str) - jamoOvercount + tabLength;
|
|
148
|
+
return str.length;
|
|
123
149
|
}
|
|
124
150
|
|
|
125
151
|
/**
|