@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.
- package/CHANGELOG.md +21 -52
- package/dist/types/apps/debug/log-viewer.d.ts +4 -0
- package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
- package/dist/types/chat/fence-figure.d.ts +42 -0
- package/dist/types/chat/svg-figure.d.ts +1 -1
- package/dist/types/chat/tool-execution.d.ts +2 -0
- package/dist/types/components/editor.d.ts +8 -3
- package/dist/types/components/markdown.d.ts +11 -6
- package/dist/types/fuzzy.d.ts +17 -3
- package/dist/types/native/backend.d.ts +2 -1
- package/dist/types/native/blobs.d.ts +4 -2
- package/dist/types/native/encode.d.ts +3 -4
- package/dist/types/overlays/agent-transcript-viewer.d.ts +8 -1
- package/dist/types/overlays/history-search.d.ts +10 -0
- package/dist/types/overlays/model-browser.d.ts +6 -0
- package/dist/types/prompt/custom-editor.d.ts +7 -4
- package/dist/types/prompt/interactive-context-helpers.d.ts +2 -1
- package/dist/types/tools/renderer.d.ts +22 -0
- package/dist/types/tools/streaming-output.d.ts +11 -1
- package/dist/types/tools/write.d.ts +8 -1
- package/package.json +9 -9
- package/src/apps/debug/log-viewer.ts +188 -72
- package/src/apps/debug/raw-sse-buffer.ts +8 -0
- package/src/apps/debug/raw-sse.ts +39 -5
- package/src/apps/git/diff-pane.ts +26 -5
- package/src/autocomplete.ts +59 -33
- package/src/chat/assistant-message.ts +30 -18
- package/src/chat/fence-figure.ts +80 -0
- package/src/chat/svg-figure.ts +2 -4
- package/src/chat/tool-execution.ts +53 -4
- package/src/components/editor.ts +16 -3
- package/src/components/markdown.ts +80 -307
- package/src/fuzzy.ts +59 -14
- package/src/native/backend.ts +170 -15
- package/src/native/blobs.ts +4 -2
- package/src/native/encode.ts +58 -31
- package/src/overlays/agent-hub-projection.ts +70 -2
- package/src/overlays/agent-hub.ts +3 -2
- package/src/overlays/agent-transcript-viewer.ts +64 -13
- package/src/overlays/agents-hub.ts +14 -7
- package/src/overlays/copy-selector.ts +30 -8
- package/src/overlays/extensions/state-manager.ts +52 -12
- package/src/overlays/history-search.ts +100 -7
- package/src/overlays/jobs-panel.ts +30 -2
- package/src/overlays/model-browser.ts +107 -21
- package/src/overlays/model-hub.ts +34 -7
- package/src/overlays/oauth-selector.ts +41 -26
- package/src/overlays/plan-review-overlay.ts +142 -64
- package/src/overlays/rewind-selector.ts +86 -17
- package/src/overlays/session-selector.ts +24 -4
- package/src/overlays/settings-selector.ts +21 -6
- package/src/overlays/tree-selector.ts +20 -4
- package/src/overlays/usage-dashboard.ts +12 -15
- package/src/prompt/attachment-chips.ts +2 -1
- package/src/prompt/composer-attachments.ts +6 -4
- package/src/prompt/custom-editor.ts +48 -19
- package/src/prompt/interactive-context-helpers.ts +27 -18
- package/src/prompt/macos-spelling.ts +7 -0
- package/src/prompt/magic-keywords.ts +11 -2
- package/src/prompt/word-completion.ts +38 -5
- package/src/setup/scenes/splash.ts +109 -45
- package/src/status-line/component.ts +220 -64
- package/src/status-line/segments.ts +5 -2
- package/src/terminal.ts +3 -2
- package/src/tools/ast-edit.ts +59 -35
- package/src/tools/ast-grep.ts +35 -16
- package/src/tools/default-renderer.ts +35 -19
- package/src/tools/eval-format/index.ts +28 -2
- package/src/tools/eval-format/javascript.ts +7 -5
- package/src/tools/eval.ts +164 -78
- package/src/tools/grep.ts +81 -48
- package/src/tools/lsp.ts +81 -59
- package/src/tools/mcp.ts +98 -68
- package/src/tools/renderer.ts +27 -0
- package/src/tools/streaming-output.ts +120 -48
- package/src/tools/task.ts +111 -54
- package/src/tools/todo.ts +115 -29
- package/src/tools/wait.ts +34 -24
- 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 =
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
702
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
726
|
-
if (
|
|
727
|
-
const raw = src.slice(0,
|
|
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
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
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
|
-
* -
|
|
1185
|
-
*
|
|
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;
|
|
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
|
-
|
|
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
|
|
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
|
|
1105
|
+
if (next === 0x20 /* space */ || next === 0x0a /* \n */) return { end: 0, count: 0 };
|
|
1250
1106
|
WHITESPACE_LINE_RE.lastIndex = end;
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
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
|
|
1340
|
-
|
|
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
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
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
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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)
|
|
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 (
|
|
90
|
-
|
|
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
|
|
414
|
-
* which
|
|
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[] {
|