Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"group": "Guides",
"pages": [
"guides/github-actions",
"guides/symbol-shortcuts",
"config/agentic-git-identity",
"agents/prompting-tips"
]
Expand Down
43 changes: 43 additions & 0 deletions docs/guides/symbol-shortcuts.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
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.
124 changes: 115 additions & 9 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ 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 {
convertSymbolCommandAtCursor,
convertTerminatedSymbolCommand,
findSymbolCommandAtCursor,
getSymbolSuggestions,
} from "@/browser/features/ChatInput/symbolShortcuts";
import {
getInlineSkillInsertionTrailingText,
getInlineSkillSuggestions,
Expand Down Expand Up @@ -373,6 +379,10 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const [showCommandSuggestions, setShowCommandSuggestions] = useState(false);

const [commandSuggestions, setCommandSuggestions] = useState<SlashSuggestion[]>([]);
// Backslash symbol-shortcut autocomplete (e.g. typing "\alpha" or "\leq").
const [showSymbolSuggestions, setShowSymbolSuggestions] = useState(false);
const [symbolSuggestions, setSymbolSuggestions] = useState<SlashSuggestion[]>([]);
const lastSymbolQueryRef = useRef<string>("");
const [agentSkillDescriptors, setAgentSkillDescriptors] = useState<AgentSkillDescriptor[]>([]);
const [toast, setToast] = useState<Toast | null>(null);
// State for destructive command confirmation modal (currently only /clear).
Expand Down Expand Up @@ -571,6 +581,27 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
}
}

// 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 =
convertSymbolCommandAtCursor(next, caret) ?? convertTerminatedSymbolCommand(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]
Expand Down Expand Up @@ -620,6 +651,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const atMentionListId = useId();
const skillListId = useId();
const commandListId = useId();
const symbolListId = useId();
const telemetry = useTelemetry();
const [vimEnabled, setVimEnabled] = usePersistedState<boolean>(VIM_ENABLED_KEY, false, {
listener: true,
Expand Down Expand Up @@ -1446,6 +1478,29 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
setShowCommandSuggestions(suggestions.length > 0);
}, [input, agentSkillDescriptors, variant, workspaceHeartbeatsExperimentEnabled]);

// 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.
setSymbolSuggestions(clearSuggestions);
setShowSymbolSuggestions(false);
return;
}

const cursor = Math.min(inputRef.current?.selectionStart ?? input.length, input.length);
const match = findSymbolCommandAtCursor(input, cursor);
if (!match) {
setSymbolSuggestions(clearSuggestions);
setShowSymbolSuggestions(false);
return;
}

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.
// Show only when suggestions are hidden and the input is exactly "/command " with no args yet.
const commandGhostHint = getCommandGhostHint(input, showCommandSuggestions, {
Expand Down Expand Up @@ -2145,6 +2200,38 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
[setInput]
);

const handleSymbolSelect = useCallback(
(suggestion: SlashSuggestion) => {
const cursor = Math.min(inputRef.current?.selectionStart ?? input.length, input.length);
const match = findSymbolCommandAtCursor(input, cursor);
if (!match) {
return;
}

// 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);
setSymbolSuggestions(clearSuggestions);
setShowSymbolSuggestions(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;
Expand Down Expand Up @@ -2668,13 +2755,15 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const hasCommandSuggestionMenu = showCommandSuggestions && commandSuggestions.length > 0;
const hasAtMentionSuggestionMenu = showAtMentionSuggestions && atMentionSuggestions.length > 0;
const hasSkillSuggestionMenu = showSkillSuggestions && skillSuggestions.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, and $skill 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))
(hasSkillSuggestionMenu && FILE_SUGGESTION_KEYS.includes(e.key)) ||
(hasSymbolSuggestionMenu && FILE_SUGGESTION_KEYS.includes(e.key))
) {
return; // Let CommandSuggestions handle it
}
Expand Down Expand Up @@ -2856,6 +2945,18 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
anchorRef={variant === "creation" ? inputRef : undefined}
/>

{/* Symbol shortcut suggestions (\alpha -> α, \leq -> ≤, \euro -> €) */}
<CommandSuggestions
suggestions={symbolSuggestions}
onSelectSuggestion={handleSymbolSelect}
onDismiss={() => setShowSymbolSuggestions(false)}
isVisible={showSymbolSuggestions}
ariaLabel="Symbol shortcuts"
listId={symbolListId}
anchorRef={variant === "creation" ? inputRef : undefined}
highlightQuery={lastSymbolQueryRef.current}
/>

<div className="relative flex items-end pb-1" data-component="ChatInputControls">
{/* Recording/transcribing overlay - replaces textarea when active */}
{voiceInput.state !== "idle" ? (
Expand Down Expand Up @@ -2889,9 +2990,11 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
? FILE_SUGGESTION_KEYS
: showSkillSuggestions
? FILE_SUGGESTION_KEYS
: showCommandSuggestions
? COMMAND_SUGGESTION_KEYS
: undefined
: showSymbolSuggestions
? FILE_SUGGESTION_KEYS
: showCommandSuggestions
? COMMAND_SUGGESTION_KEYS
: undefined
}
placeholder={placeholder}
disabled={!editingMessageForUi && (disabled || sendInFlightBlocksInput)}
Expand All @@ -2902,14 +3005,17 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
? atMentionListId
: showSkillSuggestions && skillSuggestions.length > 0
? skillListId
: showCommandSuggestions && commandSuggestions.length > 0
? commandListId
: undefined
: showSymbolSuggestions && symbolSuggestions.length > 0
? symbolListId
: showCommandSuggestions && commandSuggestions.length > 0
? commandListId
: undefined
}
aria-expanded={
(showCommandSuggestions && commandSuggestions.length > 0) ||
(showAtMentionSuggestions && atMentionSuggestions.length > 0) ||
(showSkillSuggestions && skillSuggestions.length > 0)
(showSkillSuggestions && skillSuggestions.length > 0) ||
(showSymbolSuggestions && symbolSuggestions.length > 0)
}
className={variant === "creation" ? "min-h-28" : "min-h-16"}
/>
Expand Down
Loading
Loading