@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 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[];
@@ -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.6",
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.6",
41
- "@oh-my-pi/pi-utils": "17.1.6",
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
  },
@@ -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 (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
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
- this.handleInput(paste.remaining);
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.matches(data, "tui.editor.undo")) {
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.matches(data, "tui.select.cancel")) {
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.matches(data, "tui.select.up") ||
1188
- kb.matches(data, "tui.select.down") ||
1189
- kb.matches(data, "tui.select.pageUp") ||
1190
- kb.matches(data, "tui.select.pageDown") ||
1191
- kb.matches(data, "tui.input.submit") ||
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.matches(data, "tui.input.tab")
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.matches(data, "tui.select.up") ||
1198
- kb.matches(data, "tui.select.down") ||
1199
- kb.matches(data, "tui.select.pageUp") ||
1200
- kb.matches(data, "tui.select.pageDown")
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.matches(data, "tui.input.tab")) {
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.matches(data, "tui.input.submit") || data === "\n") &&
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.matches(data, "tui.input.submit") || data === "\n") {
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.matches(data, "tui.input.tab") && !this.#autocompleteState) {
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.matches(data, "tui.editor.deleteToLineEnd")) {
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.matches(data, "tui.editor.deleteToLineStart")) {
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.matches(data, "tui.editor.deleteWordBackward")) {
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.matches(data, "tui.editor.deleteWordForward")) {
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.matches(data, "tui.editor.yank")) {
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.matches(data, "tui.editor.yankPop")) {
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.matches(data, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
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.matches(data, "tui.input.submit") || data === "\n") {
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.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
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.matches(data, "tui.editor.cursorLineStart")) {
1476
+ else if (kb.matchesCanonical(canonical, "tui.editor.cursorLineStart")) {
1437
1477
  this.#moveToLineStart();
1438
- } else if (kb.matches(data, "tui.editor.cursorLineEnd")) {
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.matches(data, "tui.editor.pageUp")) {
1485
+ else if (kb.matchesCanonical(canonical, "tui.editor.pageUp")) {
1446
1486
  this.#pageScroll(-1);
1447
- } else if (kb.matches(data, "tui.editor.pageDown")) {
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.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
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.matches(data, "tui.editor.cursorWordLeft")) {
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.matches(data, "tui.editor.cursorWordRight")) {
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.matches(data, "tui.editor.cursorUp")) {
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.matches(data, "tui.editor.cursorDown")) {
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.matches(data, "tui.editor.cursorRight")) {
1527
+ } else if (kb.matchesCanonical(canonical, "tui.editor.cursorRight")) {
1488
1528
  // Right
1489
1529
  this.#moveCursor(0, 1);
1490
- } else if (kb.matches(data, "tui.editor.cursorLeft")) {
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.matches(data, "tui.editor.jumpForward")) {
1539
+ else if (kb.matchesCanonical(canonical, "tui.editor.jumpForward")) {
1500
1540
  this.#jumpMode = "forward";
1501
- } else if (kb.matches(data, "tui.editor.jumpBackward")) {
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
- const m = /\$|\\\(|\\\[/.exec(src);
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
- const AUTOLINK_SCHEME_SCAN = /www\.|https?:\/\/|ftp:\/\//i;
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
- const m = AUTOLINK_SCHEME_SCAN.exec(src);
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 = 512 * 1024;
653
- const RENDER_CACHE_MAX_ENTRY_SIZE = 32 * 1024;
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 = markdownParser.lexer(text.slice(prefix.length));
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 = markdownParser.lexer(text);
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
- let pos = 0;
1245
- let frozenEnd = 0;
1246
- let frozenCount = 0;
1247
- for (let i = 0; i < tokens.length; i++) {
1248
- const raw = tokens[i].raw;
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
- // Skip wrapping for image protocol lines and OSC 66 sized headings
1546
- // (would corrupt escape sequences / split the indivisible sized span).
1547
- if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
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.length);
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
- // Regular text content - add indent and bullet
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
- // Regular content - hang under the item text
2295
- lines.push(continuationIndent + line.text);
2642
+ pushWrapped(line.text, continuationIndent, continuationIndent);
2296
2643
  }
2297
2644
  }
2298
2645
  } else {
2299
- lines.push(indent + this.#theme.listBullet(bullet));
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
  }
@@ -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
- const matchKeys = this.#matchKeysById.get(keybinding);
292
- return matchKeys?.has(canonicalKeyId(parsed)) ?? false;
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
- terminal.stop();
279
- // stop() never touches the alternate screen the TUI owns that
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
- terminal.write("\x1b[?1049l");
281
+ const keyboardExit =
282
+ terminal.keyboardEnhancementExitSequence ?? (terminal.kittyEnableSequence ? "\x1b[<u" : "");
283
+ terminal.write(`${keyboardExit}\x1b[?1049l`);
287
284
  altScreenActive = false;
288
285
  }
289
- terminal.showCursor();
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
- hideCursor(): void; // Hide the cursor
367
- showCursor(): void; // Show the cursor
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
- this.terminal.showCursor();
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
  }