From 081786eaa68db4f4688021c61408f548551e7d24 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 20 Apr 2025 00:25:46 +1000 Subject: [PATCH] feat: add /command autocomplete (#317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add interactive slash‑command autocomplete & navigation in chat input Description This PR enhances the chat input component by adding first‑class support for slash commands (/help, /clear, /compact, etc.) with: * **Live filtering:** As soon as the user types leading `/`, a list of matching commands is shown below the prompt. * **Arrow‑key navigation:** Up/Down arrows cycle through suggestions. * **Enter to autocomplete:** Pressing Enter on a partial command will fill it (without submitting) so you can add arguments or simply press Enter again to execute. * **Type‑safe registry:** A new `slash‑commands.ts` file declares all supported commands in one place, along with TypeScript types to prevent drift. * **Validation:** Only registered commands will ever autocomplete or be suggested; unknown single‑word slash inputs still show an “Invalid command” system message. * **Automated tests:** * Unit tests for the command registry and prefix filtering * Existing tests continue passing with no regressions Motivation Slash commands provide a quick, discoverable way to control the agent (clearing history, compacting context, opening overlays, etc.). Before, users had to memorize the exact command or rely on the generic /help list—autocomplete makes them far more accessible and reduces typos. Changes * `src/utils/slash‑commands.ts` – defines `SlashCommand` and exports a flat list of supported commands + descriptions * `terminal‑chat‑input.tsx` * Import and type the command registry * Render filtered suggestions under the prompt when input starts with `/` * Hook into `useInput` to handle Up/Down and Enter for selection & fill * Flag to swallow the first Enter (autocomplete) and only submit on the next * Updated tests in `tests/slash‑commands.test.ts` to cover registry contents and filtering logic * Removed old JS version and fixed stray `@ts‑expect‑error` How to test locally 1. Type `/` in the prompt—you should see matching commands. 2. Use arrows to move the highlight, press Enter to fill, then Enter again to execute. 3. Run the full test suite (`npm test`) to verify no regressions. Notes * Future work could include fuzzy matching, paging long lists, or more visual styling. * This change is purely additive and does not affect non‑slash inputs or existing slash handlers. --------- Co-authored-by: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Co-authored-by: Thibault Sottiaux --- .../components/chat/terminal-chat-input.tsx | 120 +++++++++++++++++- codex-cli/src/utils/slash-commands.ts | 27 ++++ codex-cli/tests/slash-commands.test.ts | 38 ++++++ 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 codex-cli/src/utils/slash-commands.ts create mode 100644 codex-cli/tests/slash-commands.test.ts diff --git a/codex-cli/src/components/chat/terminal-chat-input.tsx b/codex-cli/src/components/chat/terminal-chat-input.tsx index a046a1e7..59265221 100644 --- a/codex-cli/src/components/chat/terminal-chat-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-input.tsx @@ -10,6 +10,7 @@ import { log, isLoggingEnabled } from "../../utils/agent/log.js"; import { loadConfig } from "../../utils/config.js"; import { createInputItem } from "../../utils/input-utils.js"; import { setSessionId } from "../../utils/session.js"; +import { SLASH_COMMANDS, type SlashCommand } from "../../utils/slash-commands"; import { loadCommandHistory, addToHistory, @@ -70,12 +71,16 @@ export default function TerminalChatInput({ // New: current conversation items so we can include them in bug reports items?: Array; }): React.ReactElement { + // Slash command suggestion index + const [selectedSlashSuggestion, setSelectedSlashSuggestion] = + useState(0); const app = useApp(); const [selectedSuggestion, setSelectedSuggestion] = useState(0); const [input, setInput] = useState(""); const [history, setHistory] = useState>([]); const [historyIndex, setHistoryIndex] = useState(null); const [draftInput, setDraftInput] = useState(""); + const [skipNextSubmit, setSkipNextSubmit] = useState(false); // Load command history on component mount useEffect(() => { @@ -86,9 +91,92 @@ export default function TerminalChatInput({ loadHistory(); }, []); + // Reset slash suggestion index when input prefix changes + useEffect(() => { + if (input.trim().startsWith("/")) { + setSelectedSlashSuggestion(0); + } + }, [input]); useInput( (_input, _key) => { + // Slash command navigation: up/down to select, enter to fill + if (!confirmationPrompt && !loading && input.trim().startsWith("/")) { + const prefix = input.trim(); + const matches = SLASH_COMMANDS.filter((cmd: SlashCommand) => + cmd.command.startsWith(prefix), + ); + if (matches.length > 0) { + if (_key.tab) { + // Cycle and fill slash command suggestions on Tab + const len = matches.length; + // Determine new index based on shift state + const nextIdx = _key.shift + ? selectedSlashSuggestion <= 0 + ? len - 1 + : selectedSlashSuggestion - 1 + : selectedSlashSuggestion >= len - 1 + ? 0 + : selectedSlashSuggestion + 1; + setSelectedSlashSuggestion(nextIdx); + // Autocomplete the command in the input + const match = matches[nextIdx]; + if (!match) { + return; + } + const cmd = match.command; + setInput(cmd); + setDraftInput(cmd); + return; + } + if (_key.upArrow) { + setSelectedSlashSuggestion((prev) => + prev <= 0 ? matches.length - 1 : prev - 1, + ); + return; + } + if (_key.downArrow) { + setSelectedSlashSuggestion((prev) => + prev < 0 || prev >= matches.length - 1 ? 0 : prev + 1, + ); + return; + } + if (_key.return) { + // Execute the currently selected slash command + const selIdx = selectedSlashSuggestion; + const cmdObj = matches[selIdx]; + if (cmdObj) { + const cmd = cmdObj.command; + setInput(""); + setDraftInput(""); + setSelectedSlashSuggestion(0); + switch (cmd) { + case "/history": + openOverlay(); + break; + case "/help": + openHelpOverlay(); + break; + case "/compact": + onCompact(); + break; + case "/model": + openModelOverlay(); + break; + case "/approval": + openApprovalOverlay(); + break; + case "/bug": + onSubmit(cmd); + break; + default: + break; + } + } + return; + } + } + } if (!confirmationPrompt && !loading) { if (_key.upArrow) { if (history.length > 0) { @@ -156,6 +244,16 @@ export default function TerminalChatInput({ const onSubmit = useCallback( async (value: string) => { const inputValue = value.trim(); + // If the user only entered a slash, do not send a chat message + if (inputValue === "/") { + setInput(""); + return; + } + // Skip this submit if we just autocompleted a slash command + if (skipNextSubmit) { + setSkipNextSubmit(false); + return; + } if (!inputValue) { return; } @@ -396,8 +494,9 @@ export default function TerminalChatInput({ openApprovalOverlay, openModelOverlay, openHelpOverlay, - history, // Add history to the dependency array + history, onCompact, + skipNextSubmit, items, ], ); @@ -449,6 +548,25 @@ export default function TerminalChatInput({ )} + {/* Slash command autocomplete suggestions */} + {input.trim().startsWith("/") && ( + + {SLASH_COMMANDS.filter((cmd: SlashCommand) => + cmd.command.startsWith(input.trim()), + ).map((cmd: SlashCommand, idx: number) => ( + + + {cmd.command} + {cmd.description} + + + ))} + + )} {isNew && !input ? ( diff --git a/codex-cli/src/utils/slash-commands.ts b/codex-cli/src/utils/slash-commands.ts new file mode 100644 index 00000000..720941a9 --- /dev/null +++ b/codex-cli/src/utils/slash-commands.ts @@ -0,0 +1,27 @@ +// Defines the available slash commands and their descriptions. +// Used for autocompletion in the chat input. +export interface SlashCommand { + command: string; + description: string; +} + +export const SLASH_COMMANDS: Array = [ + { + command: "/clear", + description: "Clear conversation history and free up context", + }, + { + command: "/clearhistory", + description: "Clear command history", + }, + { + command: "/compact", + description: + "Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]", + }, + { command: "/history", description: "Open command history" }, + { command: "/help", description: "Show list of commands" }, + { command: "/model", description: "Open model selection panel" }, + { command: "/approval", description: "Open approval mode selection panel" }, + { command: "/bug", description: "Generate a prefilled GitHub bug report" }, +]; diff --git a/codex-cli/tests/slash-commands.test.ts b/codex-cli/tests/slash-commands.test.ts new file mode 100644 index 00000000..4864aa26 --- /dev/null +++ b/codex-cli/tests/slash-commands.test.ts @@ -0,0 +1,38 @@ +import { test, expect } from "vitest"; +import { SLASH_COMMANDS, type SlashCommand } from "../src/utils/slash-commands"; + +test("SLASH_COMMANDS includes expected commands", () => { + const commands = SLASH_COMMANDS.map((c: SlashCommand) => c.command); + expect(commands).toContain("/clear"); + expect(commands).toContain("/compact"); + expect(commands).toContain("/history"); + expect(commands).toContain("/help"); + expect(commands).toContain("/model"); + expect(commands).toContain("/approval"); + expect(commands).toContain("/clearhistory"); +}); + +test("filters slash commands by prefix", () => { + const prefix = "/c"; + const filtered = SLASH_COMMANDS.filter((c: SlashCommand) => + c.command.startsWith(prefix), + ); + const names = filtered.map((c: SlashCommand) => c.command); + expect(names).toEqual( + expect.arrayContaining(["/clear", "/clearhistory", "/compact"]), + ); + expect(names).not.toEqual( + expect.arrayContaining(["/history", "/help", "/model", "/approval"]), + ); + + const emptyPrefixFiltered = SLASH_COMMANDS.filter((c: SlashCommand) => + c.command.startsWith(""), + ); + const emptyPrefixNames = emptyPrefixFiltered.map( + (c: SlashCommand) => c.command, + ); + expect(emptyPrefixNames).toEqual( + expect.arrayContaining(SLASH_COMMANDS.map((c: SlashCommand) => c.command)), + ); + expect(emptyPrefixNames).toHaveLength(SLASH_COMMANDS.length); +});