@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
|
@@ -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
|
-
|
|
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", `${
|
|
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
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
54
|
-
const NATIVE_COMPOSER_PLACEHOLDER = "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
59
|
-
for (const href of
|
|
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.
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
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))
|
|
147
|
+
if (text.includes(keyword.word) && containsMagicKeyword(text, keyword.word)) {
|
|
148
|
+
found = true;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
143
151
|
}
|
|
144
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
89
|
-
* interfere, then a radial edge falloff and a downward
|
|
90
|
-
* ripples beneath the mark and dissolve them toward the
|
|
91
|
-
*
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
waterTop:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
150
|
-
const
|
|
151
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
});
|