From ee255c2c90662f09118286a07e38022a31b5290b Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Jun 2026 12:57:14 -0500 Subject: [PATCH 1/4] feat: convert \greek backslash commands into Greek letters in chat input Typing \alpha or \Alpha auto-converts to the matching Greek letter, and typing \ opens an autocomplete menu reusing the existing CommandSuggestions UI. --- .../ChatInput/greekConversion.test.ts | 101 +++++++++++ .../features/ChatInput/greekConversion.ts | 165 ++++++++++++++++++ src/browser/features/ChatInput/index.tsx | 121 ++++++++++++- 3 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 src/browser/features/ChatInput/greekConversion.test.ts create mode 100644 src/browser/features/ChatInput/greekConversion.ts diff --git a/src/browser/features/ChatInput/greekConversion.test.ts b/src/browser/features/ChatInput/greekConversion.test.ts new file mode 100644 index 0000000000..bfa362629d --- /dev/null +++ b/src/browser/features/ChatInput/greekConversion.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { + convertGreekCommandAtCursor, + findGreekCommandAtCursor, + getGreekSuggestions, +} from "@/browser/features/ChatInput/greekConversion"; + +// Build backslash-prefixed inputs without literal backslashes in source. +const bs = String.fromCharCode(92); + +describe("findGreekCommandAtCursor", () => { + test("matches a partial backslash command at the cursor", () => { + expect(findGreekCommandAtCursor(`${bs}al`, 3)).toEqual({ + partial: "al", + startIndex: 0, + endIndex: 3, + }); + }); + + test("matches the bare backslash with an empty partial", () => { + expect(findGreekCommandAtCursor(bs, 1)).toEqual({ + partial: "", + startIndex: 0, + endIndex: 1, + }); + }); + + test("captures the full token even when the caret is mid-word", () => { + // Caret after "\al" but the run continues with "pha"; the whole token is returned. + expect(findGreekCommandAtCursor(`${bs}alpha`, 3)).toEqual({ + partial: "alpha", + startIndex: 0, + endIndex: 6, + }); + }); + + test("returns null without a backslash and ignores escaped backslashes", () => { + expect(findGreekCommandAtCursor("alpha", 5)).toBeNull(); + expect(findGreekCommandAtCursor(`${bs}${bs}alpha`, 7)).toBeNull(); + }); +}); + +describe("getGreekSuggestions", () => { + test("filtering is case-sensitive so name case picks letter case", () => { + const lower = getGreekSuggestions("a"); + expect(lower.map((s) => s.display)).toEqual([`${bs}alpha`]); + expect(lower[0]?.replacement).toBe("α"); + + const upper = getGreekSuggestions("A"); + expect(upper.map((s) => s.display)).toEqual([`${bs}Alpha`]); + expect(upper[0]?.replacement).toBe("Α"); + }); + + test("a shared prefix returns every matching letter of that case", () => { + expect(getGreekSuggestions("p").map((s) => s.display)).toEqual([ + `${bs}pi`, + `${bs}phi`, + `${bs}psi`, + ]); + expect(getGreekSuggestions("P").map((s) => s.display)).toEqual([ + `${bs}Pi`, + `${bs}Phi`, + `${bs}Psi`, + ]); + }); + + test("an empty partial lists all 24 letters in both cases", () => { + expect(getGreekSuggestions("")).toHaveLength(48); + }); + + test("no match yields no suggestions", () => { + expect(getGreekSuggestions("zzz")).toEqual([]); + }); +}); + +describe("convertGreekCommandAtCursor", () => { + test("converts a completed lowercase command", () => { + expect(convertGreekCommandAtCursor(`${bs}alpha`, 6)).toEqual({ text: "α", cursor: 1 }); + }); + + test("converts a completed capitalized command to the uppercase letter", () => { + expect(convertGreekCommandAtCursor(`${bs}Alpha`, 6)).toEqual({ text: "Α", cursor: 1 }); + }); + + test("converts in place within surrounding text", () => { + expect(convertGreekCommandAtCursor(`x = ${bs}beta`, 9)).toEqual({ text: "x = β", cursor: 5 }); + }); + + test("does not convert a partial or unknown name", () => { + expect(convertGreekCommandAtCursor(`${bs}alph`, 5)).toBeNull(); + expect(convertGreekCommandAtCursor(`${bs}alphax`, 7)).toBeNull(); + }); + + test("does not convert when the caret is not at the end of the token", () => { + expect(convertGreekCommandAtCursor(`${bs}alpha`, 3)).toBeNull(); + }); + + test("does not convert an escaped backslash command", () => { + expect(convertGreekCommandAtCursor(`${bs}${bs}alpha`, 7)).toBeNull(); + }); +}); diff --git a/src/browser/features/ChatInput/greekConversion.ts b/src/browser/features/ChatInput/greekConversion.ts new file mode 100644 index 0000000000..cd73e01f0d --- /dev/null +++ b/src/browser/features/ChatInput/greekConversion.ts @@ -0,0 +1,165 @@ +import type { SlashSuggestion } from "@/browser/utils/slashCommands/types"; + +/** + * Greek-letter autocomplete + auto-conversion for the chat input. + * + * Typing a LaTeX-style backslash command converts it to the matching Greek + * letter as soon as the full name is typed: `\alpha` -> "α", `\Alpha` -> "Α". + * Case of the command name selects the letter case (lowercase name -> lowercase + * letter, capitalized name -> uppercase letter). Typing `\` also opens an + * autocomplete menu (handled in ChatInput) that filters as the name is typed. + * + * Pure module so the matching/conversion logic stays unit-testable without React. + */ + +// Built from String.fromCharCode so the source never contains a literal +// backslash (which is awkward to escape across our tooling/string layers). +const BACKSLASH = String.fromCharCode(92); + +// Standard Greek letters with their lowercase and uppercase glyphs. +const GREEK_LETTERS: ReadonlyArray<{ name: string; lower: string; upper: string }> = [ + { name: "alpha", lower: "α", upper: "Α" }, + { name: "beta", lower: "β", upper: "Β" }, + { name: "gamma", lower: "γ", upper: "Γ" }, + { name: "delta", lower: "δ", upper: "Δ" }, + { name: "epsilon", lower: "ε", upper: "Ε" }, + { name: "zeta", lower: "ζ", upper: "Ζ" }, + { name: "eta", lower: "η", upper: "Η" }, + { name: "theta", lower: "θ", upper: "Θ" }, + { name: "iota", lower: "ι", upper: "Ι" }, + { name: "kappa", lower: "κ", upper: "Κ" }, + { name: "lambda", lower: "λ", upper: "Λ" }, + { name: "mu", lower: "μ", upper: "Μ" }, + { name: "nu", lower: "ν", upper: "Ν" }, + { name: "xi", lower: "ξ", upper: "Ξ" }, + { name: "omicron", lower: "ο", upper: "Ο" }, + { name: "pi", lower: "π", upper: "Π" }, + { name: "rho", lower: "ρ", upper: "Ρ" }, + { name: "sigma", lower: "σ", upper: "Σ" }, + { name: "tau", lower: "τ", upper: "Τ" }, + { name: "upsilon", lower: "υ", upper: "Υ" }, + { name: "phi", lower: "φ", upper: "Φ" }, + { name: "chi", lower: "χ", upper: "Χ" }, + { name: "psi", lower: "ψ", upper: "Ψ" }, + { name: "omega", lower: "ω", upper: "Ω" }, +]; + +function capitalize(name: string): string { + return name.charAt(0).toUpperCase() + name.slice(1); +} + +interface GreekEntry { + /** Command name without the leading backslash, e.g. "alpha" or "Alpha". */ + name: string; + /** The Greek character the name converts to. */ + char: string; +} + +// Each base letter contributes a lowercase and a capitalized entry, grouped +// together so the empty-query menu lists "\alpha, \Alpha, \beta, \Beta, ...". +const GREEK_ENTRIES: readonly GreekEntry[] = GREEK_LETTERS.flatMap((letter) => [ + { name: letter.name, char: letter.lower }, + { name: capitalize(letter.name), char: letter.upper }, +]); + +const GREEK_BY_NAME: ReadonlyMap = new Map( + GREEK_ENTRIES.map((entry) => [entry.name, entry.char]) +); + +export interface GreekCommandMatch { + /** Letters typed after the backslash (may be empty when only `\` is typed). */ + partial: string; + /** Index of the triggering backslash. */ + startIndex: number; + /** Index just past the last letter of the token. */ + endIndex: number; +} + +const LETTER = /^[A-Za-z]$/; + +function isLetter(ch: string | undefined): boolean { + return ch !== undefined && LETTER.test(ch); +} + +/** + * Locate a `\name` command surrounding the cursor. Walks left over letters to + * find a backslash, then right over letters to capture the whole token (so we + * never convert a name embedded in a longer run of letters). Returns null when + * the cursor is not inside such a token. + */ +export function findGreekCommandAtCursor(text: string, cursor: number): GreekCommandMatch | null { + if ( + !Number.isInteger(cursor) || + cursor < 0 || + cursor > text.length || + !text.includes(BACKSLASH) + ) { + return null; + } + + let tokenStart = cursor; + while (tokenStart > 0 && isLetter(text[tokenStart - 1])) { + tokenStart--; + } + + const backslashIndex = tokenStart - 1; + if (backslashIndex < 0 || text[backslashIndex] !== BACKSLASH) { + return null; + } + + // A preceding backslash (`\alpha`) is treated as an escape so users can write + // a literal backslash command without it converting. + if (backslashIndex > 0 && text[backslashIndex - 1] === BACKSLASH) { + return null; + } + + let tokenEnd = cursor; + while (tokenEnd < text.length && isLetter(text[tokenEnd])) { + tokenEnd++; + } + + return { + partial: text.slice(backslashIndex + 1, tokenEnd), + startIndex: backslashIndex, + endIndex: tokenEnd, + }; +} + +/** + * Suggestions for the autocomplete menu. Matching is case-sensitive on purpose: + * the case of the typed name selects the letter case, so `\a` offers `\alpha` + * while `\A` offers `\Alpha`. An empty partial (just `\`) lists everything. + */ +export function getGreekSuggestions(partial: string): SlashSuggestion[] { + return GREEK_ENTRIES.filter((entry) => entry.name.startsWith(partial)).map((entry) => ({ + id: `greek:${entry.name}`, + display: `${BACKSLASH}${entry.name}`, + description: entry.char, + replacement: entry.char, + })); +} + +/** + * Convert a completed `\name` command at the cursor into its Greek letter. + * Only fires when the cursor sits at the end of the token and the full name is + * an exact (case-sensitive) match, so partial/mid-word edits are left alone. + */ +export function convertGreekCommandAtCursor( + text: string, + cursor: number +): { text: string; cursor: number } | null { + const match = findGreekCommandAtCursor(text, cursor); + if (cursor !== match?.endIndex) { + return null; + } + + const char = GREEK_BY_NAME.get(match.partial); + if (char === undefined) { + return null; + } + + return { + text: text.slice(0, match.startIndex) + char + text.slice(match.endIndex), + cursor: match.startIndex + char.length, + }; +} diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index e137a0d220..a61afa70fe 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -74,6 +74,11 @@ import { CUSTOM_EVENTS } from "@/common/constants/events"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { findAtMentionAtCursor } from "@/common/utils/atMentions"; import { findInlineSkillReferenceAtCursor } from "@/browser/utils/agentSkills/inlineSkillReferences"; +import { + convertGreekCommandAtCursor, + findGreekCommandAtCursor, + getGreekSuggestions, +} from "@/browser/features/ChatInput/greekConversion"; import { getInlineSkillInsertionTrailingText, getInlineSkillSuggestions, @@ -373,6 +378,10 @@ const ChatInputInner: React.FC = (props) => { const [showCommandSuggestions, setShowCommandSuggestions] = useState(false); const [commandSuggestions, setCommandSuggestions] = useState([]); + // Greek-letter backslash autocomplete (e.g. typing "\alpha"). + const [showGreekSuggestions, setShowGreekSuggestions] = useState(false); + const [greekSuggestions, setGreekSuggestions] = useState([]); + const lastGreekQueryRef = useRef(""); const [agentSkillDescriptors, setAgentSkillDescriptors] = useState([]); const [toast, setToast] = useState(null); // State for destructive command confirmation modal (currently only /clear). @@ -571,6 +580,25 @@ const ChatInputInner: React.FC = (props) => { } } + // Auto-convert a completed backslash command (e.g. "\alpha") into its Greek + // letter as soon as the full name is typed. Conversion only fires when the + // caret sits at the end of the token, so partial/mid-word edits are untouched. + const caret = caretFromEvent ?? inputRef.current?.selectionStart ?? next.length; + const converted = convertGreekCommandAtCursor(next, caret); + if (converted) { + setInput(converted.text); + const newCursor = converted.cursor; + requestAnimationFrame(() => { + const el = inputRef.current; + if (!el || el.disabled) { + return; + } + el.selectionStart = newCursor; + el.selectionEnd = newCursor; + }); + return; + } + setInput(next); }, [powerMode, setInput] @@ -620,6 +648,7 @@ const ChatInputInner: React.FC = (props) => { const atMentionListId = useId(); const skillListId = useId(); const commandListId = useId(); + const greekListId = useId(); const telemetry = useTelemetry(); const [vimEnabled, setVimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true, @@ -1446,6 +1475,29 @@ const ChatInputInner: React.FC = (props) => { setShowCommandSuggestions(suggestions.length > 0); }, [input, agentSkillDescriptors, variant, workspaceHeartbeatsExperimentEnabled]); + // Watch input/cursor for `\greek` backslash commands and surface the menu. + useLayoutEffect(() => { + if (showAtMentionSuggestions) { + // File mentions win precedence if an edge-case token could match both menus. + setGreekSuggestions(clearSuggestions); + setShowGreekSuggestions(false); + return; + } + + const cursor = Math.min(inputRef.current?.selectionStart ?? input.length, input.length); + const match = findGreekCommandAtCursor(input, cursor); + if (!match) { + setGreekSuggestions(clearSuggestions); + setShowGreekSuggestions(false); + return; + } + + const suggestions = getGreekSuggestions(match.partial); + lastGreekQueryRef.current = match.partial; + setGreekSuggestions((prev) => replaceSuggestions(prev, suggestions)); + setShowGreekSuggestions(suggestions.length > 0); + }, [input, showAtMentionSuggestions, atMentionCursorNonce]); + // Derive ghost hint for slash-command argument syntax. // Show only when suggestions are hidden and the input is exactly "/command " with no args yet. const commandGhostHint = getCommandGhostHint(input, showCommandSuggestions, { @@ -2145,6 +2197,38 @@ const ChatInputInner: React.FC = (props) => { [setInput] ); + const handleGreekSelect = useCallback( + (suggestion: SlashSuggestion) => { + const cursor = Math.min(inputRef.current?.selectionStart ?? input.length, input.length); + const match = findGreekCommandAtCursor(input, cursor); + if (!match) { + return; + } + + // Replace the whole `\name` token with the Greek letter; no trailing space + // so the user can keep typing (e.g. another letter or an exponent). + const next = + input.slice(0, match.startIndex) + suggestion.replacement + input.slice(match.endIndex); + + setInput(next); + setGreekSuggestions(clearSuggestions); + setShowGreekSuggestions(false); + + requestAnimationFrame(() => { + const el = inputRef.current; + if (!el || el.disabled) { + return; + } + + el.focus(); + const newCursor = match.startIndex + suggestion.replacement.length; + el.selectionStart = newCursor; + el.selectionEnd = newCursor; + }); + }, + [input, setInput] + ); + const handleSend = async (overrides?: InternalSendOverrides) => { if (!canSend) { return; @@ -2668,13 +2752,15 @@ const ChatInputInner: React.FC = (props) => { const hasCommandSuggestionMenu = showCommandSuggestions && commandSuggestions.length > 0; const hasAtMentionSuggestionMenu = showAtMentionSuggestions && atMentionSuggestions.length > 0; const hasSkillSuggestionMenu = showSkillSuggestions && skillSuggestions.length > 0; + const hasGreekSuggestionMenu = showGreekSuggestions && greekSuggestions.length > 0; // Don't handle keys if suggestions are visible. - // Enter/Tab/arrows/Escape are handled by CommandSuggestions for slash, @file, and $skill menus. + // Enter/Tab/arrows/Escape are handled by CommandSuggestions for slash, @file, $skill, and \greek menus. if ( (hasCommandSuggestionMenu && COMMAND_SUGGESTION_KEYS.includes(e.key)) || (hasAtMentionSuggestionMenu && FILE_SUGGESTION_KEYS.includes(e.key)) || - (hasSkillSuggestionMenu && FILE_SUGGESTION_KEYS.includes(e.key)) + (hasSkillSuggestionMenu && FILE_SUGGESTION_KEYS.includes(e.key)) || + (hasGreekSuggestionMenu && FILE_SUGGESTION_KEYS.includes(e.key)) ) { return; // Let CommandSuggestions handle it } @@ -2856,6 +2942,18 @@ const ChatInputInner: React.FC = (props) => { anchorRef={variant === "creation" ? inputRef : undefined} /> + {/* Greek letter suggestions (\alpha -> α) */} + setShowGreekSuggestions(false)} + isVisible={showGreekSuggestions} + ariaLabel="Greek letter suggestions" + listId={greekListId} + anchorRef={variant === "creation" ? inputRef : undefined} + highlightQuery={lastGreekQueryRef.current} + /> +
{/* Recording/transcribing overlay - replaces textarea when active */} {voiceInput.state !== "idle" ? ( @@ -2889,9 +2987,11 @@ const ChatInputInner: React.FC = (props) => { ? FILE_SUGGESTION_KEYS : showSkillSuggestions ? FILE_SUGGESTION_KEYS - : showCommandSuggestions - ? COMMAND_SUGGESTION_KEYS - : undefined + : showGreekSuggestions + ? FILE_SUGGESTION_KEYS + : showCommandSuggestions + ? COMMAND_SUGGESTION_KEYS + : undefined } placeholder={placeholder} disabled={!editingMessageForUi && (disabled || sendInFlightBlocksInput)} @@ -2902,14 +3002,17 @@ const ChatInputInner: React.FC = (props) => { ? atMentionListId : showSkillSuggestions && skillSuggestions.length > 0 ? skillListId - : showCommandSuggestions && commandSuggestions.length > 0 - ? commandListId - : undefined + : showGreekSuggestions && greekSuggestions.length > 0 + ? greekListId + : showCommandSuggestions && commandSuggestions.length > 0 + ? commandListId + : undefined } aria-expanded={ (showCommandSuggestions && commandSuggestions.length > 0) || (showAtMentionSuggestions && atMentionSuggestions.length > 0) || - (showSkillSuggestions && skillSuggestions.length > 0) + (showSkillSuggestions && skillSuggestions.length > 0) || + (showGreekSuggestions && greekSuggestions.length > 0) } className={variant === "creation" ? "min-h-28" : "min-h-16"} /> From e7493c73d9fb260d98897e126b08cff17a67dfdf Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Jun 2026 15:05:22 -0500 Subject: [PATCH 2/4] feat: generalize backslash shortcuts to math + trading symbols Rename greekConversion -> symbolShortcuts and expand beyond Greek to math relations/operators, set theory, logic, arrows, currency/trading signs, and big operators. Prefix-aware eager conversion (unambiguous names convert on completion; ambiguous ones like \in vs \int wait for Tab/Enter or a space terminator). Extract collectCodeRanges into a shared util so the \symbol trigger is suppressed inside code spans/fences. --- .../ChatInput/greekConversion.test.ts | 101 ----- .../features/ChatInput/greekConversion.ts | 165 -------- src/browser/features/ChatInput/index.tsx | 91 +++-- .../ChatInput/symbolShortcuts.test.ts | 170 ++++++++ .../features/ChatInput/symbolShortcuts.ts | 382 ++++++++++++++++++ .../agentSkills/inlineSkillReferences.ts | 194 +-------- src/browser/utils/markdown/codeRanges.ts | 199 +++++++++ 7 files changed, 803 insertions(+), 499 deletions(-) delete mode 100644 src/browser/features/ChatInput/greekConversion.test.ts delete mode 100644 src/browser/features/ChatInput/greekConversion.ts create mode 100644 src/browser/features/ChatInput/symbolShortcuts.test.ts create mode 100644 src/browser/features/ChatInput/symbolShortcuts.ts create mode 100644 src/browser/utils/markdown/codeRanges.ts diff --git a/src/browser/features/ChatInput/greekConversion.test.ts b/src/browser/features/ChatInput/greekConversion.test.ts deleted file mode 100644 index bfa362629d..0000000000 --- a/src/browser/features/ChatInput/greekConversion.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - convertGreekCommandAtCursor, - findGreekCommandAtCursor, - getGreekSuggestions, -} from "@/browser/features/ChatInput/greekConversion"; - -// Build backslash-prefixed inputs without literal backslashes in source. -const bs = String.fromCharCode(92); - -describe("findGreekCommandAtCursor", () => { - test("matches a partial backslash command at the cursor", () => { - expect(findGreekCommandAtCursor(`${bs}al`, 3)).toEqual({ - partial: "al", - startIndex: 0, - endIndex: 3, - }); - }); - - test("matches the bare backslash with an empty partial", () => { - expect(findGreekCommandAtCursor(bs, 1)).toEqual({ - partial: "", - startIndex: 0, - endIndex: 1, - }); - }); - - test("captures the full token even when the caret is mid-word", () => { - // Caret after "\al" but the run continues with "pha"; the whole token is returned. - expect(findGreekCommandAtCursor(`${bs}alpha`, 3)).toEqual({ - partial: "alpha", - startIndex: 0, - endIndex: 6, - }); - }); - - test("returns null without a backslash and ignores escaped backslashes", () => { - expect(findGreekCommandAtCursor("alpha", 5)).toBeNull(); - expect(findGreekCommandAtCursor(`${bs}${bs}alpha`, 7)).toBeNull(); - }); -}); - -describe("getGreekSuggestions", () => { - test("filtering is case-sensitive so name case picks letter case", () => { - const lower = getGreekSuggestions("a"); - expect(lower.map((s) => s.display)).toEqual([`${bs}alpha`]); - expect(lower[0]?.replacement).toBe("α"); - - const upper = getGreekSuggestions("A"); - expect(upper.map((s) => s.display)).toEqual([`${bs}Alpha`]); - expect(upper[0]?.replacement).toBe("Α"); - }); - - test("a shared prefix returns every matching letter of that case", () => { - expect(getGreekSuggestions("p").map((s) => s.display)).toEqual([ - `${bs}pi`, - `${bs}phi`, - `${bs}psi`, - ]); - expect(getGreekSuggestions("P").map((s) => s.display)).toEqual([ - `${bs}Pi`, - `${bs}Phi`, - `${bs}Psi`, - ]); - }); - - test("an empty partial lists all 24 letters in both cases", () => { - expect(getGreekSuggestions("")).toHaveLength(48); - }); - - test("no match yields no suggestions", () => { - expect(getGreekSuggestions("zzz")).toEqual([]); - }); -}); - -describe("convertGreekCommandAtCursor", () => { - test("converts a completed lowercase command", () => { - expect(convertGreekCommandAtCursor(`${bs}alpha`, 6)).toEqual({ text: "α", cursor: 1 }); - }); - - test("converts a completed capitalized command to the uppercase letter", () => { - expect(convertGreekCommandAtCursor(`${bs}Alpha`, 6)).toEqual({ text: "Α", cursor: 1 }); - }); - - test("converts in place within surrounding text", () => { - expect(convertGreekCommandAtCursor(`x = ${bs}beta`, 9)).toEqual({ text: "x = β", cursor: 5 }); - }); - - test("does not convert a partial or unknown name", () => { - expect(convertGreekCommandAtCursor(`${bs}alph`, 5)).toBeNull(); - expect(convertGreekCommandAtCursor(`${bs}alphax`, 7)).toBeNull(); - }); - - test("does not convert when the caret is not at the end of the token", () => { - expect(convertGreekCommandAtCursor(`${bs}alpha`, 3)).toBeNull(); - }); - - test("does not convert an escaped backslash command", () => { - expect(convertGreekCommandAtCursor(`${bs}${bs}alpha`, 7)).toBeNull(); - }); -}); diff --git a/src/browser/features/ChatInput/greekConversion.ts b/src/browser/features/ChatInput/greekConversion.ts deleted file mode 100644 index cd73e01f0d..0000000000 --- a/src/browser/features/ChatInput/greekConversion.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { SlashSuggestion } from "@/browser/utils/slashCommands/types"; - -/** - * Greek-letter autocomplete + auto-conversion for the chat input. - * - * Typing a LaTeX-style backslash command converts it to the matching Greek - * letter as soon as the full name is typed: `\alpha` -> "α", `\Alpha` -> "Α". - * Case of the command name selects the letter case (lowercase name -> lowercase - * letter, capitalized name -> uppercase letter). Typing `\` also opens an - * autocomplete menu (handled in ChatInput) that filters as the name is typed. - * - * Pure module so the matching/conversion logic stays unit-testable without React. - */ - -// Built from String.fromCharCode so the source never contains a literal -// backslash (which is awkward to escape across our tooling/string layers). -const BACKSLASH = String.fromCharCode(92); - -// Standard Greek letters with their lowercase and uppercase glyphs. -const GREEK_LETTERS: ReadonlyArray<{ name: string; lower: string; upper: string }> = [ - { name: "alpha", lower: "α", upper: "Α" }, - { name: "beta", lower: "β", upper: "Β" }, - { name: "gamma", lower: "γ", upper: "Γ" }, - { name: "delta", lower: "δ", upper: "Δ" }, - { name: "epsilon", lower: "ε", upper: "Ε" }, - { name: "zeta", lower: "ζ", upper: "Ζ" }, - { name: "eta", lower: "η", upper: "Η" }, - { name: "theta", lower: "θ", upper: "Θ" }, - { name: "iota", lower: "ι", upper: "Ι" }, - { name: "kappa", lower: "κ", upper: "Κ" }, - { name: "lambda", lower: "λ", upper: "Λ" }, - { name: "mu", lower: "μ", upper: "Μ" }, - { name: "nu", lower: "ν", upper: "Ν" }, - { name: "xi", lower: "ξ", upper: "Ξ" }, - { name: "omicron", lower: "ο", upper: "Ο" }, - { name: "pi", lower: "π", upper: "Π" }, - { name: "rho", lower: "ρ", upper: "Ρ" }, - { name: "sigma", lower: "σ", upper: "Σ" }, - { name: "tau", lower: "τ", upper: "Τ" }, - { name: "upsilon", lower: "υ", upper: "Υ" }, - { name: "phi", lower: "φ", upper: "Φ" }, - { name: "chi", lower: "χ", upper: "Χ" }, - { name: "psi", lower: "ψ", upper: "Ψ" }, - { name: "omega", lower: "ω", upper: "Ω" }, -]; - -function capitalize(name: string): string { - return name.charAt(0).toUpperCase() + name.slice(1); -} - -interface GreekEntry { - /** Command name without the leading backslash, e.g. "alpha" or "Alpha". */ - name: string; - /** The Greek character the name converts to. */ - char: string; -} - -// Each base letter contributes a lowercase and a capitalized entry, grouped -// together so the empty-query menu lists "\alpha, \Alpha, \beta, \Beta, ...". -const GREEK_ENTRIES: readonly GreekEntry[] = GREEK_LETTERS.flatMap((letter) => [ - { name: letter.name, char: letter.lower }, - { name: capitalize(letter.name), char: letter.upper }, -]); - -const GREEK_BY_NAME: ReadonlyMap = new Map( - GREEK_ENTRIES.map((entry) => [entry.name, entry.char]) -); - -export interface GreekCommandMatch { - /** Letters typed after the backslash (may be empty when only `\` is typed). */ - partial: string; - /** Index of the triggering backslash. */ - startIndex: number; - /** Index just past the last letter of the token. */ - endIndex: number; -} - -const LETTER = /^[A-Za-z]$/; - -function isLetter(ch: string | undefined): boolean { - return ch !== undefined && LETTER.test(ch); -} - -/** - * Locate a `\name` command surrounding the cursor. Walks left over letters to - * find a backslash, then right over letters to capture the whole token (so we - * never convert a name embedded in a longer run of letters). Returns null when - * the cursor is not inside such a token. - */ -export function findGreekCommandAtCursor(text: string, cursor: number): GreekCommandMatch | null { - if ( - !Number.isInteger(cursor) || - cursor < 0 || - cursor > text.length || - !text.includes(BACKSLASH) - ) { - return null; - } - - let tokenStart = cursor; - while (tokenStart > 0 && isLetter(text[tokenStart - 1])) { - tokenStart--; - } - - const backslashIndex = tokenStart - 1; - if (backslashIndex < 0 || text[backslashIndex] !== BACKSLASH) { - return null; - } - - // A preceding backslash (`\alpha`) is treated as an escape so users can write - // a literal backslash command without it converting. - if (backslashIndex > 0 && text[backslashIndex - 1] === BACKSLASH) { - return null; - } - - let tokenEnd = cursor; - while (tokenEnd < text.length && isLetter(text[tokenEnd])) { - tokenEnd++; - } - - return { - partial: text.slice(backslashIndex + 1, tokenEnd), - startIndex: backslashIndex, - endIndex: tokenEnd, - }; -} - -/** - * Suggestions for the autocomplete menu. Matching is case-sensitive on purpose: - * the case of the typed name selects the letter case, so `\a` offers `\alpha` - * while `\A` offers `\Alpha`. An empty partial (just `\`) lists everything. - */ -export function getGreekSuggestions(partial: string): SlashSuggestion[] { - return GREEK_ENTRIES.filter((entry) => entry.name.startsWith(partial)).map((entry) => ({ - id: `greek:${entry.name}`, - display: `${BACKSLASH}${entry.name}`, - description: entry.char, - replacement: entry.char, - })); -} - -/** - * Convert a completed `\name` command at the cursor into its Greek letter. - * Only fires when the cursor sits at the end of the token and the full name is - * an exact (case-sensitive) match, so partial/mid-word edits are left alone. - */ -export function convertGreekCommandAtCursor( - text: string, - cursor: number -): { text: string; cursor: number } | null { - const match = findGreekCommandAtCursor(text, cursor); - if (cursor !== match?.endIndex) { - return null; - } - - const char = GREEK_BY_NAME.get(match.partial); - if (char === undefined) { - return null; - } - - return { - text: text.slice(0, match.startIndex) + char + text.slice(match.endIndex), - cursor: match.startIndex + char.length, - }; -} diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index a61afa70fe..deb1a88f64 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -75,10 +75,11 @@ import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { findAtMentionAtCursor } from "@/common/utils/atMentions"; import { findInlineSkillReferenceAtCursor } from "@/browser/utils/agentSkills/inlineSkillReferences"; import { - convertGreekCommandAtCursor, - findGreekCommandAtCursor, - getGreekSuggestions, -} from "@/browser/features/ChatInput/greekConversion"; + convertSymbolCommandAtCursor, + convertTerminatedSymbolCommand, + findSymbolCommandAtCursor, + getSymbolSuggestions, +} from "@/browser/features/ChatInput/symbolShortcuts"; import { getInlineSkillInsertionTrailingText, getInlineSkillSuggestions, @@ -378,10 +379,10 @@ const ChatInputInner: React.FC = (props) => { const [showCommandSuggestions, setShowCommandSuggestions] = useState(false); const [commandSuggestions, setCommandSuggestions] = useState([]); - // Greek-letter backslash autocomplete (e.g. typing "\alpha"). - const [showGreekSuggestions, setShowGreekSuggestions] = useState(false); - const [greekSuggestions, setGreekSuggestions] = useState([]); - const lastGreekQueryRef = useRef(""); + // Backslash symbol-shortcut autocomplete (e.g. typing "\alpha" or "\leq"). + const [showSymbolSuggestions, setShowSymbolSuggestions] = useState(false); + const [symbolSuggestions, setSymbolSuggestions] = useState([]); + const lastSymbolQueryRef = useRef(""); const [agentSkillDescriptors, setAgentSkillDescriptors] = useState([]); const [toast, setToast] = useState(null); // State for destructive command confirmation modal (currently only /clear). @@ -580,11 +581,13 @@ const ChatInputInner: React.FC = (props) => { } } - // Auto-convert a completed backslash command (e.g. "\alpha") into its Greek - // letter as soon as the full name is typed. Conversion only fires when the - // caret sits at the end of the token, so partial/mid-word edits are untouched. + // Auto-convert a backslash symbol command (e.g. "\alpha" -> α, "\leq" -> ≤). + // Eager path fires only for unambiguous names; the terminator path accepts + // a completed name when a space/punctuation follows (e.g. "\in " -> "∈ "). + // Both only act at the caret, so partial/mid-word edits are left untouched. const caret = caretFromEvent ?? inputRef.current?.selectionStart ?? next.length; - const converted = convertGreekCommandAtCursor(next, caret); + const converted = + convertSymbolCommandAtCursor(next, caret) ?? convertTerminatedSymbolCommand(next, caret); if (converted) { setInput(converted.text); const newCursor = converted.cursor; @@ -648,7 +651,7 @@ const ChatInputInner: React.FC = (props) => { const atMentionListId = useId(); const skillListId = useId(); const commandListId = useId(); - const greekListId = useId(); + const symbolListId = useId(); const telemetry = useTelemetry(); const [vimEnabled, setVimEnabled] = usePersistedState(VIM_ENABLED_KEY, false, { listener: true, @@ -1475,27 +1478,27 @@ const ChatInputInner: React.FC = (props) => { setShowCommandSuggestions(suggestions.length > 0); }, [input, agentSkillDescriptors, variant, workspaceHeartbeatsExperimentEnabled]); - // Watch input/cursor for `\greek` backslash commands and surface the menu. + // Watch input/cursor for `\symbol` backslash commands and surface the menu. useLayoutEffect(() => { if (showAtMentionSuggestions) { // File mentions win precedence if an edge-case token could match both menus. - setGreekSuggestions(clearSuggestions); - setShowGreekSuggestions(false); + setSymbolSuggestions(clearSuggestions); + setShowSymbolSuggestions(false); return; } const cursor = Math.min(inputRef.current?.selectionStart ?? input.length, input.length); - const match = findGreekCommandAtCursor(input, cursor); + const match = findSymbolCommandAtCursor(input, cursor); if (!match) { - setGreekSuggestions(clearSuggestions); - setShowGreekSuggestions(false); + setSymbolSuggestions(clearSuggestions); + setShowSymbolSuggestions(false); return; } - const suggestions = getGreekSuggestions(match.partial); - lastGreekQueryRef.current = match.partial; - setGreekSuggestions((prev) => replaceSuggestions(prev, suggestions)); - setShowGreekSuggestions(suggestions.length > 0); + const suggestions = getSymbolSuggestions(match.partial); + lastSymbolQueryRef.current = match.partial; + setSymbolSuggestions((prev) => replaceSuggestions(prev, suggestions)); + setShowSymbolSuggestions(suggestions.length > 0); }, [input, showAtMentionSuggestions, atMentionCursorNonce]); // Derive ghost hint for slash-command argument syntax. @@ -2197,22 +2200,22 @@ const ChatInputInner: React.FC = (props) => { [setInput] ); - const handleGreekSelect = useCallback( + const handleSymbolSelect = useCallback( (suggestion: SlashSuggestion) => { const cursor = Math.min(inputRef.current?.selectionStart ?? input.length, input.length); - const match = findGreekCommandAtCursor(input, cursor); + const match = findSymbolCommandAtCursor(input, cursor); if (!match) { return; } - // Replace the whole `\name` token with the Greek letter; no trailing space - // so the user can keep typing (e.g. another letter or an exponent). + // Replace the whole `\name` token with the symbol; no trailing space so the + // user can keep typing (e.g. another symbol, an exponent, or a number). const next = input.slice(0, match.startIndex) + suggestion.replacement + input.slice(match.endIndex); setInput(next); - setGreekSuggestions(clearSuggestions); - setShowGreekSuggestions(false); + setSymbolSuggestions(clearSuggestions); + setShowSymbolSuggestions(false); requestAnimationFrame(() => { const el = inputRef.current; @@ -2752,15 +2755,15 @@ const ChatInputInner: React.FC = (props) => { const hasCommandSuggestionMenu = showCommandSuggestions && commandSuggestions.length > 0; const hasAtMentionSuggestionMenu = showAtMentionSuggestions && atMentionSuggestions.length > 0; const hasSkillSuggestionMenu = showSkillSuggestions && skillSuggestions.length > 0; - const hasGreekSuggestionMenu = showGreekSuggestions && greekSuggestions.length > 0; + const hasSymbolSuggestionMenu = showSymbolSuggestions && symbolSuggestions.length > 0; // Don't handle keys if suggestions are visible. - // Enter/Tab/arrows/Escape are handled by CommandSuggestions for slash, @file, $skill, and \greek menus. + // Enter/Tab/arrows/Escape are handled by CommandSuggestions for slash, @file, $skill, and \symbol menus. if ( (hasCommandSuggestionMenu && COMMAND_SUGGESTION_KEYS.includes(e.key)) || (hasAtMentionSuggestionMenu && FILE_SUGGESTION_KEYS.includes(e.key)) || (hasSkillSuggestionMenu && FILE_SUGGESTION_KEYS.includes(e.key)) || - (hasGreekSuggestionMenu && FILE_SUGGESTION_KEYS.includes(e.key)) + (hasSymbolSuggestionMenu && FILE_SUGGESTION_KEYS.includes(e.key)) ) { return; // Let CommandSuggestions handle it } @@ -2942,16 +2945,16 @@ const ChatInputInner: React.FC = (props) => { anchorRef={variant === "creation" ? inputRef : undefined} /> - {/* Greek letter suggestions (\alpha -> α) */} + {/* Symbol shortcut suggestions (\alpha -> α, \leq -> ≤, \euro -> €) */} setShowGreekSuggestions(false)} - isVisible={showGreekSuggestions} - ariaLabel="Greek letter suggestions" - listId={greekListId} + suggestions={symbolSuggestions} + onSelectSuggestion={handleSymbolSelect} + onDismiss={() => setShowSymbolSuggestions(false)} + isVisible={showSymbolSuggestions} + ariaLabel="Symbol shortcuts" + listId={symbolListId} anchorRef={variant === "creation" ? inputRef : undefined} - highlightQuery={lastGreekQueryRef.current} + highlightQuery={lastSymbolQueryRef.current} />
@@ -2987,7 +2990,7 @@ const ChatInputInner: React.FC = (props) => { ? FILE_SUGGESTION_KEYS : showSkillSuggestions ? FILE_SUGGESTION_KEYS - : showGreekSuggestions + : showSymbolSuggestions ? FILE_SUGGESTION_KEYS : showCommandSuggestions ? COMMAND_SUGGESTION_KEYS @@ -3002,8 +3005,8 @@ const ChatInputInner: React.FC = (props) => { ? atMentionListId : showSkillSuggestions && skillSuggestions.length > 0 ? skillListId - : showGreekSuggestions && greekSuggestions.length > 0 - ? greekListId + : showSymbolSuggestions && symbolSuggestions.length > 0 + ? symbolListId : showCommandSuggestions && commandSuggestions.length > 0 ? commandListId : undefined @@ -3012,7 +3015,7 @@ const ChatInputInner: React.FC = (props) => { (showCommandSuggestions && commandSuggestions.length > 0) || (showAtMentionSuggestions && atMentionSuggestions.length > 0) || (showSkillSuggestions && skillSuggestions.length > 0) || - (showGreekSuggestions && greekSuggestions.length > 0) + (showSymbolSuggestions && symbolSuggestions.length > 0) } className={variant === "creation" ? "min-h-28" : "min-h-16"} /> diff --git a/src/browser/features/ChatInput/symbolShortcuts.test.ts b/src/browser/features/ChatInput/symbolShortcuts.test.ts new file mode 100644 index 0000000000..6ef643f5fd --- /dev/null +++ b/src/browser/features/ChatInput/symbolShortcuts.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "bun:test"; +import { + convertSymbolCommandAtCursor, + convertTerminatedSymbolCommand, + findSymbolCommandAtCursor, + getSymbolSuggestions, +} from "@/browser/features/ChatInput/symbolShortcuts"; + +// Build backslash-prefixed inputs without literal backslashes in source. +const bs = String.fromCharCode(92); +const cmd = (name: string) => `${bs}${name}`; + +describe("findSymbolCommandAtCursor", () => { + test("matches a partial command at the cursor", () => { + expect(findSymbolCommandAtCursor(cmd("al"), 3)).toEqual({ + partial: "al", + startIndex: 0, + endIndex: 3, + }); + }); + + test("matches the bare trigger with an empty partial", () => { + expect(findSymbolCommandAtCursor(bs, 1)).toEqual({ partial: "", startIndex: 0, endIndex: 1 }); + }); + + test("captures the full token even when the caret is mid-word", () => { + expect(findSymbolCommandAtCursor(cmd("alpha"), 3)).toEqual({ + partial: "alpha", + startIndex: 0, + endIndex: 6, + }); + }); + + test("returns null without a trigger and ignores escaped triggers", () => { + expect(findSymbolCommandAtCursor("alpha", 5)).toBeNull(); + expect(findSymbolCommandAtCursor(`${bs}${bs}alpha`, 7)).toBeNull(); + }); + + test("does not match inside inline code or fenced blocks", () => { + // Inline code span: `\div` + expect(findSymbolCommandAtCursor("`" + cmd("div") + "`", 5)).toBeNull(); + // Fenced block + const fenced = "```\n" + cmd("sum") + "\n```"; + const cursorInFence = fenced.indexOf("m") + 1; + expect(findSymbolCommandAtCursor(fenced, cursorInFence)).toBeNull(); + // Same token outside code still matches + expect(findSymbolCommandAtCursor(cmd("div"), 4)?.partial).toBe("div"); + }); +}); + +describe("getSymbolSuggestions", () => { + test("filtering is case-sensitive so name case picks glyph case", () => { + const lower = getSymbolSuggestions("a"); + expect(lower.map((s) => s.display)).toEqual([ + cmd("alpha"), + cmd("ast"), + cmd("approx"), + cmd("angle"), + ]); + const upper = getSymbolSuggestions("A"); + expect(upper.map((s) => s.display)).toEqual([cmd("Alpha")]); + }); + + test("prefix-colliding names all appear in the menu", () => { + expect( + getSymbolSuggestions("in") + .map((s) => s.display) + .sort() + ).toEqual([cmd("in"), cmd("infty"), cmd("int")].sort()); + expect( + getSymbolSuggestions("sub") + .map((s) => s.display) + .sort() + ).toEqual([cmd("subset"), cmd("subseteq")].sort()); + // Single-letter set names collide with capitalized Greek + arrows. + expect( + getSymbolSuggestions("R") + .map((s) => s.display) + .sort() + ).toEqual([cmd("R"), cmd("Rho"), cmd("Rightarrow")].sort()); + }); + + test("each suggestion's replacement matches its glyph", () => { + for (const s of getSymbolSuggestions("")) { + expect(s.replacement).toBe(s.description); + expect(s.display.startsWith(bs)).toBe(true); + } + }); + + test("command names are unique", () => { + const names = getSymbolSuggestions("").map((s) => s.display); + expect(new Set(names).size).toBe(names.length); + }); + + test("no match yields no suggestions", () => { + expect(getSymbolSuggestions("zzz")).toEqual([]); + }); +}); + +describe("convertSymbolCommandAtCursor (eager, unambiguous only)", () => { + test("converts completed Greek commands by case", () => { + expect(convertSymbolCommandAtCursor(cmd("alpha"), 6)).toEqual({ text: "α", cursor: 1 }); + expect(convertSymbolCommandAtCursor(cmd("Alpha"), 6)).toEqual({ text: "Α", cursor: 1 }); + }); + + test("converts representative symbols across categories", () => { + expect(convertSymbolCommandAtCursor(cmd("leq"), 4)?.text).toBe("≤"); // math + expect(convertSymbolCommandAtCursor(cmd("times"), 6)?.text).toBe("×"); // math + expect(convertSymbolCommandAtCursor(cmd("subseteq"), 9)?.text).toBe("⊆"); // set + expect(convertSymbolCommandAtCursor(cmd("implies"), 8)?.text).toBe("⟹"); // logic + expect(convertSymbolCommandAtCursor(cmd("rightarrow"), 11)?.text).toBe("→"); // arrow + expect(convertSymbolCommandAtCursor(cmd("euro"), 5)?.text).toBe("€"); // currency + expect(convertSymbolCommandAtCursor(cmd("bitcoin"), 8)?.text).toBe("₿"); // currency + expect(convertSymbolCommandAtCursor(cmd("sum"), 4)?.text).toBe("∑"); // bigop + }); + + test("converts in place within surrounding text", () => { + expect(convertSymbolCommandAtCursor(`x ${cmd("geq")}`, 6)).toEqual({ text: "x ≥", cursor: 3 }); + }); + + test("does NOT eager-convert a name that is a prefix of another", () => { + expect(convertSymbolCommandAtCursor(cmd("in"), 3)).toBeNull(); // prefix of int/infty + expect(convertSymbolCommandAtCursor(cmd("to"), 3)).toBeNull(); // prefix of top + expect(convertSymbolCommandAtCursor(cmd("subset"), 7)).toBeNull(); // prefix of subseteq + expect(convertSymbolCommandAtCursor(cmd("R"), 2)).toBeNull(); // prefix of Rho/Rightarrow + }); + + test("eager-converts once the name becomes unambiguous", () => { + expect(convertSymbolCommandAtCursor(cmd("int"), 4)).toEqual({ text: "∫", cursor: 1 }); + expect(convertSymbolCommandAtCursor(cmd("top"), 4)).toEqual({ text: "⊤", cursor: 1 }); + expect(convertSymbolCommandAtCursor(cmd("subseteq"), 9)).toEqual({ text: "⊆", cursor: 1 }); + }); + + test("does not convert a partial, unknown, or mid-token name", () => { + expect(convertSymbolCommandAtCursor(cmd("alph"), 5)).toBeNull(); + expect(convertSymbolCommandAtCursor(cmd("alphax"), 7)).toBeNull(); + expect(convertSymbolCommandAtCursor(cmd("alpha"), 3)).toBeNull(); + }); + + test("does not convert an escaped command", () => { + expect(convertSymbolCommandAtCursor(`${bs}${bs}alpha`, 7)).toBeNull(); + }); +}); + +describe("convertTerminatedSymbolCommand (accept on space/punctuation)", () => { + test("converts an ambiguous name when a terminator follows", () => { + expect(convertTerminatedSymbolCommand(cmd("in") + " ", 4)).toEqual({ text: "∈ ", cursor: 2 }); + expect(convertTerminatedSymbolCommand(cmd("subset") + ")", 8)).toEqual({ + text: "⊂)", + cursor: 2, + }); + expect(convertTerminatedSymbolCommand(cmd("R") + ")", 3)).toEqual({ text: "ℝ)", cursor: 2 }); + }); + + test("converts a pasted unambiguous run ending in a terminator", () => { + expect(convertTerminatedSymbolCommand(cmd("alpha") + " ", 7)).toEqual({ + text: "α ", + cursor: 2, + }); + }); + + test("does nothing without a terminator or for unknown names", () => { + expect(convertTerminatedSymbolCommand(cmd("in"), 3)).toBeNull(); // no terminator + expect(convertTerminatedSymbolCommand(cmd("nope") + " ", 6)).toBeNull(); + }); + + test("does not convert an escaped command on terminator", () => { + expect(convertTerminatedSymbolCommand(`${bs}${bs}in `, 5)).toBeNull(); + }); +}); diff --git a/src/browser/features/ChatInput/symbolShortcuts.ts b/src/browser/features/ChatInput/symbolShortcuts.ts new file mode 100644 index 0000000000..b659b7ca19 --- /dev/null +++ b/src/browser/features/ChatInput/symbolShortcuts.ts @@ -0,0 +1,382 @@ +import type { SlashSuggestion } from "@/browser/utils/slashCommands/types"; +import { collectCodeRanges, isCursorInsideCodeRange } from "@/browser/utils/markdown/codeRanges"; + +/** + * Backslash symbol shortcuts for the chat input (math + trading use cases). + * + * Typing a LaTeX-style command expands it into a Unicode symbol: the Greek + * letters (alpha -> α, Alpha -> Α), plus math relations/operators, set theory, + * logic, arrows, currency/trading signs, and big operators. Typing the trigger + * also opens an autocomplete menu (wired in ChatInput) that filters as the name + * is typed. + * + * Conversion timing (matches Julia/Jupyter/VS Code-style completion): + * - A name that is NOT a strict prefix of any other symbol name converts + * eagerly the instant it is fully typed (keeps "alpha" -> α snappy). + * - A name that IS a strict prefix of another (e.g. "in" precedes "int"/ + * "infty") does not auto-convert; the menu stays open. Accept it via the + * menu (Tab/Enter) or by typing a terminator (space/punctuation), which the + * terminator path below converts. + * + * Pure module so all matching/conversion logic stays unit-testable without React. + */ + +// Built via char code so the source contains no literal backslash (awkward to +// escape consistently across our string/tooling layers). +const BACKSLASH = String.fromCharCode(92); + +type SymbolCategory = "greek" | "math" | "set" | "logic" | "arrow" | "currency" | "bigop"; + +interface SymbolEntry { + /** Command name without the leading backslash, e.g. "alpha" or "leq". */ + name: string; + /** The Unicode character the name expands to. */ + char: string; + category: SymbolCategory; +} + +// Standard Greek letters; each yields a lowercase + capitalized command whose +// case selects the glyph case (alpha -> α, Alpha -> Α). +const GREEK_LETTERS: ReadonlyArray<{ name: string; lower: string; upper: string }> = [ + { name: "alpha", lower: "α", upper: "Α" }, + { name: "beta", lower: "β", upper: "Β" }, + { name: "gamma", lower: "γ", upper: "Γ" }, + { name: "delta", lower: "δ", upper: "Δ" }, + { name: "epsilon", lower: "ε", upper: "Ε" }, + { name: "zeta", lower: "ζ", upper: "Ζ" }, + { name: "eta", lower: "η", upper: "Η" }, + { name: "theta", lower: "θ", upper: "Θ" }, + { name: "iota", lower: "ι", upper: "Ι" }, + { name: "kappa", lower: "κ", upper: "Κ" }, + { name: "lambda", lower: "λ", upper: "Λ" }, + { name: "mu", lower: "μ", upper: "Μ" }, + { name: "nu", lower: "ν", upper: "Ν" }, + { name: "xi", lower: "ξ", upper: "Ξ" }, + { name: "omicron", lower: "ο", upper: "Ο" }, + { name: "pi", lower: "π", upper: "Π" }, + { name: "rho", lower: "ρ", upper: "Ρ" }, + { name: "sigma", lower: "σ", upper: "Σ" }, + { name: "tau", lower: "τ", upper: "Τ" }, + { name: "upsilon", lower: "υ", upper: "Υ" }, + { name: "phi", lower: "φ", upper: "Φ" }, + { name: "chi", lower: "χ", upper: "Χ" }, + { name: "psi", lower: "ψ", upper: "Ψ" }, + { name: "omega", lower: "ω", upper: "Ω" }, +]; + +function capitalize(name: string): string { + return name.charAt(0).toUpperCase() + name.slice(1); +} + +const GREEK_ENTRIES: readonly SymbolEntry[] = GREEK_LETTERS.flatMap((letter) => [ + { name: letter.name, char: letter.lower, category: "greek" as const }, + { name: capitalize(letter.name), char: letter.upper, category: "greek" as const }, +]); + +// Curated non-Greek symbols. Code points verified against the Unicode charts. +const MATH_ENTRIES: ReadonlyArray<[string, string]> = [ + ["times", "×"], + ["div", "÷"], + ["pm", "±"], + ["mp", "∓"], + ["cdot", "·"], + ["ast", "∗"], + ["neq", "≠"], + ["leq", "≤"], + ["geq", "≥"], + ["ll", "≪"], + ["gg", "≫"], + ["approx", "≈"], + ["equiv", "≡"], + ["cong", "≅"], + ["sim", "∼"], + ["propto", "∝"], + ["infty", "∞"], + ["partial", "∂"], + ["nabla", "∇"], + ["sqrt", "√"], + ["angle", "∠"], + ["perp", "⊥"], + ["parallel", "∥"], + ["degree", "°"], + ["prime", "′"], + ["dprime", "″"], +]; + +const SET_ENTRIES: ReadonlyArray<[string, string]> = [ + ["in", "∈"], + ["notin", "∉"], + ["ni", "∋"], + ["subset", "⊂"], + ["supset", "⊃"], + ["subseteq", "⊆"], + ["supseteq", "⊇"], + ["cup", "∪"], + ["cap", "∩"], + ["setminus", "∖"], + ["emptyset", "∅"], + ["varnothing", "∅"], + ["forall", "∀"], + ["exists", "∃"], + ["nexists", "∄"], + ["R", "ℝ"], + ["N", "ℕ"], + ["Z", "ℤ"], + ["Q", "ℚ"], + ["C", "ℂ"], +]; + +const LOGIC_ENTRIES: ReadonlyArray<[string, string]> = [ + ["land", "∧"], + ["lor", "∨"], + ["lnot", "¬"], + ["neg", "¬"], + ["implies", "⟹"], + ["iff", "⟺"], + ["therefore", "∴"], + ["because", "∵"], + ["top", "⊤"], + ["bot", "⊥"], + ["models", "⊨"], + ["vdash", "⊢"], +]; + +const ARROW_ENTRIES: ReadonlyArray<[string, string]> = [ + ["to", "→"], + ["rightarrow", "→"], + ["gets", "←"], + ["leftarrow", "←"], + ["leftrightarrow", "↔"], + ["Rightarrow", "⇒"], + ["Leftarrow", "⇐"], + ["Leftrightarrow", "⇔"], + ["uparrow", "↑"], + ["downarrow", "↓"], + ["updownarrow", "↕"], + ["mapsto", "↦"], + ["nearrow", "↗"], + ["searrow", "↘"], +]; + +const CURRENCY_ENTRIES: ReadonlyArray<[string, string]> = [ + ["euro", "€"], + ["pound", "£"], + ["yen", "¥"], + ["cent", "¢"], + ["bitcoin", "₿"], + ["rupee", "₹"], + ["won", "₩"], + ["ruble", "₽"], + ["naira", "₦"], + ["peso", "₱"], + ["lira", "₺"], + ["franc", "₣"], + ["baht", "฿"], + ["shekel", "₪"], + ["currency", "¤"], + ["permille", "‰"], + ["bps", "‱"], + ["trademark", "™"], + ["registered", "®"], + ["copyright", "©"], +]; + +const BIGOP_ENTRIES: ReadonlyArray<[string, string]> = [ + ["sum", "∑"], + ["prod", "∏"], + ["coprod", "∐"], + ["int", "∫"], + ["oint", "∮"], + ["bigcup", "⋃"], + ["bigcap", "⋂"], + ["bigoplus", "⨁"], + ["bigotimes", "⨂"], +]; + +function toEntries( + pairs: ReadonlyArray<[string, string]>, + category: SymbolCategory +): SymbolEntry[] { + return pairs.map(([name, char]) => ({ name, char, category })); +} + +const SYMBOLS: readonly SymbolEntry[] = [ + ...GREEK_ENTRIES, + ...toEntries(MATH_ENTRIES, "math"), + ...toEntries(SET_ENTRIES, "set"), + ...toEntries(LOGIC_ENTRIES, "logic"), + ...toEntries(ARROW_ENTRIES, "arrow"), + ...toEntries(CURRENCY_ENTRIES, "currency"), + ...toEntries(BIGOP_ENTRIES, "bigop"), +]; + +const SYMBOL_BY_NAME: ReadonlyMap = new Map( + SYMBOLS.map((entry) => [entry.name, entry.char]) +); + +const ALL_NAMES: readonly string[] = SYMBOLS.map((entry) => entry.name); + +// Names that are a strict (case-sensitive) prefix of another command name. +// These must NOT auto-convert eagerly because more typing could still extend +// them (e.g. "in" -> "int"/"infty", "R" -> "Rho"/"Rightarrow"). +const STRICT_PREFIX_NAMES: ReadonlySet = (() => { + const result = new Set(); + for (const name of ALL_NAMES) { + if (ALL_NAMES.some((other) => other !== name && other.startsWith(name))) { + result.add(name); + } + } + return result; +})(); + +export interface SymbolCommandMatch { + /** Letters typed after the backslash (may be empty when only the trigger is typed). */ + partial: string; + /** Index of the triggering backslash. */ + startIndex: number; + /** Index just past the last letter of the token. */ + endIndex: number; +} + +const LETTER = /^[A-Za-z]$/; + +function isLetter(ch: string | undefined): boolean { + return ch !== undefined && LETTER.test(ch); +} + +function isInsideCode(text: string, cursor: number): boolean { + // Avoid triggering inside inline code / fenced blocks (e.g. a path or escape + // sequence that happens to look like a symbol command). + const ranges = collectCodeRanges(text); + return ranges.some((range) => isCursorInsideCodeRange(cursor, range)); +} + +/** + * Locate a backslash command surrounding the cursor. Walks left over letters to + * find the backslash, then right over letters to capture the whole token (so we + * never convert a name embedded in a longer run of letters). Returns null when + * the cursor is not inside such a token, or when it sits inside a code span. + */ +export function findSymbolCommandAtCursor(text: string, cursor: number): SymbolCommandMatch | null { + if ( + !Number.isInteger(cursor) || + cursor < 0 || + cursor > text.length || + !text.includes(BACKSLASH) + ) { + return null; + } + + if (isInsideCode(text, cursor)) { + return null; + } + + let tokenStart = cursor; + while (tokenStart > 0 && isLetter(text[tokenStart - 1])) { + tokenStart--; + } + + const backslashIndex = tokenStart - 1; + if (backslashIndex < 0 || text[backslashIndex] !== BACKSLASH) { + return null; + } + + // A preceding backslash (e.g. an escaped command) suppresses expansion so a + // literal backslash command can be written. + if (backslashIndex > 0 && text[backslashIndex - 1] === BACKSLASH) { + return null; + } + + let tokenEnd = cursor; + while (tokenEnd < text.length && isLetter(text[tokenEnd])) { + tokenEnd++; + } + + return { + partial: text.slice(backslashIndex + 1, tokenEnd), + startIndex: backslashIndex, + endIndex: tokenEnd, + }; +} + +/** + * Suggestions for the autocomplete menu. Matching is case-sensitive on purpose: + * the case of the typed name selects the glyph case, so "a" offers "alpha" + * while "A" offers "Alpha". An empty partial (just the trigger) lists everything. + */ +export function getSymbolSuggestions(partial: string): SlashSuggestion[] { + return SYMBOLS.filter((entry) => entry.name.startsWith(partial)).map((entry) => ({ + id: `symbol:${entry.name}`, + display: `${BACKSLASH}${entry.name}`, + description: entry.char, + replacement: entry.char, + })); +} + +/** + * Eager conversion: convert a completed command at the cursor into its symbol, + * but ONLY when the name is unambiguous (not a strict prefix of another name). + * Ambiguous names defer to the menu or the terminator path. + */ +export function convertSymbolCommandAtCursor( + text: string, + cursor: number +): { text: string; cursor: number } | null { + const match = findSymbolCommandAtCursor(text, cursor); + if (cursor !== match?.endIndex || STRICT_PREFIX_NAMES.has(match.partial)) { + return null; + } + + return convertMatch(text, match); +} + +/** + * Terminator-accept conversion: when the user types a space/punctuation right + * after a token whose letters exactly match a symbol name, convert that token + * (preserving the terminator and trailing text). Handles ambiguous names like + * "in " -> "∈ " and pasted "alpha " runs. + */ +export function convertTerminatedSymbolCommand( + text: string, + cursor: number +): { text: string; cursor: number } | null { + if (!Number.isInteger(cursor) || cursor < 1) { + return null; + } + + const terminatorIndex = cursor - 1; + const terminator = text[terminatorIndex]; + // Only a non-letter, non-backslash char ends a command. Backslash is excluded + // so escaped commands are not converted here either. + if (terminator === undefined || isLetter(terminator) || terminator === BACKSLASH) { + return null; + } + + const match = findSymbolCommandAtCursor(text, terminatorIndex); + if (match?.endIndex !== terminatorIndex) { + return null; + } + + const converted = convertMatch(text, match); + if (!converted) { + return null; + } + + // Keep the caret after the typed terminator (and any text the caret spanned). + return { text: converted.text, cursor: converted.cursor + (cursor - terminatorIndex) }; +} + +function convertMatch( + text: string, + match: SymbolCommandMatch +): { text: string; cursor: number } | null { + const char = SYMBOL_BY_NAME.get(match.partial); + if (char === undefined) { + return null; + } + + return { + text: text.slice(0, match.startIndex) + char + text.slice(match.endIndex), + cursor: match.startIndex + char.length, + }; +} diff --git a/src/browser/utils/agentSkills/inlineSkillReferences.ts b/src/browser/utils/agentSkills/inlineSkillReferences.ts index 856e520ebc..f54387d397 100644 --- a/src/browser/utils/agentSkills/inlineSkillReferences.ts +++ b/src/browser/utils/agentSkills/inlineSkillReferences.ts @@ -4,6 +4,11 @@ import { SkillNameSchema } from "@/common/orpc/schemas/agentSkill"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import type { AgentSkillReference } from "@/common/types/message"; import { dedupeAgentSkillRefs } from "@/common/types/message"; +import { + collectCodeRanges, + isCursorInsideCodeRange, + isPositionInRange, +} from "@/browser/utils/markdown/codeRanges"; /** Parser-only candidate. The startIndex/endIndex are autocomplete-replacement aids * and MUST NOT be persisted in metadata (they become ambiguous after edits/reviews/etc.). */ @@ -20,21 +25,6 @@ interface InlineSkillCursorMatch { endIndex: number; } -interface TextRange { - start: number; - end: number; -} - -const MIN_FENCE_MARKER_LENGTH = 3; -const MAX_FENCE_MARKER_INDENTATION = 3; -type FenceChar = "`" | "~"; - -interface FenceMarker { - char: FenceChar; - length: number; - markerStart: number; -} - const LEFT_BOUNDARY_BLOCKED_RE = /[\w$]/; function isSkillStartChar(ch: string | undefined): boolean { @@ -53,180 +43,6 @@ function hasSaneLeftBoundary(text: string, dollarIndex: number): boolean { return !LEFT_BOUNDARY_BLOCKED_RE.test(text[dollarIndex - 1] ?? ""); } -function getCharRunLength(text: string, start: number, ch: string): number { - let end = start; - while (end < text.length && text[end] === ch) { - end++; - } - - return end - start; -} - -function getBacktickRunLength(text: string, start: number): number { - return getCharRunLength(text, start, "`"); -} - -function isFenceChar(ch: string | undefined): ch is FenceChar { - return ch === "`" || ch === "~"; -} - -function isLineStart(text: string, index: number): boolean { - return index === 0 || text[index - 1] === "\n" || text[index - 1] === "\r"; -} - -function getFenceMarkerAtLineStart(text: string, index: number): FenceMarker | null { - if (!isLineStart(text, index)) { - return null; - } - - let markerStart = index; - let indentation = 0; - while (indentation < MAX_FENCE_MARKER_INDENTATION && text[markerStart] === " ") { - markerStart++; - indentation++; - } - - const ch = text[markerStart]; - if (!isFenceChar(ch)) { - return null; - } - - const length = getCharRunLength(text, markerStart, ch); - if (length < MIN_FENCE_MARKER_LENGTH) { - return null; - } - - return { char: ch, length, markerStart }; -} - -function findLineEnd(text: string, start: number): number { - let end = start; - while (end < text.length && text[end] !== "\n" && text[end] !== "\r") { - end++; - } - - return end; -} - -function findNextLineStart(text: string, start: number): number { - const lineEnd = findLineEnd(text, start); - if (lineEnd >= text.length) { - return text.length; - } - - return text[lineEnd] === "\r" && text[lineEnd + 1] === "\n" ? lineEnd + 2 : lineEnd + 1; -} - -function hasOnlySpacesOrTabsUntilLineEnd(text: string, start: number): boolean { - const lineEnd = findLineEnd(text, start); - for (let index = start; index < lineEnd; index++) { - const ch = text[index]; - if (ch !== " " && ch !== "\t") { - return false; - } - } - - return true; -} - -function findInlineCodeEnd(text: string, start: number, delimiterLength: number): number | null { - let index = start; - while (index < text.length) { - const ch = text[index]; - if (ch === "\n" || ch === "\r") { - return null; - } - - if (ch !== "`") { - index++; - continue; - } - - const runLength = getBacktickRunLength(text, index); - index += runLength; - - // Markdown inline code spans close only on the first backtick run of the same length. - if (runLength === delimiterLength) { - return index; - } - } - - return null; -} - -function collectCodeRanges(text: string): TextRange[] { - const ranges: TextRange[] = []; - let index = 0; - - while (index < text.length) { - const fenceMarker = getFenceMarkerAtLineStart(text, index); - if (fenceMarker) { - const fenceStart = index; - index = findNextLineStart(text, index); - - while (index < text.length) { - const closingFenceMarker = getFenceMarkerAtLineStart(text, index); - if ( - closingFenceMarker && - closingFenceMarker.char === fenceMarker.char && - closingFenceMarker.length >= fenceMarker.length && - hasOnlySpacesOrTabsUntilLineEnd( - text, - closingFenceMarker.markerStart + closingFenceMarker.length - ) - ) { - index = closingFenceMarker.markerStart + closingFenceMarker.length; - break; - } - - index = findNextLineStart(text, index); - } - - ranges.push({ start: fenceStart, end: index }); - continue; - } - - const ch = text[index]; - if (ch === "\n" || ch === "\r") { - index++; - continue; - } - - if (ch === "`") { - const rangeStart = index; - const delimiterLength = getBacktickRunLength(text, index); - index += delimiterLength; - - const rangeEnd = findInlineCodeEnd(text, index, delimiterLength); - if (rangeEnd !== null) { - ranges.push({ start: rangeStart, end: rangeEnd }); - index = rangeEnd; - continue; - } - - if (delimiterLength > 1) { - const lineEnd = findLineEnd(text, index); - ranges.push({ start: rangeStart, end: lineEnd }); - index = lineEnd; - } - - continue; - } - - index++; - } - - return ranges; -} - -function isPositionInRange(position: number, range: TextRange): boolean { - return position >= range.start && position < range.end; -} - -function isCursorInsideCodeRange(cursor: number, range: TextRange): boolean { - return cursor > range.start && cursor < range.end; -} - function isPartialToken(rawPartial: string): boolean { if (rawPartial.length === 0) { return true; diff --git a/src/browser/utils/markdown/codeRanges.ts b/src/browser/utils/markdown/codeRanges.ts new file mode 100644 index 0000000000..279c293113 --- /dev/null +++ b/src/browser/utils/markdown/codeRanges.ts @@ -0,0 +1,199 @@ +/** + * Markdown code-span / fenced-code-block detection. + * + * Shared by inline-token features (e.g. `$skill` references and `\symbol` + * shortcuts) that must NOT trigger inside code. Extracted from + * inlineSkillReferences.ts so multiple consumers reuse one implementation. + */ + +export interface TextRange { + start: number; + end: number; +} + +const MIN_FENCE_MARKER_LENGTH = 3; +const MAX_FENCE_MARKER_INDENTATION = 3; +type FenceChar = "`" | "~"; + +interface FenceMarker { + char: FenceChar; + length: number; + markerStart: number; +} + +function getCharRunLength(text: string, start: number, ch: string): number { + let end = start; + while (end < text.length && text[end] === ch) { + end++; + } + + return end - start; +} + +function getBacktickRunLength(text: string, start: number): number { + return getCharRunLength(text, start, "`"); +} + +function isFenceChar(ch: string | undefined): ch is FenceChar { + return ch === "`" || ch === "~"; +} + +function isLineStart(text: string, index: number): boolean { + return index === 0 || text[index - 1] === "\n" || text[index - 1] === "\r"; +} + +function getFenceMarkerAtLineStart(text: string, index: number): FenceMarker | null { + if (!isLineStart(text, index)) { + return null; + } + + let markerStart = index; + let indentation = 0; + while (indentation < MAX_FENCE_MARKER_INDENTATION && text[markerStart] === " ") { + markerStart++; + indentation++; + } + + const ch = text[markerStart]; + if (!isFenceChar(ch)) { + return null; + } + + const length = getCharRunLength(text, markerStart, ch); + if (length < MIN_FENCE_MARKER_LENGTH) { + return null; + } + + return { char: ch, length, markerStart }; +} + +function findLineEnd(text: string, start: number): number { + let end = start; + while (end < text.length && text[end] !== "\n" && text[end] !== "\r") { + end++; + } + + return end; +} + +function findNextLineStart(text: string, start: number): number { + const lineEnd = findLineEnd(text, start); + if (lineEnd >= text.length) { + return text.length; + } + + return text[lineEnd] === "\r" && text[lineEnd + 1] === "\n" ? lineEnd + 2 : lineEnd + 1; +} + +function hasOnlySpacesOrTabsUntilLineEnd(text: string, start: number): boolean { + const lineEnd = findLineEnd(text, start); + for (let index = start; index < lineEnd; index++) { + const ch = text[index]; + if (ch !== " " && ch !== "\t") { + return false; + } + } + + return true; +} + +function findInlineCodeEnd(text: string, start: number, delimiterLength: number): number | null { + let index = start; + while (index < text.length) { + const ch = text[index]; + if (ch === "\n" || ch === "\r") { + return null; + } + + if (ch !== "`") { + index++; + continue; + } + + const runLength = getBacktickRunLength(text, index); + index += runLength; + + // Markdown inline code spans close only on the first backtick run of the same length. + if (runLength === delimiterLength) { + return index; + } + } + + return null; +} + +/** Collect all fenced-code-block and inline-code-span ranges in `text`. */ +export function collectCodeRanges(text: string): TextRange[] { + const ranges: TextRange[] = []; + let index = 0; + + while (index < text.length) { + const fenceMarker = getFenceMarkerAtLineStart(text, index); + if (fenceMarker) { + const fenceStart = index; + index = findNextLineStart(text, index); + + while (index < text.length) { + const closingFenceMarker = getFenceMarkerAtLineStart(text, index); + if ( + closingFenceMarker && + closingFenceMarker.char === fenceMarker.char && + closingFenceMarker.length >= fenceMarker.length && + hasOnlySpacesOrTabsUntilLineEnd( + text, + closingFenceMarker.markerStart + closingFenceMarker.length + ) + ) { + index = closingFenceMarker.markerStart + closingFenceMarker.length; + break; + } + + index = findNextLineStart(text, index); + } + + ranges.push({ start: fenceStart, end: index }); + continue; + } + + const ch = text[index]; + if (ch === "\n" || ch === "\r") { + index++; + continue; + } + + if (ch === "`") { + const rangeStart = index; + const delimiterLength = getBacktickRunLength(text, index); + index += delimiterLength; + + const rangeEnd = findInlineCodeEnd(text, index, delimiterLength); + if (rangeEnd !== null) { + ranges.push({ start: rangeStart, end: rangeEnd }); + index = rangeEnd; + continue; + } + + if (delimiterLength > 1) { + const lineEnd = findLineEnd(text, index); + ranges.push({ start: rangeStart, end: lineEnd }); + index = lineEnd; + } + + continue; + } + + index++; + } + + return ranges; +} + +/** True when `position` falls within `[range.start, range.end)`. */ +export function isPositionInRange(position: number, range: TextRange): boolean { + return position >= range.start && position < range.end; +} + +/** True when a cursor sits strictly inside a code range (not on its edges). */ +export function isCursorInsideCodeRange(cursor: number, range: TextRange): boolean { + return cursor > range.start && cursor < range.end; +} From bae8372814c53ab929e131ec6b878b91c9fec916 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Jun 2026 15:12:56 -0500 Subject: [PATCH 3/4] fix: preselect exact symbol match; add user docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Float exact full-name matches to the top of the suggestion menu so typing \in + Tab inserts the exact match (∈) rather than a longer prefix-completion (∞/∫); other matches keep curated order. - Add docs/config/symbol-shortcuts.mdx (usage, conversion timing, escaping, category reference) + nav entry; regenerate offline docs snapshot. --- docs/config/symbol-shortcuts.mdx | 41 ++++++++++++++++ docs/docs.json | 3 +- .../ChatInput/symbolShortcuts.test.ts | 15 ++++++ .../features/ChatInput/symbolShortcuts.ts | 12 ++++- src/node/builtinSkills/mux-docs.md | 1 + .../builtInSkillContent.generated.ts | 48 ++++++++++++++++++- 6 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 docs/config/symbol-shortcuts.mdx diff --git a/docs/config/symbol-shortcuts.mdx b/docs/config/symbol-shortcuts.mdx new file mode 100644 index 0000000000..4466621d29 --- /dev/null +++ b/docs/config/symbol-shortcuts.mdx @@ -0,0 +1,41 @@ +--- +title: Symbol Shortcuts +description: Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands +--- + +Type a LaTeX-style backslash command in the chat input to insert a Unicode symbol. `\alpha` becomes α, `\leq` becomes ≤, `\subseteq` becomes ⊆, and `\euro` becomes €. This covers math, set theory, logic, arrows, and currency/trading notation without leaving the keyboard. + +## Usage + +Type `\` to open an autocomplete menu that filters as you type. Accept the highlighted entry with **Tab** or **Enter**, navigate with the arrow keys, and dismiss with **Esc**. + +Greek letters follow the case of the command: `\alpha` inserts α, while `\Alpha` inserts Α. + +## Conversion timing + +Unambiguous commands convert the moment you finish typing the name — `\alpha` turns into α without needing Tab. + +When a name is a prefix of another command, Mux keeps the menu open instead of guessing. For example `\in` is a prefix of `\int` and `\infty`, so typing `\in` does **not** convert on its own. Accept it explicitly to disambiguate: + +- Press **Tab** or **Enter** to take the highlighted entry (an exact name match is always preselected, so `\in` + Tab gives ∈). +- Or type a space or punctuation after the name (`\in ` becomes `∈ `). + +## Escaping + +Prefix the command with a second backslash to insert it literally. `\\alpha` stays as `\alpha` and does not convert. Commands are also left untouched inside inline code spans and fenced code blocks. + +## Available symbols + +Matching is case-sensitive. Names mirror their LaTeX equivalents where one exists. + +| Category | Examples | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Greek | `\alpha` α, `\beta` β, `\pi` π, `\Sigma` Σ, `\Omega` Ω | +| Relations & operators | `\times` ×, `\div` ÷, `\pm` ±, `\neq` ≠, `\leq` ≤, `\geq` ≥, `\approx` ≈, `\equiv` ≡, `\infty` ∞, `\sqrt` √, `\degree` ° | +| Set theory | `\in` ∈, `\notin` ∉, `\subset` ⊂, `\subseteq` ⊆, `\cup` ∪, `\cap` ∩, `\emptyset` ∅, `\forall` ∀, `\exists` ∃, `\R` ℝ, `\Z` ℤ, `\N` ℕ | +| Logic | `\land` ∧, `\lor` ∨, `\neg` ¬, `\implies` ⟹, `\iff` ⟺, `\therefore` ∴ | +| Arrows | `\to` →, `\gets` ←, `\leftrightarrow` ↔, `\Rightarrow` ⇒, `\uparrow` ↑, `\downarrow` ↓, `\mapsto` ↦ | +| Currency & trading | `\euro` €, `\pound` £, `\yen` ¥, `\cent` ¢, `\bitcoin` ₿, `\permille` ‰, `\bps` ‱, `\trademark` ™ | +| Big operators | `\sum` ∑, `\prod` ∏, `\int` ∫, `\oint` ∮, `\bigcup` ⋃, `\bigcap` ⋂ | + +Open the `\` menu and browse to see the full list. diff --git a/docs/docs.json b/docs/docs.json index b568f0519b..5209ce3bca 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -97,7 +97,8 @@ "config/keybinds", "config/notifications", "config/server-access", - "config/vim-mode" + "config/vim-mode", + "config/symbol-shortcuts" ] }, diff --git a/src/browser/features/ChatInput/symbolShortcuts.test.ts b/src/browser/features/ChatInput/symbolShortcuts.test.ts index 6ef643f5fd..c50390b423 100644 --- a/src/browser/features/ChatInput/symbolShortcuts.test.ts +++ b/src/browser/features/ChatInput/symbolShortcuts.test.ts @@ -61,6 +61,21 @@ describe("getSymbolSuggestions", () => { expect(upper.map((s) => s.display)).toEqual([cmd("Alpha")]); }); + test("an exact match is the default (first) suggestion so Tab accepts it", () => { + // Regression: typing "\in" + Tab must yield ∈, not ∞/∫ — the exact match + // floats above its longer prefix-completions. + expect(getSymbolSuggestions("in")[0]?.display).toBe(cmd("in")); + expect(getSymbolSuggestions("in")[0]?.replacement).toBe("∈"); + expect(getSymbolSuggestions("to")[0]?.display).toBe(cmd("to")); + expect(getSymbolSuggestions("subset")[0]?.display).toBe(cmd("subset")); + }); + + test("non-exact queries keep curated order (no shorter-sibling hijack)", () => { + // "\a" has no exact match, so the curated Greek-first ordering stands and + // \alpha remains the default rather than the shorter \ast. + expect(getSymbolSuggestions("a")[0]?.display).toBe(cmd("alpha")); + }); + test("prefix-colliding names all appear in the menu", () => { expect( getSymbolSuggestions("in") diff --git a/src/browser/features/ChatInput/symbolShortcuts.ts b/src/browser/features/ChatInput/symbolShortcuts.ts index b659b7ca19..e572efc344 100644 --- a/src/browser/features/ChatInput/symbolShortcuts.ts +++ b/src/browser/features/ChatInput/symbolShortcuts.ts @@ -303,9 +303,19 @@ export function findSymbolCommandAtCursor(text: string, cursor: number): SymbolC * Suggestions for the autocomplete menu. Matching is case-sensitive on purpose: * the case of the typed name selects the glyph case, so "a" offers "alpha" * while "A" offers "Alpha". An empty partial (just the trigger) lists everything. + * + * An exact (full-name) match is floated to the top so it wins the menu's + * default selection — e.g. "\in" + Tab yields ∈, not ∞/∫ even though "\infty"/ + * "\int" also start with "in". All other matches keep curated table order + * (Array.sort is stable), so e.g. "\a" still defaults to "\alpha" rather than a + * shorter sibling like "\ast". */ export function getSymbolSuggestions(partial: string): SlashSuggestion[] { - return SYMBOLS.filter((entry) => entry.name.startsWith(partial)).map((entry) => ({ + const matches = SYMBOLS.filter((entry) => entry.name.startsWith(partial)); + if (partial.length > 0) { + matches.sort((a, b) => Number(b.name === partial) - Number(a.name === partial)); + } + return matches.map((entry) => ({ id: `symbol:${entry.name}`, display: `${BACKSLASH}${entry.name}`, description: entry.char, diff --git a/src/node/builtinSkills/mux-docs.md b/src/node/builtinSkills/mux-docs.md index 5520cee1eb..8c6b24b645 100644 --- a/src/node/builtinSkills/mux-docs.md +++ b/src/node/builtinSkills/mux-docs.md @@ -93,6 +93,7 @@ Use this index to find a page's: - Notifications (`/config/notifications`) → `references/docs/config/notifications.mdx` — Configure how agents notify you about important events - Server Access (`/config/server-access`) → `references/docs/config/server-access.mdx` — Configure authentication and session controls for mux server/browser mode - Vim Mode (`/config/vim-mode`) → `references/docs/config/vim-mode.mdx` — Vim-style editing in the Mux chat input + - Symbol Shortcuts (`/config/symbol-shortcuts`) → `references/docs/config/symbol-shortcuts.mdx` — Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands - **Guides** - GitHub Actions (`/guides/github-actions`) → `references/docs/guides/github-actions.mdx` — Automate your workflows with mux run in GitHub Actions - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx` — Configure a separate Git identity for AI-generated commits diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 81760fc7ef..74a6e71502 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -2929,6 +2929,50 @@ export const BUILTIN_SKILL_FILES: Record> = { "- [CLI reference](/reference/cli)", "", ].join("\n"), + "references/docs/config/symbol-shortcuts.mdx": [ + "---", + "title: Symbol Shortcuts", + "description: Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands", + "---", + "", + "Type a LaTeX-style backslash command in the chat input to insert a Unicode symbol. `\\alpha` becomes α, `\\leq` becomes ≤, `\\subseteq` becomes ⊆, and `\\euro` becomes €. This covers math, set theory, logic, arrows, and currency/trading notation without leaving the keyboard.", + "", + "## Usage", + "", + "Type `\\` to open an autocomplete menu that filters as you type. Accept the highlighted entry with **Tab** or **Enter**, navigate with the arrow keys, and dismiss with **Esc**.", + "", + "Greek letters follow the case of the command: `\\alpha` inserts α, while `\\Alpha` inserts Α.", + "", + "## Conversion timing", + "", + "Unambiguous commands convert the moment you finish typing the name — `\\alpha` turns into α without needing Tab.", + "", + "When a name is a prefix of another command, Mux keeps the menu open instead of guessing. For example `\\in` is a prefix of `\\int` and `\\infty`, so typing `\\in` does **not** convert on its own. Accept it explicitly to disambiguate:", + "", + "- Press **Tab** or **Enter** to take the highlighted entry (an exact name match is always preselected, so `\\in` + Tab gives ∈).", + "- Or type a space or punctuation after the name (`\\in ` becomes `∈ `).", + "", + "## Escaping", + "", + "Prefix the command with a second backslash to insert it literally. `\\\\alpha` stays as `\\alpha` and does not convert. Commands are also left untouched inside inline code spans and fenced code blocks.", + "", + "## Available symbols", + "", + "Matching is case-sensitive. Names mirror their LaTeX equivalents where one exists.", + "", + "| Category | Examples |", + "| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |", + "| Greek | `\\alpha` α, `\\beta` β, `\\pi` π, `\\Sigma` Σ, `\\Omega` Ω |", + "| Relations & operators | `\\times` ×, `\\div` ÷, `\\pm` ±, `\\neq` ≠, `\\leq` ≤, `\\geq` ≥, `\\approx` ≈, `\\equiv` ≡, `\\infty` ∞, `\\sqrt` √, `\\degree` ° |", + "| Set theory | `\\in` ∈, `\\notin` ∉, `\\subset` ⊂, `\\subseteq` ⊆, `\\cup` ∪, `\\cap` ∩, `\\emptyset` ∅, `\\forall` ∀, `\\exists` ∃, `\\R` ℝ, `\\Z` ℤ, `\\N` ℕ |", + "| Logic | `\\land` ∧, `\\lor` ∨, `\\neg` ¬, `\\implies` ⟹, `\\iff` ⟺, `\\therefore` ∴ |", + "| Arrows | `\\to` →, `\\gets` ←, `\\leftrightarrow` ↔, `\\Rightarrow` ⇒, `\\uparrow` ↑, `\\downarrow` ↓, `\\mapsto` ↦ |", + "| Currency & trading | `\\euro` €, `\\pound` £, `\\yen` ¥, `\\cent` ¢, `\\bitcoin` ₿, `\\permille` ‰, `\\bps` ‱, `\\trademark` ™ |", + "| Big operators | `\\sum` ∑, `\\prod` ∏, `\\int` ∫, `\\oint` ∮, `\\bigcup` ⋃, `\\bigcap` ⋂ |", + "", + "Open the `\\` menu and browse to see the full list.", + "", + ].join("\n"), "references/docs/config/vim-mode.mdx": [ "---", "title: Vim Mode", @@ -3318,7 +3362,8 @@ export const BUILTIN_SKILL_FILES: Record> = { ' "config/keybinds",', ' "config/notifications",', ' "config/server-access",', - ' "config/vim-mode"', + ' "config/vim-mode",', + ' "config/symbol-shortcuts"', " ]", " },", "", @@ -6404,6 +6449,7 @@ export const BUILTIN_SKILL_FILES: Record> = { " - Notifications (`/config/notifications`) → `references/docs/config/notifications.mdx` — Configure how agents notify you about important events", " - Server Access (`/config/server-access`) → `references/docs/config/server-access.mdx` — Configure authentication and session controls for mux server/browser mode", " - Vim Mode (`/config/vim-mode`) → `references/docs/config/vim-mode.mdx` — Vim-style editing in the Mux chat input", + " - Symbol Shortcuts (`/config/symbol-shortcuts`) → `references/docs/config/symbol-shortcuts.mdx` — Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands", " - **Guides**", " - GitHub Actions (`/guides/github-actions`) → `references/docs/guides/github-actions.mdx` — Automate your workflows with mux run in GitHub Actions", " - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx` — Configure a separate Git identity for AI-generated commits", From 3188cb74e1c684cad400030127a33c0aad5c5b8d Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Jun 2026 15:18:11 -0500 Subject: [PATCH 4/4] docs: move symbol-shortcuts to guides; add two-way sync comments - Symbol shortcuts are always-on (not configurable), so move the page from Configuration to Guides. - Cross-reference the SYMBOLS table in symbolShortcuts.ts and the doc's example table so they stay in sync. - Regenerate offline docs snapshot. --- docs/docs.json | 4 +- docs/{config => guides}/symbol-shortcuts.mdx | 2 + .../features/ChatInput/symbolShortcuts.ts | 3 + src/node/builtinSkills/mux-docs.md | 2 +- .../builtInSkillContent.generated.ts | 96 ++++++++++--------- 5 files changed, 57 insertions(+), 50 deletions(-) rename docs/{config => guides}/symbol-shortcuts.mdx (94%) diff --git a/docs/docs.json b/docs/docs.json index 5209ce3bca..3f3b1d0ecd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -97,8 +97,7 @@ "config/keybinds", "config/notifications", "config/server-access", - "config/vim-mode", - "config/symbol-shortcuts" + "config/vim-mode" ] }, @@ -106,6 +105,7 @@ "group": "Guides", "pages": [ "guides/github-actions", + "guides/symbol-shortcuts", "config/agentic-git-identity", "agents/prompting-tips" ] diff --git a/docs/config/symbol-shortcuts.mdx b/docs/guides/symbol-shortcuts.mdx similarity index 94% rename from docs/config/symbol-shortcuts.mdx rename to docs/guides/symbol-shortcuts.mdx index 4466621d29..49f5190890 100644 --- a/docs/config/symbol-shortcuts.mdx +++ b/docs/guides/symbol-shortcuts.mdx @@ -28,6 +28,8 @@ Prefix the command with a second backslash to insert it literally. `\\alpha` sta Matching is case-sensitive. Names mirror their LaTeX equivalents where one exists. +{/* The full list lives in `src/browser/features/ChatInput/symbolShortcuts.ts` (the `SYMBOLS` table). This is an illustrative subset; keep it in sync when symbols are added or renamed. */} + | Category | Examples | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | Greek | `\alpha` α, `\beta` β, `\pi` π, `\Sigma` Σ, `\Omega` Ω | diff --git a/src/browser/features/ChatInput/symbolShortcuts.ts b/src/browser/features/ChatInput/symbolShortcuts.ts index e572efc344..09b54e3fb1 100644 --- a/src/browser/features/ChatInput/symbolShortcuts.ts +++ b/src/browser/features/ChatInput/symbolShortcuts.ts @@ -200,6 +200,9 @@ function toEntries( return pairs.map(([name, char]) => ({ name, char, category })); } +// The full set of shortcuts. A representative subset is mirrored in the user +// docs at docs/guides/symbol-shortcuts.mdx — update that table's examples when +// adding or renaming categories/symbols here. const SYMBOLS: readonly SymbolEntry[] = [ ...GREEK_ENTRIES, ...toEntries(MATH_ENTRIES, "math"), diff --git a/src/node/builtinSkills/mux-docs.md b/src/node/builtinSkills/mux-docs.md index 8c6b24b645..8404d281f7 100644 --- a/src/node/builtinSkills/mux-docs.md +++ b/src/node/builtinSkills/mux-docs.md @@ -93,9 +93,9 @@ Use this index to find a page's: - Notifications (`/config/notifications`) → `references/docs/config/notifications.mdx` — Configure how agents notify you about important events - Server Access (`/config/server-access`) → `references/docs/config/server-access.mdx` — Configure authentication and session controls for mux server/browser mode - Vim Mode (`/config/vim-mode`) → `references/docs/config/vim-mode.mdx` — Vim-style editing in the Mux chat input - - Symbol Shortcuts (`/config/symbol-shortcuts`) → `references/docs/config/symbol-shortcuts.mdx` — Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands - **Guides** - GitHub Actions (`/guides/github-actions`) → `references/docs/guides/github-actions.mdx` — Automate your workflows with mux run in GitHub Actions + - Symbol Shortcuts (`/guides/symbol-shortcuts`) → `references/docs/guides/symbol-shortcuts.mdx` — Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx` — Configure a separate Git identity for AI-generated commits - Prompting Tips (`/agents/prompting-tips`) → `references/docs/agents/prompting-tips.mdx` — Tips and tricks for getting the most out of your AI agents - **Integrations** diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 74a6e71502..82fa3c0cd7 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -2929,50 +2929,6 @@ export const BUILTIN_SKILL_FILES: Record> = { "- [CLI reference](/reference/cli)", "", ].join("\n"), - "references/docs/config/symbol-shortcuts.mdx": [ - "---", - "title: Symbol Shortcuts", - "description: Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands", - "---", - "", - "Type a LaTeX-style backslash command in the chat input to insert a Unicode symbol. `\\alpha` becomes α, `\\leq` becomes ≤, `\\subseteq` becomes ⊆, and `\\euro` becomes €. This covers math, set theory, logic, arrows, and currency/trading notation without leaving the keyboard.", - "", - "## Usage", - "", - "Type `\\` to open an autocomplete menu that filters as you type. Accept the highlighted entry with **Tab** or **Enter**, navigate with the arrow keys, and dismiss with **Esc**.", - "", - "Greek letters follow the case of the command: `\\alpha` inserts α, while `\\Alpha` inserts Α.", - "", - "## Conversion timing", - "", - "Unambiguous commands convert the moment you finish typing the name — `\\alpha` turns into α without needing Tab.", - "", - "When a name is a prefix of another command, Mux keeps the menu open instead of guessing. For example `\\in` is a prefix of `\\int` and `\\infty`, so typing `\\in` does **not** convert on its own. Accept it explicitly to disambiguate:", - "", - "- Press **Tab** or **Enter** to take the highlighted entry (an exact name match is always preselected, so `\\in` + Tab gives ∈).", - "- Or type a space or punctuation after the name (`\\in ` becomes `∈ `).", - "", - "## Escaping", - "", - "Prefix the command with a second backslash to insert it literally. `\\\\alpha` stays as `\\alpha` and does not convert. Commands are also left untouched inside inline code spans and fenced code blocks.", - "", - "## Available symbols", - "", - "Matching is case-sensitive. Names mirror their LaTeX equivalents where one exists.", - "", - "| Category | Examples |", - "| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |", - "| Greek | `\\alpha` α, `\\beta` β, `\\pi` π, `\\Sigma` Σ, `\\Omega` Ω |", - "| Relations & operators | `\\times` ×, `\\div` ÷, `\\pm` ±, `\\neq` ≠, `\\leq` ≤, `\\geq` ≥, `\\approx` ≈, `\\equiv` ≡, `\\infty` ∞, `\\sqrt` √, `\\degree` ° |", - "| Set theory | `\\in` ∈, `\\notin` ∉, `\\subset` ⊂, `\\subseteq` ⊆, `\\cup` ∪, `\\cap` ∩, `\\emptyset` ∅, `\\forall` ∀, `\\exists` ∃, `\\R` ℝ, `\\Z` ℤ, `\\N` ℕ |", - "| Logic | `\\land` ∧, `\\lor` ∨, `\\neg` ¬, `\\implies` ⟹, `\\iff` ⟺, `\\therefore` ∴ |", - "| Arrows | `\\to` →, `\\gets` ←, `\\leftrightarrow` ↔, `\\Rightarrow` ⇒, `\\uparrow` ↑, `\\downarrow` ↓, `\\mapsto` ↦ |", - "| Currency & trading | `\\euro` €, `\\pound` £, `\\yen` ¥, `\\cent` ¢, `\\bitcoin` ₿, `\\permille` ‰, `\\bps` ‱, `\\trademark` ™ |", - "| Big operators | `\\sum` ∑, `\\prod` ∏, `\\int` ∫, `\\oint` ∮, `\\bigcup` ⋃, `\\bigcap` ⋂ |", - "", - "Open the `\\` menu and browse to see the full list.", - "", - ].join("\n"), "references/docs/config/vim-mode.mdx": [ "---", "title: Vim Mode", @@ -3362,8 +3318,7 @@ export const BUILTIN_SKILL_FILES: Record> = { ' "config/keybinds",', ' "config/notifications",', ' "config/server-access",', - ' "config/vim-mode",', - ' "config/symbol-shortcuts"', + ' "config/vim-mode"', " ]", " },", "", @@ -3371,6 +3326,7 @@ export const BUILTIN_SKILL_FILES: Record> = { ' "group": "Guides",', ' "pages": [', ' "guides/github-actions",', + ' "guides/symbol-shortcuts",', ' "config/agentic-git-identity",', ' "agents/prompting-tips"', " ]", @@ -3661,6 +3617,52 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "", ].join("\n"), + "references/docs/guides/symbol-shortcuts.mdx": [ + "---", + "title: Symbol Shortcuts", + "description: Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands", + "---", + "", + "Type a LaTeX-style backslash command in the chat input to insert a Unicode symbol. `\\alpha` becomes α, `\\leq` becomes ≤, `\\subseteq` becomes ⊆, and `\\euro` becomes €. This covers math, set theory, logic, arrows, and currency/trading notation without leaving the keyboard.", + "", + "## Usage", + "", + "Type `\\` to open an autocomplete menu that filters as you type. Accept the highlighted entry with **Tab** or **Enter**, navigate with the arrow keys, and dismiss with **Esc**.", + "", + "Greek letters follow the case of the command: `\\alpha` inserts α, while `\\Alpha` inserts Α.", + "", + "## Conversion timing", + "", + "Unambiguous commands convert the moment you finish typing the name — `\\alpha` turns into α without needing Tab.", + "", + "When a name is a prefix of another command, Mux keeps the menu open instead of guessing. For example `\\in` is a prefix of `\\int` and `\\infty`, so typing `\\in` does **not** convert on its own. Accept it explicitly to disambiguate:", + "", + "- Press **Tab** or **Enter** to take the highlighted entry (an exact name match is always preselected, so `\\in` + Tab gives ∈).", + "- Or type a space or punctuation after the name (`\\in ` becomes `∈ `).", + "", + "## Escaping", + "", + "Prefix the command with a second backslash to insert it literally. `\\\\alpha` stays as `\\alpha` and does not convert. Commands are also left untouched inside inline code spans and fenced code blocks.", + "", + "## Available symbols", + "", + "Matching is case-sensitive. Names mirror their LaTeX equivalents where one exists.", + "", + "{/* The full list lives in `src/browser/features/ChatInput/symbolShortcuts.ts` (the `SYMBOLS` table). This is an illustrative subset; keep it in sync when symbols are added or renamed. */}", + "", + "| Category | Examples |", + "| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |", + "| Greek | `\\alpha` α, `\\beta` β, `\\pi` π, `\\Sigma` Σ, `\\Omega` Ω |", + "| Relations & operators | `\\times` ×, `\\div` ÷, `\\pm` ±, `\\neq` ≠, `\\leq` ≤, `\\geq` ≥, `\\approx` ≈, `\\equiv` ≡, `\\infty` ∞, `\\sqrt` √, `\\degree` ° |", + "| Set theory | `\\in` ∈, `\\notin` ∉, `\\subset` ⊂, `\\subseteq` ⊆, `\\cup` ∪, `\\cap` ∩, `\\emptyset` ∅, `\\forall` ∀, `\\exists` ∃, `\\R` ℝ, `\\Z` ℤ, `\\N` ℕ |", + "| Logic | `\\land` ∧, `\\lor` ∨, `\\neg` ¬, `\\implies` ⟹, `\\iff` ⟺, `\\therefore` ∴ |", + "| Arrows | `\\to` →, `\\gets` ←, `\\leftrightarrow` ↔, `\\Rightarrow` ⇒, `\\uparrow` ↑, `\\downarrow` ↓, `\\mapsto` ↦ |", + "| Currency & trading | `\\euro` €, `\\pound` £, `\\yen` ¥, `\\cent` ¢, `\\bitcoin` ₿, `\\permille` ‰, `\\bps` ‱, `\\trademark` ™ |", + "| Big operators | `\\sum` ∑, `\\prod` ∏, `\\int` ∫, `\\oint` ∮, `\\bigcup` ⋃, `\\bigcap` ⋂ |", + "", + "Open the `\\` menu and browse to see the full list.", + "", + ].join("\n"), "references/docs/hooks/environment-variables.mdx": [ "---", "title: Environment Variables", @@ -6449,9 +6451,9 @@ export const BUILTIN_SKILL_FILES: Record> = { " - Notifications (`/config/notifications`) → `references/docs/config/notifications.mdx` — Configure how agents notify you about important events", " - Server Access (`/config/server-access`) → `references/docs/config/server-access.mdx` — Configure authentication and session controls for mux server/browser mode", " - Vim Mode (`/config/vim-mode`) → `references/docs/config/vim-mode.mdx` — Vim-style editing in the Mux chat input", - " - Symbol Shortcuts (`/config/symbol-shortcuts`) → `references/docs/config/symbol-shortcuts.mdx` — Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands", " - **Guides**", " - GitHub Actions (`/guides/github-actions`) → `references/docs/guides/github-actions.mdx` — Automate your workflows with mux run in GitHub Actions", + " - Symbol Shortcuts (`/guides/symbol-shortcuts`) → `references/docs/guides/symbol-shortcuts.mdx` — Insert math and trading symbols in the Mux chat input with LaTeX-style backslash commands", " - Agentic Git Identity (`/config/agentic-git-identity`) → `references/docs/config/agentic-git-identity.mdx` — Configure a separate Git identity for AI-generated commits", " - Prompting Tips (`/agents/prompting-tips`) → `references/docs/agents/prompting-tips.mdx` — Tips and tricks for getting the most out of your AI agents", " - **Integrations**",