@oh-my-pi/pi-tui 17.1.5 → 17.1.7

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,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.1.7] - 2026-07-27
6
+
7
+ ### Added
8
+
9
+ - Added bulk-input fast path and iterative processing for bracketed paste in the editor
10
+ - Added windowed incremental lexing for large markdown documents
11
+
12
+ ### Changed
13
+
14
+ - 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
15
+ - 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
16
+ - Raised the markdown render cache entry budget (32 KiB → 256 KiB) so large messages — exactly the expensive renders — are cacheable
17
+ - Deduplicated terminal cursor-visibility writes to skip redundant escape sequences
18
+
19
+ ## [17.1.6] - 2026-07-27
20
+
21
+ ### Fixed
22
+
23
+ - Fixed omp dying with an uncaught `setRawMode failed with errno: 2` instead of exiting 129 when the terminal disconnects. A recycled terminal pane revokes the pty, so the raw-mode restore in `stop()` hit an fd that is no longer a tty; the throw escaped `#markTerminalDisconnected()` and preempted its own SIGHUP. Terminal teardown on the disconnect path is now best-effort, matching `emergencyTerminalRestore()`, so the exit added in [#5837](https://github.com/can1357/oh-my-pi/pull/5837) always runs.
24
+ - Fixed the multi-line prompt editor bypassing the keybindings registry for word/line delete and yank: `ctrl+backspace` (a declared default of `tui.editor.deleteWordBackward`) never fired and `keybindings.yml` remaps of `deleteWordBackward`, `deleteWordForward`, `deleteToLineStart`, `deleteToLineEnd`, `yank`, and `yankPop` were ignored, because those actions were matched with hardcoded chords instead of `keybindings.matches(...)` like cursor motion and the single-line `Input` already do ([#6782](https://github.com/can1357/oh-my-pi/issues/6782)).
25
+ - Restored the Windows Terminal raw `0x08` → `ctrl+backspace` disambiguation (`WT_SESSION` set, `SSH_*` unset) by routing the exported `matchesRawBackspace` helper through the `matchesKey`/`parseKey` seam. Remote SSH/container sessions where terminal identity is unavailable can opt in with `PI_TUI_RAW_BACKSPACE_IS_CTRL=1` ([#6782](https://github.com/can1357/oh-my-pi/issues/6782)).
26
+ - Fixed plain Backspace deleting a whole word inside tmux/GNU screen/Zellij panes launched from Windows Terminal: multiplexers inherit `WT_SESSION` but emit raw `0x08` for plain Backspace, so the automatic raw-backspace → `ctrl+backspace` heuristic misfired. The heuristic now skips multiplexer sessions (`TMUX`/`STY`/`ZELLIJ` or `TERM` starting with `tmux`/`screen`); `PI_TUI_RAW_BACKSPACE_IS_CTRL=1` remains the explicit opt-in everywhere ([#6784](https://github.com/can1357/oh-my-pi/pull/6784)).
27
+
5
28
  ## [17.1.4] - 2026-07-26
6
29
 
7
30
  ### 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[];
@@ -18,18 +18,19 @@
18
18
  * - isKittyProtocolActive() - Query global Kitty protocol state
19
19
  */
20
20
  import type { KeyEventType } from "@oh-my-pi/pi-natives";
21
- declare function isWindowsTerminalSession(): boolean;
21
+ /** Whether the local process is running directly under Windows Terminal. */
22
+ export declare function isWindowsTerminalSession(): boolean;
22
23
  /**
23
- * Raw 0x08 (BS) is ambiguous in legacy terminals.
24
+ * Match ambiguous legacy Backspace bytes against an expected modifier mask.
24
25
  *
25
- * - Windows Terminal uses it for Ctrl+Backspace.
26
- * - Some legacy terminals and tmux setups send it for plain Backspace.
27
- *
28
- * Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are
29
- * available. Fall back to a Windows Terminal heuristic only for raw BS bytes.
26
+ * Windows Terminal encodes Ctrl+Backspace as raw `0x08` (BS) and plain
27
+ * Backspace as `0x7f` (DEL). Remote/container sessions lose terminal identity,
28
+ * and multiplexers (tmux/screen/Zellij) inherit `WT_SESSION` while emitting
29
+ * raw `0x08` for plain Backspace themselves, so the automatic heuristic is
30
+ * limited to direct Windows Terminal sessions. `PI_TUI_RAW_BACKSPACE_IS_CTRL=1`
31
+ * explicitly opts into the mapping everywhere.
30
32
  */
31
- declare function matchesRawBackspace(data: string, expectedModifier: number): boolean;
32
- export { isWindowsTerminalSession, matchesRawBackspace };
33
+ export declare function matchesRawBackspace(data: string, expectedModifier: number): boolean;
33
34
  /**
34
35
  * Set the global Kitty keyboard protocol state.
35
36
  * Called by ProcessTerminal after detecting protocol support.
@@ -206,3 +207,4 @@ export declare function matchesKey(data: string, keyId: KeyId): boolean;
206
207
  * @param data - Raw input data from terminal
207
208
  */
208
209
  export declare function parseKey(data: string): string | undefined;
210
+ export {};
@@ -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.5",
4
+ "version": "17.1.7",
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.5",
41
- "@oh-my-pi/pi-utils": "17.1.5",
40
+ "@oh-my-pi/pi-natives": "17.1.7",
41
+ "@oh-my-pi/pi-utils": "17.1.7",
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,44 +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
- // Ctrl+K - Delete to end of line
1331
- if (matchesKey(data, "ctrl+k")) {
1370
+ // Delete to end of line
1371
+ if (kb.matchesCanonical(canonical, "tui.editor.deleteToLineEnd")) {
1332
1372
  this.#deleteToEndOfLine();
1333
1373
  }
1334
- // Ctrl+U - Delete to start of line
1335
- else if (matchesKey(data, "ctrl+u")) {
1374
+ // Delete to start of line
1375
+ else if (kb.matchesCanonical(canonical, "tui.editor.deleteToLineStart")) {
1336
1376
  this.#deleteToStartOfLine();
1337
1377
  }
1338
- // Ctrl+W - Delete word backwards
1339
- else if (matchesKey(data, "ctrl+w")) {
1378
+ // Delete word backward. Registry defaults cover ctrl+w, alt+backspace,
1379
+ // ctrl+backspace, and super+alt+backspace (Ghostty on macOS reports
1380
+ // Option+Backspace as super+alt — kitty mod 11, see #2064).
1381
+ else if (kb.matchesCanonical(canonical, "tui.editor.deleteWordBackward")) {
1340
1382
  this.#deleteWordBackwards();
1341
1383
  }
1342
- // Option/Alt+Backspace - Delete word backwards.
1343
- // Ghostty on macOS reports Option+Backspace as super+alt (kitty mod 11) see #2064.
1344
- else if (matchesKey(data, "alt+backspace") || matchesKey(data, "super+alt+backspace")) {
1345
- this.#deleteWordBackwards();
1346
- }
1347
- // Option/Alt+D and Option+Delete - Delete word forwards. Same Ghostty quirk applies.
1348
- else if (
1349
- matchesKey(data, "alt+d") ||
1350
- matchesKey(data, "alt+delete") ||
1351
- matchesKey(data, "super+alt+d") ||
1352
- matchesKey(data, "super+alt+delete")
1353
- ) {
1384
+ // Delete word forward. Registry defaults cover alt+d/alt+delete and their
1385
+ // super+alt variants for the same Ghostty quirk.
1386
+ else if (kb.matchesCanonical(canonical, "tui.editor.deleteWordForward")) {
1354
1387
  this.#deleteWordForwards();
1355
1388
  }
1356
- // Ctrl+Y - Yank from kill ring
1357
- else if (matchesKey(data, "ctrl+y")) {
1389
+ // Yank from kill ring
1390
+ else if (kb.matchesCanonical(canonical, "tui.editor.yank")) {
1358
1391
  this.#yankFromKillRing();
1359
1392
  }
1360
- // Alt+Y - Yank-pop (cycle kill ring)
1361
- else if (matchesKey(data, "alt+y")) {
1393
+ // Yank-pop (cycle kill ring)
1394
+ else if (kb.matchesCanonical(canonical, "tui.editor.yankPop")) {
1362
1395
  this.#yankPop();
1363
1396
  }
1364
1397
  // Ctrl+A - Move to start of line
@@ -1383,7 +1416,7 @@ export class Editor implements Component, Focusable {
1383
1416
  matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
1384
1417
  data === "\x1b\r" || // Option+Enter in some terminals (legacy)
1385
1418
  data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
1386
- 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)
1387
1420
  (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
1388
1421
  (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
1389
1422
  ) {
@@ -1395,7 +1428,7 @@ export class Editor implements Component, Focusable {
1395
1428
  this.#addNewLine();
1396
1429
  }
1397
1430
  // Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
1398
- else if (kb.matches(data, "tui.input.submit") || data === "\n") {
1431
+ else if (kb.matchesCanonical(canonical, "tui.input.submit") || data === "\n") {
1399
1432
  // If submit is disabled, do nothing
1400
1433
  if (this.disableSubmit) {
1401
1434
  return;
@@ -1436,40 +1469,40 @@ export class Editor implements Component, Focusable {
1436
1469
  this.#submitValue();
1437
1470
  }
1438
1471
  // Backspace (including Shift+Backspace)
1439
- 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")) {
1440
1473
  this.#handleBackspace();
1441
1474
  }
1442
1475
  // Line navigation shortcuts (Home/End keys)
1443
- else if (kb.matches(data, "tui.editor.cursorLineStart")) {
1476
+ else if (kb.matchesCanonical(canonical, "tui.editor.cursorLineStart")) {
1444
1477
  this.#moveToLineStart();
1445
- } else if (kb.matches(data, "tui.editor.cursorLineEnd")) {
1478
+ } else if (kb.matchesCanonical(canonical, "tui.editor.cursorLineEnd")) {
1446
1479
  this.#moveToLineEnd();
1447
1480
  }
1448
1481
  // Page navigation (PageUp/PageDown): page the editor viewport only. On a
1449
1482
  // short draft this is a no-op — it never steps prompt history (that stays
1450
1483
  // on Up/Down), so an idle empty editor swallows the keys instead of
1451
1484
  // surprising the user by loading the previous prompt (#4754).
1452
- else if (kb.matches(data, "tui.editor.pageUp")) {
1485
+ else if (kb.matchesCanonical(canonical, "tui.editor.pageUp")) {
1453
1486
  this.#pageScroll(-1);
1454
- } else if (kb.matches(data, "tui.editor.pageDown")) {
1487
+ } else if (kb.matchesCanonical(canonical, "tui.editor.pageDown")) {
1455
1488
  this.#pageScroll(1);
1456
1489
  }
1457
1490
  // Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
1458
- 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")) {
1459
1492
  this.#handleForwardDelete();
1460
1493
  }
1461
1494
  // Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
1462
- else if (kb.matches(data, "tui.editor.cursorWordLeft")) {
1495
+ else if (kb.matchesCanonical(canonical, "tui.editor.cursorWordLeft")) {
1463
1496
  // Word left
1464
1497
  this.#resetKillSequence();
1465
1498
  this.#moveWordBackwards();
1466
- } else if (kb.matches(data, "tui.editor.cursorWordRight")) {
1499
+ } else if (kb.matchesCanonical(canonical, "tui.editor.cursorWordRight")) {
1467
1500
  // Word right
1468
1501
  this.#resetKillSequence();
1469
1502
  this.#moveWordForwards();
1470
1503
  }
1471
1504
  // Arrow keys
1472
- else if (kb.matches(data, "tui.editor.cursorUp")) {
1505
+ else if (kb.matchesCanonical(canonical, "tui.editor.cursorUp")) {
1473
1506
  // Up - history navigation or cursor movement
1474
1507
  if (this.#isEditorEmpty()) {
1475
1508
  this.#navigateHistory(-1); // Start browsing history
@@ -1481,7 +1514,7 @@ export class Editor implements Component, Focusable {
1481
1514
  } else {
1482
1515
  this.#moveCursor(-1, 0); // Cursor movement (within text or history entry)
1483
1516
  }
1484
- } else if (kb.matches(data, "tui.editor.cursorDown")) {
1517
+ } else if (kb.matchesCanonical(canonical, "tui.editor.cursorDown")) {
1485
1518
  // Down - history navigation or cursor movement
1486
1519
  if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
1487
1520
  this.#navigateHistory(1); // Navigate to newer history entry or clear
@@ -1491,10 +1524,10 @@ export class Editor implements Component, Focusable {
1491
1524
  } else {
1492
1525
  this.#moveCursor(1, 0); // Cursor movement (within text or history entry)
1493
1526
  }
1494
- } else if (kb.matches(data, "tui.editor.cursorRight")) {
1527
+ } else if (kb.matchesCanonical(canonical, "tui.editor.cursorRight")) {
1495
1528
  // Right
1496
1529
  this.#moveCursor(0, 1);
1497
- } else if (kb.matches(data, "tui.editor.cursorLeft")) {
1530
+ } else if (kb.matchesCanonical(canonical, "tui.editor.cursorLeft")) {
1498
1531
  // Left
1499
1532
  this.#moveCursor(0, -1);
1500
1533
  }
@@ -1503,9 +1536,9 @@ export class Editor implements Component, Focusable {
1503
1536
  this.#insertCharacter(" ");
1504
1537
  }
1505
1538
  // Character jump mode triggers
1506
- else if (kb.matches(data, "tui.editor.jumpForward")) {
1539
+ else if (kb.matchesCanonical(canonical, "tui.editor.jumpForward")) {
1507
1540
  this.#jumpMode = "forward";
1508
- } else if (kb.matches(data, "tui.editor.jumpBackward")) {
1541
+ } else if (kb.matchesCanonical(canonical, "tui.editor.jumpBackward")) {
1509
1542
  this.#jumpMode = "backward";
1510
1543
  }
1511
1544
  // Printable keystrokes, including Kitty CSI-u text-producing sequences.
@@ -1637,6 +1670,15 @@ export class Editor implements Component, Focusable {
1637
1670
  return this.#state.lines.join("\n");
1638
1671
  }
1639
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
+
1640
1682
  #expandPasteMarkers(text: string): string {
1641
1683
  let result = text;
1642
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) {
@@ -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/keys.ts CHANGED
@@ -24,35 +24,38 @@ import {
24
24
  parseKey as parseKeyNative,
25
25
  parseKittySequence as parseKittySequenceNative,
26
26
  } from "@oh-my-pi/pi-natives";
27
+ import { isInsideTerminalMultiplexer } from "./terminal-capabilities";
27
28
 
28
29
  // =============================================================================
29
30
  // Platform Detection
30
31
  // =============================================================================
31
32
 
32
- function isWindowsTerminalSession(): boolean {
33
+ /** Whether the local process is running directly under Windows Terminal. */
34
+ export function isWindowsTerminalSession(): boolean {
33
35
  return (
34
36
  Boolean(process.env.WT_SESSION) && !process.env.SSH_CONNECTION && !process.env.SSH_CLIENT && !process.env.SSH_TTY
35
37
  );
36
38
  }
37
39
 
38
40
  /**
39
- * Raw 0x08 (BS) is ambiguous in legacy terminals.
41
+ * Match ambiguous legacy Backspace bytes against an expected modifier mask.
40
42
  *
41
- * - Windows Terminal uses it for Ctrl+Backspace.
42
- * - Some legacy terminals and tmux setups send it for plain Backspace.
43
- *
44
- * Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are
45
- * available. Fall back to a Windows Terminal heuristic only for raw BS bytes.
43
+ * Windows Terminal encodes Ctrl+Backspace as raw `0x08` (BS) and plain
44
+ * Backspace as `0x7f` (DEL). Remote/container sessions lose terminal identity,
45
+ * and multiplexers (tmux/screen/Zellij) inherit `WT_SESSION` while emitting
46
+ * raw `0x08` for plain Backspace themselves, so the automatic heuristic is
47
+ * limited to direct Windows Terminal sessions. `PI_TUI_RAW_BACKSPACE_IS_CTRL=1`
48
+ * explicitly opts into the mapping everywhere.
46
49
  */
47
- function matchesRawBackspace(data: string, expectedModifier: number): boolean {
50
+ export function matchesRawBackspace(data: string, expectedModifier: number): boolean {
48
51
  if (data === "\x7f") return expectedModifier === 0;
49
52
  if (data !== "\x08") return false;
50
- // On Windows Terminal, 0x08 = Ctrl+Backspace. On others, it's plain Backspace.
51
- return isWindowsTerminalSession() ? expectedModifier === 4 : expectedModifier === 0;
53
+ const rawBackspaceIsCtrl =
54
+ process.env.PI_TUI_RAW_BACKSPACE_IS_CTRL === "1" ||
55
+ (isWindowsTerminalSession() && !isInsideTerminalMultiplexer(process.env));
56
+ return rawBackspaceIsCtrl ? expectedModifier === 4 : expectedModifier === 0;
52
57
  }
53
58
 
54
- export { isWindowsTerminalSession, matchesRawBackspace };
55
-
56
59
  // =============================================================================
57
60
  // Global Kitty Protocol State
58
61
  // =============================================================================
@@ -545,6 +548,7 @@ function matchesKeypadKey(data: string, keyId: KeyId): boolean | undefined {
545
548
  * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
546
549
  */
547
550
  export function matchesKey(data: string, keyId: KeyId): boolean {
551
+ if (matchesRawBackspace(data, 4)) return keyId === "ctrl+backspace";
548
552
  return matchesKeypadKey(data, keyId) ?? matchesKeyNative(data, keyId, kittyProtocolActive);
549
553
  }
550
554
 
@@ -557,5 +561,6 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
557
561
  * @param data - Raw input data from terminal
558
562
  */
559
563
  export function parseKey(data: string): string | undefined {
564
+ if (matchesRawBackspace(data, 4)) return "ctrl+backspace";
560
565
  return decodeKittyKeypadText(data) ?? parseKeyNative(data, kittyProtocolActive) ?? undefined;
561
566
  }
package/src/terminal.ts CHANGED
@@ -286,7 +286,7 @@ export function emergencyTerminalRestore(): void {
286
286
  terminal.write("\x1b[?1049l");
287
287
  altScreenActive = false;
288
288
  }
289
- terminal.showCursor();
289
+ terminal.showCursor(true);
290
290
  } else if (terminalEverStarted && !isTerminalHeadless()) {
291
291
  // Blind restore only if we know a terminal was started but lost track of it
292
292
  // This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
@@ -362,9 +362,11 @@ export interface Terminal {
362
362
  // Cursor positioning (relative to current position)
363
363
  moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
364
364
 
365
- // Cursor visibility
366
- hideCursor(): void; // Hide the cursor
367
- showCursor(): void; // Show the cursor
365
+ // Cursor visibility. Same-state calls are deduped against the visibility
366
+ // last written to the terminal; pass force=true to write unconditionally
367
+ // (crash/exit restore paths).
368
+ hideCursor(force?: boolean): void; // Hide the cursor
369
+ showCursor(force?: boolean): void; // Show the cursor
368
370
 
369
371
  // Clear operations
370
372
  clearLine(): void; // Clear current line
@@ -478,6 +480,12 @@ export class ProcessTerminal implements Terminal {
478
480
  this.#markTerminalDisconnected("stdin failed", err);
479
481
  };
480
482
  #dead = false;
483
+ // Last cursor visibility written to the terminal, sniffed from every
484
+ // outgoing sequence (frame buffers embed their own ?25h/?25l), so
485
+ // hideCursor()/showCursor() can skip same-state writes. `undefined` =
486
+ // unknown (fresh start, resize, or an alt-screen switch newer than the
487
+ // last cursor sequence — some hosts keep DECTCEM per buffer).
488
+ #cursorVisible: boolean | undefined;
481
489
  // Captured at construction and re-read at start(): when true, every real
482
490
  // terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
483
491
  // suppressed. Defaults on under `bun test` — see isTerminalHeadless().
@@ -575,6 +583,8 @@ export class ProcessTerminal implements Terminal {
575
583
  this.#inputHandler = onInput;
576
584
  this.#resizeHandler = onResize;
577
585
  this.#disconnectHandler = onDisconnect;
586
+ // The host terminal's cursor visibility is unknown until we write it.
587
+ this.#cursorVisible = undefined;
578
588
 
579
589
  // Headless (tests): suppress every real-terminal side effect. Skip raw
580
590
  // mode, stdin listeners, capability probes, SIGWINCH, and emergency-restore
@@ -620,6 +630,9 @@ export class ProcessTerminal implements Terminal {
620
630
  // dimensions before firing `resize`, so it is authoritative for geometry:
621
631
  // reconcile any stale cached DEC 2048 report before notifying the renderer.
622
632
  this.#stdoutResizeListener = () => {
633
+ // Conservative: some hosts reset modes across a resize/reattach, so
634
+ // re-establish cursor visibility on the next explicit call.
635
+ this.#cursorVisible = undefined;
623
636
  this.#reconcileInBandGeometryOnResize();
624
637
  this.#resizeHandler?.();
625
638
  };
@@ -1462,12 +1475,21 @@ export class ProcessTerminal implements Terminal {
1462
1475
  // where Ctrl+D could close the parent shell over SSH.
1463
1476
  process.stdin.pause();
1464
1477
 
1465
- // Restore raw mode state
1466
- if (process.stdin.setRawMode) {
1467
- process.stdin.setRawMode(this.#wasRaw);
1478
+ // Restore raw mode state. On a disconnected terminal (pane recycled, ssh
1479
+ // dropped) the fd is no longer a tty and Bun's node:tty shim throws; there
1480
+ // is nothing left to restore, and throwing would abort the caller. On a
1481
+ // live terminal the failure still surfaces - swallowing it would silently
1482
+ // leave stdin in raw mode.
1483
+ try {
1484
+ process.stdin.setRawMode?.(this.#wasRaw);
1485
+ } catch (err) {
1486
+ if (!this.#dead) throw err;
1468
1487
  }
1469
1488
  this.#stdoutErrorCleanup?.();
1470
1489
  this.#stdoutErrorCleanup = undefined;
1490
+ // After stop() the terminal is shared with other writers; visibility
1491
+ // tracking is only meaningful while this instance owns the TTY.
1492
+ this.#cursorVisible = undefined;
1471
1493
  }
1472
1494
 
1473
1495
  #ensureStdoutErrorHandler(): void {
@@ -1482,7 +1504,14 @@ export class ProcessTerminal implements Terminal {
1482
1504
  const disconnectHandler = this.#disconnectHandler;
1483
1505
  this.#disconnectHandler = undefined;
1484
1506
  if (!disconnectHandler) return;
1485
- disconnectHandler();
1507
+ // The handler tears the TUI down against a terminal that is already gone,
1508
+ // so any step in it can fail. Swallow that: the exit below is the whole
1509
+ // point of this method and must not be preempted by teardown noise.
1510
+ try {
1511
+ disconnectHandler();
1512
+ } catch (handlerErr) {
1513
+ logger.error("Terminal disconnect handler failed; exiting anyway", { err: handlerErr });
1514
+ }
1486
1515
 
1487
1516
  if (process.platform === "win32") {
1488
1517
  void postmortem.quit(129);
@@ -1514,6 +1543,7 @@ export class ProcessTerminal implements Terminal {
1514
1543
  // files). They serve no purpose there and would surface as visible noise.
1515
1544
  if (!process.stdout.isTTY) return;
1516
1545
  this.#ensureStdoutErrorHandler();
1546
+ this.#trackCursorVisibility(data);
1517
1547
  // A console-sharing child process may have flipped the console codepage
1518
1548
  // away from UTF-8; repair it before any bytes hit WriteFile so no frame
1519
1549
  // is ever translated through an OEM codepage. See ensureWindowsConsoleUtf8.
@@ -1565,14 +1595,37 @@ export class ProcessTerminal implements Terminal {
1565
1595
  // lines === 0: no movement
1566
1596
  }
1567
1597
 
1568
- hideCursor(): void {
1598
+ hideCursor(force = false): void {
1599
+ if (!force && this.#cursorVisible === false) return;
1569
1600
  this.#safeWrite("\x1b[?25l");
1570
1601
  }
1571
1602
 
1572
- showCursor(): void {
1603
+ showCursor(force = false): void {
1604
+ if (!force && this.#cursorVisible === true) return;
1573
1605
  this.#safeWrite("\x1b[?25h");
1574
1606
  }
1575
1607
 
1608
+ /**
1609
+ * Sniff outgoing data for the last cursor-visibility change so the tracked
1610
+ * state stays correct for sequences embedded in frame buffers
1611
+ * (TUI#cursorControlSequence appends ?25h/?25l inside the paint write). An
1612
+ * alt-screen switch (DECSET/DECRST 1049) newer than the last cursor
1613
+ * sequence resets tracking to unknown: some hosts keep DECTCEM per buffer.
1614
+ */
1615
+ #trackCursorVisibility(data: string): void {
1616
+ let idx = data.lastIndexOf("\x1b[?25");
1617
+ while (idx !== -1) {
1618
+ const final = data.charCodeAt(idx + 5);
1619
+ if (final === 0x68 /* h */ || final === 0x6c /* l */) break;
1620
+ idx = idx === 0 ? -1 : data.lastIndexOf("\x1b[?25", idx - 1);
1621
+ }
1622
+ if (data.lastIndexOf("\x1b[?1049") > idx) {
1623
+ this.#cursorVisible = undefined;
1624
+ return;
1625
+ }
1626
+ if (idx !== -1) this.#cursorVisible = data.charCodeAt(idx + 5) === 0x68;
1627
+ }
1628
+
1576
1629
  clearLine(): void {
1577
1630
  this.#safeWrite("\x1b[K");
1578
1631
  }
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
  }