@oh-my-pi/pi-tui 14.9.7 → 14.9.8
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 +43 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +14 -5
- package/src/components/editor.ts +16 -4
- package/src/components/markdown.ts +46 -3
- package/src/keys.ts +61 -22
- package/src/stdin-buffer.ts +29 -4
- package/src/terminal-capabilities.ts +19 -7
- package/src/terminal.ts +36 -2
- package/src/tui.ts +117 -74
- package/src/utils.ts +21 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [14.9.8] - 2026-05-12
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `Terminal.setProgress(active)` to emit OSC 9;4 progress sequences with a ~1s keepalive interval so Ghostty does not clear the indicator during long-running work (ports pi-mono `a900d251` + `76bc605a`)
|
|
10
|
+
- Added optional `argumentHint?: string` to `SlashCommand`; rendered before the description in the autocomplete dropdown (ports pi-mono `aa25726e`)
|
|
11
|
+
- Added `VirtualTerminal.waitForRender()` test helper for the throttled render pipeline (ports pi-mono `41377ee8`)
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- `ProcessTerminal` `columns`/`rows` getters consult `Bun.env.COLUMNS` / `Bun.env.LINES` before falling back to 80×24, so piped/non-TTY runs honour environment-provided dimensions (ports pi-mono `32f7fc6a`)
|
|
16
|
+
- `requestRender()` non-force calls are coalesced to a ~16ms frame budget; `requestRender(true)` still flushes immediately via `process.nextTick` (ports pi-mono `6f5f37f8`)
|
|
17
|
+
- `KNOWN_TERMINALS.base` / `KNOWN_TERMINALS.trueColor` default `hyperlinks: false`; tmux and screen (`TMUX` env or `TERM` starts with `tmux`/`screen`) force `hyperlinks: false` even when the outer terminal would advertise OSC 8 (adapts pi-mono `30a8a41f`)
|
|
18
|
+
- `SlashCommand.getArgumentCompletions()` may return a `Promise`; results are now awaited and non-array returns are ignored (ports pi-mono `a1e10789`)
|
|
19
|
+
- Fuzzy `@` autocomplete now follows symlinked directories via `ScanOptions.follow_links` plumbed through the native walker (ports pi-mono `780d5367`)
|
|
20
|
+
- Plain `@<query>` (no slash) fuzzy matches by basename only, so `@plan` no longer surfaces every file whose ancestor directories contain `plan` (ports pi-mono `968430f6`)
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Fixed editor corruption on Thai Sara Am (U+0E33) and Lao AM (U+0EB3) vowels by normalizing to their compatibility decompositions on the terminal-write path while keeping editor content logically unchanged (ports pi-mono `bc668826` + `338ce3a3` + `20ca45d5`)
|
|
25
|
+
- Fixed cell-size detection (`CSI 6;h;w t` response) to consume only exact replies, so a bare `Escape` keystroke is no longer swallowed while waiting for terminal image metadata (ports pi-mono `49c0d860`)
|
|
26
|
+
- Fixed Kitty CSI-u printable input duplicating on layouts (e.g. Italian) where the terminal also emits the raw character: the immediately-following matching codepoint is now suppressed (ports pi-mono `bdb416cb`)
|
|
27
|
+
- Fixed bracketed-paste CSI-u `Ctrl+<letter>` re-encoding (tmux popup with `extended-keys-format=csi-u`) leaking literal `[<code>;5u` into the editor; control bytes are decoded back to their literal byte before per-char filtering (ports pi-mono `d06db09a`)
|
|
28
|
+
- Fixed xterm `modifyOtherKeys` shifted printable input so uppercase letters inserted via `CSI 27;mod;codepoint~` reach the editor correctly (ports pi-mono `6b55d685`)
|
|
29
|
+
- Fixed `super`-modified Kitty shortcuts (`super+k`, `ctrl+super+enter`, …) to parse and match via the new `KITTY_MOD_SUPER` mask (ports pi-mono `ddb8454c` + `5ed46003`)
|
|
30
|
+
- Fixed `ctrl+alt+<letter>` in tmux falling through to CSI-u / `modifyOtherKeys` when the legacy `ESC<ctrl-char>` form does not match (ports pi-mono `6cf5098f`)
|
|
31
|
+
- Fixed Markdown strikethrough requiring strict `~~text~~` delimiters with non-whitespace boundaries; single tildes no longer render strikethrough (ports pi-mono `db5274b4`)
|
|
32
|
+
|
|
33
|
+
- Allowed `SlashCommand.getArgumentCompletions` to return asynchronous results by accepting Promise-based completions
|
|
34
|
+
- Added `argumentHint` support to slash command definitions and displayed it in command suggestion descriptions
|
|
35
|
+
- Added support for xterm `modifyOtherKeys` printable key sequences by decoding `CSI 27;mod;key~` into text input
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- Changed slash-command autocomplete list rendering to combine command hint and description in a single displayed suggestion text
|
|
40
|
+
- Changed render scheduling to throttle `requestRender` calls to roughly 60fps by batching updates
|
|
41
|
+
- Changed terminal input handling to process complete cell-size responses without buffering partial input
|
|
42
|
+
- Changed `KeyId` to accept super-modifier combinations and improve typed key-id validation
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- Normalized line output during rendering to correct Thai/Lao AM glyph composition for displayed text
|
|
47
|
+
- Fixed duplicated Kitty key input emissions by dropping the matching unmodified follow-up sequence after a Kitty CSI-u printable-key event
|
|
5
48
|
|
|
6
49
|
## [14.9.5] - 2026-05-12
|
|
7
50
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "14.9.
|
|
4
|
+
"version": "14.9.8",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
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": "14.9.
|
|
41
|
-
"@oh-my-pi/pi-utils": "14.9.
|
|
40
|
+
"@oh-my-pi/pi-natives": "14.9.8",
|
|
41
|
+
"@oh-my-pi/pi-utils": "14.9.8",
|
|
42
42
|
"lru-cache": "11.3.6",
|
|
43
43
|
"marked": "^18.0.3"
|
|
44
44
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -156,12 +156,15 @@ export interface AutocompleteItem {
|
|
|
156
156
|
hint?: string;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
type Awaitable<T> = T | Promise<T>;
|
|
160
|
+
|
|
159
161
|
export interface SlashCommand {
|
|
160
162
|
name: string;
|
|
161
163
|
description?: string;
|
|
164
|
+
argumentHint?: string;
|
|
162
165
|
// Function to get argument completions for this command
|
|
163
166
|
// Returns null if no argument completion is available
|
|
164
|
-
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null
|
|
167
|
+
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
|
|
165
168
|
/** Return inline hint text for the current argument state (shown as dim ghost text after cursor) */
|
|
166
169
|
getInlineHint?(argumentText: string): string | null;
|
|
167
170
|
}
|
|
@@ -268,11 +271,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
268
271
|
// Score name matches higher than description matches
|
|
269
272
|
const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
|
|
270
273
|
const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
|
|
274
|
+
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
|
|
275
|
+
const desc = cmd.description ?? "";
|
|
276
|
+
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
|
|
271
277
|
return {
|
|
272
278
|
value: name,
|
|
273
279
|
label: "name" in cmd ? cmd.name : cmd.label,
|
|
274
280
|
score: Math.max(nameScore, descScore),
|
|
275
|
-
...(
|
|
281
|
+
...(fullDesc && { description: fullDesc }),
|
|
276
282
|
};
|
|
277
283
|
})
|
|
278
284
|
.sort((a, b) => b.score - a.score)
|
|
@@ -297,8 +303,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
297
303
|
return null; // No argument completion for this command
|
|
298
304
|
}
|
|
299
305
|
|
|
300
|
-
const argumentSuggestions = command.getArgumentCompletions(argumentText);
|
|
301
|
-
if (!argumentSuggestions || argumentSuggestions.length === 0) {
|
|
306
|
+
const argumentSuggestions = await command.getArgumentCompletions(argumentText);
|
|
307
|
+
if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
|
|
302
308
|
return null;
|
|
303
309
|
}
|
|
304
310
|
|
|
@@ -802,11 +808,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
802
808
|
const lowerDesc = cmd.description?.toLowerCase() ?? "";
|
|
803
809
|
const nameScore = fuzzyMatch(lowerPrefix, lowerName) ? fuzzyScore(lowerPrefix, lowerName) : 0;
|
|
804
810
|
const descScore = fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
|
|
811
|
+
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
|
|
812
|
+
const desc = cmd.description ?? "";
|
|
813
|
+
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
|
|
805
814
|
return {
|
|
806
815
|
value: name,
|
|
807
816
|
label: "name" in cmd ? cmd.name : cmd.label,
|
|
808
817
|
score: Math.max(nameScore, descScore),
|
|
809
|
-
...(
|
|
818
|
+
...(fullDesc && { description: fullDesc }),
|
|
810
819
|
} as AutocompleteItem & { score: number };
|
|
811
820
|
})
|
|
812
821
|
.sort((a, b) => b.score - a.score)
|
package/src/components/editor.ts
CHANGED
|
@@ -1543,8 +1543,20 @@ export class Editor implements Component, Focusable {
|
|
|
1543
1543
|
this.#recordUndoState();
|
|
1544
1544
|
|
|
1545
1545
|
this.#withUndoSuspended(() => {
|
|
1546
|
+
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
|
|
1547
|
+
// control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
|
|
1548
|
+
// (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
|
|
1549
|
+
// per-char filter below preserves newlines instead of stripping ESC and
|
|
1550
|
+
// leaking the printable tail (e.g. "[106;5u") into the editor.
|
|
1551
|
+
const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
|
|
1552
|
+
const cp = Number(code);
|
|
1553
|
+
if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
|
|
1554
|
+
if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
|
|
1555
|
+
return match;
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1546
1558
|
// Clean the pasted text
|
|
1547
|
-
const cleanText =
|
|
1559
|
+
const cleanText = decodedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1548
1560
|
|
|
1549
1561
|
// Convert tabs to spaces (4 spaces per tab)
|
|
1550
1562
|
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
@@ -2427,7 +2439,7 @@ export class Editor implements Component, Focusable {
|
|
|
2427
2439
|
);
|
|
2428
2440
|
if (requestId !== this.#autocompleteRequestId) return;
|
|
2429
2441
|
|
|
2430
|
-
if (suggestions && suggestions.items.length > 0) {
|
|
2442
|
+
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
2431
2443
|
this.#autocompletePrefix = suggestions.prefix;
|
|
2432
2444
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
2433
2445
|
this.#autocompleteState = "regular";
|
|
@@ -2491,7 +2503,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2491
2503
|
);
|
|
2492
2504
|
if (requestId !== this.#autocompleteRequestId) return;
|
|
2493
2505
|
|
|
2494
|
-
if (suggestions && suggestions.items.length > 0) {
|
|
2506
|
+
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
2495
2507
|
// If there's exactly one suggestion and this was an explicit Tab press, apply it immediately
|
|
2496
2508
|
if (explicitTab && suggestions.items.length === 1) {
|
|
2497
2509
|
const item = suggestions.items[0]!;
|
|
@@ -2557,7 +2569,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2557
2569
|
);
|
|
2558
2570
|
if (requestId !== this.#autocompleteRequestId) return;
|
|
2559
2571
|
|
|
2560
|
-
if (suggestions && suggestions.items.length > 0) {
|
|
2572
|
+
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
2561
2573
|
this.#autocompletePrefix = suggestions.prefix;
|
|
2562
2574
|
// Always create new SelectList to ensure update
|
|
2563
2575
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
import { LRUCache } from "lru-cache/raw";
|
|
2
|
-
import { marked, type Token, type Tokens } from "marked";
|
|
2
|
+
import { Marked, marked, type Token, Tokenizer, type Tokens } from "marked";
|
|
3
3
|
import type { SymbolTheme } from "../symbols";
|
|
4
4
|
import { TERMINAL } from "../terminal-capabilities";
|
|
5
5
|
import type { Component } from "../tui";
|
|
6
6
|
import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
7
7
|
|
|
8
|
+
const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
|
|
9
|
+
|
|
10
|
+
class StrictStrikethroughTokenizer extends Tokenizer {
|
|
11
|
+
override del(src: string): Tokens.Del | undefined {
|
|
12
|
+
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
|
|
13
|
+
if (!match) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const text = match[2];
|
|
18
|
+
return {
|
|
19
|
+
type: "del",
|
|
20
|
+
raw: match[0],
|
|
21
|
+
text,
|
|
22
|
+
tokens: this.lexer.inlineTokens(text),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const markdownParser = new Marked();
|
|
28
|
+
markdownParser.setOptions({
|
|
29
|
+
tokenizer: new StrictStrikethroughTokenizer(),
|
|
30
|
+
});
|
|
31
|
+
|
|
8
32
|
// ---------------------------------------------------------------------------
|
|
9
33
|
// Module-level LRU render cache
|
|
10
34
|
// ---------------------------------------------------------------------------
|
|
@@ -172,7 +196,18 @@ export class Markdown implements Component {
|
|
|
172
196
|
// L2: module-level LRU — survives component disposal/recreation across
|
|
173
197
|
// session-tree navigations. Key encodes every dimension that affects the
|
|
174
198
|
// render output so different configurations never collide.
|
|
175
|
-
|
|
199
|
+
// Encode terminal capability state and theme/style function output samples
|
|
200
|
+
// so that capability shifts (image protocol changes, hyperlink toggle) or
|
|
201
|
+
// caller-supplied theme/bgColor functions that mutate their output without
|
|
202
|
+
// changing object identity invalidate the cache entry.
|
|
203
|
+
// bgColor probe uses \x01 (single non-printable byte): chalk/ANSI wrappers
|
|
204
|
+
// pass arbitrary bytes through verbatim, so this is safe and minimizes the
|
|
205
|
+
// risk of clashing with a function that returns text verbatim.
|
|
206
|
+
// theme.heading is used as the representative theme probe — it's required
|
|
207
|
+
// by MarkdownTheme and is one of the most styling-sensitive entries.
|
|
208
|
+
const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
|
|
209
|
+
const headingProbe = this.#theme.heading("");
|
|
210
|
+
const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
|
|
176
211
|
const cached = renderCache.get(cacheKey);
|
|
177
212
|
if (cached !== undefined) {
|
|
178
213
|
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
|
|
@@ -183,7 +218,7 @@ export class Markdown implements Component {
|
|
|
183
218
|
}
|
|
184
219
|
|
|
185
220
|
// Parse markdown to HTML-like tokens
|
|
186
|
-
const tokens =
|
|
221
|
+
const tokens = markdownParser.lexer(normalizedText);
|
|
187
222
|
|
|
188
223
|
// Convert tokens to styled terminal output
|
|
189
224
|
const renderedLines: string[] = [];
|
|
@@ -582,6 +617,14 @@ export class Markdown implements Component {
|
|
|
582
617
|
}
|
|
583
618
|
}
|
|
584
619
|
|
|
620
|
+
// Strip dangling re-opened-default SGR prefix left over from the last inline
|
|
621
|
+
// token (strong/em/codespan/link/del/etc.) so the emitted line self-terminates
|
|
622
|
+
// at its last styled segment instead of carrying an unmatched SGR open into
|
|
623
|
+
// the next line. Matches upstream behavior.
|
|
624
|
+
while (stylePrefix && result.endsWith(stylePrefix)) {
|
|
625
|
+
result = result.slice(0, -stylePrefix.length);
|
|
626
|
+
}
|
|
627
|
+
|
|
585
628
|
return result;
|
|
586
629
|
}
|
|
587
630
|
|
package/src/keys.ts
CHANGED
|
@@ -174,28 +174,17 @@ type SpecialKey =
|
|
|
174
174
|
| "f12";
|
|
175
175
|
|
|
176
176
|
type BaseKey = Letter | Digit | SymbolKey | SpecialKey;
|
|
177
|
+
type ModifierName = "ctrl" | "shift" | "alt" | "super";
|
|
178
|
+
|
|
179
|
+
type ModifiedKeyId<Key extends string, RemainingModifiers extends ModifierName = ModifierName> = {
|
|
180
|
+
[M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId<Key, Exclude<RemainingModifiers, M>>}`;
|
|
181
|
+
}[RemainingModifiers];
|
|
177
182
|
|
|
178
183
|
/**
|
|
179
184
|
* Union type of all valid key identifiers.
|
|
180
185
|
* Provides autocomplete and catches typos at compile time.
|
|
181
186
|
*/
|
|
182
|
-
export type KeyId =
|
|
183
|
-
| BaseKey
|
|
184
|
-
| `ctrl+${BaseKey}`
|
|
185
|
-
| `shift+${BaseKey}`
|
|
186
|
-
| `alt+${BaseKey}`
|
|
187
|
-
| `ctrl+shift+${BaseKey}`
|
|
188
|
-
| `shift+ctrl+${BaseKey}`
|
|
189
|
-
| `ctrl+alt+${BaseKey}`
|
|
190
|
-
| `alt+ctrl+${BaseKey}`
|
|
191
|
-
| `shift+alt+${BaseKey}`
|
|
192
|
-
| `alt+shift+${BaseKey}`
|
|
193
|
-
| `ctrl+shift+alt+${BaseKey}`
|
|
194
|
-
| `ctrl+alt+shift+${BaseKey}`
|
|
195
|
-
| `shift+ctrl+alt+${BaseKey}`
|
|
196
|
-
| `shift+alt+ctrl+${BaseKey}`
|
|
197
|
-
| `alt+ctrl+shift+${BaseKey}`
|
|
198
|
-
| `alt+shift+ctrl+${BaseKey}`;
|
|
187
|
+
export type KeyId = BaseKey | ModifiedKeyId<BaseKey>;
|
|
199
188
|
|
|
200
189
|
// =============================================================================
|
|
201
190
|
// Kitty Protocol Parsing
|
|
@@ -218,7 +207,10 @@ const KITTY_CSI_U_PATTERN = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\
|
|
|
218
207
|
const KITTY_MOD_SHIFT = 1;
|
|
219
208
|
const KITTY_MOD_ALT = 2;
|
|
220
209
|
const KITTY_MOD_CTRL = 4;
|
|
210
|
+
const KITTY_MOD_SUPER = 8;
|
|
221
211
|
const KITTY_MOD_NUM_LOCK = 128;
|
|
212
|
+
const KITTY_LOCK_MASK = 64 + 128; // Caps Lock + Num Lock
|
|
213
|
+
const MODIFY_OTHER_KEYS_PATTERN = /^\x1b\[27;(\d+);(\d+)~$/;
|
|
222
214
|
const KITTY_KEYPAD_OPERATOR_TEXT: Record<number, string> = {
|
|
223
215
|
57410: "/",
|
|
224
216
|
57411: "*",
|
|
@@ -311,11 +303,11 @@ function decodeKittyPrintable(data: string): string | undefined {
|
|
|
311
303
|
const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined;
|
|
312
304
|
const modValue = match[4] ? Number.parseInt(match[4], 10) : 1;
|
|
313
305
|
const modifier = Number.isFinite(modValue) ? modValue - 1 : 0;
|
|
314
|
-
const effectiveMod = modifier & ~
|
|
315
|
-
const supportedModifierMask = KITTY_MOD_SHIFT | KITTY_MOD_ALT | KITTY_MOD_CTRL;
|
|
306
|
+
const effectiveMod = modifier & ~KITTY_LOCK_MASK;
|
|
307
|
+
const supportedModifierMask = KITTY_MOD_SHIFT | KITTY_MOD_ALT | KITTY_MOD_CTRL | KITTY_MOD_SUPER;
|
|
316
308
|
|
|
317
309
|
if (effectiveMod & ~supportedModifierMask) return undefined;
|
|
318
|
-
if (effectiveMod & (KITTY_MOD_ALT | KITTY_MOD_CTRL)) return undefined;
|
|
310
|
+
if (effectiveMod & (KITTY_MOD_ALT | KITTY_MOD_CTRL | KITTY_MOD_SUPER)) return undefined;
|
|
319
311
|
|
|
320
312
|
const textField = match[6];
|
|
321
313
|
if (textField && textField.length > 0) {
|
|
@@ -365,12 +357,59 @@ function decodeKittyPrintable(data: string): string | undefined {
|
|
|
365
357
|
* keypad digits, keypad operators, and shifted symbols the same as direct character input.
|
|
366
358
|
*/
|
|
367
359
|
export function extractPrintableText(data: string): string | undefined {
|
|
368
|
-
const
|
|
369
|
-
if (
|
|
360
|
+
const printable = decodePrintableKey(data);
|
|
361
|
+
if (printable !== undefined) return printable;
|
|
370
362
|
if (data.length === 0 || hasControlChars(data)) return undefined;
|
|
371
363
|
return data;
|
|
372
364
|
}
|
|
373
365
|
|
|
366
|
+
interface ParsedModifyOtherKeysSequence {
|
|
367
|
+
codepoint: number;
|
|
368
|
+
modifier: number;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Parse an xterm `modifyOtherKeys` format sequence: `CSI 27 ; modifiers ; keycode ~`.
|
|
373
|
+
* Modifier values are 1-indexed in the wire format; we normalize to a 0-based bitmask.
|
|
374
|
+
*/
|
|
375
|
+
function parseModifyOtherKeysSequence(data: string): ParsedModifyOtherKeysSequence | null {
|
|
376
|
+
const match = data.match(MODIFY_OTHER_KEYS_PATTERN);
|
|
377
|
+
if (!match) return null;
|
|
378
|
+
const modValue = Number.parseInt(match[1] ?? "", 10);
|
|
379
|
+
const codepoint = Number.parseInt(match[2] ?? "", 10);
|
|
380
|
+
if (!Number.isFinite(modValue) || !Number.isFinite(codepoint)) return null;
|
|
381
|
+
return { codepoint, modifier: modValue - 1 };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Decode an xterm modifyOtherKeys sequence into the printable character it represents.
|
|
386
|
+
*
|
|
387
|
+
* Only sequences with no modifiers or Shift alone produce text; Ctrl/Alt/Super combos
|
|
388
|
+
* are treated as bindings, not text input.
|
|
389
|
+
*/
|
|
390
|
+
function decodeModifyOtherKeysPrintable(data: string): string | undefined {
|
|
391
|
+
const parsed = parseModifyOtherKeysSequence(data);
|
|
392
|
+
if (!parsed) return undefined;
|
|
393
|
+
const modifier = parsed.modifier & ~KITTY_LOCK_MASK;
|
|
394
|
+
if ((modifier & ~KITTY_MOD_SHIFT) !== 0) return undefined;
|
|
395
|
+
if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32) return undefined;
|
|
396
|
+
try {
|
|
397
|
+
return String.fromCodePoint(parsed.codepoint);
|
|
398
|
+
} catch {
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Decode terminal input into the printable character it represents.
|
|
405
|
+
*
|
|
406
|
+
* Tries Kitty CSI-u first, then falls back to xterm modifyOtherKeys. Returns
|
|
407
|
+
* undefined for control sequences and modifier-only events.
|
|
408
|
+
*/
|
|
409
|
+
export function decodePrintableKey(data: string): string | undefined {
|
|
410
|
+
return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data);
|
|
411
|
+
}
|
|
412
|
+
|
|
374
413
|
/**
|
|
375
414
|
* Match input data against a key identifier string.
|
|
376
415
|
*
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -180,6 +180,14 @@ function isCompleteApcSequence(data: string): "complete" | "incomplete" {
|
|
|
180
180
|
/**
|
|
181
181
|
* Split accumulated buffer into complete sequences
|
|
182
182
|
*/
|
|
183
|
+
function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | undefined {
|
|
184
|
+
const match = sequence.match(/^\x1b\[(\d+)(?::\d*)?(?::\d+)?u$/);
|
|
185
|
+
if (!match) return undefined;
|
|
186
|
+
|
|
187
|
+
const codepoint = parseInt(match[1]!, 10);
|
|
188
|
+
return codepoint >= 32 ? codepoint : undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
183
191
|
function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
|
|
184
192
|
const sequences: string[] = [];
|
|
185
193
|
let pos = 0;
|
|
@@ -245,6 +253,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
245
253
|
readonly #timeoutMs: number;
|
|
246
254
|
#pasteMode: boolean = false;
|
|
247
255
|
#pasteBuffer: string = "";
|
|
256
|
+
#pendingKittyPrintableCodepoint: number | undefined;
|
|
248
257
|
|
|
249
258
|
constructor(options: StdinBufferOptions = {}) {
|
|
250
259
|
super();
|
|
@@ -273,7 +282,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
273
282
|
}
|
|
274
283
|
|
|
275
284
|
if (str.length === 0 && this.#buffer.length === 0) {
|
|
276
|
-
this
|
|
285
|
+
this.#emitDataSequence("");
|
|
277
286
|
return;
|
|
278
287
|
}
|
|
279
288
|
|
|
@@ -290,6 +299,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
290
299
|
|
|
291
300
|
this.#pasteMode = false;
|
|
292
301
|
this.#pasteBuffer = "";
|
|
302
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
293
303
|
|
|
294
304
|
this.emit("paste", pastedContent);
|
|
295
305
|
|
|
@@ -306,10 +316,11 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
306
316
|
const beforePaste = this.#buffer.slice(0, startIndex);
|
|
307
317
|
const result = extractCompleteSequences(beforePaste);
|
|
308
318
|
for (const sequence of result.sequences) {
|
|
309
|
-
this
|
|
319
|
+
this.#emitDataSequence(sequence);
|
|
310
320
|
}
|
|
311
321
|
}
|
|
312
322
|
|
|
323
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
313
324
|
this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length);
|
|
314
325
|
this.#pasteMode = true;
|
|
315
326
|
this.#pasteBuffer = this.#buffer;
|
|
@@ -322,6 +333,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
322
333
|
|
|
323
334
|
this.#pasteMode = false;
|
|
324
335
|
this.#pasteBuffer = "";
|
|
336
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
325
337
|
|
|
326
338
|
this.emit("paste", pastedContent);
|
|
327
339
|
|
|
@@ -336,7 +348,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
336
348
|
this.#buffer = result.remainder;
|
|
337
349
|
|
|
338
350
|
for (const sequence of result.sequences) {
|
|
339
|
-
this
|
|
351
|
+
this.#emitDataSequence(sequence);
|
|
340
352
|
}
|
|
341
353
|
|
|
342
354
|
if (this.#buffer.length > 0) {
|
|
@@ -344,12 +356,23 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
344
356
|
const flushed = this.flush();
|
|
345
357
|
|
|
346
358
|
for (const sequence of flushed) {
|
|
347
|
-
this
|
|
359
|
+
this.#emitDataSequence(sequence);
|
|
348
360
|
}
|
|
349
361
|
}, this.#timeoutMs);
|
|
350
362
|
}
|
|
351
363
|
}
|
|
352
364
|
|
|
365
|
+
#emitDataSequence(sequence: string): void {
|
|
366
|
+
const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
|
|
367
|
+
if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) {
|
|
368
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
this.#pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence);
|
|
373
|
+
this.emit("data", sequence);
|
|
374
|
+
}
|
|
375
|
+
|
|
353
376
|
flush(): string[] {
|
|
354
377
|
if (this.#timeout) {
|
|
355
378
|
clearTimeout(this.#timeout);
|
|
@@ -362,6 +385,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
362
385
|
|
|
363
386
|
const sequences = [this.#buffer];
|
|
364
387
|
this.#buffer = "";
|
|
388
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
365
389
|
return sequences;
|
|
366
390
|
}
|
|
367
391
|
|
|
@@ -373,6 +397,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
373
397
|
this.#buffer = "";
|
|
374
398
|
this.#pasteMode = false;
|
|
375
399
|
this.#pasteBuffer = "";
|
|
400
|
+
this.#pendingKittyPrintableCodepoint = undefined;
|
|
376
401
|
}
|
|
377
402
|
|
|
378
403
|
getBuffer(): string {
|
|
@@ -102,8 +102,8 @@ function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null
|
|
|
102
102
|
}
|
|
103
103
|
const KNOWN_TERMINALS = Object.freeze({
|
|
104
104
|
// Fallback terminals
|
|
105
|
-
base: new TerminalInfo("base", null, false,
|
|
106
|
-
trueColor: new TerminalInfo("trueColor", null, true,
|
|
105
|
+
base: new TerminalInfo("base", null, false, false, NotifyProtocol.Bell),
|
|
106
|
+
trueColor: new TerminalInfo("trueColor", null, true, false, NotifyProtocol.Bell),
|
|
107
107
|
// Recognized terminals
|
|
108
108
|
kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99),
|
|
109
109
|
ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9),
|
|
@@ -157,19 +157,19 @@ export const TERMINAL_ID: TerminalId = (() => {
|
|
|
157
157
|
export const TERMINAL = (() => {
|
|
158
158
|
const terminal = getTerminalInfo(TERMINAL_ID);
|
|
159
159
|
const forcedImageProtocol = getForcedImageProtocol();
|
|
160
|
+
let resolved = terminal;
|
|
160
161
|
if (forcedImageProtocol !== undefined) {
|
|
161
|
-
|
|
162
|
+
resolved = new TerminalInfo(
|
|
162
163
|
terminal.id,
|
|
163
164
|
forcedImageProtocol,
|
|
164
165
|
terminal.trueColor,
|
|
165
166
|
terminal.hyperlinks,
|
|
166
167
|
terminal.notifyProtocol,
|
|
167
168
|
);
|
|
168
|
-
}
|
|
169
|
-
if (!terminal.imageProtocol) {
|
|
169
|
+
} else if (!terminal.imageProtocol) {
|
|
170
170
|
const fallbackImageProtocol = getFallbackImageProtocol(terminal.id);
|
|
171
171
|
if (fallbackImageProtocol) {
|
|
172
|
-
|
|
172
|
+
resolved = new TerminalInfo(
|
|
173
173
|
terminal.id,
|
|
174
174
|
fallbackImageProtocol,
|
|
175
175
|
terminal.trueColor,
|
|
@@ -178,7 +178,19 @@ export const TERMINAL = (() => {
|
|
|
178
178
|
);
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
|
-
|
|
181
|
+
// tmux and screen multiplexers do not reliably forward OSC 8 hyperlinks
|
|
182
|
+
// to the outer terminal, so force them off regardless of detected terminal.
|
|
183
|
+
const term = Bun.env.TERM?.toLowerCase() ?? "";
|
|
184
|
+
if (resolved.hyperlinks && (Bun.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"))) {
|
|
185
|
+
resolved = new TerminalInfo(
|
|
186
|
+
resolved.id,
|
|
187
|
+
resolved.imageProtocol,
|
|
188
|
+
resolved.trueColor,
|
|
189
|
+
false,
|
|
190
|
+
resolved.notifyProtocol,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return resolved;
|
|
182
194
|
})();
|
|
183
195
|
|
|
184
196
|
type MutableTerminalInfo = {
|
package/src/terminal.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { $env, logger } from "@oh-my-pi/pi-utils";
|
|
|
4
4
|
import { setKittyProtocolActive } from "./keys";
|
|
5
5
|
import { StdinBuffer } from "./stdin-buffer";
|
|
6
6
|
|
|
7
|
+
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
|
8
|
+
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
|
9
|
+
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
|
|
10
|
+
|
|
7
11
|
/**
|
|
8
12
|
* Minimal terminal interface for TUI
|
|
9
13
|
*/
|
|
@@ -85,6 +89,9 @@ export interface Terminal {
|
|
|
85
89
|
// Title operations
|
|
86
90
|
setTitle(title: string): void; // Set terminal window title
|
|
87
91
|
|
|
92
|
+
// Progress indicator (OSC 9;4)
|
|
93
|
+
setProgress(active: boolean): void;
|
|
94
|
+
|
|
88
95
|
/**
|
|
89
96
|
* Register a callback for terminal appearance (dark/light) changes.
|
|
90
97
|
* Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
|
|
@@ -123,6 +130,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
123
130
|
#pendingDa1Sentinels = 0;
|
|
124
131
|
#osc11PollTimer?: Timer;
|
|
125
132
|
#mode2031DebounceTimer?: Timer;
|
|
133
|
+
#progressTimer?: ReturnType<typeof setInterval>;
|
|
126
134
|
|
|
127
135
|
get kittyProtocolActive(): boolean {
|
|
128
136
|
return this.#kittyProtocolActive;
|
|
@@ -505,6 +513,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
505
513
|
activeTerminal = null;
|
|
506
514
|
}
|
|
507
515
|
|
|
516
|
+
if (this.#clearProgressTimer()) {
|
|
517
|
+
this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
|
518
|
+
}
|
|
519
|
+
|
|
508
520
|
// Disable bracketed paste mode
|
|
509
521
|
this.#safeWrite("\x1b[?2004l");
|
|
510
522
|
|
|
@@ -589,11 +601,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
589
601
|
}
|
|
590
602
|
|
|
591
603
|
get columns(): number {
|
|
592
|
-
return process.stdout.columns || 80;
|
|
604
|
+
return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
|
|
593
605
|
}
|
|
594
606
|
|
|
595
607
|
get rows(): number {
|
|
596
|
-
return process.stdout.rows || 24;
|
|
608
|
+
return process.stdout.rows || Number(Bun.env.LINES) || 24;
|
|
597
609
|
}
|
|
598
610
|
|
|
599
611
|
moveBy(lines: number): void {
|
|
@@ -631,4 +643,26 @@ export class ProcessTerminal implements Terminal {
|
|
|
631
643
|
// OSC 0;title BEL - set terminal window title
|
|
632
644
|
this.#safeWrite(`\x1b]0;${title}\x07`);
|
|
633
645
|
}
|
|
646
|
+
|
|
647
|
+
setProgress(active: boolean): void {
|
|
648
|
+
if (active) {
|
|
649
|
+
this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
|
|
650
|
+
if (!this.#progressTimer) {
|
|
651
|
+
this.#progressTimer = setInterval(() => {
|
|
652
|
+
this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
|
|
653
|
+
}, TERMINAL_PROGRESS_KEEPALIVE_MS);
|
|
654
|
+
this.#progressTimer.unref?.();
|
|
655
|
+
}
|
|
656
|
+
} else {
|
|
657
|
+
this.#clearProgressTimer();
|
|
658
|
+
this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
#clearProgressTimer(): boolean {
|
|
663
|
+
if (!this.#progressTimer) return false;
|
|
664
|
+
clearInterval(this.#progressTimer);
|
|
665
|
+
this.#progressTimer = undefined;
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
634
668
|
}
|
package/src/tui.ts
CHANGED
|
@@ -3,13 +3,29 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as path from "node:path";
|
|
6
|
+
import { performance } from "node:perf_hooks";
|
|
6
7
|
import { $flag, getDebugLogPath } from "@oh-my-pi/pi-utils";
|
|
7
8
|
import { isKeyRelease, matchesKey } from "./keys";
|
|
8
9
|
import type { Terminal } from "./terminal";
|
|
9
10
|
import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
Ellipsis,
|
|
13
|
+
extractSegments,
|
|
14
|
+
normalizeTerminalOutput,
|
|
15
|
+
sliceByColumn,
|
|
16
|
+
sliceWithWidth,
|
|
17
|
+
truncateToWidth,
|
|
18
|
+
visibleWidth,
|
|
19
|
+
} from "./utils";
|
|
11
20
|
|
|
12
21
|
const SEGMENT_RESET = "\x1b[0m";
|
|
22
|
+
/**
|
|
23
|
+
* Per-line terminator written at the end of every non-image line. Closes both
|
|
24
|
+
* SGR state and any in-flight OSC 8 hyperlink so styles/links cannot bleed
|
|
25
|
+
* across lines in scrollback. Applied by {@link TUI.#applyLineResets} before
|
|
26
|
+
* diffing so `#previousLines` mirrors what was actually written.
|
|
27
|
+
*/
|
|
28
|
+
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
|
13
29
|
|
|
14
30
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
15
31
|
type InputListener = (data: string) => InputListenerResult;
|
|
@@ -218,11 +234,12 @@ export class TUI extends Container {
|
|
|
218
234
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
219
235
|
onDebug?: () => void;
|
|
220
236
|
#renderRequested = false;
|
|
237
|
+
#renderTimer: NodeJS.Timeout | undefined;
|
|
238
|
+
#lastRenderAt = 0;
|
|
239
|
+
static readonly #MIN_RENDER_INTERVAL_MS = 16;
|
|
221
240
|
#cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
222
241
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
223
242
|
#viewportTopRow = 0; // Content row currently mapped to screen row 0
|
|
224
|
-
#inputBuffer = ""; // Buffer for parsing terminal responses
|
|
225
|
-
#cellSizeQueryPending = false;
|
|
226
243
|
#sixelProbePendingDa = false;
|
|
227
244
|
#sixelProbePendingGraphics = false;
|
|
228
245
|
#sixelProbeBuffer = "";
|
|
@@ -540,13 +557,16 @@ export class TUI extends Container {
|
|
|
540
557
|
}
|
|
541
558
|
// Query terminal for cell size in pixels: CSI 16 t
|
|
542
559
|
// Response format: CSI 6 ; height ; width t
|
|
543
|
-
this.#cellSizeQueryPending = true;
|
|
544
560
|
this.terminal.write("\x1b[16t");
|
|
545
561
|
}
|
|
546
562
|
|
|
547
563
|
stop(): void {
|
|
548
564
|
this.#clearSixelProbeState();
|
|
549
565
|
this.#stopped = true;
|
|
566
|
+
if (this.#renderTimer) {
|
|
567
|
+
clearTimeout(this.#renderTimer);
|
|
568
|
+
this.#renderTimer = undefined;
|
|
569
|
+
}
|
|
550
570
|
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
|
551
571
|
if (this.#previousLines.length > 0) {
|
|
552
572
|
const targetRow = this.#previousLines.length; // Line after the last content
|
|
@@ -572,13 +592,44 @@ export class TUI extends Container {
|
|
|
572
592
|
this.#hardwareCursorRow = 0;
|
|
573
593
|
this.#viewportTopRow = 0;
|
|
574
594
|
this.#maxLinesRendered = 0;
|
|
595
|
+
if (this.#renderTimer) {
|
|
596
|
+
clearTimeout(this.#renderTimer);
|
|
597
|
+
this.#renderTimer = undefined;
|
|
598
|
+
}
|
|
599
|
+
this.#renderRequested = true;
|
|
600
|
+
process.nextTick(() => {
|
|
601
|
+
if (this.#stopped || !this.#renderRequested) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
this.#renderRequested = false;
|
|
605
|
+
this.#lastRenderAt = performance.now();
|
|
606
|
+
this.#doRender();
|
|
607
|
+
});
|
|
608
|
+
return;
|
|
575
609
|
}
|
|
576
610
|
if (this.#renderRequested) return;
|
|
577
611
|
this.#renderRequested = true;
|
|
578
|
-
process.nextTick(() =>
|
|
612
|
+
process.nextTick(() => this.#scheduleRender());
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
#scheduleRender(): void {
|
|
616
|
+
if (this.#stopped || this.#renderTimer || !this.#renderRequested) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const elapsed = performance.now() - this.#lastRenderAt;
|
|
620
|
+
const delay = Math.max(0, TUI.#MIN_RENDER_INTERVAL_MS - elapsed);
|
|
621
|
+
this.#renderTimer = setTimeout(() => {
|
|
622
|
+
this.#renderTimer = undefined;
|
|
623
|
+
if (this.#stopped || !this.#renderRequested) {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
579
626
|
this.#renderRequested = false;
|
|
627
|
+
this.#lastRenderAt = performance.now();
|
|
580
628
|
this.#doRender();
|
|
581
|
-
|
|
629
|
+
if (this.#renderRequested) {
|
|
630
|
+
this.#scheduleRender();
|
|
631
|
+
}
|
|
632
|
+
}, delay);
|
|
582
633
|
}
|
|
583
634
|
|
|
584
635
|
#handleInput(data: string): void {
|
|
@@ -599,12 +650,9 @@ export class TUI extends Container {
|
|
|
599
650
|
data = current;
|
|
600
651
|
}
|
|
601
652
|
|
|
602
|
-
//
|
|
603
|
-
if (this.#
|
|
604
|
-
|
|
605
|
-
const filtered = this.#parseCellSizeResponse();
|
|
606
|
-
if (filtered.length === 0) return;
|
|
607
|
-
data = filtered;
|
|
653
|
+
// Consume terminal cell size responses without blocking unrelated input.
|
|
654
|
+
if (this.#consumeCellSizeResponse(data)) {
|
|
655
|
+
return;
|
|
608
656
|
}
|
|
609
657
|
|
|
610
658
|
// Global debug key handler (Shift+Ctrl+D)
|
|
@@ -639,46 +687,24 @@ export class TUI extends Container {
|
|
|
639
687
|
}
|
|
640
688
|
}
|
|
641
689
|
|
|
642
|
-
#
|
|
690
|
+
#consumeCellSizeResponse(data: string): boolean {
|
|
643
691
|
// Response format: ESC [ 6 ; height ; width t
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
if (match) {
|
|
649
|
-
const heightPx = parseInt(match[1], 10);
|
|
650
|
-
const widthPx = parseInt(match[2], 10);
|
|
651
|
-
|
|
652
|
-
if (heightPx > 0 && widthPx > 0) {
|
|
653
|
-
setCellDimensions({ widthPx, heightPx });
|
|
654
|
-
// Invalidate all components so images re-render with correct dimensions
|
|
655
|
-
this.invalidate();
|
|
656
|
-
this.requestRender();
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// Remove the response from buffer
|
|
660
|
-
this.#inputBuffer = this.#inputBuffer.replace(responsePattern, "");
|
|
661
|
-
this.#cellSizeQueryPending = false;
|
|
692
|
+
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
|
693
|
+
if (!match) {
|
|
694
|
+
return false;
|
|
662
695
|
}
|
|
663
696
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
// Check if it's actually a complete different escape sequence (ends with a letter)
|
|
669
|
-
// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
|
|
670
|
-
const lastChar = this.#inputBuffer[this.#inputBuffer.length - 1];
|
|
671
|
-
if (!/[a-zA-Z~]/.test(lastChar)) {
|
|
672
|
-
// Doesn't end with a terminator, might be incomplete - wait for more
|
|
673
|
-
return "";
|
|
674
|
-
}
|
|
697
|
+
const heightPx = parseInt(match[1], 10);
|
|
698
|
+
const widthPx = parseInt(match[2], 10);
|
|
699
|
+
if (heightPx <= 0 || widthPx <= 0) {
|
|
700
|
+
return true;
|
|
675
701
|
}
|
|
676
702
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
this
|
|
680
|
-
this
|
|
681
|
-
return
|
|
703
|
+
setCellDimensions({ widthPx, heightPx });
|
|
704
|
+
// Invalidate all components so images re-render with correct dimensions.
|
|
705
|
+
this.invalidate();
|
|
706
|
+
this.requestRender();
|
|
707
|
+
return true;
|
|
682
708
|
}
|
|
683
709
|
|
|
684
710
|
/**
|
|
@@ -978,6 +1004,26 @@ export class TUI extends Container {
|
|
|
978
1004
|
return null;
|
|
979
1005
|
}
|
|
980
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* Append the per-line terminator ({@link LINE_TERMINATOR}) to every
|
|
1009
|
+
* non-image line and normalize for terminal rendering. Mutates the input
|
|
1010
|
+
* array in place so downstream diffing/storage sees exactly the bytes
|
|
1011
|
+
* written to the terminal — without this, the diff cache disagrees with
|
|
1012
|
+
* emitted output and OSC 8 hyperlink state can leak across lines.
|
|
1013
|
+
*/
|
|
1014
|
+
#applyLineResets(lines: string[]): string[] {
|
|
1015
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1016
|
+
const line = lines[i];
|
|
1017
|
+
if (TERMINAL.isImageLine(line)) continue;
|
|
1018
|
+
const normalized = normalizeTerminalOutput(line);
|
|
1019
|
+
// Only close OSC 8 hyperlinks when the line actually opened one;
|
|
1020
|
+
// emitting `\x1b]8;;\x07` on every line just feeds the terminal's OSC
|
|
1021
|
+
// parser for no reason (measurable cost in xterm.js parse loop).
|
|
1022
|
+
lines[i] = normalized + (normalized.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
1023
|
+
}
|
|
1024
|
+
return lines;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
981
1027
|
#doRender(): void {
|
|
982
1028
|
if (this.#stopped) return;
|
|
983
1029
|
const width = this.terminal.columns;
|
|
@@ -1002,6 +1048,12 @@ export class TUI extends Container {
|
|
|
1002
1048
|
// Extract cursor position (marker must be found before diff comparison)
|
|
1003
1049
|
const cursorPos = this.#extractCursorPosition(newLines, height);
|
|
1004
1050
|
|
|
1051
|
+
// Terminate every non-image line so #previousLines mirrors emitted bytes
|
|
1052
|
+
// (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
|
|
1053
|
+
// because the marker is embedded mid-line, and before any diff/full render
|
|
1054
|
+
// path so cache comparisons stay byte-accurate.
|
|
1055
|
+
newLines = this.#applyLineResets(newLines);
|
|
1056
|
+
|
|
1005
1057
|
// Width changed - need full re-render (line wrapping changes)
|
|
1006
1058
|
const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
|
|
1007
1059
|
const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
|
|
@@ -1012,11 +1064,11 @@ export class TUI extends Container {
|
|
|
1012
1064
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
1013
1065
|
// Skip clearing scrollback (3J) in multiplexers — users actively navigate scrollback history
|
|
1014
1066
|
if (clear) buffer += isMultiplexer ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
|
|
1015
|
-
const reset = SEGMENT_RESET;
|
|
1016
1067
|
for (let i = 0; i < newLines.length; i++) {
|
|
1017
1068
|
if (i > 0) buffer += "\r\n";
|
|
1018
|
-
|
|
1019
|
-
|
|
1069
|
+
// Lines were pre-terminated/normalized by #applyLineResets; image
|
|
1070
|
+
// lines were left untouched there.
|
|
1071
|
+
buffer += newLines[i];
|
|
1020
1072
|
}
|
|
1021
1073
|
this.#cursorRow = Math.max(0, newLines.length - 1);
|
|
1022
1074
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, this.#cursorRow);
|
|
@@ -1151,29 +1203,13 @@ export class TUI extends Container {
|
|
|
1151
1203
|
return;
|
|
1152
1204
|
}
|
|
1153
1205
|
|
|
1154
|
-
//
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
fullRender(true);
|
|
1162
|
-
return;
|
|
1163
|
-
}
|
|
1164
|
-
// Viewport is stable or shifting down — skip invisible above-viewport changes
|
|
1165
|
-
firstChanged = previousContentViewportTop;
|
|
1166
|
-
if (lastChanged < firstChanged) {
|
|
1167
|
-
// All changes are above the viewport — nothing visible to update
|
|
1168
|
-
this.#cursorRow = Math.max(0, newLines.length - 1);
|
|
1169
|
-
this.#maxLinesRendered = newLines.length;
|
|
1170
|
-
this.#viewportTopRow = Math.max(0, newLines.length - height);
|
|
1171
|
-
this.#writeCursorPosition(cursorPos, newLines.length);
|
|
1172
|
-
this.#previousLines = newLines;
|
|
1173
|
-
this.#previousWidth = width;
|
|
1174
|
-
this.#previousHeight = height;
|
|
1175
|
-
return;
|
|
1176
|
-
}
|
|
1206
|
+
// Differential rendering can only touch what was actually visible.
|
|
1207
|
+
// Any change above the previous viewport requires a full redraw so terminal
|
|
1208
|
+
// scrollback ends up consistent with the new transcript state.
|
|
1209
|
+
if (firstChanged < prevViewportTop) {
|
|
1210
|
+
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
1211
|
+
fullRender(true);
|
|
1212
|
+
return;
|
|
1177
1213
|
}
|
|
1178
1214
|
|
|
1179
1215
|
// Render from first changed line to end
|
|
@@ -1228,8 +1264,15 @@ export class TUI extends Container {
|
|
|
1228
1264
|
}
|
|
1229
1265
|
}
|
|
1230
1266
|
truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
|
|
1267
|
+
// Re-append the terminator: truncateToWidth removes trailing
|
|
1268
|
+
// content past the visible-width budget, which may also drop the
|
|
1269
|
+
// terminator appended by #applyLineResets. Match the conditional
|
|
1270
|
+
// OSC 8 close strategy used there.
|
|
1271
|
+
truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
|
|
1231
1272
|
}
|
|
1232
|
-
|
|
1273
|
+
// Non-image lines are pre-terminated/normalized by #applyLineResets;
|
|
1274
|
+
// truncated lines re-append LINE_TERMINATOR above.
|
|
1275
|
+
buffer += truncatedLine;
|
|
1233
1276
|
}
|
|
1234
1277
|
|
|
1235
1278
|
// Track where cursor ended up after rendering
|
package/src/utils.ts
CHANGED
|
@@ -112,12 +112,26 @@ export function visibleWidth(str: string): number {
|
|
|
112
112
|
return visibleWidthRaw(str);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
const
|
|
116
|
-
|
|
115
|
+
const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
|
|
116
|
+
const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Normalize text for terminal output without changing logical editor content.
|
|
120
|
+
* Some terminals render precomposed Thai/Lao AM vowels inconsistently during
|
|
121
|
+
* differential repaint. Their compatibility decompositions have the same cell
|
|
122
|
+
* width but avoid stale-cell artifacts in terminal renderers.
|
|
123
|
+
*/
|
|
124
|
+
export function normalizeTerminalOutput(str: string): string {
|
|
125
|
+
if (!THAI_LAO_AM_REGEX.test(str)) return str;
|
|
126
|
+
return str.replace(THAI_LAO_AM_GLOBAL_REGEX, char => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2"));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const makeBoolArray = (chars: string): Uint8Array => {
|
|
130
|
+
const table = new Uint8Array(128);
|
|
117
131
|
for (let i = 0; i < chars.length; i++) {
|
|
118
132
|
const code = chars.charCodeAt(i);
|
|
119
133
|
if (code < table.length) {
|
|
120
|
-
table[code] =
|
|
134
|
+
table[code] = 1;
|
|
121
135
|
}
|
|
122
136
|
}
|
|
123
137
|
return table;
|
|
@@ -129,8 +143,8 @@ const ASCII_WHITESPACE = makeBoolArray("\x09\x0a\x0b\x0c\x0d\x20");
|
|
|
129
143
|
* Check if a character is whitespace.
|
|
130
144
|
*/
|
|
131
145
|
export function isWhitespaceChar(char: string): boolean {
|
|
132
|
-
const code = char.codePointAt(0)
|
|
133
|
-
return ASCII_WHITESPACE[code]
|
|
146
|
+
const code = char.codePointAt(0) ?? 0;
|
|
147
|
+
return code < 128 && ASCII_WHITESPACE[code] === 1;
|
|
134
148
|
}
|
|
135
149
|
|
|
136
150
|
const ASCII_PUNCTUATION = makeBoolArray("(){}[]<>.,;:'\"!?+-=*/\\|&%^$#@~`");
|
|
@@ -139,8 +153,8 @@ const ASCII_PUNCTUATION = makeBoolArray("(){}[]<>.,;:'\"!?+-=*/\\|&%^$#@~`");
|
|
|
139
153
|
* Check if a character is punctuation.
|
|
140
154
|
*/
|
|
141
155
|
export function isPunctuationChar(char: string): boolean {
|
|
142
|
-
const code = char.codePointAt(0)
|
|
143
|
-
return ASCII_PUNCTUATION[code]
|
|
156
|
+
const code = char.codePointAt(0) ?? 0;
|
|
157
|
+
return code < 128 && ASCII_PUNCTUATION[code] === 1;
|
|
144
158
|
}
|
|
145
159
|
|
|
146
160
|
export type WordNavKind = "whitespace" | "delimiter" | "cjk" | "word" | "other";
|