@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
@@ -463,16 +463,14 @@ function detailWindowLabel(label: string, limit: UsageLimit): string | undefined
463
463
  return sanitizeDisplayLine(windowLabel);
464
464
  }
465
465
 
466
+ const WHOLE_DOLLARS = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
467
+ const COMPACT_COUNT = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
468
+
466
469
  /** `$1,234 · 5.6K requests` totals for the activity summary. */
467
470
  function formatActivityTotals(layout: HeatmapLayout): string {
468
471
  const cost =
469
- layout.totalCost >= 1
470
- ? `$${new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(layout.totalCost)}`
471
- : `$${layout.totalCost.toFixed(2)}`;
472
- const requests = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(
473
- layout.totalRequests,
474
- );
475
- return `${cost} · ${requests} requests`;
472
+ layout.totalCost >= 1 ? `$${WHOLE_DOLLARS.format(layout.totalCost)}` : `$${layout.totalCost.toFixed(2)}`;
473
+ return `${cost} · ${COMPACT_COUNT.format(layout.totalRequests)} requests`;
476
474
  }
477
475
 
478
476
  // =============================================================================
@@ -547,6 +545,8 @@ export class UsageDashboardComponent implements Component {
547
545
  #activityError: string | null = null;
548
546
  #syncing = true;
549
547
  #detailCache: { width: number; lines: string[] } | null = null;
548
+ /** ANSI overview rows; rebuilt when the revision or width changes. */
549
+ #overviewCache: { revision: number; width: number; lines: string[] } | null = null;
550
550
  #lastViewportRows = 10;
551
551
  #closed = false;
552
552
  readonly #panel: OverlayPanel;
@@ -592,6 +592,7 @@ export class UsageDashboardComponent implements Component {
592
592
 
593
593
  invalidate(): void {
594
594
  this.#detailCache = null;
595
+ this.#overviewCache = null;
595
596
  this.#panel.invalidate();
596
597
  }
597
598
 
@@ -836,15 +837,8 @@ export class UsageDashboardComponent implements Component {
836
837
  const ramp = this.#heatRamp();
837
838
  const reset = "\x1b[39m";
838
839
 
839
- const cost =
840
- layout.totalCost >= 1
841
- ? `$${new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(layout.totalCost)}`
842
- : `$${layout.totalCost.toFixed(2)}`;
843
- const requests = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(
844
- layout.totalRequests,
845
- );
846
840
  summary.push(
847
- `${theme.bold(theme.fg("accent", "Activity"))} ${theme.fg("dim", `${cost} · ${requests} requests · last ${weeks} weeks`)}${this.#syncing ? theme.fg("dim", " · syncing…") : ""}`,
841
+ `${theme.bold(theme.fg("accent", "Activity"))} ${theme.fg("dim", `${formatActivityTotals(layout)} · last ${weeks} weeks`)}${this.#syncing ? theme.fg("dim", " · syncing…") : ""}`,
848
842
  );
849
843
  summary.push("");
850
844
 
@@ -876,10 +870,13 @@ export class UsageDashboardComponent implements Component {
876
870
  // ---------------------------------------------------------------------------
877
871
 
878
872
  #overviewLines(innerWidth: number): string[] {
873
+ const cached = this.#overviewCache;
874
+ if (cached?.revision === this.#revision && cached.width === innerWidth) return cached.lines;
879
875
  const lines: string[] = [];
880
876
  lines.push(...this.#renderCardsGrid(innerWidth));
881
877
  lines.push("");
882
878
  lines.push(...this.#renderHeatmap(innerWidth));
879
+ this.#overviewCache = { revision: this.#revision, width: innerWidth, lines };
883
880
  return lines;
884
881
  }
885
882
 
@@ -249,7 +249,8 @@ export class AttachmentChipsBand implements Component {
249
249
 
250
250
  /** Leading 4 rows x 12 cols of the pasted text, muted. */
251
251
  #textInterior(entry: TextAttachment): string[] {
252
- const lines = entry.content.split("\n");
252
+ // Only the leading rows are drawn; don't split a multi-KB paste every frame.
253
+ const lines = entry.content.split("\n", INNER_ROWS);
253
254
  const rows: string[] = [];
254
255
  for (let r = 0; r < INNER_ROWS; r++) {
255
256
  const cut = truncateToWidth(replaceTabs(lines[r] ?? ""), INNER_COLS);
@@ -165,6 +165,9 @@ export const COMPOSER_TOKEN_REGEX = new RegExp(
165
165
  "gu",
166
166
  );
167
167
 
168
+ /** Last compiled {@link referencedAttachments} scanner, keyed by its source (labels and glyphs rarely change). */
169
+ let cachedScanner: { source: string; scanner: RegExp } | undefined;
170
+
168
171
  /**
169
172
  * Attachment indices referenced by a composer buffer, per kind. Image/video count compact chips
170
173
  * and expanded markers; paste counts compact chips only, since `[Paste #N]` markers number the
@@ -182,10 +185,9 @@ export function referencedAttachments(
182
185
  const icons = [...kindByIcon.keys()].sort((a, b) => b.length - a.length).map(glyphSource);
183
186
  const labels = [...recorded.keys()].sort((a, b) => b.length - a.length).map(label => RegExp.escape(label));
184
187
  const recordedSource = labels.length > 0 ? labels.join("|") : "(?!)";
185
- const scanner = new RegExp(
186
- `${PLACEHOLDER_REGEX.source}|(${recordedSource})(?!\\d)|(${icons.join("|")}) #([1-9]\\d*)`,
187
- "gu",
188
- );
188
+ const source = `${PLACEHOLDER_REGEX.source}|(${recordedSource})(?!\\d)|(${icons.join("|")}) #([1-9]\\d*)`;
189
+ if (cachedScanner?.source !== source) cachedScanner = { source, scanner: new RegExp(source, "gu") };
190
+ const { scanner } = cachedScanner;
189
191
 
190
192
  const refs: Record<ChipKind, Set<number>> = { image: new Set(), video: new Set(), paste: new Set() };
191
193
  for (const match of text.matchAll(scanner)) {
@@ -41,7 +41,7 @@ import {
41
41
  } from "./composer-attachments";
42
42
  import { type MacOSSpellingFeatures, MacOSSpellingProvider } from "./macos-spelling";
43
43
  import { hasMagicKeyword, highlightMagicKeywords, magicKeywordRanges } from "./magic-keywords";
44
- import type { TspEditorDecoration } from "@oh-my-pi/pi-wire";
44
+ import type { TspEditorDecoration, TspText } from "@oh-my-pi/pi-wire";
45
45
  import { isNativeRendering } from "../native/state";
46
46
  import { isQueuedMessageList, parseQueueShorthand, QUEUE_LIST_MARKER_RE } from "./queue-input";
47
47
  import { type WordCompletionMethod, WordCompletionProvider } from "./word-completion";
@@ -50,8 +50,8 @@ import { fgOrPlain, theme } from "../theme/theme";
50
50
  /** A shell-mode draft's sigil (`!`, `!!`, `$`, `$$`) with its surrounding blanks; the mode chip stands in for it natively. */
51
51
  const SHELL_SIGIL_RE = /^\s*(?:!!?|\$\$?)[ \t]?/;
52
52
 
53
- /** The composer's TSP placeholder; the rotating ANSI hints (thinking effort, …) move into control tooltips. */
54
- const NATIVE_COMPOSER_PLACEHOLDER = "Ask omp — / commands · @ files · ! bash";
53
+ /** The untitled session's TSP placeholder; the rotating ANSI hints (thinking effort, …) move into control tooltips. */
54
+ const NATIVE_COMPOSER_PLACEHOLDER = "What are we cooking?";
55
55
 
56
56
  /** Live composer state the TSP layout shows; the interactive host wires {@link CustomEditor.composerState}. */
57
57
  export interface ComposerNativeState {
@@ -67,6 +67,8 @@ export interface ComposerNativeState {
67
67
  readonly running: boolean;
68
68
  /** The viewed subagent's lineage, outermost first and the viewed agent last; undefined on the main session. */
69
69
  readonly viewing?: readonly string[];
70
+ /** The session's title, quoted in italics as the TSP placeholder; undefined until it has one. */
71
+ readonly title?: string;
70
72
  }
71
73
 
72
74
  /** Action code prefix of the viewing header's links: `focus:<agent id>`, {@link MAIN_AGENT_ID} for the main session. */
@@ -220,7 +222,7 @@ function normalizePastedPath(path: string): string {
220
222
  }
221
223
  }
222
224
  }
223
- return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
225
+ return (unquoted.startsWith("\\~/") ? unquoted.slice(1) : unquoted).replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
224
226
  }
225
227
 
226
228
  function isExplicitPastedPath(path: string): boolean {
@@ -798,10 +800,21 @@ export class CustomEditor extends Editor {
798
800
  * indivisible: a stray backspace deletes the whole token instead of corrupting it. */
799
801
  override atomicTokenPattern = COMPOSER_TOKEN_REGEX;
800
802
 
803
+ /** Atom-table revision and pattern the composer token matcher was last built for. */
804
+ #tokenPatternAtomsRevision = -1;
805
+ #tokenPattern: RegExp | undefined;
806
+
801
807
  #syncComposerTokenPattern(): void {
802
- const labels = [...this.atoms].filter(([, expansion]) => expansion.startsWith("^")).map(([label]) => label);
808
+ // Rebuild only when the atom table changed or someone replaced the pattern since the last sync.
809
+ if (this.#tokenPatternAtomsRevision === this.atomsRevision && this.#tokenPattern === this.atomicTokenPattern) {
810
+ return;
811
+ }
812
+ const labels: string[] = [];
813
+ for (const [label, expansion] of this.atoms) if (expansion.startsWith("^")) labels.push(label);
803
814
  const next = composerTokenRegex(labels);
804
815
  if (next.source !== this.atomicTokenPattern.source) this.atomicTokenPattern = next;
816
+ this.#tokenPatternAtomsRevision = this.atomsRevision;
817
+ this.#tokenPattern = this.atomicTokenPattern;
805
818
  }
806
819
 
807
820
  /** Magic-keyword shimmer cadence — drives one editor repaint every 70 ms while
@@ -819,27 +832,35 @@ export class CustomEditor extends Editor {
819
832
  * timer to request the next animation frame. Undefined when nobody is
820
833
  * listening (tests, headless callers); the timer chain still self-cleans. */
821
834
  #requestShimmerRepaint: (() => void) | undefined;
822
- #queueDecorationText: string | undefined;
835
+ /** Text revision the per-revision decoration inputs below were computed for. */
836
+ #decorationRevision = -1;
837
+ #decorationText = "";
823
838
  #decorationLines: readonly string[] = [""];
824
839
  #queueShorthandActive = false;
825
840
  #queueListActive = false;
826
841
 
827
842
  /** Decorate magic keywords, attachments, and the queue-composer header/list markers.
828
843
  * Queue shorthand reserves its first logical line as a dim `Queueing` label; sequential
829
- * item markers use the accent color so separate follow-ups remain visible while composing. */
844
+ * item markers use the accent color so separate follow-ups remain visible while composing.
845
+ * Called once per layout segment, so whole-buffer inputs are computed once per text revision. */
830
846
  override decorateText = (text: string, context: EditorTextDecorationContext): string => {
831
847
  this.#syncComposerTokenPattern();
832
- const editorText = this.getText();
848
+ if (this.#decorationRevision !== this.textRevision) {
849
+ this.#decorationRevision = this.textRevision;
850
+ const editorText = this.getText();
851
+ if (this.#decorationText !== editorText) {
852
+ this.#decorationText = editorText;
853
+ this.#decorationLines = this.getLines();
854
+ const queueBody = parseQueueShorthand(editorText);
855
+ this.#queueShorthandActive = queueBody !== undefined;
856
+ this.#queueListActive = queueBody !== undefined && isQueuedMessageList(queueBody);
857
+ }
858
+ }
859
+ // One string instance per revision: whole-buffer memos downstream hit on identity.
860
+ const editorText = this.#decorationText;
833
861
  const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText);
834
862
  const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0;
835
863
  if (animated) this.#scheduleShimmerFrame();
836
- if (this.#queueDecorationText !== editorText) {
837
- this.#queueDecorationText = editorText;
838
- this.#decorationLines = this.getLines();
839
- const queueBody = parseQueueShorthand(editorText);
840
- this.#queueShorthandActive = queueBody !== undefined;
841
- this.#queueListActive = queueBody !== undefined && isQueuedMessageList(queueBody);
842
- }
843
864
  let sourceSearchOffset = 0;
844
865
  const locateSource = (value: string): number => {
845
866
  const offset = text.indexOf(value, sourceSearchOffset);
@@ -1018,10 +1039,18 @@ export class CustomEditor extends Editor {
1018
1039
  protected override get nativeSendable(): boolean {
1019
1040
  return this.onSubmit !== undefined && !this.disableSubmit;
1020
1041
  }
1021
- /** Viewing a subagent, the draft goes to it: the placeholder names it. */
1022
- override describePlaceholder = (): string => {
1023
- const agent = this.composerState().viewing?.at(-1);
1024
- return agent === undefined ? NATIVE_COMPOSER_PLACEHOLDER : `Message ${agent}`;
1042
+ /** The quoted-title placeholder, kept while the title stands so the editor node stays reused. */
1043
+ #titlePlaceholder: { title: string; text: TspText } | undefined;
1044
+ /** Viewing a subagent, the draft goes to it: the placeholder names it; else the session's title, quoted in italics. */
1045
+ override describePlaceholder = (): TspText => {
1046
+ const { viewing, title } = this.composerState();
1047
+ const agent = viewing?.at(-1);
1048
+ if (agent !== undefined) return `Message ${agent}`;
1049
+ if (!title) return NATIVE_COMPOSER_PLACEHOLDER;
1050
+ if (this.#titlePlaceholder?.title !== title) {
1051
+ this.#titlePlaceholder = { title, text: [{ t: `“${title}”`, s: "em" }] };
1052
+ }
1053
+ return this.#titlePlaceholder.text;
1025
1054
  };
1026
1055
  /** A shell-mode draft highlights as its language. */
1027
1056
  override describeLanguage = (): string | undefined => this.composerState().shell?.kind;
@@ -1,5 +1,5 @@
1
1
  /** Shared assistant transcript construction and model-authored link caching. */
2
- import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
2
+ import type { AssistantMessage, Model, TextContent } from "@oh-my-pi/pi-ai";
3
3
  import { getMarkdownLinkUrls } from "../index";
4
4
  import { EMPTY_LINK_TARGETS } from "../render/render-utils";
5
5
  import type { ImageBudget } from "../components/image";
@@ -23,7 +23,8 @@ export interface AssistantMessageHost {
23
23
  readonly hideToolActivity: boolean;
24
24
  readonly toolOutputExpanded: boolean;
25
25
  readonly ui: { requestRender(): void; readonly imageBudget: ImageBudget };
26
- resolveAssistantMessageLinks(texts: readonly string[]): Promise<ReadonlyMap<string, string>>;
26
+ /** Resolve already-extracted Markdown link destinations to local targets. */
27
+ resolveAssistantMessageLinkHrefs(hrefs: readonly string[]): Promise<ReadonlyMap<string, string>>;
27
28
  }
28
29
 
29
30
  const kMarkdownLinkTargets = Symbol("markdownLinkTargets");
@@ -31,14 +32,26 @@ type SessionWithMarkdownLinkTargets = AssistantMessageSession & {
31
32
  [kMarkdownLinkTargets]?: ReadonlyMap<string, string>;
32
33
  };
33
34
 
34
- function assistantTextBlocks(messages: readonly AssistantMessage[]): string[] {
35
- const texts: string[] = [];
36
- for (const message of messages) {
37
- for (const content of message.content) {
38
- if (content.type === "text") texts.push(content.text);
35
+ /** Link destinations per text block, revalidated against the block's text (streaming blocks grow in place). */
36
+ const textBlockHrefs = new WeakMap<TextContent, { text: string; hrefs: readonly string[] }>();
37
+
38
+ /**
39
+ * Distinct Markdown link destinations authored in `message`'s text blocks.
40
+ * Each block is lexed once per text revision; transcript rebuilds and the
41
+ * message_end refresh reuse the result.
42
+ */
43
+ function assistantMessageLinkHrefs(message: AssistantMessage): string[] {
44
+ const hrefs = new Set<string>();
45
+ for (const content of message.content) {
46
+ if (content.type !== "text") continue;
47
+ let memo = textBlockHrefs.get(content);
48
+ if (memo?.text !== content.text) {
49
+ memo = { text: content.text, hrefs: getMarkdownLinkUrls(content.text) };
50
+ textBlockHrefs.set(content, memo);
39
51
  }
52
+ for (const href of memo.hrefs) hrefs.add(href);
40
53
  }
41
- return texts;
54
+ return [...hrefs];
42
55
  }
43
56
 
44
57
  /**
@@ -53,13 +66,12 @@ export async function refreshAssistantMessageLinkTargets(
53
66
  ): Promise<ReadonlyMap<string, string>> {
54
67
  const session: SessionWithMarkdownLinkTargets = ctx.viewSession;
55
68
  const previous = session[kMarkdownLinkTargets] ?? EMPTY_LINK_TARGETS;
56
- const texts = assistantTextBlocks(messages);
57
69
  const hrefs = new Set<string>();
58
- for (const text of texts) {
59
- for (const href of getMarkdownLinkUrls(text)) hrefs.add(href);
70
+ for (const message of messages) {
71
+ for (const href of assistantMessageLinkHrefs(message)) hrefs.add(href);
60
72
  }
61
73
  if (hrefs.size === 0) return previous;
62
- const resolved = await ctx.resolveAssistantMessageLinks(texts);
74
+ const resolved = await ctx.resolveAssistantMessageLinkHrefs([...hrefs]);
63
75
  let changed = false;
64
76
  for (const href of hrefs) {
65
77
  if (previous.get(href) !== resolved.get(href)) {
@@ -87,12 +99,9 @@ export function assistantMessageLinkTargets(
87
99
  targets: ReadonlyMap<string, string>,
88
100
  ): ReadonlyMap<string, string> {
89
101
  const selected = new Map<string, string>();
90
- for (const content of message.content) {
91
- if (content.type !== "text") continue;
92
- for (const href of getMarkdownLinkUrls(content.text)) {
93
- const target = targets.get(href);
94
- if (target) selected.set(href, target);
95
- }
102
+ for (const href of assistantMessageLinkHrefs(message)) {
103
+ const target = targets.get(href);
104
+ if (target) selected.set(href, target);
96
105
  }
97
106
  return selected;
98
107
  }
@@ -56,6 +56,8 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
56
56
  #availabilityChecked = false;
57
57
  #cacheGeneration = 0;
58
58
  #typoCache = new Map<string, readonly native.SpellingRange[]>();
59
+ /** {@link #projectTypoRanges} answers for the current {@link #typoCache}; segments re-ask every frame. */
60
+ #projectionMemo = new Map<string, readonly native.SpellingRange[] | undefined>();
59
61
  #typoInFlight = new Map<string, Promise<readonly native.SpellingRange[]>>();
60
62
  #automaticTypoActive = false;
61
63
  #automaticTypoQueue = new Map<string, string>();
@@ -237,6 +239,7 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
237
239
  return request;
238
240
  }
239
241
  #projectTypoRanges(text: string): readonly native.SpellingRange[] | undefined {
242
+ if (this.#projectionMemo.has(text)) return this.#projectionMemo.get(text);
240
243
  let projected: native.SpellingRange[] | undefined;
241
244
  let matchLength = -1;
242
245
  for (const [previous, ranges] of this.#typoCache) {
@@ -265,6 +268,8 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
265
268
  });
266
269
  matchLength = prefix + suffix;
267
270
  }
271
+ if (this.#projectionMemo.size >= CACHE_LIMIT) this.#projectionMemo.clear();
272
+ this.#projectionMemo.set(text, projected);
268
273
  return projected;
269
274
  }
270
275
 
@@ -316,6 +321,7 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
316
321
  const hadProjectedRanges = (this.#projectTypoRanges(text)?.length ?? 0) > 0;
317
322
  if (this.#typoCache.size >= CACHE_LIMIT) this.#typoCache.clear();
318
323
  this.#typoCache.set(text, ranges);
324
+ this.#projectionMemo.clear();
319
325
  if (ranges.length > 0 || hadProjectedRanges) this.onUpdate?.();
320
326
  return ranges;
321
327
  }
@@ -323,6 +329,7 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
323
329
  #clearCaches(): void {
324
330
  this.#cacheGeneration++;
325
331
  this.#typoCache.clear();
332
+ this.#projectionMemo.clear();
326
333
  this.#typoInFlight.clear();
327
334
  this.#automaticTypoQueue.clear();
328
335
  }
@@ -131,6 +131,9 @@ export function magicKeywordRanges(text: string): { from: number; to: number }[]
131
131
  return ranges.sort((a, b) => a.from - b.from);
132
132
  }
133
133
 
134
+ /** Last {@link hasMagicKeyword} answer: the live editor asks once per layout segment with the same buffer. */
135
+ let lastProbe: { text: string; registry: readonly RegisteredKeyword[]; found: boolean } | undefined;
136
+
134
137
  /**
135
138
  * Cheap test for "does this text contain any registered keyword as standalone
136
139
  * prose?". Short-circuits on a substring probe before paying for the
@@ -138,8 +141,14 @@ export function magicKeywordRanges(text: string): { from: number; to: number }[]
138
141
  * `String#includes` per word. Used by the live editor to gate the shimmer timer.
139
142
  */
140
143
  export function hasMagicKeyword(text: string): boolean {
144
+ if (lastProbe?.text === text && lastProbe.registry === registry) return lastProbe.found;
145
+ let found = false;
141
146
  for (const keyword of registry) {
142
- if (text.includes(keyword.word) && containsMagicKeyword(text, keyword.word)) return true;
147
+ if (text.includes(keyword.word) && containsMagicKeyword(text, keyword.word)) {
148
+ found = true;
149
+ break;
150
+ }
143
151
  }
144
- return false;
152
+ lastProbe = { text, registry, found };
153
+ return found;
145
154
  }
@@ -56,9 +56,20 @@ interface WordQuery extends WordCompletionQuery {
56
56
 
57
57
  /** Editor text before the word starting at `start`, truncated to its last {@link BEFORE_LIMIT} code units. */
58
58
  function textBefore(lines: readonly string[], cursorLine: number, start: number): string {
59
- let text = (lines[cursorLine] ?? "").slice(0, start);
60
- for (let line = cursorLine - 1; line >= 0 && text.length < BEFORE_LIMIT; line--) {
61
- text = `${lines[line] ?? ""}\n${text}`;
59
+ const head = (lines[cursorLine] ?? "").slice(0, start);
60
+ // Find the first line the capped tail reaches, then join once.
61
+ let length = head.length;
62
+ let first = cursorLine;
63
+ while (first > 0 && length < BEFORE_LIMIT) {
64
+ first--;
65
+ length += (lines[first] ?? "").length + 1;
66
+ }
67
+ let text = head;
68
+ if (first < cursorLine) {
69
+ const parts: string[] = [];
70
+ for (let line = first; line < cursorLine; line++) parts.push(lines[line] ?? "");
71
+ parts.push(head);
72
+ text = parts.join("\n");
62
73
  }
63
74
  if (text.length <= BEFORE_LIMIT) return text;
64
75
  let cut = text.length - BEFORE_LIMIT;
@@ -111,6 +122,16 @@ export class WordCompletionProvider implements EditorTextAssistProvider {
111
122
  #lastShown: { before: string; word: string } | undefined;
112
123
  /** What the last `getWordCompletion` returned, so a fetch repaints only when it changes that. */
113
124
  #displayed: { key: string; suffix: string | null } | undefined;
125
+ /** Last {@link #query} input snapshot (lines are copied: the editor mutates its array in place). */
126
+ #queryMemo:
127
+ | {
128
+ method: WordCompletionMethod;
129
+ lines: readonly string[];
130
+ cursorLine: number;
131
+ cursorCol: number;
132
+ query: WordQuery | undefined;
133
+ }
134
+ | undefined;
114
135
 
115
136
  /** Invoked when an asynchronous answer changes the rendered ghost text. */
116
137
  onUpdate: (() => void) | undefined;
@@ -171,11 +192,23 @@ export class WordCompletionProvider implements EditorTextAssistProvider {
171
192
 
172
193
  #query(lines: readonly string[], cursorLine: number, cursorCol: number): WordQuery | undefined {
173
194
  if (this.#method === "off") return undefined;
195
+ // Rendering asks every frame; reuse the answer until the buffer, caret, or engine changes.
196
+ const memo = this.#queryMemo;
197
+ if (
198
+ memo?.method === this.#method &&
199
+ memo.cursorLine === cursorLine &&
200
+ memo.cursorCol === cursorCol &&
201
+ memo.lines.length === lines.length &&
202
+ memo.lines.every((line, index) => line === lines[index])
203
+ ) {
204
+ return memo.query;
205
+ }
174
206
  const query = wordCompletionQuery(lines, cursorLine, cursorCol, this.#prose);
175
- if (!query) return undefined;
176
207
  // The method keeps a request still in flight for the previous engine from
177
208
  // shadowing the same word state on the new one.
178
- return { key: `${this.#method}\u0000${query.prefix}\u0000${query.before}`, ...query };
209
+ const result = query && { key: `${this.#method}\u0000${query.prefix}\u0000${query.before}`, ...query };
210
+ this.#queryMemo = { method: this.#method, lines: lines.slice(), cursorLine, cursorCol, query: result };
211
+ return result;
179
212
  }
180
213
 
181
214
  #project(query: WordQuery): string | null {
@@ -31,14 +31,6 @@ function skipHint(): string {
31
31
  return `press ${formatKeyHint("enter")} to skip`;
32
32
  }
33
33
 
34
- /** Density ramp for the rippling water, lightest → heaviest. */
35
- const WATER_RAMP = [
36
- { min: 0.62, char: "█" },
37
- { min: 0.5, char: "▓" },
38
- { min: 0.36, char: "▒" },
39
- { min: 0.24, char: "░" },
40
- ];
41
-
42
34
  function starAt(x: number, y: number, frame: number): string {
43
35
  const hash = (x * 73856093) ^ (y * 19349663) ^ (frame * 83492791);
44
36
  const bucket = Math.abs(hash) % 97;
@@ -85,33 +77,86 @@ function waterJitter(x: number, y: number): number {
85
77
  }
86
78
 
87
79
  /**
88
- * Rippling water amplitude in [0,1] at (x, y): three travelling sine waves
89
- * interfere, then a radial edge falloff and a downward fade concentrate the
90
- * ripples beneath the mark and dissolve them toward the edges/bottom. `t`
91
- * advances each tick, so the surface drifts.
80
+ * Time-invariant terms of the rippling water for one screen size: three
81
+ * travelling sine waves interfere, then a radial edge falloff and a downward
82
+ * fade concentrate the ripples beneath the mark and dissolve them toward the
83
+ * edges/bottom. Only the sine phases move per tick, so everything else is
84
+ * computed once per size.
92
85
  */
93
- function waterAmplitude(
94
- x: number,
95
- y: number,
96
- cx: number,
97
- waterTop: number,
98
- waterHeight: number,
99
- width: number,
100
- t: number,
101
- ): number {
102
- const dx = (x - cx) / 2;
103
- const dy = y - waterTop;
104
- const dist = Math.sqrt(dx * dx + dy * dy);
105
- const wave =
106
- 0.5 * Math.sin(dist * 0.55 - t) +
107
- 0.3 * Math.sin(x * 0.22 + y * 0.45 - t * 0.7) +
108
- 0.2 * Math.sin(Math.abs(dx) * 0.8 + dy * 0.5 - t * 1.4);
109
- const level = 0.5 + 0.5 * wave;
110
- const edge = Math.max(0, 1 - Math.abs(x - cx) / (width * 0.5));
111
- const fade = Math.max(0, 1 - (dy / Math.max(1, waterHeight)) * 0.55);
112
- return level * edge ** 0.7 * fade;
86
+ interface WaterField {
87
+ readonly width: number;
88
+ readonly height: number;
89
+ readonly waterTop: number;
90
+ /** Per cell (row-major from `waterTop`): `dist * 0.55`. */
91
+ readonly radial: Float64Array;
92
+ /** Per cell: `x * 0.22 + y * 0.45`. */
93
+ readonly diagonal: Float64Array;
94
+ /** Per cell: `|dx| * 0.8 + dy * 0.5`. */
95
+ readonly cross: Float64Array;
96
+ /** Per cell: `(waterJitter(x, y) - 0.5) * 0.06`. */
97
+ readonly jitter: Float64Array;
98
+ /** Per column: `edge ** 0.7`. */
99
+ readonly edge: Float64Array;
100
+ /** Per water row: downward fade. */
101
+ readonly fade: Float64Array;
102
+ }
103
+
104
+ let waterField: WaterField | undefined;
105
+
106
+ function getWaterField(width: number, height: number, cx: number, waterTop: number): WaterField {
107
+ const cached = waterField;
108
+ if (cached && cached.width === width && cached.height === height && cached.waterTop === waterTop) return cached;
109
+ const waterHeight = Math.max(1, height - waterTop);
110
+ const rows = Math.max(0, height - waterTop);
111
+ const radial = new Float64Array(rows * width);
112
+ const diagonal = new Float64Array(rows * width);
113
+ const cross = new Float64Array(rows * width);
114
+ const jitter = new Float64Array(rows * width);
115
+ const edge = new Float64Array(width);
116
+ const fade = new Float64Array(rows);
117
+ for (let x = 0; x < width; x++) edge[x] = Math.max(0, 1 - Math.abs(x - cx) / (width * 0.5)) ** 0.7;
118
+ for (let row = 0; row < rows; row++) {
119
+ const y = waterTop + row;
120
+ const dy = y - waterTop;
121
+ fade[row] = Math.max(0, 1 - (dy / waterHeight) * 0.55);
122
+ for (let x = 0; x < width; x++) {
123
+ const i = row * width + x;
124
+ const dx = (x - cx) / 2;
125
+ radial[i] = Math.sqrt(dx * dx + dy * dy) * 0.55;
126
+ diagonal[i] = x * 0.22 + y * 0.45;
127
+ cross[i] = Math.abs(dx) * 0.8 + dy * 0.5;
128
+ jitter[i] = (waterJitter(x, y) - 0.5) * 0.06;
129
+ }
130
+ }
131
+ waterField = { width, height, waterTop, radial, diagonal, cross, jitter, edge, fade };
132
+ return waterField;
133
+ }
134
+
135
+ /** Water glyph for an amplitude, lightest → heaviest density ramp; undefined below the floor. */
136
+ function waterChar(amp: number): string | undefined {
137
+ if (amp > 0.62) return "█";
138
+ if (amp > 0.5) return "▓";
139
+ if (amp > 0.36) return "▒";
140
+ if (amp > 0.24) return "░";
141
+ return undefined;
142
+ }
143
+
144
+ /** Reused cell grid; resized when the screen changes. */
145
+ let splashCells: string[][] = [];
146
+
147
+ function resetCells(width: number, height: number): string[][] {
148
+ if (splashCells.length !== height || splashCells[0]?.length !== width) {
149
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
150
+ splashCells = Array.from({ length: height }, () => new Array<string>(width).fill(" "));
151
+ } else {
152
+ for (const row of splashCells) row.fill(" ");
153
+ }
154
+ return splashCells;
113
155
  }
114
156
 
157
+ /** Per-frame gradient escapes, one per screen diagonal (the gradient only varies along it). */
158
+ let diagonalEscapes: (string | undefined)[] = [];
159
+
115
160
  /**
116
161
  * Animated setup splash, in the spirit of the omp landing page: the brand π
117
162
  * mark rendered with the live diagonal gradient + shine sweep, rising out of a
@@ -132,23 +177,46 @@ export function renderSetupSplash(width: number, height: number, elapsedMs: numb
132
177
  const cx = Math.floor(w / 2);
133
178
  const surfaceTime = frame * 0.13;
134
179
 
135
- // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
136
- const cells: string[][] = Array.from({ length: h }, () => new Array<string>(w).fill(" "));
180
+ const cells = resetCells(w, h);
137
181
  const put = (x: number, y: number, glyph: string): void => {
138
182
  if (y >= 0 && y < h && x >= 0 && x < w) cells[y][x] = glyph;
139
183
  };
184
+ // Every cell on a diagonal shares one gradient position, so resolve each
185
+ // escape once per frame instead of once per cell.
186
+ const diagonals = w + h - 1;
187
+ if (diagonalEscapes.length !== diagonals) diagonalEscapes = Array.from({ length: diagonals });
188
+ else diagonalEscapes.fill(undefined);
189
+ const escapes = diagonalEscapes;
190
+ const gradient = (x: number, y: number): string => {
191
+ const d = x + (h - 1 - y);
192
+ let escape = escapes[d];
193
+ if (escape === undefined) {
194
+ escape = gradientEscape(screenGradientT(x, y, w, h, phase), shine);
195
+ escapes[d] = escape;
196
+ }
197
+ return escape;
198
+ };
140
199
 
141
200
  const hx = Math.floor((w - LOGO_WIDTH) / 2);
142
201
  const hy = Math.max(2, Math.floor(h * 0.16));
143
202
  const waterTop = hy + LOGO_HEIGHT;
144
- const waterHeight = Math.max(1, h - waterTop);
145
203
 
146
204
  // 1. rippling water surface (shares the screen-wide gradient with the mark)
205
+ const field = getWaterField(w, h, cx, waterTop);
206
+ const tDiagonal = surfaceTime * 0.7;
207
+ const tCross = surfaceTime * 1.4;
147
208
  for (let y = waterTop; y < h; y++) {
209
+ const row = y - waterTop;
210
+ const fade = field.fade[row];
148
211
  for (let x = 0; x < w; x++) {
149
- const amp = waterAmplitude(x, y, cx, waterTop, waterHeight, w, surfaceTime) + (waterJitter(x, y) - 0.5) * 0.06;
150
- const cell = WATER_RAMP.find(step => amp > step.min);
151
- if (cell) put(x, y, gradientEscape(screenGradientT(x, y, w, h, phase), shine) + cell.char + RESET);
212
+ const i = row * w + x;
213
+ const wave =
214
+ 0.5 * Math.sin(field.radial[i] - surfaceTime) +
215
+ 0.3 * Math.sin(field.diagonal[i] - tDiagonal) +
216
+ 0.2 * Math.sin(field.cross[i] - tCross);
217
+ const level = 0.5 + 0.5 * wave;
218
+ const char = waterChar(level * field.edge[x] * fade + field.jitter[i]);
219
+ if (char) cells[y][x] = gradient(x, y) + char + RESET;
152
220
  }
153
221
  }
154
222
  // 2. twinkling starfield in the sky above the water
@@ -162,13 +230,9 @@ export function renderSetupSplash(width: number, height: number, elapsedMs: numb
162
230
  LARGE_LOGO.forEach((line, row) => {
163
231
  let col = 0;
164
232
  for (const ch of line) {
165
- if (ch !== " ") {
166
- put(
167
- hx + col,
168
- hy + row,
169
- gradientEscape(screenGradientT(hx + col, hy + row, w, h, phase), shine) + ch + RESET,
170
- );
171
- }
233
+ const x = hx + col;
234
+ const y = hy + row;
235
+ if (ch !== " " && y >= 0 && y < h && x >= 0 && x < w) cells[y][x] = gradient(x, y) + ch + RESET;
172
236
  col++;
173
237
  }
174
238
  });