@oh-my-pi/pi-tui 18.7.0 → 18.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +21 -52
  2. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  3. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  4. package/dist/types/chat/fence-figure.d.ts +42 -0
  5. package/dist/types/chat/svg-figure.d.ts +1 -1
  6. package/dist/types/chat/tool-execution.d.ts +2 -0
  7. package/dist/types/components/editor.d.ts +8 -3
  8. package/dist/types/components/markdown.d.ts +11 -6
  9. package/dist/types/fuzzy.d.ts +17 -3
  10. package/dist/types/native/backend.d.ts +2 -1
  11. package/dist/types/native/blobs.d.ts +4 -2
  12. package/dist/types/native/encode.d.ts +3 -4
  13. package/dist/types/overlays/agent-transcript-viewer.d.ts +8 -1
  14. package/dist/types/overlays/history-search.d.ts +10 -0
  15. package/dist/types/overlays/model-browser.d.ts +6 -0
  16. package/dist/types/prompt/custom-editor.d.ts +7 -4
  17. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -1
  18. package/dist/types/tools/renderer.d.ts +22 -0
  19. package/dist/types/tools/streaming-output.d.ts +11 -1
  20. package/dist/types/tools/write.d.ts +8 -1
  21. package/package.json +9 -9
  22. package/src/apps/debug/log-viewer.ts +188 -72
  23. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  24. package/src/apps/debug/raw-sse.ts +39 -5
  25. package/src/apps/git/diff-pane.ts +26 -5
  26. package/src/autocomplete.ts +59 -33
  27. package/src/chat/assistant-message.ts +30 -18
  28. package/src/chat/fence-figure.ts +80 -0
  29. package/src/chat/svg-figure.ts +2 -4
  30. package/src/chat/tool-execution.ts +53 -4
  31. package/src/components/editor.ts +16 -3
  32. package/src/components/markdown.ts +80 -307
  33. package/src/fuzzy.ts +59 -14
  34. package/src/native/backend.ts +170 -15
  35. package/src/native/blobs.ts +4 -2
  36. package/src/native/encode.ts +58 -31
  37. package/src/overlays/agent-hub-projection.ts +70 -2
  38. package/src/overlays/agent-hub.ts +3 -2
  39. package/src/overlays/agent-transcript-viewer.ts +64 -13
  40. package/src/overlays/agents-hub.ts +14 -7
  41. package/src/overlays/copy-selector.ts +30 -8
  42. package/src/overlays/extensions/state-manager.ts +52 -12
  43. package/src/overlays/history-search.ts +100 -7
  44. package/src/overlays/jobs-panel.ts +30 -2
  45. package/src/overlays/model-browser.ts +107 -21
  46. package/src/overlays/model-hub.ts +34 -7
  47. package/src/overlays/oauth-selector.ts +41 -26
  48. package/src/overlays/plan-review-overlay.ts +142 -64
  49. package/src/overlays/rewind-selector.ts +86 -17
  50. package/src/overlays/session-selector.ts +24 -4
  51. package/src/overlays/settings-selector.ts +21 -6
  52. package/src/overlays/tree-selector.ts +20 -4
  53. package/src/overlays/usage-dashboard.ts +12 -15
  54. package/src/prompt/attachment-chips.ts +2 -1
  55. package/src/prompt/composer-attachments.ts +6 -4
  56. package/src/prompt/custom-editor.ts +48 -19
  57. package/src/prompt/interactive-context-helpers.ts +27 -18
  58. package/src/prompt/macos-spelling.ts +7 -0
  59. package/src/prompt/magic-keywords.ts +11 -2
  60. package/src/prompt/word-completion.ts +38 -5
  61. package/src/setup/scenes/splash.ts +109 -45
  62. package/src/status-line/component.ts +220 -64
  63. package/src/status-line/segments.ts +5 -2
  64. package/src/terminal.ts +3 -2
  65. package/src/tools/ast-edit.ts +59 -35
  66. package/src/tools/ast-grep.ts +35 -16
  67. package/src/tools/default-renderer.ts +35 -19
  68. package/src/tools/eval-format/index.ts +28 -2
  69. package/src/tools/eval-format/javascript.ts +7 -5
  70. package/src/tools/eval.ts +164 -78
  71. package/src/tools/grep.ts +81 -48
  72. package/src/tools/lsp.ts +81 -59
  73. package/src/tools/mcp.ts +98 -68
  74. package/src/tools/renderer.ts +27 -0
  75. package/src/tools/streaming-output.ts +120 -48
  76. package/src/tools/task.ts +111 -54
  77. package/src/tools/todo.ts +115 -29
  78. package/src/tools/wait.ts +34 -24
  79. package/src/tools/write.ts +77 -39
@@ -9,15 +9,15 @@ import {
9
9
  type TokensList,
10
10
  } from "@oh-my-pi/pi-utils/marked";
11
11
  import {
12
- MathBlockScan,
13
12
  type MathBlockOpener,
14
- mathBlockAt,
15
13
  mathBlockCloserIndex,
14
+ mathBlockInContext,
16
15
  mathBlockMayCloseAt,
17
16
  mathBlockOpenerAt,
18
17
  mathSpanInContext,
19
18
  mathStartIndex,
20
19
  } from "@oh-my-pi/pi-utils/math-delimiters";
20
+ import { listMayContinueAt } from "@oh-my-pi/pi-utils/marked-list";
21
21
  import { latexToBlock } from "../latex-block";
22
22
  import { isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
23
23
  import { plainText } from "../native/spans";
@@ -603,7 +603,6 @@ const markdownParser = new Marked();
603
603
  // never math. Inline extensions run before marked's escape tokenizer, so
604
604
  // `\(…\)` becomes math while a genuinely escaped `\$` is left to `escape` and
605
605
  // renders as a literal dollar.
606
- const CUSTOM_HR_START_REGEX = /(?:^|\n) {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/;
607
606
  const CUSTOM_HR_TOKENIZER_REGEX = /^ {0,3}([-*_─━═=–—])[ \t]*(?:\1[ \t]*){2,}(?:\n+|$)/;
608
607
 
609
608
  function getHrChar(char: string, hrChar: string): string {
@@ -629,15 +628,6 @@ function getHrChar(char: string, hrChar: string): string {
629
628
  const customHrExtension: TokenizerAndRendererExtension = {
630
629
  name: "customHr",
631
630
  level: "block",
632
- start(src) {
633
- const match = CUSTOM_HR_START_REGEX.exec(src);
634
- if (!match) return undefined;
635
- let idx = match.index;
636
- if (src[idx] === "\n") {
637
- idx += 1;
638
- }
639
- return idx;
640
- },
641
631
  tokenizer(src) {
642
632
  const match = CUSTOM_HR_TOKENIZER_REGEX.exec(src);
643
633
  if (match) {
@@ -674,7 +664,7 @@ const mathBlockExtension: TokenizerAndRendererExtension = {
674
664
  // No `start` hint: marked only probes block extensions at a block boundary
675
665
  // here and never consults their hints.
676
666
  tokenizer(src) {
677
- const block = mathBlockAt(src);
667
+ const block = mathBlockInContext(this, src);
678
668
  if (!block) return undefined;
679
669
  return { type: "math", raw: block.raw, text: block.body, display: true };
680
670
  },
@@ -691,40 +681,67 @@ const mathBlockExtension: TokenizerAndRendererExtension = {
691
681
  // starts at offset 0" guards keep fenced/indented `\begin{cases}` code blocks
692
682
  // for marked's own code rules.
693
683
  const BARE_ENV_BEGIN = /(?:^|\n)[ \t]{0,3}\\begin\{([A-Za-z]+\*?)\}/;
694
- function bareMathEnvBlock(src: string): readonly [number, number] | null {
695
- const bm = BARE_ENV_BEGIN.exec(src);
696
- if (!bm || !isBareMathEnvironment(bm[1])) return null;
684
+ const BLANK_LINE_RE = /\n[ \t]*\n/;
685
+
686
+ /** Where a search for an `\end{…}` closer in a lexer source started, and what it found. */
687
+ interface EnvCloserSearch {
688
+ from: number;
689
+ at: number;
690
+ }
691
+ const envCloserSearches = new WeakMap<TokenizerThis, Map<string, EnvCloserSearch>>();
692
+
693
+ /**
694
+ * The first `endToken` at or after `from` in the source of `context`, or -1. Block
695
+ * starts ask at increasing offsets, so a search is remembered per source: an
696
+ * unclosed environment otherwise rescans the rest of the document at each one.
697
+ */
698
+ function envCloserIndex(context: TokenizerThis, source: string, endToken: string, from: number): number {
699
+ let searches = envCloserSearches.get(context);
700
+ if (!searches) envCloserSearches.set(context, (searches = new Map()));
701
+ const last = searches.get(endToken);
702
+ if (last !== undefined && last.from <= from && (last.at === -1 || from <= last.at)) return last.at;
703
+ const at = source.indexOf(endToken, from);
704
+ searches.set(endToken, { from, at });
705
+ return at;
706
+ }
707
+
708
+ /** Length of the bare math-environment block that starts at offset 0 of `src`, or 0. */
709
+ function bareMathEnvBlockLength(context: TokenizerThis, src: string): number {
710
+ // A block at offset 0 has its `\begin` on the first line, or on the second
711
+ // after an `lhs =` line, so the opener scan stops there.
712
+ const firstBreak = src.indexOf("\n");
713
+ const secondBreak = firstBreak === -1 ? -1 : src.indexOf("\n", firstBreak + 1);
714
+ const bm = BARE_ENV_BEGIN.exec(secondBreak === -1 ? src : src.slice(0, secondBreak));
715
+ if (!bm || !isBareMathEnvironment(bm[1])) return 0;
697
716
  // Skip a matched leading `\n`, at offset 0 too: a blank line before the block
698
717
  // is a space token of its own, not part of the block.
699
718
  const beginLineStart = src[bm.index] === "\n" ? bm.index + 1 : bm.index;
719
+ // Pull in one immediately-preceding `lhs =`/open-delimiter line (e.g. `f(x) =`).
720
+ if (beginLineStart > 0 && !/[=([{]\s*$/.test(src.slice(0, beginLineStart - 1))) return 0;
700
721
  const endToken = `\\end{${bm[1]}}`;
701
- const endAt = src.indexOf(endToken, bm.index);
702
- if (endAt === -1) return null;
722
+ const source = context.source ?? src;
723
+ const base = (context.end ?? source.length) - src.length;
724
+ const closer = envCloserIndex(context, source, endToken, base + bm.index);
725
+ if (closer === -1) return 0;
726
+ const endAt = closer - base;
703
727
  // The `\end` must close before any blank line (i.e. within the same block).
704
- if (/\n[ \t]*\n/.test(src.slice(beginLineStart, endAt))) return null;
728
+ // Scan only up to the closer: the rest of the document is irrelevant here, and
729
+ // scanning it per block is quadratic across consecutive environments.
730
+ if (BLANK_LINE_RE.test(src.slice(beginLineStart, endAt))) return 0;
705
731
  let blockEnd = endAt + endToken.length;
706
732
  while (src[blockEnd] === " " || src[blockEnd] === "\t") blockEnd++;
707
733
  if (src[blockEnd] === "\n") blockEnd++;
708
- // Pull in one immediately-preceding `lhs =`/open-delimiter line (e.g. `f(x) =`).
709
- let start = beginLineStart;
710
- if (start > 0 && src[start - 1] === "\n") {
711
- const prevStart = src.lastIndexOf("\n", start - 2) + 1;
712
- const prevLine = src.slice(prevStart, start - 1);
713
- if (/[=([{]\s*$/.test(prevLine)) start = prevStart;
714
- }
715
- return [start, blockEnd];
734
+ return blockEnd;
716
735
  }
736
+
717
737
  const mathEnvBlockExtension: TokenizerAndRendererExtension = {
718
738
  name: "mathEnvBlock",
719
739
  level: "block",
720
- start(src) {
721
- const r = bareMathEnvBlock(src);
722
- return r ? r[0] : undefined;
723
- },
740
+ // No `start` hint: marked only probes block extensions at a block boundary.
724
741
  tokenizer(src) {
725
- const r = bareMathEnvBlock(src);
726
- if (r?.[0] !== 0) return undefined; // only consume when the block starts at offset 0
727
- const raw = src.slice(0, r[1]);
742
+ const length = bareMathEnvBlockLength(this, src);
743
+ if (length === 0) return undefined;
744
+ const raw = src.slice(0, length);
728
745
  const text = raw.replace(/\n[ \t]*$/, "");
729
746
  if (text.trim().length === 0) return undefined;
730
747
  return { type: "math", raw, text, display: true };
@@ -774,77 +791,13 @@ markdownParser.use({
774
791
  ],
775
792
  });
776
793
 
777
- // Setext-underline pre-gate for marked's `lheading` rule. The rule's lazy body
778
- // `((?:.|\n(?!<block-start>))+?)` re-runs its block-start lookahead while
779
- // expanding character by character, so even a FAILING attempt at offset 0
780
- // costs O(len × lookahead) — ~26µs per 200-char list-item body, and marked's
781
- // list tokenizer block-tokenizes every item's content (47.8% of a streaming
782
- // bench profile). A match REQUIRES the setext underline `\n {0,3}(=+|-+)`
783
- // somewhere in src, so this O(n) charCode scan never rejects a src the
784
- // built-in rule would match; single-line srcs (every tight list item) reject
785
- // on the first indexOf.
786
- function lheadingPossible(src: string): boolean {
787
- let i = src.indexOf("\n");
788
- while (i !== -1) {
789
- let j = i + 1;
790
- const limit = j + 3; // underline allows up to 3 leading spaces
791
- while (j < limit && src.charCodeAt(j) === 0x20 /* space */) j++;
792
- const c = src.charCodeAt(j); // NaN past the end fails both comparisons
793
- if (c === 0x3d /* = */ || c === 0x2d /* - */) return true;
794
- i = src.indexOf("\n", j);
795
- }
796
- return false;
797
- }
798
-
799
794
  markdownParser.use({
800
795
  tokenizer: {
801
- // `false` → marked falls back to the built-in tokenizer;
802
- // `undefined` → no token here, built-in never runs.
803
- lheading(src: string): Tokens.Heading | undefined | false {
804
- return lheadingPossible(src) ? false : undefined;
805
- },
806
796
  // Strikethrough is `strikethroughExtension`'s: marked's own looser rule never runs.
807
797
  del: () => undefined,
808
798
  },
809
799
  });
810
800
 
811
- // ---------------------------------------------------------------------------
812
- // Sticky clones of marked's pathological block rules
813
- // ---------------------------------------------------------------------------
814
- // Bun's (JSC) regex engine skips the start-anchor fast-fail for several of
815
- // marked's `^`-anchored block rules — `hr`, `lheading`, `table` and `html` are
816
- // anchored alternations of quantified branches, and a failing `exec`/`test`
817
- // rescans the entire remaining source instead of stopping after offset 0.
818
- // marked's list tokenizer runs `hr.test` and `lheading` per list line against
819
- // the remaining source, so lexing a long list is quadratic (66% of a streaming
820
- // bench profile sat in these two regexes). A sticky (`y`) clone with
821
- // `lastIndex` pinned to 0 attempts the match at offset 0 only.
822
- //
823
- // Equivalence: for a flagless rule whose source is `^`-anchored, a sticky
824
- // clone at `lastIndex = 0` matches exactly when the original matches (same
825
- // match object, same captures) — `^` already restricted matches to offset 0
826
- // (no `m` flag), and stickiness only removes the futile later attempts. The
827
- // flags/anchor guard below skips any rule a future marked version changes.
828
- class AnchoredAtZero extends RegExp {
829
- override exec(str: string): RegExpExecArray | null {
830
- this.lastIndex = 0; // sticky matches set lastIndex; rules are shared
831
- return super.exec(str);
832
- }
833
- override test(str: string): boolean {
834
- this.lastIndex = 0;
835
- return super.test(str);
836
- }
837
- }
838
-
839
- for (const table of [Lexer.rules.block.normal, Lexer.rules.block.gfm]) {
840
- for (const name of ["hr", "lheading", "table", "html"] as const) {
841
- const rule = table[name];
842
- if (rule.flags === "" && rule.source.startsWith("^")) {
843
- table[name] = new AnchoredAtZero(rule.source, "y");
844
- }
845
- }
846
- }
847
-
848
801
  // ---------------------------------------------------------------------------
849
802
  // Module-level LRU render cache
850
803
  // ---------------------------------------------------------------------------
@@ -1057,85 +1010,12 @@ function lexInlineTokens(text: string): Token[] {
1057
1010
  const REF_DEF_LINE_RE = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/;
1058
1011
  const HAS_REF_DEF = new RegExp(REF_DEF_LINE_RE.source, "m");
1059
1012
 
1060
- // marked's list tokenizer (Tokenizer.list, marked v18) continues a list across
1061
- // blank lines only when the remaining source matches
1062
- // `listItemRegex(marker)` = `^( {0,3}${marker})((?:[\t ][^\n]*)?(?:\n|$))`,
1063
- // where `marker` is the exact bullet char for unordered lists (`\${char}`) or
1064
- // 1-9 digits plus the exact delimiter for ordered lists (`\d{1,9}\${delim}`).
1065
- // The marker is derived from the list's FIRST item (`n = t[1].trim()`), which
1066
- // sits at the start of a top-level list token's raw:
1067
- const LIST_MARKER_RE = /^ {0,3}(?:([*+-])|\d{1,9}([.)]))/;
1068
-
1069
- // Streaming-freeze equivalence invariant: lex(prefix) ++ lex(tail) must equal
1070
- // lex(full text) — for the CURRENT text and for every append-only extension of
1071
- // it, because a frozen prefix is sticky (it keeps being reused while the text
1072
- // grows). At a blank-line (`\n\n`) cut directly after a top-level `list`
1073
- // token, the only construct that can straddle the cut is a continuation item
1074
- // of that list: marked consumed the blank line into the last item's raw and
1075
- // re-ran `listItemRegex` at exactly `tailStart`, merging a same-marker item
1076
- // into one renumbered loose list. The cut is safe only when that regex can
1077
- // NEVER match at `tailStart`, no matter what is appended later.
1078
- //
1079
- // Append-only growth means existing characters are immutable while new ones
1080
- // may appear after them, so "closed" may only be concluded from a present
1081
- // character that contradicts every possible continuation (e.g. tail "1x" can
1082
- // never grow into an ordered item, but tail "1" can become "1. c"). Running
1083
- // out of text mid-marker therefore answers "may continue".
1084
- //
1085
- // Returns true when the tail could still continue the list (or the list's
1086
- // marker is unrecognizable) — the conservative "don't freeze" answer. marked
1087
- // may break the list anyway when the matching line is also an hr (`- - -`);
1088
- // treating that as "may continue" merely skips a freeze, never corrupts one.
1089
- function listMayContinueAt(text: string, tailStart: number, listRaw: string): boolean {
1090
- const marker = LIST_MARKER_RE.exec(listRaw);
1091
- if (marker === null) return true; // unrecognized list shape — stay conservative
1092
- const n = text.length;
1093
- let i = tailStart;
1094
- // `listItemRegex` allows up to 3 leading spaces (the caller's next-char
1095
- // guard rejects whitespace at the final cut, but mirror the rule exactly).
1096
- while (i < n && i - tailStart < 3 && text.charCodeAt(i) === 0x20 /* space */) i++;
1097
- if (i >= n) return true;
1098
- const bullet = marker[1];
1099
- if (bullet !== undefined) {
1100
- if (text[i] !== bullet) return false; // wrong marker char — closed forever
1101
- i++;
1102
- } else {
1103
- // Ordered: 1-9 digits, then the same `.`/`)` delimiter.
1104
- let digits = 0;
1105
- while (i < n && digits < 10) {
1106
- const c = text.charCodeAt(i);
1107
- if (c < 0x30 /* 0 */ || c > 0x39 /* 9 */) break;
1108
- digits++;
1109
- i++;
1110
- }
1111
- if (digits === 0 || digits > 9) return false; // no digit run / too long — closed forever
1112
- if (i >= n) return true; // delimiter (or more digits) may still arrive
1113
- if (text[i] !== marker[2]) return false; // wrong delimiter — closed forever
1114
- i++;
1115
- }
1116
- // After the marker: `(?:[\t ][^\n]*)?(?:\n|$)` — tab/space + anything, a
1117
- // bare newline, or end-of-input (which appends can still extend).
1118
- if (i >= n) return true;
1119
- const after = text.charCodeAt(i);
1120
- return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a; /* \n */
1121
- }
1122
-
1123
- /** A probe window: see {@link stableBlockBoundary}. */
1124
- interface ProbeWindow {
1125
- /** Offset in `text` where the window, and so the lex of `tokens`, ends. */
1126
- end: number;
1127
- /** The display-math blocks of the whole of `text`. */
1128
- mathBlocks: MathBlockScan;
1129
- }
1130
-
1131
1013
  /** The last stable block boundary of a token run: see {@link stableBlockBoundary}. */
1132
1014
  interface BlockBoundary {
1133
1015
  /** Offset just past the boundary token, or 0 when the run holds none. */
1134
1016
  end: number;
1135
1017
  /** Number of tokens up to and including the boundary token, or 0. */
1136
1018
  count: number;
1137
- /** End of the display-math block a probe's scan stopped at, which a later window must reach past; 0 when it stopped at none. */
1138
- blockEnd: number;
1139
1019
  }
1140
1020
 
1141
1021
  // A whitespace-only line, capturing its terminator: "\n", or "" at the end of the text.
@@ -1161,15 +1041,10 @@ const NO_OPENERS: readonly PrefixOpener[] = [];
1161
1041
  * `"\n\n"` break, together with the number of tokens up to and including it.
1162
1042
  * `count === 0` means the run holds no usable boundary.
1163
1043
  *
1164
- * `base` is where `tokens[0]` starts inside `text`. Without `window`, `tokens`
1165
- * lex the rest of `text`, which appends may still extend: the streaming
1166
- * freeze. With it, they lex `text.slice(base, window.end)` of a whole
1167
- * document: a probe. A boundary qualifies only when splitting there is
1168
- * invisible to the lexer, i.e. `lex(head) ++ lex(tail) === lex(text)`:
1169
- * - In a probe, the token must end before `window.end`. An unclosed fence,
1170
- * HTML block or comment runs to the end of its input, so a window that
1171
- * ends just after a blank line inside one hands back a truncated token
1172
- * whose raw ends in `"\n\n"`.
1044
+ * `base` is where `tokens[0]` starts inside `text`; `tokens` lex the rest of
1045
+ * `text`, which appends may still extend (the streaming freeze). A boundary
1046
+ * qualifies only when splitting there is invisible to the lexer, i.e.
1047
+ * `lex(head) ++ lex(tail) === lex(text)`:
1173
1048
  * - The break must sit inside `text`. At end-of-text the next character is
1174
1049
  * unknown (and, while streaming, may still arrive), so the cut is deferred.
1175
1050
  * - The next line must start real block content. A leading space or newline
@@ -1181,15 +1056,8 @@ const NO_OPENERS: readonly PrefixOpener[] = [];
1181
1056
  * - A preceding `list` must be provably closed: CommonMark lets a same-marker
1182
1057
  * item continue the list across the blank line, and marked merges both into
1183
1058
  * one renumbered loose list (`listMayContinueAt`).
1184
- * - In a probe, no earlier token may open a display-math block that the
1185
- * window cut short: a token other than `math` (which is the block itself)
1186
- * where `window.mathBlocks` finds a block in the whole document. The
1187
- * one-pass lex makes that block one `math` token across its blank lines,
1188
- * so the scan stops there and reports the block's end as `blockEnd`. An
1189
- * opener with no closer, or with a whitespace-only body, is no block in
1190
- * either lex, so it leaves later boundaries alone.
1191
- * - While streaming, `tokens` are the one-pass lex of `text` as it stands,
1192
- * so a block the lex already made is a `math` token, and one that an
1059
+ * - Display math: `tokens` are the one-pass lex of `text` as it stands, so
1060
+ * a block the lex already made is a `math` token, and one that an
1193
1061
  * append could still close becomes one only once a closer line arrives,
1194
1062
  * which Markdown#lexTokens watches for. With `settle`, the scan instead
1195
1063
  * stops at a token whose display-math block an append could still close
@@ -1212,30 +1080,18 @@ function stableBlockBoundary(
1212
1080
  {
1213
1081
  startIndex = 0,
1214
1082
  endIndex = tokens.length,
1215
- window,
1216
1083
  settle = false,
1217
- }: { startIndex?: number; endIndex?: number; window?: ProbeWindow; settle?: boolean } = {},
1084
+ }: { startIndex?: number; endIndex?: number; settle?: boolean } = {},
1218
1085
  ): BlockBoundary {
1219
1086
  let pos = base;
1220
1087
  let end = 0;
1221
1088
  let count = 0;
1222
- let blockEnd = 0;
1223
1089
  for (let i = startIndex; i < endIndex; i++) {
1224
1090
  const token = tokens[i];
1225
1091
  const raw = token.raw;
1226
1092
  const tokenEnd = pos + raw.length;
1227
- if (token.type !== "math") {
1228
- if (window !== undefined) {
1229
- const block = window.mathBlocks.at(pos);
1230
- if (block !== undefined) {
1231
- blockEnd = pos + block.raw.length;
1232
- break;
1233
- }
1234
- } else if (settle && mathBlockMayCloseAt(text, pos)) {
1235
- break;
1236
- }
1237
- }
1238
- if (raw.endsWith("\n\n") && (window === undefined || tokenEnd < window.end)) {
1093
+ if (settle && token.type !== "math" && mathBlockMayCloseAt(text, pos)) break;
1094
+ if (raw.endsWith("\n\n")) {
1239
1095
  const prev = i > 0 ? tokens[i - 1] : undefined;
1240
1096
  if (prev === undefined || prev.type !== "list" || !listMayContinueAt(text, tokenEnd, prev.raw)) {
1241
1097
  end = tokenEnd;
@@ -1244,116 +1100,33 @@ function stableBlockBoundary(
1244
1100
  }
1245
1101
  pos = tokenEnd;
1246
1102
  }
1247
- if (count === 0 || end >= text.length) return { end: 0, count: 0, blockEnd };
1103
+ if (count === 0 || end >= text.length) return { end: 0, count: 0 };
1248
1104
  const next = text.charCodeAt(end);
1249
- if (next === 0x20 /* space */ || next === 0x0a /* \n */) return { end: 0, count: 0, blockEnd };
1105
+ if (next === 0x20 /* space */ || next === 0x0a /* \n */) return { end: 0, count: 0 };
1250
1106
  WHITESPACE_LINE_RE.lastIndex = end;
1251
- const blank = WHITESPACE_LINE_RE.exec(text);
1252
- if (blank !== null && (blank[1] === "\n" || window === undefined)) return { end: 0, count: 0, blockEnd };
1253
- return { end, count, blockEnd };
1254
- }
1255
-
1256
- // Bun's regex engine skips the start-anchor optimization for several of marked's
1257
- // block rules — `hr`, `lheading`, `table` and `html` are `^`-anchored
1258
- // alternations of quantified branches — so each failing `exec` rescans the whole
1259
- // remaining source instead of stopping at offset 0. Lexing is then quadratic in
1260
- // document length: an 800 KB message costs ~41 s under Bun where Node/V8 needs
1261
- // ~60 ms, and it runs on the render path, freezing the UI. Bounded windows keep
1262
- // every scan short and restore linear behavior (~0.7 s for that same message).
1263
- /** @internal exported for tests — the windowed lexer's first-probe window size. */
1264
- export const LEX_WINDOW_BYTES = 2 * 1024;
1265
- // Under this size a single pass beats probing for window boundaries; the
1266
- // crossover measured on pathological Markdown sits around 16 KB.
1267
- const WINDOWED_LEX_MIN_BYTES = 16 * 1024;
1268
-
1269
- /**
1270
- * Lex `text` in bounded windows, producing the exact token stream
1271
- * `markdownParser.lexer(text)` would.
1272
- *
1273
- * Window cuts come from marked itself: a throwaway BLOCK-ONLY probe lex of the
1274
- * window reports its last stable block boundary ({@link stableBlockBoundary})
1275
- * and only that confirmed segment is handed to the real lexer; a window
1276
- * holding no boundary grows ({@link nextProbeSize}) until it finds one or
1277
- * reaches the end. Probes never run inline tokenization (their inlineQueue is
1278
- * discarded) — a boundary is a property of block structure alone, and probe
1279
- * inline passes were the dominant cost of an earlier revision. Block
1280
- * tokenization runs per window while inline tokenization is deferred to the
1281
- * end — mirroring `Lexer.lex` — so a `[label]: dest` definition anywhere in
1282
- * the document still resolves for every inline span.
1283
- *
1284
- * Each round's first window reaches just past the next blank line
1285
- * ({@link firstProbeSize}); a tail with no blank line left (e.g. one long
1286
- * tight list) goes to the lexer whole.
1287
- */
1288
- function lexWindowed(text: string): TokensList {
1289
- const lexer = new Lexer(markdownParser.defaults);
1290
- const mathBlocks = new MathBlockScan(text);
1291
- let offset = 0;
1292
- while (offset < text.length) {
1293
- let end = text.length;
1294
- for (let size = firstProbeSize(text, offset); offset + size < text.length;) {
1295
- const boundary = probeBoundary(text, offset, size, mathBlocks);
1296
- if (boundary.end > 0) {
1297
- end = boundary.end;
1298
- break;
1299
- }
1300
- size = nextProbeSize(text, offset, size, boundary.blockEnd);
1301
- }
1302
- lexer.blockTokens(text.slice(offset, end), lexer.tokens);
1303
- offset = end;
1304
- }
1305
- for (const queued of lexer.inlineQueue) lexer.inlineTokens(queued.src, queued.tokens);
1306
- lexer.inlineQueue = [];
1307
- return lexer.tokens;
1308
- }
1309
-
1310
- /**
1311
- * Size of the first probe window at `offset`: `LEX_WINDOW_BYTES`, or up to one
1312
- * character past the next blank line when that lies further. A boundary is a
1313
- * token whose raw ends in `"\n\n"` and that ends inside its window, so no
1314
- * smaller window can cut. With no blank line left, it is the rest of the text.
1315
- */
1316
- function firstProbeSize(text: string, offset: number): number {
1317
- const nextBlank = text.indexOf("\n\n", offset);
1318
- return nextBlank === -1 ? text.length - offset : Math.max(LEX_WINDOW_BYTES, nextBlank + 3 - offset);
1107
+ if (WHITESPACE_LINE_RE.test(text)) return { end: 0, count: 0 };
1108
+ return { end, count };
1319
1109
  }
1320
1110
 
1321
1111
  /**
1322
- * Size of the next probe window after the one of `size` at `offset` gave no
1323
- * usable cut: at least double, and one character past the first blank line
1324
- * that reaches the window's edge, or `blockEnd` when the probe stopped at a
1325
- * display-math block ending there, since only such a blank line can end a
1326
- * later boundary. Doubling toward a far closer would re-lex the block once
1327
- * per window. With no blank line left, it is the rest of the text.
1328
- */
1329
- function nextProbeSize(text: string, offset: number, size: number, blockEnd: number): number {
1330
- const nextBlank = text.indexOf("\n\n", Math.max(offset + size, blockEnd) - 2);
1331
- return nextBlank === -1 ? text.length - offset : Math.max(2 * size, nextBlank + 3 - offset);
1332
- }
1333
-
1334
- /**
1335
- * The last stable block boundary ({@link stableBlockBoundary}) in the window
1336
- * `text.slice(offset, offset + size)`, from a throwaway block-only lex of the
1337
- * window. `mathBlocks` holds the display-math blocks of all of `text`.
1112
+ * Lex a whole document with the renderer's rules (math, custom rules,
1113
+ * strikethrough). Callers that split a document along its blocks (table
1114
+ * charts) lex through this so they see the blocks Markdown renders.
1338
1115
  */
1339
- function probeBoundary(text: string, offset: number, size: number, mathBlocks: MathBlockScan): BlockBoundary {
1340
- const probe = new Lexer(markdownParser.defaults);
1341
- probe.blockTokens(text.slice(offset, offset + size), probe.tokens);
1342
- return stableBlockBoundary(text, offset, probe.tokens, { window: { end: offset + size, mathBlocks } });
1116
+ export function lexDocument(text: string): TokensList {
1117
+ return markdownParser.lexer(text);
1343
1118
  }
1344
1119
 
1345
1120
  /**
1346
- * Lex a whole document with the renderer's rules (math, custom rules,
1347
- * strikethrough), windowing anything large enough for the quadratic scan to
1348
- * bite. `links` holds every reference definition, at any nesting depth. Callers
1349
- * that split a document along its blocks (table charts) lex through this so
1350
- * they see the blocks Markdown renders.
1121
+ * `source` as a fenced code block in `lang`: the fence is one backtick longer
1122
+ * than any backtick run in the source (at least three), so the block always
1123
+ * closes where intended. `open` leaves it unclosed, the source verbatim, for
1124
+ * a body that is still arriving.
1351
1125
  */
1352
- export function lexDocument(text: string): TokensList {
1353
- // A CR shifts every `raw` span (marked normalizes CRLF before tokenizing), so
1354
- // window offsets would address the wrong characters — lex those in one pass.
1355
- if (text.length < WINDOWED_LEX_MIN_BYTES || text.includes("\r")) return markdownParser.lexer(text);
1356
- return lexWindowed(text);
1126
+ export function fencedCode(lang: string, source: string, options?: { open?: boolean }): string {
1127
+ const longest = source.match(/`+/g)?.reduce((max, run) => Math.max(max, run.length), 2) ?? 2;
1128
+ const fence = "`".repeat(longest + 1);
1129
+ return options?.open ? `${fence}${lang}\n${source}` : `${fence}${lang}\n${source.trimEnd()}\n${fence}`;
1357
1130
  }
1358
1131
 
1359
1132
  /** A hyperlink as the renderer sees it: inline `[text](href)`, `<autolink>`, bare GFM URL, or reference link. */
@@ -1396,7 +1169,7 @@ export function extractMarkdownLinks(text: string): MarkdownLink[] {
1396
1169
  if (any.rows) for (const row of any.rows) for (const cell of row) walk(cell.tokens);
1397
1170
  }
1398
1171
  };
1399
- walk(lexDocument(text));
1172
+ walk(markdownParser.lexer(text));
1400
1173
  return links;
1401
1174
  }
1402
1175
 
@@ -2224,7 +1997,7 @@ export class Markdown implements Component {
2224
1997
  this.#lastScanValid = true;
2225
1998
  this.#appendOnlySinceLastScan = true;
2226
1999
  if (canStream && hasPrefix) {
2227
- const tailTokens = lexDocument(refDefText);
2000
+ const tailTokens = markdownParser.lexer(refDefText);
2228
2001
  // HAS_REF_DEF sees top-level definition lines only. A definition nested
2229
2002
  // in a quote or list item still registers for the whole document and
2230
2003
  // can resolve a reference in the frozen prefix, which was lexed
@@ -2236,7 +2009,7 @@ export class Markdown implements Component {
2236
2009
  return tokens;
2237
2010
  }
2238
2011
  }
2239
- const tokens = lexDocument(text);
2012
+ const tokens = markdownParser.lexer(text);
2240
2013
  // A definition frozen into the prefix would be missing from every later
2241
2014
  // tail lex, so a full lex that registered any definition freezes nothing.
2242
2015
  if (canStream && retainPrefix && Object.keys(tokens.links).length === 0) {
package/src/fuzzy.ts CHANGED
@@ -68,27 +68,41 @@ function normalizeForSearch(value: string): string {
68
68
  // index across keystrokes eliminates the redundant normalize + word-split + Set
69
69
  // build on every character. Consumers only read the result, so sharing is safe.
70
70
  //
71
- // Admission is conservative so the cache helps the repeated-filter hot path
72
- // without paying for one-off text: only short texts are cached (long inputs —
73
- // pasted prompts, transcripts searched via the message selector — would bloat
74
- // memory), and admission stops at the cap instead of evicting, so a stream of
75
- // unique texts (message/session search) can't churn the map.
71
+ // Only short texts are cached (long inputs — pasted prompts, transcripts
72
+ // searched via the message selector — would bloat memory). The map is a
73
+ // bounded LRU: a hit re-inserts the entry (Map iteration order is insertion
74
+ // order) and a miss at capacity evicts the oldest entry, so a burst of one-off
75
+ // texts can't pin itself forever and starve later selectors of the cache.
76
+ // A single ranking pass only admits/promotes its first INDEX_CACHE_MAX
77
+ // candidates; the tail of a larger list is looked up without promotion and
78
+ // built uncached otherwise, so re-ranking an oversized list keeps its head
79
+ // cached instead of cyclically evicting every entry. Candidate lists larger
80
+ // than the cap should hold a {@link FuzzyCorpus}.
76
81
  const INDEX_CACHE_MAX = 4096;
77
82
  const MAX_CACHED_TEXT_LEN = 4096;
78
83
  const indexCache = new Map<string, SearchIndex>();
79
84
 
80
- function buildSearchIndex(text: string): SearchIndex {
85
+ function buildSearchIndex(text: string, admit = true): SearchIndex {
81
86
  // Long inputs (pasted prompts, transcripts) are never cached; bypass the Map
82
87
  // entirely so they don't pay a hash lookup on every search.
83
88
  if (text.length > MAX_CACHED_TEXT_LEN) return buildUncachedSearchIndex(text);
84
89
 
85
90
  const cached = indexCache.get(text);
86
- if (cached !== undefined) return cached;
91
+ if (cached !== undefined) {
92
+ if (admit) {
93
+ indexCache.delete(text);
94
+ indexCache.set(text, cached);
95
+ }
96
+ return cached;
97
+ }
87
98
 
88
99
  const result = buildUncachedSearchIndex(text);
89
- if (indexCache.size < INDEX_CACHE_MAX) {
90
- indexCache.set(text, result);
100
+ if (!admit) return result;
101
+ if (indexCache.size >= INDEX_CACHE_MAX) {
102
+ const oldest = indexCache.keys().next().value;
103
+ if (oldest !== undefined) indexCache.delete(oldest);
91
104
  }
105
+ indexCache.set(text, result);
92
106
  return result;
93
107
  }
94
108
 
@@ -384,6 +398,33 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
384
398
  return fuzzyMatchCore(pq, buildSearchIndex(text));
385
399
  }
386
400
 
401
+ /**
402
+ * A query normalized and tokenized once for matching against many texts.
403
+ *
404
+ * `fuzzyMatch(query, text)` re-normalizes `query` (and rebuilds its
405
+ * alphanumeric-swap variants) on every call; filters that test one query
406
+ * against every row should build one `FuzzyQuery` per keystroke instead.
407
+ * `query.match(text)` returns exactly what `fuzzyMatch(query, text)` returns.
408
+ */
409
+ export class FuzzyQuery {
410
+ readonly #prepared: PreparedQuery | null;
411
+
412
+ constructor(query: string) {
413
+ this.#prepared = prepareQuery(query);
414
+ }
415
+
416
+ /** Same result as `fuzzyMatch(query, text)`; `text` may be a prepared {@link FuzzyText}. */
417
+ match(text: string | FuzzyText): FuzzyMatch {
418
+ const pq = this.#prepared;
419
+ if (pq === null) return { matches: true, score: 0 };
420
+ return fuzzyMatchCore(pq, typeof text === "string" ? buildSearchIndex(text) : fuzzyTextIndex(text));
421
+ }
422
+ }
423
+
424
+ // Module-private read access to `FuzzyText#index` for `FuzzyQuery.match`;
425
+ // assigned by FuzzyText's static initializer.
426
+ let fuzzyTextIndex: (text: FuzzyText) => SearchIndex;
427
+
387
428
  /**
388
429
  * A text prepared once for repeated fuzzy matching.
389
430
  *
@@ -401,17 +442,21 @@ export class FuzzyText {
401
442
  this.#index = buildUncachedSearchIndex(text);
402
443
  }
403
444
 
445
+ static {
446
+ fuzzyTextIndex = text => text.#index;
447
+ }
448
+
404
449
  /** Match `query` (space-separated tokens; all must match) against the prepared text. */
405
- match(query: string): FuzzyMatch {
406
- return fuzzyMatchCore(prepareQuery(query), this.#index);
450
+ match(query: string | FuzzyQuery): FuzzyMatch {
451
+ return typeof query === "string" ? fuzzyMatchCore(prepareQuery(query), this.#index) : query.match(this);
407
452
  }
408
453
  }
409
454
 
410
455
  /**
411
456
  * Items prepared once for repeated {@link fuzzyRank}-equivalent ranking.
412
457
  *
413
- * `fuzzyRank` resolves each candidate's search index through the module cache,
414
- * which stops admitting entries at {@link INDEX_CACHE_MAX}; larger candidate
458
+ * `fuzzyRank` resolves each candidate's search index through the bounded module
459
+ * cache, which holds at most {@link INDEX_CACHE_MAX} entries; larger candidate
415
460
  * lists (the full model catalog) rebuild the overflow on every query. Hold one
416
461
  * corpus per stable candidate list and call {@link rank} per keystroke instead.
417
462
  */
@@ -462,7 +507,7 @@ export function fuzzyRank<T>(items: readonly T[], query: string, getText: (item:
462
507
  // A non-blank query that normalizes to empty (pure punctuation) matches
463
508
  // everything with score 0, but still calls getText per item — consumers rely
464
509
  // on its side effects (see fuzzy-cache.test.ts).
465
- return rankIndexed(items, prepareQuery(query), i => buildSearchIndex(getText(items[i]!)));
510
+ return rankIndexed(items, prepareQuery(query), i => buildSearchIndex(getText(items[i]!), i < INDEX_CACHE_MAX));
466
511
  }
467
512
 
468
513
  export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {