@oh-my-pi/pi-tui 17.1.6 → 17.1.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 +21 -0
- package/README.md +2 -2
- package/dist/types/components/editor.d.ts +3 -0
- package/dist/types/components/markdown.d.ts +6 -0
- package/dist/types/keybindings.d.ts +6 -0
- package/dist/types/terminal.d.ts +4 -4
- package/package.json +3 -3
- package/src/components/editor.ts +91 -42
- package/src/components/markdown.ts +404 -56
- package/src/keybindings.ts +11 -2
- package/src/terminal.ts +53 -15
- package/src/tui.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.1.8] - 2026-07-28
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed wrapped Markdown list continuations losing their hanging indentation in narrow terminal layouts.
|
|
10
|
+
- Fixed an issue where emergency exits from fullscreen overlays could leave the Kitty keyboard protocol active, corrupting Arrow Up input in the terminal after exiting.
|
|
11
|
+
|
|
12
|
+
## [17.1.7] - 2026-07-27
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Added bulk-input fast path and iterative processing for bracketed paste in the editor
|
|
17
|
+
- Added windowed incremental lexing for large markdown documents
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Eliminated the dominant markdown streaming CPU cost (73% of a profiled interactive session): marked's GFM `url` tokenizer and `lheading` rule are now gated by O(1)/O(n) charCode pre-checks, the pathological `hr`/`lheading`/`table`/`html` block rules use sticky clones that fail at offset 0 instead of rescanning the source, and the inline math/autolink `start()` scans dropped their regex alternations
|
|
22
|
+
- Streaming markdown now freezes the stable prefix through provably closed lists instead of re-lexing everything after the last non-list block on every delta
|
|
23
|
+
- Raised the markdown render cache entry budget (32 KiB → 256 KiB) so large messages — exactly the expensive renders — are cacheable
|
|
24
|
+
- Deduplicated terminal cursor-visibility writes to skip redundant escape sequences
|
|
25
|
+
|
|
5
26
|
## [17.1.6] - 2026-07-27
|
|
6
27
|
|
|
7
28
|
### Fixed
|
package/README.md
CHANGED
|
@@ -528,8 +528,8 @@ interface Terminal {
|
|
|
528
528
|
get columns(): number;
|
|
529
529
|
get rows(): number;
|
|
530
530
|
moveBy(lines: number): void;
|
|
531
|
-
hideCursor(): void;
|
|
532
|
-
showCursor(): void;
|
|
531
|
+
hideCursor(force?: boolean): void;
|
|
532
|
+
showCursor(force?: boolean): void;
|
|
533
533
|
clearLine(): void;
|
|
534
534
|
clearFromCursor(): void;
|
|
535
535
|
clearScreen(): void;
|
|
@@ -111,6 +111,9 @@ export declare class Editor implements Component, Focusable {
|
|
|
111
111
|
render(width: number): readonly string[];
|
|
112
112
|
handleInput(data: string): void;
|
|
113
113
|
getText(): string;
|
|
114
|
+
/** Whether the buffer text equals `value`, without `getText()`'s full join —
|
|
115
|
+
* O(1) for the hot per-keystroke probes against short single-line values. */
|
|
116
|
+
textEquals(value: string): boolean;
|
|
114
117
|
/**
|
|
115
118
|
* Get text with paste markers expanded to their actual content.
|
|
116
119
|
* Use this when you need the full content (e.g., for external editor).
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { SymbolTheme } from "../symbols.js";
|
|
2
2
|
import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui.js";
|
|
3
|
+
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
4
|
+
export declare function mathStartIndex(src: string): number | undefined;
|
|
5
|
+
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
6
|
+
export declare function autolinkSchemeScanIndex(src: string): number | undefined;
|
|
7
|
+
/** @internal exported for tests — must never return false for a src the built-in url regex matches. */
|
|
8
|
+
export declare function urlTokenPossible(src: string): boolean;
|
|
3
9
|
/** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
|
|
4
10
|
export declare function clearRenderCache(): void;
|
|
5
11
|
/**
|
|
@@ -180,6 +180,12 @@ export declare class KeybindingsManager {
|
|
|
180
180
|
#private;
|
|
181
181
|
constructor(definitions: KeybindingDefinitions, userBindings?: KeybindingsConfig);
|
|
182
182
|
matches(data: string, keybinding: Keybinding): boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Set-lookup variant of {@link matches} for hot input paths: the caller
|
|
185
|
+
* parses `data` once (`parseKey` + `canonicalKeyId`) and probes many
|
|
186
|
+
* bindings without re-parsing the raw sequence per probe.
|
|
187
|
+
*/
|
|
188
|
+
matchesCanonical(canonical: string | undefined, keybinding: Keybinding): boolean;
|
|
183
189
|
getKeys(keybinding: Keybinding): KeyId[];
|
|
184
190
|
getDefinition(keybinding: Keybinding): KeybindingDefinition;
|
|
185
191
|
getConflicts(): KeybindingConflict[];
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -46,8 +46,8 @@ export interface Terminal {
|
|
|
46
46
|
readonly keyboardEnhancementEnterSequence?: string | null;
|
|
47
47
|
readonly keyboardEnhancementExitSequence?: string | null;
|
|
48
48
|
moveBy(lines: number): void;
|
|
49
|
-
hideCursor(): void;
|
|
50
|
-
showCursor(): void;
|
|
49
|
+
hideCursor(force?: boolean): void;
|
|
50
|
+
showCursor(force?: boolean): void;
|
|
51
51
|
clearLine(): void;
|
|
52
52
|
clearFromCursor(): void;
|
|
53
53
|
clearScreen(): void;
|
|
@@ -118,8 +118,8 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
118
118
|
get columns(): number;
|
|
119
119
|
get rows(): number;
|
|
120
120
|
moveBy(lines: number): void;
|
|
121
|
-
hideCursor(): void;
|
|
122
|
-
showCursor(): void;
|
|
121
|
+
hideCursor(force?: boolean): void;
|
|
122
|
+
showCursor(force?: boolean): void;
|
|
123
123
|
clearLine(): void;
|
|
124
124
|
clearFromCursor(): void;
|
|
125
125
|
clearScreen(): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "17.1.
|
|
4
|
+
"version": "17.1.8",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "17.1.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.1.
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.1.8",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.1.8",
|
|
42
42
|
"lru-cache": "11.5.2",
|
|
43
43
|
"marked": "^18.0.6"
|
|
44
44
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
midPromptSkillTokenMatches,
|
|
7
7
|
} from "../autocomplete";
|
|
8
8
|
import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
|
|
9
|
-
import { getKeybindings, type KeybindingsManager } from "../keybindings";
|
|
10
|
-
import { extractPrintableText, matchesKey } from "../keys";
|
|
9
|
+
import { canonicalKeyId, getKeybindings, type KeybindingsManager } from "../keybindings";
|
|
10
|
+
import { extractPrintableText, matchesKey, parseKey } from "../keys";
|
|
11
11
|
import { KillRing } from "../kill-ring";
|
|
12
12
|
import type { SymbolTheme } from "../symbols";
|
|
13
13
|
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
@@ -341,6 +341,17 @@ function maxSegmentVisualCol(text: string, isLastSegment: boolean): number {
|
|
|
341
341
|
return isLastSegment ? total : Math.max(0, total - lastWidth);
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
+
/** True when every code unit is plain printable text: no C0 controls (so no
|
|
345
|
+
* ESC/CR/LF/TAB), no DEL, no C1 range — the same set `extractPrintableText`
|
|
346
|
+
* rejects. Such a run can never encode a key sequence. */
|
|
347
|
+
function isPlainTextRun(data: string): boolean {
|
|
348
|
+
for (let i = 0; i < data.length; i++) {
|
|
349
|
+
const code = data.charCodeAt(i);
|
|
350
|
+
if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) return false;
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
344
355
|
const DEFAULT_PAGE_SCROLL_LINES = 10;
|
|
345
356
|
|
|
346
357
|
const MAX_UNDO_STACK = 100;
|
|
@@ -1126,12 +1137,30 @@ export class Editor implements Component, Focusable {
|
|
|
1126
1137
|
}
|
|
1127
1138
|
|
|
1128
1139
|
handleInput(data: string): void {
|
|
1140
|
+
// Iterative, not recursive: the bytes trailing a completed bracketed
|
|
1141
|
+
// paste (which may themselves contain further pastes) loop back here,
|
|
1142
|
+
// so a fragmented paste stream can never grow the call stack.
|
|
1143
|
+
let next: string | undefined = data;
|
|
1144
|
+
while (next !== undefined && next.length > 0) {
|
|
1145
|
+
next = this.#handleInputChunk(next);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Process one input chunk. Returns the unconsumed tail of a completed paste, if any. */
|
|
1150
|
+
#handleInputChunk(data: string): string | undefined {
|
|
1129
1151
|
const kb = getKeybindings();
|
|
1152
|
+
// Parse the sequence once; every binding probe below is then a set
|
|
1153
|
+
// lookup instead of re-parsing `data` per probe (~35 probes per key).
|
|
1154
|
+
const parsedKey = parseKey(data);
|
|
1155
|
+
const canonical = parsedKey === undefined ? undefined : canonicalKeyId(parsedKey);
|
|
1130
1156
|
|
|
1131
1157
|
// Handle character jump mode (awaiting next character to jump to)
|
|
1132
1158
|
if (this.#jumpMode !== null) {
|
|
1133
1159
|
// Cancel if the hotkey is pressed again
|
|
1134
|
-
if (
|
|
1160
|
+
if (
|
|
1161
|
+
kb.matchesCanonical(canonical, "tui.editor.jumpForward") ||
|
|
1162
|
+
kb.matchesCanonical(canonical, "tui.editor.jumpBackward")
|
|
1163
|
+
) {
|
|
1135
1164
|
this.#jumpMode = null;
|
|
1136
1165
|
return;
|
|
1137
1166
|
}
|
|
@@ -1154,12 +1183,23 @@ export class Editor implements Component, Focusable {
|
|
|
1154
1183
|
if (paste.pasteContent !== undefined) {
|
|
1155
1184
|
this.#handlePaste(paste.pasteContent);
|
|
1156
1185
|
if (paste.remaining.length > 0) {
|
|
1157
|
-
|
|
1186
|
+
return paste.remaining;
|
|
1158
1187
|
}
|
|
1159
1188
|
}
|
|
1160
1189
|
return;
|
|
1161
1190
|
}
|
|
1162
1191
|
|
|
1192
|
+
// Bulk printable fast path: a multi-scalar run of plain text (paste
|
|
1193
|
+
// remainder, batched stdin) parses to no key, so no binding probe or
|
|
1194
|
+
// special-key branch below can consume it — it always falls through to
|
|
1195
|
+
// one #insertCharacter call. Take that path directly and skip the
|
|
1196
|
+
// dispatch cascade. Runs containing ESC or control bytes (including
|
|
1197
|
+
// \r/\n) keep the full path: those bytes carry key semantics.
|
|
1198
|
+
if (canonical === undefined && data.length > 1 && isPlainTextRun(data)) {
|
|
1199
|
+
this.#insertCharacter(data);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1163
1203
|
// Handle special key combinations first
|
|
1164
1204
|
|
|
1165
1205
|
// Ctrl+C is reserved by parent components for app-level handling.
|
|
@@ -1170,7 +1210,7 @@ export class Editor implements Component, Focusable {
|
|
|
1170
1210
|
}
|
|
1171
1211
|
|
|
1172
1212
|
// Undo
|
|
1173
|
-
if (kb.
|
|
1213
|
+
if (kb.matchesCanonical(canonical, "tui.editor.undo")) {
|
|
1174
1214
|
this.#applyUndo();
|
|
1175
1215
|
return;
|
|
1176
1216
|
}
|
|
@@ -1178,26 +1218,26 @@ export class Editor implements Component, Focusable {
|
|
|
1178
1218
|
// Handle autocomplete special keys first (but don't block other input)
|
|
1179
1219
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1180
1220
|
// Escape - cancel autocomplete
|
|
1181
|
-
if (kb.
|
|
1221
|
+
if (kb.matchesCanonical(canonical, "tui.select.cancel")) {
|
|
1182
1222
|
this.#cancelAutocomplete(true);
|
|
1183
1223
|
return;
|
|
1184
1224
|
}
|
|
1185
1225
|
// Let the autocomplete list handle navigation and selection
|
|
1186
1226
|
else if (
|
|
1187
|
-
kb.
|
|
1188
|
-
kb.
|
|
1189
|
-
kb.
|
|
1190
|
-
kb.
|
|
1191
|
-
kb.
|
|
1227
|
+
kb.matchesCanonical(canonical, "tui.select.up") ||
|
|
1228
|
+
kb.matchesCanonical(canonical, "tui.select.down") ||
|
|
1229
|
+
kb.matchesCanonical(canonical, "tui.select.pageUp") ||
|
|
1230
|
+
kb.matchesCanonical(canonical, "tui.select.pageDown") ||
|
|
1231
|
+
kb.matchesCanonical(canonical, "tui.input.submit") ||
|
|
1192
1232
|
data === "\n" ||
|
|
1193
|
-
kb.
|
|
1233
|
+
kb.matchesCanonical(canonical, "tui.input.tab")
|
|
1194
1234
|
) {
|
|
1195
1235
|
// Only pass navigation keys to the list, not Enter/Tab (we handle those directly)
|
|
1196
1236
|
if (
|
|
1197
|
-
kb.
|
|
1198
|
-
kb.
|
|
1199
|
-
kb.
|
|
1200
|
-
kb.
|
|
1237
|
+
kb.matchesCanonical(canonical, "tui.select.up") ||
|
|
1238
|
+
kb.matchesCanonical(canonical, "tui.select.down") ||
|
|
1239
|
+
kb.matchesCanonical(canonical, "tui.select.pageUp") ||
|
|
1240
|
+
kb.matchesCanonical(canonical, "tui.select.pageDown")
|
|
1201
1241
|
) {
|
|
1202
1242
|
this.#autocompleteList.handleInput(data);
|
|
1203
1243
|
this.onAutocompleteUpdate?.();
|
|
@@ -1205,7 +1245,7 @@ export class Editor implements Component, Focusable {
|
|
|
1205
1245
|
}
|
|
1206
1246
|
|
|
1207
1247
|
// If Tab was pressed, always apply the selection
|
|
1208
|
-
if (kb.
|
|
1248
|
+
if (kb.matchesCanonical(canonical, "tui.input.tab")) {
|
|
1209
1249
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1210
1250
|
// Check for stale autocomplete state due to buffer edits since last refresh
|
|
1211
1251
|
// (destructive keys or paste can outrun the debounced update).
|
|
@@ -1249,7 +1289,7 @@ export class Editor implements Component, Focusable {
|
|
|
1249
1289
|
// If Enter was pressed on a submitted slash command (not an absolute-path
|
|
1250
1290
|
// completion sharing the leading-slash prefix), apply and submit.
|
|
1251
1291
|
if (
|
|
1252
|
-
(kb.
|
|
1292
|
+
(kb.matchesCanonical(canonical, "tui.input.submit") || data === "\n") &&
|
|
1253
1293
|
findLeadingSlashCommandStart(this.#autocompletePrefix) !== null &&
|
|
1254
1294
|
this.#isInSubmittedSlashCommandContext() &&
|
|
1255
1295
|
!this.#selectedCompletionIsPath()
|
|
@@ -1281,7 +1321,7 @@ export class Editor implements Component, Focusable {
|
|
|
1281
1321
|
// Don't return - fall through to submission logic
|
|
1282
1322
|
}
|
|
1283
1323
|
// Otherwise, apply the completion without submitting the surrounding draft.
|
|
1284
|
-
else if (kb.
|
|
1324
|
+
else if (kb.matchesCanonical(canonical, "tui.input.submit") || data === "\n") {
|
|
1285
1325
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1286
1326
|
// Check for stale autocomplete state due to buffer edits since last refresh.
|
|
1287
1327
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
@@ -1321,37 +1361,37 @@ export class Editor implements Component, Focusable {
|
|
|
1321
1361
|
}
|
|
1322
1362
|
|
|
1323
1363
|
// Tab key - context-aware completion (but not when already autocompleting)
|
|
1324
|
-
if (kb.
|
|
1364
|
+
if (kb.matchesCanonical(canonical, "tui.input.tab") && !this.#autocompleteState) {
|
|
1325
1365
|
this.#handleTabCompletion();
|
|
1326
1366
|
return;
|
|
1327
1367
|
}
|
|
1328
1368
|
|
|
1329
1369
|
// Continue with rest of input handling
|
|
1330
1370
|
// Delete to end of line
|
|
1331
|
-
if (kb.
|
|
1371
|
+
if (kb.matchesCanonical(canonical, "tui.editor.deleteToLineEnd")) {
|
|
1332
1372
|
this.#deleteToEndOfLine();
|
|
1333
1373
|
}
|
|
1334
1374
|
// Delete to start of line
|
|
1335
|
-
else if (kb.
|
|
1375
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.deleteToLineStart")) {
|
|
1336
1376
|
this.#deleteToStartOfLine();
|
|
1337
1377
|
}
|
|
1338
1378
|
// Delete word backward. Registry defaults cover ctrl+w, alt+backspace,
|
|
1339
1379
|
// ctrl+backspace, and super+alt+backspace (Ghostty on macOS reports
|
|
1340
1380
|
// Option+Backspace as super+alt — kitty mod 11, see #2064).
|
|
1341
|
-
else if (kb.
|
|
1381
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.deleteWordBackward")) {
|
|
1342
1382
|
this.#deleteWordBackwards();
|
|
1343
1383
|
}
|
|
1344
1384
|
// Delete word forward. Registry defaults cover alt+d/alt+delete and their
|
|
1345
1385
|
// super+alt variants for the same Ghostty quirk.
|
|
1346
|
-
else if (kb.
|
|
1386
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.deleteWordForward")) {
|
|
1347
1387
|
this.#deleteWordForwards();
|
|
1348
1388
|
}
|
|
1349
1389
|
// Yank from kill ring
|
|
1350
|
-
else if (kb.
|
|
1390
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.yank")) {
|
|
1351
1391
|
this.#yankFromKillRing();
|
|
1352
1392
|
}
|
|
1353
1393
|
// Yank-pop (cycle kill ring)
|
|
1354
|
-
else if (kb.
|
|
1394
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.yankPop")) {
|
|
1355
1395
|
this.#yankPop();
|
|
1356
1396
|
}
|
|
1357
1397
|
// Ctrl+A - Move to start of line
|
|
@@ -1376,7 +1416,7 @@ export class Editor implements Component, Focusable {
|
|
|
1376
1416
|
matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
|
|
1377
1417
|
data === "\x1b\r" || // Option+Enter in some terminals (legacy)
|
|
1378
1418
|
data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
|
|
1379
|
-
kb.
|
|
1419
|
+
kb.matchesCanonical(canonical, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
|
|
1380
1420
|
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
|
1381
1421
|
(data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
|
|
1382
1422
|
) {
|
|
@@ -1388,7 +1428,7 @@ export class Editor implements Component, Focusable {
|
|
|
1388
1428
|
this.#addNewLine();
|
|
1389
1429
|
}
|
|
1390
1430
|
// Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
|
|
1391
|
-
else if (kb.
|
|
1431
|
+
else if (kb.matchesCanonical(canonical, "tui.input.submit") || data === "\n") {
|
|
1392
1432
|
// If submit is disabled, do nothing
|
|
1393
1433
|
if (this.disableSubmit) {
|
|
1394
1434
|
return;
|
|
@@ -1429,40 +1469,40 @@ export class Editor implements Component, Focusable {
|
|
|
1429
1469
|
this.#submitValue();
|
|
1430
1470
|
}
|
|
1431
1471
|
// Backspace (including Shift+Backspace)
|
|
1432
|
-
else if (kb.
|
|
1472
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
|
|
1433
1473
|
this.#handleBackspace();
|
|
1434
1474
|
}
|
|
1435
1475
|
// Line navigation shortcuts (Home/End keys)
|
|
1436
|
-
else if (kb.
|
|
1476
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.cursorLineStart")) {
|
|
1437
1477
|
this.#moveToLineStart();
|
|
1438
|
-
} else if (kb.
|
|
1478
|
+
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorLineEnd")) {
|
|
1439
1479
|
this.#moveToLineEnd();
|
|
1440
1480
|
}
|
|
1441
1481
|
// Page navigation (PageUp/PageDown): page the editor viewport only. On a
|
|
1442
1482
|
// short draft this is a no-op — it never steps prompt history (that stays
|
|
1443
1483
|
// on Up/Down), so an idle empty editor swallows the keys instead of
|
|
1444
1484
|
// surprising the user by loading the previous prompt (#4754).
|
|
1445
|
-
else if (kb.
|
|
1485
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.pageUp")) {
|
|
1446
1486
|
this.#pageScroll(-1);
|
|
1447
|
-
} else if (kb.
|
|
1487
|
+
} else if (kb.matchesCanonical(canonical, "tui.editor.pageDown")) {
|
|
1448
1488
|
this.#pageScroll(1);
|
|
1449
1489
|
}
|
|
1450
1490
|
// Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
|
|
1451
|
-
else if (kb.
|
|
1491
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
|
|
1452
1492
|
this.#handleForwardDelete();
|
|
1453
1493
|
}
|
|
1454
1494
|
// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
|
|
1455
|
-
else if (kb.
|
|
1495
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.cursorWordLeft")) {
|
|
1456
1496
|
// Word left
|
|
1457
1497
|
this.#resetKillSequence();
|
|
1458
1498
|
this.#moveWordBackwards();
|
|
1459
|
-
} else if (kb.
|
|
1499
|
+
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorWordRight")) {
|
|
1460
1500
|
// Word right
|
|
1461
1501
|
this.#resetKillSequence();
|
|
1462
1502
|
this.#moveWordForwards();
|
|
1463
1503
|
}
|
|
1464
1504
|
// Arrow keys
|
|
1465
|
-
else if (kb.
|
|
1505
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.cursorUp")) {
|
|
1466
1506
|
// Up - history navigation or cursor movement
|
|
1467
1507
|
if (this.#isEditorEmpty()) {
|
|
1468
1508
|
this.#navigateHistory(-1); // Start browsing history
|
|
@@ -1474,7 +1514,7 @@ export class Editor implements Component, Focusable {
|
|
|
1474
1514
|
} else {
|
|
1475
1515
|
this.#moveCursor(-1, 0); // Cursor movement (within text or history entry)
|
|
1476
1516
|
}
|
|
1477
|
-
} else if (kb.
|
|
1517
|
+
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorDown")) {
|
|
1478
1518
|
// Down - history navigation or cursor movement
|
|
1479
1519
|
if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
|
|
1480
1520
|
this.#navigateHistory(1); // Navigate to newer history entry or clear
|
|
@@ -1484,10 +1524,10 @@ export class Editor implements Component, Focusable {
|
|
|
1484
1524
|
} else {
|
|
1485
1525
|
this.#moveCursor(1, 0); // Cursor movement (within text or history entry)
|
|
1486
1526
|
}
|
|
1487
|
-
} else if (kb.
|
|
1527
|
+
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorRight")) {
|
|
1488
1528
|
// Right
|
|
1489
1529
|
this.#moveCursor(0, 1);
|
|
1490
|
-
} else if (kb.
|
|
1530
|
+
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorLeft")) {
|
|
1491
1531
|
// Left
|
|
1492
1532
|
this.#moveCursor(0, -1);
|
|
1493
1533
|
}
|
|
@@ -1496,9 +1536,9 @@ export class Editor implements Component, Focusable {
|
|
|
1496
1536
|
this.#insertCharacter(" ");
|
|
1497
1537
|
}
|
|
1498
1538
|
// Character jump mode triggers
|
|
1499
|
-
else if (kb.
|
|
1539
|
+
else if (kb.matchesCanonical(canonical, "tui.editor.jumpForward")) {
|
|
1500
1540
|
this.#jumpMode = "forward";
|
|
1501
|
-
} else if (kb.
|
|
1541
|
+
} else if (kb.matchesCanonical(canonical, "tui.editor.jumpBackward")) {
|
|
1502
1542
|
this.#jumpMode = "backward";
|
|
1503
1543
|
}
|
|
1504
1544
|
// Printable keystrokes, including Kitty CSI-u text-producing sequences.
|
|
@@ -1630,6 +1670,15 @@ export class Editor implements Component, Focusable {
|
|
|
1630
1670
|
return this.#state.lines.join("\n");
|
|
1631
1671
|
}
|
|
1632
1672
|
|
|
1673
|
+
/** Whether the buffer text equals `value`, without `getText()`'s full join —
|
|
1674
|
+
* O(1) for the hot per-keystroke probes against short single-line values. */
|
|
1675
|
+
textEquals(value: string): boolean {
|
|
1676
|
+
const lines = this.#state.lines;
|
|
1677
|
+
if (lines.length === 1) return lines[0] === value;
|
|
1678
|
+
if (value.indexOf("\n") === -1) return false;
|
|
1679
|
+
return this.getText() === value;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1633
1682
|
#expandPasteMarkers(text: string): string {
|
|
1634
1683
|
let result = text;
|
|
1635
1684
|
for (const [pasteId, pasteContent] of this.#pastes) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { LRUCache } from "lru-cache/raw";
|
|
2
|
-
import { Marked, type Token, Tokenizer, type TokenizerAndRendererExtension, type Tokens } from "marked";
|
|
2
|
+
import { Lexer, Marked, type Token, Tokenizer, type TokenizerAndRendererExtension, type Tokens } from "marked";
|
|
3
3
|
import { latexToBlock } from "../latex-block";
|
|
4
4
|
import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
|
|
5
5
|
import type { SymbolTheme } from "../symbols";
|
|
@@ -492,12 +492,25 @@ const customHrExtension: TokenizerAndRendererExtension = {
|
|
|
492
492
|
},
|
|
493
493
|
};
|
|
494
494
|
|
|
495
|
+
// Leftmost-match scan replacing /\$|\\\(|\\\[/ in mathExtension.start —
|
|
496
|
+
// marked calls start() on the remaining source at every inline position, so
|
|
497
|
+
// the regex alternation showed up in CPU profiles (part of a ~4.3% start()
|
|
498
|
+
// tail). Three indexOf scans yield the identical leftmost index.
|
|
499
|
+
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
500
|
+
export function mathStartIndex(src: string): number | undefined {
|
|
501
|
+
let best = src.indexOf("$");
|
|
502
|
+
const paren = src.indexOf("\\(");
|
|
503
|
+
if (paren !== -1 && (best === -1 || paren < best)) best = paren;
|
|
504
|
+
const bracket = src.indexOf("\\[");
|
|
505
|
+
if (bracket !== -1 && (best === -1 || bracket < best)) best = bracket;
|
|
506
|
+
return best === -1 ? undefined : best;
|
|
507
|
+
}
|
|
508
|
+
|
|
495
509
|
const mathExtension: TokenizerAndRendererExtension = {
|
|
496
510
|
name: "math",
|
|
497
511
|
level: "inline",
|
|
498
512
|
start(src) {
|
|
499
|
-
|
|
500
|
-
return m ? m.index : undefined;
|
|
513
|
+
return mathStartIndex(src);
|
|
501
514
|
},
|
|
502
515
|
tokenizer(src) {
|
|
503
516
|
if (src.startsWith("$$")) {
|
|
@@ -614,14 +627,63 @@ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
|
|
|
614
627
|
// tokenizer at a valid start. Candidates at a legal boundary fall through
|
|
615
628
|
// (return undefined) to marked's own autolink handling unchanged.
|
|
616
629
|
const AUTOLINK_SCHEME_REGEX = /^(?:www\.|https?:\/\/|ftp:\/\/)/i;
|
|
617
|
-
|
|
630
|
+
// Case-insensitive scheme scan replacing /www\.|https?:\/\/|ftp:\/\//i in
|
|
631
|
+
// boundedAutolinkExtension.start — like mathStartIndex above, this runs on the
|
|
632
|
+
// remaining source at every inline position (part of a ~4.3% CPU start() scan
|
|
633
|
+
// tail in profiles). charCode-only: no allocation, no toLowerCase copies.
|
|
634
|
+
// `| 32` lower-cases ASCII letters; `.`/`:`/`/` are compared exactly, matching
|
|
635
|
+
// the regex's ASCII-only `i` semantics. charCodeAt past the end returns NaN,
|
|
636
|
+
// which fails every comparison, so no explicit bounds checks are needed.
|
|
637
|
+
function isAutolinkSchemeAt(src: string, i: number): boolean {
|
|
638
|
+
const c = src.charCodeAt(i) | 32;
|
|
639
|
+
if (c === 119 /* w */) {
|
|
640
|
+
// www.
|
|
641
|
+
return (
|
|
642
|
+
(src.charCodeAt(i + 1) | 32) === 119 &&
|
|
643
|
+
(src.charCodeAt(i + 2) | 32) === 119 &&
|
|
644
|
+
src.charCodeAt(i + 3) === 46 /* . */
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
if (c === 104 /* h */) {
|
|
648
|
+
// http:// | https://
|
|
649
|
+
if (
|
|
650
|
+
(src.charCodeAt(i + 1) | 32) !== 116 /* t */ ||
|
|
651
|
+
(src.charCodeAt(i + 2) | 32) !== 116 /* t */ ||
|
|
652
|
+
(src.charCodeAt(i + 3) | 32) !== 112 /* p */
|
|
653
|
+
) {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
let j = i + 4;
|
|
657
|
+
if ((src.charCodeAt(j) | 32) === 115 /* s */) j++;
|
|
658
|
+
return src.charCodeAt(j) === 58 /* : */ && src.charCodeAt(j + 1) === 47 /* / */ && src.charCodeAt(j + 2) === 47;
|
|
659
|
+
}
|
|
660
|
+
if (c === 102 /* f */) {
|
|
661
|
+
// ftp://
|
|
662
|
+
return (
|
|
663
|
+
(src.charCodeAt(i + 1) | 32) === 116 /* t */ &&
|
|
664
|
+
(src.charCodeAt(i + 2) | 32) === 112 /* p */ &&
|
|
665
|
+
src.charCodeAt(i + 3) === 58 /* : */ &&
|
|
666
|
+
src.charCodeAt(i + 4) === 47 /* / */ &&
|
|
667
|
+
src.charCodeAt(i + 5) === 47 /* / */
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** @internal exported for tests — must stay index-identical to the old regex scan. */
|
|
674
|
+
export function autolinkSchemeScanIndex(src: string): number | undefined {
|
|
675
|
+
for (let i = 0; i < src.length; i++) {
|
|
676
|
+
const c = src.charCodeAt(i) | 32;
|
|
677
|
+
if ((c === 119 || c === 104 || c === 102) && isAutolinkSchemeAt(src, i)) return i;
|
|
678
|
+
}
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
618
681
|
const VALID_AUTOLINK_LEFT_BOUNDARY = /[\s*_~(]/;
|
|
619
682
|
const boundedAutolinkExtension: TokenizerAndRendererExtension = {
|
|
620
683
|
name: "boundedAutolink",
|
|
621
684
|
level: "inline",
|
|
622
685
|
start(src) {
|
|
623
|
-
|
|
624
|
-
return m ? m.index : undefined;
|
|
686
|
+
return autolinkSchemeScanIndex(src);
|
|
625
687
|
},
|
|
626
688
|
tokenizer(src, tokens) {
|
|
627
689
|
const match = AUTOLINK_SCHEME_REGEX.exec(src);
|
|
@@ -639,6 +701,128 @@ markdownParser.use({
|
|
|
639
701
|
extensions: [customHrExtension, mathBlockExtension, mathEnvBlockExtension, mathExtension, boundedAutolinkExtension],
|
|
640
702
|
});
|
|
641
703
|
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
// GFM `url` tokenizer gate
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
// marked tries the bundled GFM `url` tokenizer at every inline tokenization
|
|
708
|
+
// step, and its regex is expensive to FAIL: the email alternative
|
|
709
|
+
// `^[A-Za-z0-9._+-]+(@)…` linearly consumes an identifier run, then backtracks
|
|
710
|
+
// it one character at a time when no `@` follows. A 71414-sample / 1ms CPU
|
|
711
|
+
// profile of the TUI put 73.3% of total CPU (74.9s of a 102s capture) inside
|
|
712
|
+
// this single regex. The override below runs an O(bounded) charCode gate first
|
|
713
|
+
// and only falls through to the built-in tokenizer — by returning `false`,
|
|
714
|
+
// marked's tokenizer-override fallback contract — when a match is possible.
|
|
715
|
+
//
|
|
716
|
+
// Conservativeness argument. The built-in rule (no flags) is
|
|
717
|
+
// /^((?:[hH][tT][tT][pP][sS]?|[fF][tT][pP]):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*
|
|
718
|
+
// |^[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/
|
|
719
|
+
// Both alternatives are anchored, so any match constrains the head of src:
|
|
720
|
+
// • Branch 1 requires src to start with `http://`, `https://`, `ftp://`
|
|
721
|
+
// (scheme letters in any case) or lowercase `www.`. The gate accepts all of
|
|
722
|
+
// these via isAutolinkSchemeAt(src, 0); it also over-accepts `WWW.`, a
|
|
723
|
+
// harmless false positive (the built-in regex simply fails to match).
|
|
724
|
+
// • Branch 2 requires src to start with one-or-more chars from
|
|
725
|
+
// `[A-Za-z0-9._+-]` immediately followed by `@`. The gate scans that exact
|
|
726
|
+
// class: if the run ends within URL_GATE_EMAIL_SCAN_LIMIT chars it accepts
|
|
727
|
+
// iff the terminator is `@`; a run reaching the limit is accepted
|
|
728
|
+
// unconditionally. Every src branch 2 can match is therefore accepted —
|
|
729
|
+
// the gate never rejects a src the built-in regex would match.
|
|
730
|
+
const URL_GATE_EMAIL_SCAN_LIMIT = 320;
|
|
731
|
+
|
|
732
|
+
/** @internal exported for tests — must never return false for a src the built-in url regex matches. */
|
|
733
|
+
export function urlTokenPossible(src: string): boolean {
|
|
734
|
+
if (isAutolinkSchemeAt(src, 0)) return true;
|
|
735
|
+
let i = 0;
|
|
736
|
+
while (i < URL_GATE_EMAIL_SCAN_LIMIT) {
|
|
737
|
+
const c = src.charCodeAt(i);
|
|
738
|
+
const isLocalChar =
|
|
739
|
+
(c >= 97 && c <= 122) /* a-z */ ||
|
|
740
|
+
(c >= 65 && c <= 90) /* A-Z */ ||
|
|
741
|
+
(c >= 48 && c <= 57) /* 0-9 */ ||
|
|
742
|
+
c === 46 /* . */ ||
|
|
743
|
+
c === 95 /* _ */ ||
|
|
744
|
+
c === 43 /* + */ ||
|
|
745
|
+
c === 45; /* - */
|
|
746
|
+
if (!isLocalChar) break;
|
|
747
|
+
i++;
|
|
748
|
+
}
|
|
749
|
+
if (i === 0) return false;
|
|
750
|
+
if (i >= URL_GATE_EMAIL_SCAN_LIMIT) return true; // over-long run: give up conservatively
|
|
751
|
+
return src.charCodeAt(i) === 64 /* @ */;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Setext-underline pre-gate for marked's `lheading` rule. The rule's lazy body
|
|
755
|
+
// `((?:.|\n(?!<block-start>))+?)` re-runs its block-start lookahead while
|
|
756
|
+
// expanding character by character, so even a FAILING attempt at offset 0
|
|
757
|
+
// costs O(len × lookahead) — ~26µs per 200-char list-item body, and marked's
|
|
758
|
+
// list tokenizer block-tokenizes every item's content (47.8% of a streaming
|
|
759
|
+
// bench profile). A match REQUIRES the setext underline `\n {0,3}(=+|-+)`
|
|
760
|
+
// somewhere in src, so this O(n) charCode scan never rejects a src the
|
|
761
|
+
// built-in rule would match; single-line srcs (every tight list item) reject
|
|
762
|
+
// on the first indexOf.
|
|
763
|
+
function lheadingPossible(src: string): boolean {
|
|
764
|
+
let i = src.indexOf("\n");
|
|
765
|
+
while (i !== -1) {
|
|
766
|
+
let j = i + 1;
|
|
767
|
+
const limit = j + 3; // underline allows up to 3 leading spaces
|
|
768
|
+
while (j < limit && src.charCodeAt(j) === 0x20 /* space */) j++;
|
|
769
|
+
const c = src.charCodeAt(j); // NaN past the end fails both comparisons
|
|
770
|
+
if (c === 0x3d /* = */ || c === 0x2d /* - */) return true;
|
|
771
|
+
i = src.indexOf("\n", j);
|
|
772
|
+
}
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
markdownParser.use({
|
|
777
|
+
tokenizer: {
|
|
778
|
+
// `false` → marked falls back to the built-in tokenizer;
|
|
779
|
+
// `undefined` → no token here, built-in never runs.
|
|
780
|
+
url(src: string): Tokens.Link | undefined | false {
|
|
781
|
+
return urlTokenPossible(src) ? false : undefined;
|
|
782
|
+
},
|
|
783
|
+
lheading(src: string): Tokens.Heading | undefined | false {
|
|
784
|
+
return lheadingPossible(src) ? false : undefined;
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
// Sticky clones of marked's pathological block rules
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
// Bun's (JSC) regex engine skips the start-anchor fast-fail for several of
|
|
793
|
+
// marked's `^`-anchored block rules — `hr`, `lheading`, `table` and `html` are
|
|
794
|
+
// anchored alternations of quantified branches, and a failing `exec`/`test`
|
|
795
|
+
// rescans the entire remaining source instead of stopping after offset 0.
|
|
796
|
+
// marked's list tokenizer runs `hr.test` and `lheading` per list line against
|
|
797
|
+
// the remaining source, so lexing a long list is quadratic (66% of a streaming
|
|
798
|
+
// bench profile sat in these two regexes). A sticky (`y`) clone with
|
|
799
|
+
// `lastIndex` pinned to 0 attempts the match at offset 0 only.
|
|
800
|
+
//
|
|
801
|
+
// Equivalence: for a flagless rule whose source is `^`-anchored, a sticky
|
|
802
|
+
// clone at `lastIndex = 0` matches exactly when the original matches (same
|
|
803
|
+
// match object, same captures) — `^` already restricted matches to offset 0
|
|
804
|
+
// (no `m` flag), and stickiness only removes the futile later attempts. The
|
|
805
|
+
// flags/anchor guard below skips any rule a future marked version changes.
|
|
806
|
+
class AnchoredAtZero extends RegExp {
|
|
807
|
+
exec(str: string): RegExpExecArray | null {
|
|
808
|
+
this.lastIndex = 0; // sticky matches set lastIndex; rules are shared
|
|
809
|
+
return super.exec(str);
|
|
810
|
+
}
|
|
811
|
+
test(str: string): boolean {
|
|
812
|
+
this.lastIndex = 0;
|
|
813
|
+
return super.test(str);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
for (const table of [Lexer.rules.block.normal, Lexer.rules.block.gfm]) {
|
|
818
|
+
for (const name of ["hr", "lheading", "table", "html"] as const) {
|
|
819
|
+
const rule = table[name];
|
|
820
|
+
if (rule.flags === "" && rule.source.startsWith("^")) {
|
|
821
|
+
table[name] = new AnchoredAtZero(rule.source, "y");
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
642
826
|
// ---------------------------------------------------------------------------
|
|
643
827
|
// Module-level LRU render cache
|
|
644
828
|
// ---------------------------------------------------------------------------
|
|
@@ -649,8 +833,8 @@ markdownParser.use({
|
|
|
649
833
|
// (Rust FFI) work for content/layout combinations already seen this session.
|
|
650
834
|
|
|
651
835
|
const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
|
|
652
|
-
const RENDER_CACHE_MAX_SIZE =
|
|
653
|
-
const RENDER_CACHE_MAX_ENTRY_SIZE =
|
|
836
|
+
const RENDER_CACHE_MAX_SIZE = 4 * 1024 * 1024;
|
|
837
|
+
const RENDER_CACHE_MAX_ENTRY_SIZE = 256 * 1024;
|
|
654
838
|
const EMPTY_RENDER_LINES: readonly string[] = [];
|
|
655
839
|
|
|
656
840
|
interface RenderCacheEntry {
|
|
@@ -684,6 +868,179 @@ function renderCacheEntrySize(entry: RenderCacheEntry): number {
|
|
|
684
868
|
// over-matching is safe (it only costs the fast path), under-matching is not.
|
|
685
869
|
const HAS_REF_DEF = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/m;
|
|
686
870
|
|
|
871
|
+
// marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
|
|
872
|
+
// blank lines only when the remaining source matches
|
|
873
|
+
// `listItemRegex(marker)` = `^( {0,3}${marker})((?:[\t ][^\n]*)?(?:\n|$))`,
|
|
874
|
+
// where `marker` is the exact bullet char for unordered lists (`\${char}`) or
|
|
875
|
+
// 1-9 digits plus the exact delimiter for ordered lists (`\d{1,9}\${delim}`).
|
|
876
|
+
// The marker is derived from the list's FIRST item (`n = t[1].trim()`), which
|
|
877
|
+
// sits at the start of a top-level list token's raw:
|
|
878
|
+
const LIST_MARKER_RE = /^ {0,3}(?:([*+-])|\d{1,9}([.)]))/;
|
|
879
|
+
|
|
880
|
+
// Streaming-freeze equivalence invariant: lex(prefix) ++ lex(tail) must equal
|
|
881
|
+
// lex(full text) — for the CURRENT text and for every append-only extension of
|
|
882
|
+
// it, because a frozen prefix is sticky (it keeps being reused while the text
|
|
883
|
+
// grows). At a blank-line (`\n\n`) cut directly after a top-level `list`
|
|
884
|
+
// token, the only construct that can straddle the cut is a continuation item
|
|
885
|
+
// of that list: marked consumed the blank line into the last item's raw and
|
|
886
|
+
// re-ran `listItemRegex` at exactly `tailStart`, merging a same-marker item
|
|
887
|
+
// into one renumbered loose list. The cut is safe only when that regex can
|
|
888
|
+
// NEVER match at `tailStart`, no matter what is appended later.
|
|
889
|
+
//
|
|
890
|
+
// Append-only growth means existing characters are immutable while new ones
|
|
891
|
+
// may appear after them, so "closed" may only be concluded from a present
|
|
892
|
+
// character that contradicts every possible continuation (e.g. tail "1x" can
|
|
893
|
+
// never grow into an ordered item, but tail "1" can become "1. c"). Running
|
|
894
|
+
// out of text mid-marker therefore answers "may continue".
|
|
895
|
+
//
|
|
896
|
+
// Returns true when the tail could still continue the list (or the list's
|
|
897
|
+
// marker is unrecognizable) — the conservative "don't freeze" answer. marked
|
|
898
|
+
// may break the list anyway when the matching line is also an hr (`- - -`);
|
|
899
|
+
// treating that as "may continue" merely skips a freeze, never corrupts one.
|
|
900
|
+
function listMayContinueAt(text: string, tailStart: number, listRaw: string): boolean {
|
|
901
|
+
const marker = LIST_MARKER_RE.exec(listRaw);
|
|
902
|
+
if (marker === null) return true; // unrecognized list shape — stay conservative
|
|
903
|
+
const n = text.length;
|
|
904
|
+
let i = tailStart;
|
|
905
|
+
// `listItemRegex` allows up to 3 leading spaces (the caller's next-char
|
|
906
|
+
// guard rejects whitespace at the final cut, but mirror the rule exactly).
|
|
907
|
+
while (i < n && i - tailStart < 3 && text.charCodeAt(i) === 0x20 /* space */) i++;
|
|
908
|
+
if (i >= n) return true;
|
|
909
|
+
const bullet = marker[1];
|
|
910
|
+
if (bullet !== undefined) {
|
|
911
|
+
if (text[i] !== bullet) return false; // wrong marker char — closed forever
|
|
912
|
+
i++;
|
|
913
|
+
} else {
|
|
914
|
+
// Ordered: 1-9 digits, then the same `.`/`)` delimiter.
|
|
915
|
+
let digits = 0;
|
|
916
|
+
while (i < n && digits < 10) {
|
|
917
|
+
const c = text.charCodeAt(i);
|
|
918
|
+
if (c < 0x30 /* 0 */ || c > 0x39 /* 9 */) break;
|
|
919
|
+
digits++;
|
|
920
|
+
i++;
|
|
921
|
+
}
|
|
922
|
+
if (digits === 0 || digits > 9) return false; // no digit run / too long — closed forever
|
|
923
|
+
if (i >= n) return true; // delimiter (or more digits) may still arrive
|
|
924
|
+
if (text[i] !== marker[2]) return false; // wrong delimiter — closed forever
|
|
925
|
+
i++;
|
|
926
|
+
}
|
|
927
|
+
// After the marker: `(?:[\t ][^\n]*)?(?:\n|$)` — tab/space + anything, a
|
|
928
|
+
// bare newline, or end-of-input (which appends can still extend).
|
|
929
|
+
if (i >= n) return true;
|
|
930
|
+
const after = text.charCodeAt(i);
|
|
931
|
+
return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a /* \n */;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const NO_BLOCK_BOUNDARY = { end: 0, count: 0 } as const;
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Offset just past the last token in `tokens` that closes a block on a hard
|
|
938
|
+
* `"\n\n"` break, together with the number of tokens up to and including it.
|
|
939
|
+
* `count === 0` means the run holds no usable boundary.
|
|
940
|
+
*
|
|
941
|
+
* `base` is where `tokens[0]` starts inside `text`. A boundary qualifies only
|
|
942
|
+
* when splitting there is invisible to the lexer, i.e. `lex(head) ++ lex(tail)
|
|
943
|
+
* === lex(text)`:
|
|
944
|
+
* - The break must sit inside `text`. At end-of-text the next character is
|
|
945
|
+
* unknown (and, while streaming, may still arrive), so the cut is deferred.
|
|
946
|
+
* - The next character must start real block content. Whitespace means the
|
|
947
|
+
* block separator straddles the cut — e.g. a fence followed by
|
|
948
|
+
* `"\n\n\n- list"` — and the two lexes desync.
|
|
949
|
+
* - A preceding `list` must be provably closed: CommonMark lets a same-marker
|
|
950
|
+
* item continue the list across the blank line, and marked merges both into
|
|
951
|
+
* one renumbered loose list (`listMayContinueAt`).
|
|
952
|
+
*/
|
|
953
|
+
function stableBlockBoundary(text: string, base: number, tokens: Token[]): { end: number; count: number } {
|
|
954
|
+
let pos = base;
|
|
955
|
+
let end = 0;
|
|
956
|
+
let count = 0;
|
|
957
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
958
|
+
const raw = tokens[i].raw;
|
|
959
|
+
const tokenEnd = pos + raw.length;
|
|
960
|
+
if (raw.endsWith("\n\n")) {
|
|
961
|
+
const prev = i > 0 ? tokens[i - 1] : undefined;
|
|
962
|
+
if (prev === undefined || prev.type !== "list" || !listMayContinueAt(text, tokenEnd, prev.raw)) {
|
|
963
|
+
end = tokenEnd;
|
|
964
|
+
count = i + 1;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
pos = tokenEnd;
|
|
968
|
+
}
|
|
969
|
+
if (count === 0 || end >= text.length) return NO_BLOCK_BOUNDARY;
|
|
970
|
+
const next = text.charCodeAt(end);
|
|
971
|
+
if (next === 0x20 /* space */ || next === 0x0a /* \n */) return NO_BLOCK_BOUNDARY;
|
|
972
|
+
return { end, count };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Bun's regex engine skips the start-anchor optimization for several of marked's
|
|
976
|
+
// block rules — `hr`, `lheading`, `table` and `html` are `^`-anchored
|
|
977
|
+
// alternations of quantified branches — so each failing `exec` rescans the whole
|
|
978
|
+
// remaining source instead of stopping at offset 0. Lexing is then quadratic in
|
|
979
|
+
// document length: an 800 KB message costs ~41 s under Bun where Node/V8 needs
|
|
980
|
+
// ~60 ms, and it runs on the render path, freezing the UI. Bounded windows keep
|
|
981
|
+
// every scan short and restore linear behavior (~0.7 s for that same message).
|
|
982
|
+
const LEX_WINDOW_BYTES = 2 * 1024;
|
|
983
|
+
// Under this size a single pass beats probing for window boundaries; the
|
|
984
|
+
// crossover measured on pathological Markdown sits around 16 KB.
|
|
985
|
+
const WINDOWED_LEX_MIN_BYTES = 16 * 1024;
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Lex `text` in bounded windows, producing the exact token stream
|
|
989
|
+
* `markdownParser.lexer(text)` would.
|
|
990
|
+
*
|
|
991
|
+
* Window cuts come from marked itself: a throwaway BLOCK-ONLY probe lex of the
|
|
992
|
+
* window reports its last stable block boundary ({@link stableBlockBoundary})
|
|
993
|
+
* and only that confirmed segment is handed to the real lexer; a window
|
|
994
|
+
* holding no boundary doubles until it finds one or reaches the end. Probes
|
|
995
|
+
* never run inline tokenization (their inlineQueue is discarded) — a boundary
|
|
996
|
+
* is a property of block structure alone, and probe inline passes were the
|
|
997
|
+
* dominant cost of an earlier revision. Block tokenization runs per window
|
|
998
|
+
* while inline tokenization is deferred to the end — mirroring `Lexer.lex` —
|
|
999
|
+
* so a `[label]: dest` definition anywhere in the document still resolves for
|
|
1000
|
+
* every inline span.
|
|
1001
|
+
*
|
|
1002
|
+
* A boundary requires some top-level token whose raw ends in `"\n\n"`, so a
|
|
1003
|
+
* window that contains no blank line cannot cut: each round starts at the next
|
|
1004
|
+
* `"\n\n"` (skipping straight to the end when there is none — e.g. a tail
|
|
1005
|
+
* that is one long tight list) instead of probing sizes that cannot succeed.
|
|
1006
|
+
*/
|
|
1007
|
+
function lexWindowed(text: string): Token[] {
|
|
1008
|
+
const lexer = new Lexer(markdownParser.defaults);
|
|
1009
|
+
let offset = 0;
|
|
1010
|
+
while (offset < text.length) {
|
|
1011
|
+
let segment = "";
|
|
1012
|
+
const nextBlank = text.indexOf("\n\n", offset);
|
|
1013
|
+
if (nextBlank === -1) {
|
|
1014
|
+
segment = text.slice(offset);
|
|
1015
|
+
} else {
|
|
1016
|
+
const minSize = Math.max(LEX_WINDOW_BYTES, nextBlank + 2 - offset);
|
|
1017
|
+
for (let size = minSize; segment.length === 0; size *= 2) {
|
|
1018
|
+
if (offset + size >= text.length) {
|
|
1019
|
+
segment = text.slice(offset);
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
const probe = new Lexer(markdownParser.defaults);
|
|
1023
|
+
probe.blockTokens(text.slice(offset, offset + size), probe.tokens);
|
|
1024
|
+
const boundary = stableBlockBoundary(text, offset, probe.tokens);
|
|
1025
|
+
if (boundary.count > 0) segment = text.slice(offset, boundary.end);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
lexer.blockTokens(segment, lexer.tokens);
|
|
1029
|
+
offset += segment.length;
|
|
1030
|
+
}
|
|
1031
|
+
for (const queued of lexer.inlineQueue) lexer.inlineTokens(queued.src, queued.tokens);
|
|
1032
|
+
lexer.inlineQueue = [];
|
|
1033
|
+
return lexer.tokens;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** Lex a whole document, windowing anything large enough for the quadratic scan to bite. */
|
|
1037
|
+
function lexDocument(text: string): Token[] {
|
|
1038
|
+
// A CR shifts every `raw` span (marked normalizes CRLF before tokenizing), so
|
|
1039
|
+
// window offsets would address the wrong characters — lex those in one pass.
|
|
1040
|
+
if (text.length < WINDOWED_LEX_MIN_BYTES || text.includes("\r")) return markdownParser.lexer(text);
|
|
1041
|
+
return lexWindowed(text);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
687
1044
|
/** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
|
|
688
1045
|
export function clearRenderCache(): void {
|
|
689
1046
|
renderCache.clear();
|
|
@@ -1219,12 +1576,12 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
1219
1576
|
text.length > prefix.length &&
|
|
1220
1577
|
text.startsWith(prefix)
|
|
1221
1578
|
) {
|
|
1222
|
-
const tailTokens =
|
|
1579
|
+
const tailTokens = lexDocument(text.slice(prefix.length));
|
|
1223
1580
|
const tokens = [...prefixTokens, ...tailTokens];
|
|
1224
1581
|
this.#freezeStablePrefix(text, tokens, { preserveExisting: true });
|
|
1225
1582
|
return tokens;
|
|
1226
1583
|
}
|
|
1227
|
-
const tokens =
|
|
1584
|
+
const tokens = lexDocument(text);
|
|
1228
1585
|
if (canStream) {
|
|
1229
1586
|
this.#freezeStablePrefix(text, tokens, { preserveExisting: false });
|
|
1230
1587
|
} else {
|
|
@@ -1241,36 +1598,11 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
1241
1598
|
// reference definitions, so each token's `raw` is a verbatim slice of `text`
|
|
1242
1599
|
// and the summed offsets address `text` exactly.
|
|
1243
1600
|
#freezeStablePrefix(text: string, tokens: Token[], opts: { preserveExisting: boolean }): void {
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
const end = pos + raw.length;
|
|
1250
|
-
// A `space` token ending in "\n\n" closes the preceding block, but a
|
|
1251
|
-
// `list` before it can still be extended by a following same-marker
|
|
1252
|
-
// item across the blank line (CommonMark loose-list continuation),
|
|
1253
|
-
// which marked merges into one renumbered loose list. Freezing across
|
|
1254
|
-
// such a cut would keep the lists separate. Never freeze right after a
|
|
1255
|
-
// list — it stays in the re-lexed tail.
|
|
1256
|
-
if (raw.endsWith("\n\n") && tokens[i - 1]?.type !== "list") {
|
|
1257
|
-
frozenEnd = end;
|
|
1258
|
-
frozenCount = i + 1;
|
|
1259
|
-
}
|
|
1260
|
-
pos = end;
|
|
1261
|
-
}
|
|
1262
|
-
// Freeze only when the tail begins with real block content. If the next
|
|
1263
|
-
// char is whitespace (an extra blank line, or an indented continuation),
|
|
1264
|
-
// the block separator straddles the cut and lex(prefix)++lex(tail) would
|
|
1265
|
-
// desync from a full lex — e.g. a fence followed by "\n\n\n- list". When
|
|
1266
|
-
// frozenEnd is at end-of-text the next char is unknown, so defer.
|
|
1267
|
-
if (frozenCount > 0 && frozenEnd < text.length) {
|
|
1268
|
-
const next = text.charCodeAt(frozenEnd);
|
|
1269
|
-
if (next !== 0x20 /* space */ && next !== 0x0a /* \n */) {
|
|
1270
|
-
this.#streamPrefixText = text.slice(0, frozenEnd);
|
|
1271
|
-
this.#streamPrefixTokens = tokens.slice(0, frozenCount);
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1601
|
+
const frozen = stableBlockBoundary(text, 0, tokens);
|
|
1602
|
+
if (frozen.count > 0) {
|
|
1603
|
+
this.#streamPrefixText = text.slice(0, frozen.end);
|
|
1604
|
+
this.#streamPrefixTokens = tokens.slice(0, frozen.count);
|
|
1605
|
+
return;
|
|
1274
1606
|
}
|
|
1275
1607
|
|
|
1276
1608
|
if (!opts.preserveExisting) {
|
|
@@ -1542,9 +1874,10 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
1542
1874
|
);
|
|
1543
1875
|
const tokenLineOffsets = [0];
|
|
1544
1876
|
for (const line of renderedTokenLines) {
|
|
1545
|
-
//
|
|
1546
|
-
//
|
|
1547
|
-
|
|
1877
|
+
// Lists wrap while their structural prefixes are still available, so
|
|
1878
|
+
// continuation rows retain the correct hanging indent. Re-wrapping the
|
|
1879
|
+
// flattened rows here would discard that structure.
|
|
1880
|
+
if (token.type === "list" || TERMINAL.isImageLine(line) || isOsc66Line(line)) {
|
|
1548
1881
|
wrappedLines.push(line);
|
|
1549
1882
|
} else {
|
|
1550
1883
|
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
|
|
@@ -1941,7 +2274,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
1941
2274
|
}
|
|
1942
2275
|
|
|
1943
2276
|
case "list": {
|
|
1944
|
-
const listLines = this.#renderList(token as ListToken, 0, styleContext);
|
|
2277
|
+
const listLines = this.#renderList(token as ListToken, 0, width, styleContext);
|
|
1945
2278
|
lines.push(...listLines);
|
|
1946
2279
|
// Don't add spacing after lists if a space token follows
|
|
1947
2280
|
// (the space token will handle it)
|
|
@@ -2257,46 +2590,60 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
2257
2590
|
/**
|
|
2258
2591
|
* Render a list with proper nesting support
|
|
2259
2592
|
*/
|
|
2260
|
-
#renderList(token: ListToken, depth: number, styleContext?: InlineStyleContext): string[] {
|
|
2593
|
+
#renderList(token: ListToken, depth: number, width: number, styleContext?: InlineStyleContext): string[] {
|
|
2261
2594
|
const lines: string[] = [];
|
|
2262
2595
|
const indent = " ".repeat(depth);
|
|
2263
2596
|
// Use the list's start property (defaults to 1 for ordered lists)
|
|
2264
2597
|
const startNumber = token.start ?? 1;
|
|
2598
|
+
const pushWrapped = (text: string, firstPrefix: string, continuationPrefix: string): void => {
|
|
2599
|
+
const prefixWidth = visibleWidth(firstPrefix);
|
|
2600
|
+
if (prefixWidth >= width) {
|
|
2601
|
+
lines.push(truncateToWidth(firstPrefix, width, Ellipsis.Omit));
|
|
2602
|
+
lines.push(...wrapTextWithAnsi(text, Math.max(1, width)));
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
const bodyWidth = width - prefixWidth;
|
|
2606
|
+
const wrapped = wrapTextWithAnsi(text, bodyWidth);
|
|
2607
|
+
if (wrapped.length === 0) {
|
|
2608
|
+
lines.push(firstPrefix);
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
lines.push(firstPrefix + wrapped[0]);
|
|
2612
|
+
for (let lineIndex = 1; lineIndex < wrapped.length; lineIndex++) {
|
|
2613
|
+
lines.push(continuationPrefix + wrapped[lineIndex]);
|
|
2614
|
+
}
|
|
2615
|
+
};
|
|
2265
2616
|
|
|
2266
2617
|
for (let i = 0; i < token.items.length; i++) {
|
|
2267
2618
|
const item = token.items[i];
|
|
2268
2619
|
const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
|
|
2620
|
+
const firstPrefix = indent + this.#theme.listBullet(bullet);
|
|
2269
2621
|
// Continuation rows align under the item text, so the hang matches the
|
|
2270
2622
|
// actual bullet width (`10. ` is 4 cells, not 2).
|
|
2271
|
-
const continuationIndent = indent + padding(bullet
|
|
2623
|
+
const continuationIndent = indent + padding(visibleWidth(bullet));
|
|
2272
2624
|
|
|
2273
2625
|
// Process item tokens; nested-list lines arrive structurally tagged and
|
|
2274
2626
|
// already carry their own full indent.
|
|
2275
|
-
const itemLines = this.#renderListItem(item.tokens || [], depth, styleContext);
|
|
2627
|
+
const itemLines = this.#renderListItem(item.tokens || [], depth, width, styleContext);
|
|
2276
2628
|
|
|
2277
2629
|
if (itemLines.length > 0) {
|
|
2278
2630
|
const firstLine = itemLines[0]!;
|
|
2279
2631
|
if (firstLine.nested) {
|
|
2280
|
-
// Nested list first - keep as-is (already has full indent)
|
|
2281
2632
|
lines.push(firstLine.text);
|
|
2282
2633
|
} else {
|
|
2283
|
-
|
|
2284
|
-
lines.push(indent + this.#theme.listBullet(bullet) + firstLine.text);
|
|
2634
|
+
pushWrapped(firstLine.text, firstPrefix, continuationIndent);
|
|
2285
2635
|
}
|
|
2286
2636
|
|
|
2287
|
-
// Rest of the lines
|
|
2288
2637
|
for (let j = 1; j < itemLines.length; j++) {
|
|
2289
2638
|
const line = itemLines[j]!;
|
|
2290
2639
|
if (line.nested) {
|
|
2291
|
-
// Nested list line - already has full indent
|
|
2292
2640
|
lines.push(line.text);
|
|
2293
2641
|
} else {
|
|
2294
|
-
|
|
2295
|
-
lines.push(continuationIndent + line.text);
|
|
2642
|
+
pushWrapped(line.text, continuationIndent, continuationIndent);
|
|
2296
2643
|
}
|
|
2297
2644
|
}
|
|
2298
2645
|
} else {
|
|
2299
|
-
lines.push(
|
|
2646
|
+
lines.push(firstPrefix);
|
|
2300
2647
|
}
|
|
2301
2648
|
}
|
|
2302
2649
|
|
|
@@ -2312,6 +2659,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
2312
2659
|
#renderListItem(
|
|
2313
2660
|
tokens: Token[],
|
|
2314
2661
|
parentDepth: number,
|
|
2662
|
+
width: number,
|
|
2315
2663
|
styleContext?: InlineStyleContext,
|
|
2316
2664
|
): Array<{ text: string; nested: boolean }> {
|
|
2317
2665
|
const lines: Array<{ text: string; nested: boolean }> = [];
|
|
@@ -2320,7 +2668,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
|
|
|
2320
2668
|
if (token.type === "list") {
|
|
2321
2669
|
// Nested list - render with one additional indent level
|
|
2322
2670
|
// These lines carry their own indent, so tag them for pass-through
|
|
2323
|
-
const nestedLines = this.#renderList(token as ListToken, parentDepth + 1, styleContext);
|
|
2671
|
+
const nestedLines = this.#renderList(token as ListToken, parentDepth + 1, width, styleContext);
|
|
2324
2672
|
for (const nestedLine of nestedLines) {
|
|
2325
2673
|
lines.push({ text: nestedLine, nested: true });
|
|
2326
2674
|
}
|
package/src/keybindings.ts
CHANGED
|
@@ -288,8 +288,17 @@ export class KeybindingsManager {
|
|
|
288
288
|
matches(data: string, keybinding: Keybinding): boolean {
|
|
289
289
|
const parsed = parseKey(data);
|
|
290
290
|
if (parsed === undefined) return false;
|
|
291
|
-
|
|
292
|
-
|
|
291
|
+
return this.matchesCanonical(canonicalKeyId(parsed), keybinding);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Set-lookup variant of {@link matches} for hot input paths: the caller
|
|
296
|
+
* parses `data` once (`parseKey` + `canonicalKeyId`) and probes many
|
|
297
|
+
* bindings without re-parsing the raw sequence per probe.
|
|
298
|
+
*/
|
|
299
|
+
matchesCanonical(canonical: string | undefined, keybinding: Keybinding): boolean {
|
|
300
|
+
if (canonical === undefined) return false;
|
|
301
|
+
return this.#matchKeysById.get(keybinding)?.has(canonical) ?? false;
|
|
293
302
|
}
|
|
294
303
|
|
|
295
304
|
getKeys(keybinding: Keybinding): KeyId[] {
|
package/src/terminal.ts
CHANGED
|
@@ -275,18 +275,16 @@ export function emergencyTerminalRestore(): void {
|
|
|
275
275
|
restoreTerminalStderr();
|
|
276
276
|
const terminal = activeTerminal;
|
|
277
277
|
if (terminal) {
|
|
278
|
-
|
|
279
|
-
//
|
|
280
|
-
// state and exits it on the normal shutdown path. Only crash paths
|
|
281
|
-
// with a fullscreen overlay still hold the alt buffer here. The
|
|
282
|
-
// leave sequence is gated on the tracked state because it is NOT a
|
|
283
|
-
// universally safe no-op: Windows' VT dispatcher homes the cursor
|
|
284
|
-
// on DECRST 1049 even when the alt buffer is inactive.
|
|
278
|
+
// Keyboard enhancement state is screen-local: pop the alt-screen
|
|
279
|
+
// frame before leaving it, then let stop() pop omp's main-screen frame.
|
|
285
280
|
if (altScreenActive) {
|
|
286
|
-
|
|
281
|
+
const keyboardExit =
|
|
282
|
+
terminal.keyboardEnhancementExitSequence ?? (terminal.kittyEnableSequence ? "\x1b[<u" : "");
|
|
283
|
+
terminal.write(`${keyboardExit}\x1b[?1049l`);
|
|
287
284
|
altScreenActive = false;
|
|
288
285
|
}
|
|
289
|
-
terminal.
|
|
286
|
+
terminal.stop();
|
|
287
|
+
terminal.showCursor(true);
|
|
290
288
|
} else if (terminalEverStarted && !isTerminalHeadless()) {
|
|
291
289
|
// Blind restore only if we know a terminal was started but lost track of it
|
|
292
290
|
// This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
|
|
@@ -305,7 +303,7 @@ export function emergencyTerminalRestore(): void {
|
|
|
305
303
|
// actually holds it — on Windows, DECRST 1049 on the main
|
|
306
304
|
// buffer homes the cursor (unconditional CursorRestoreState
|
|
307
305
|
// with no prior save), corrupting the shell handoff on exit.
|
|
308
|
-
(altScreenActive ? "\x1b[?1049l" : "") +
|
|
306
|
+
(altScreenActive ? "\x1b[?1049l\x1b[?1l\x1b>\x1b[<u" : "") + // Leave alt; reset main keyboard
|
|
309
307
|
"\x1b[?25h", // Show cursor
|
|
310
308
|
);
|
|
311
309
|
altScreenActive = false;
|
|
@@ -362,9 +360,11 @@ export interface Terminal {
|
|
|
362
360
|
// Cursor positioning (relative to current position)
|
|
363
361
|
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
|
|
364
362
|
|
|
365
|
-
// Cursor visibility
|
|
366
|
-
|
|
367
|
-
|
|
363
|
+
// Cursor visibility. Same-state calls are deduped against the visibility
|
|
364
|
+
// last written to the terminal; pass force=true to write unconditionally
|
|
365
|
+
// (crash/exit restore paths).
|
|
366
|
+
hideCursor(force?: boolean): void; // Hide the cursor
|
|
367
|
+
showCursor(force?: boolean): void; // Show the cursor
|
|
368
368
|
|
|
369
369
|
// Clear operations
|
|
370
370
|
clearLine(): void; // Clear current line
|
|
@@ -478,6 +478,12 @@ export class ProcessTerminal implements Terminal {
|
|
|
478
478
|
this.#markTerminalDisconnected("stdin failed", err);
|
|
479
479
|
};
|
|
480
480
|
#dead = false;
|
|
481
|
+
// Last cursor visibility written to the terminal, sniffed from every
|
|
482
|
+
// outgoing sequence (frame buffers embed their own ?25h/?25l), so
|
|
483
|
+
// hideCursor()/showCursor() can skip same-state writes. `undefined` =
|
|
484
|
+
// unknown (fresh start, resize, or an alt-screen switch newer than the
|
|
485
|
+
// last cursor sequence — some hosts keep DECTCEM per buffer).
|
|
486
|
+
#cursorVisible: boolean | undefined;
|
|
481
487
|
// Captured at construction and re-read at start(): when true, every real
|
|
482
488
|
// terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
|
|
483
489
|
// suppressed. Defaults on under `bun test` — see isTerminalHeadless().
|
|
@@ -575,6 +581,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
575
581
|
this.#inputHandler = onInput;
|
|
576
582
|
this.#resizeHandler = onResize;
|
|
577
583
|
this.#disconnectHandler = onDisconnect;
|
|
584
|
+
// The host terminal's cursor visibility is unknown until we write it.
|
|
585
|
+
this.#cursorVisible = undefined;
|
|
578
586
|
|
|
579
587
|
// Headless (tests): suppress every real-terminal side effect. Skip raw
|
|
580
588
|
// mode, stdin listeners, capability probes, SIGWINCH, and emergency-restore
|
|
@@ -620,6 +628,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
620
628
|
// dimensions before firing `resize`, so it is authoritative for geometry:
|
|
621
629
|
// reconcile any stale cached DEC 2048 report before notifying the renderer.
|
|
622
630
|
this.#stdoutResizeListener = () => {
|
|
631
|
+
// Conservative: some hosts reset modes across a resize/reattach, so
|
|
632
|
+
// re-establish cursor visibility on the next explicit call.
|
|
633
|
+
this.#cursorVisible = undefined;
|
|
623
634
|
this.#reconcileInBandGeometryOnResize();
|
|
624
635
|
this.#resizeHandler?.();
|
|
625
636
|
};
|
|
@@ -1474,6 +1485,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
1474
1485
|
}
|
|
1475
1486
|
this.#stdoutErrorCleanup?.();
|
|
1476
1487
|
this.#stdoutErrorCleanup = undefined;
|
|
1488
|
+
// After stop() the terminal is shared with other writers; visibility
|
|
1489
|
+
// tracking is only meaningful while this instance owns the TTY.
|
|
1490
|
+
this.#cursorVisible = undefined;
|
|
1477
1491
|
}
|
|
1478
1492
|
|
|
1479
1493
|
#ensureStdoutErrorHandler(): void {
|
|
@@ -1527,6 +1541,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1527
1541
|
// files). They serve no purpose there and would surface as visible noise.
|
|
1528
1542
|
if (!process.stdout.isTTY) return;
|
|
1529
1543
|
this.#ensureStdoutErrorHandler();
|
|
1544
|
+
this.#trackCursorVisibility(data);
|
|
1530
1545
|
// A console-sharing child process may have flipped the console codepage
|
|
1531
1546
|
// away from UTF-8; repair it before any bytes hit WriteFile so no frame
|
|
1532
1547
|
// is ever translated through an OEM codepage. See ensureWindowsConsoleUtf8.
|
|
@@ -1578,14 +1593,37 @@ export class ProcessTerminal implements Terminal {
|
|
|
1578
1593
|
// lines === 0: no movement
|
|
1579
1594
|
}
|
|
1580
1595
|
|
|
1581
|
-
hideCursor(): void {
|
|
1596
|
+
hideCursor(force = false): void {
|
|
1597
|
+
if (!force && this.#cursorVisible === false) return;
|
|
1582
1598
|
this.#safeWrite("\x1b[?25l");
|
|
1583
1599
|
}
|
|
1584
1600
|
|
|
1585
|
-
showCursor(): void {
|
|
1601
|
+
showCursor(force = false): void {
|
|
1602
|
+
if (!force && this.#cursorVisible === true) return;
|
|
1586
1603
|
this.#safeWrite("\x1b[?25h");
|
|
1587
1604
|
}
|
|
1588
1605
|
|
|
1606
|
+
/**
|
|
1607
|
+
* Sniff outgoing data for the last cursor-visibility change so the tracked
|
|
1608
|
+
* state stays correct for sequences embedded in frame buffers
|
|
1609
|
+
* (TUI#cursorControlSequence appends ?25h/?25l inside the paint write). An
|
|
1610
|
+
* alt-screen switch (DECSET/DECRST 1049) newer than the last cursor
|
|
1611
|
+
* sequence resets tracking to unknown: some hosts keep DECTCEM per buffer.
|
|
1612
|
+
*/
|
|
1613
|
+
#trackCursorVisibility(data: string): void {
|
|
1614
|
+
let idx = data.lastIndexOf("\x1b[?25");
|
|
1615
|
+
while (idx !== -1) {
|
|
1616
|
+
const final = data.charCodeAt(idx + 5);
|
|
1617
|
+
if (final === 0x68 /* h */ || final === 0x6c /* l */) break;
|
|
1618
|
+
idx = idx === 0 ? -1 : data.lastIndexOf("\x1b[?25", idx - 1);
|
|
1619
|
+
}
|
|
1620
|
+
if (data.lastIndexOf("\x1b[?1049") > idx) {
|
|
1621
|
+
this.#cursorVisible = undefined;
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (idx !== -1) this.#cursorVisible = data.charCodeAt(idx + 5) === 0x68;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1589
1627
|
clearLine(): void {
|
|
1590
1628
|
this.#safeWrite("\x1b[K");
|
|
1591
1629
|
}
|
package/src/tui.ts
CHANGED
|
@@ -1875,7 +1875,9 @@ export class TUI extends Container {
|
|
|
1875
1875
|
this.terminal.write(targetRow <= viewportBottom ? "\r" : "\r\n");
|
|
1876
1876
|
}
|
|
1877
1877
|
|
|
1878
|
-
|
|
1878
|
+
// Force: the parent shell needs the cursor back regardless of what the
|
|
1879
|
+
// terminal-level dedupe believes was last written.
|
|
1880
|
+
this.terminal.showCursor(true);
|
|
1879
1881
|
this.#forgetHardwareCursorState();
|
|
1880
1882
|
this.terminal.stop();
|
|
1881
1883
|
}
|