feat: add /command autocomplete (#317)
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 <tibo@openai.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { log, isLoggingEnabled } from "../../utils/agent/log.js";
|
|||||||
import { loadConfig } from "../../utils/config.js";
|
import { loadConfig } from "../../utils/config.js";
|
||||||
import { createInputItem } from "../../utils/input-utils.js";
|
import { createInputItem } from "../../utils/input-utils.js";
|
||||||
import { setSessionId } from "../../utils/session.js";
|
import { setSessionId } from "../../utils/session.js";
|
||||||
|
import { SLASH_COMMANDS, type SlashCommand } from "../../utils/slash-commands";
|
||||||
import {
|
import {
|
||||||
loadCommandHistory,
|
loadCommandHistory,
|
||||||
addToHistory,
|
addToHistory,
|
||||||
@@ -70,12 +71,16 @@ export default function TerminalChatInput({
|
|||||||
// New: current conversation items so we can include them in bug reports
|
// New: current conversation items so we can include them in bug reports
|
||||||
items?: Array<ResponseItem>;
|
items?: Array<ResponseItem>;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
|
// Slash command suggestion index
|
||||||
|
const [selectedSlashSuggestion, setSelectedSlashSuggestion] =
|
||||||
|
useState<number>(0);
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
|
const [selectedSuggestion, setSelectedSuggestion] = useState<number>(0);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [history, setHistory] = useState<Array<HistoryEntry>>([]);
|
const [history, setHistory] = useState<Array<HistoryEntry>>([]);
|
||||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||||
const [draftInput, setDraftInput] = useState<string>("");
|
const [draftInput, setDraftInput] = useState<string>("");
|
||||||
|
const [skipNextSubmit, setSkipNextSubmit] = useState<boolean>(false);
|
||||||
|
|
||||||
// Load command history on component mount
|
// Load command history on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,9 +91,92 @@ export default function TerminalChatInput({
|
|||||||
|
|
||||||
loadHistory();
|
loadHistory();
|
||||||
}, []);
|
}, []);
|
||||||
|
// Reset slash suggestion index when input prefix changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (input.trim().startsWith("/")) {
|
||||||
|
setSelectedSlashSuggestion(0);
|
||||||
|
}
|
||||||
|
}, [input]);
|
||||||
|
|
||||||
useInput(
|
useInput(
|
||||||
(_input, _key) => {
|
(_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 (!confirmationPrompt && !loading) {
|
||||||
if (_key.upArrow) {
|
if (_key.upArrow) {
|
||||||
if (history.length > 0) {
|
if (history.length > 0) {
|
||||||
@@ -156,6 +244,16 @@ export default function TerminalChatInput({
|
|||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
async (value: string) => {
|
async (value: string) => {
|
||||||
const inputValue = value.trim();
|
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) {
|
if (!inputValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -396,8 +494,9 @@ export default function TerminalChatInput({
|
|||||||
openApprovalOverlay,
|
openApprovalOverlay,
|
||||||
openModelOverlay,
|
openModelOverlay,
|
||||||
openHelpOverlay,
|
openHelpOverlay,
|
||||||
history, // Add history to the dependency array
|
history,
|
||||||
onCompact,
|
onCompact,
|
||||||
|
skipNextSubmit,
|
||||||
items,
|
items,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -449,6 +548,25 @@ export default function TerminalChatInput({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{/* Slash command autocomplete suggestions */}
|
||||||
|
{input.trim().startsWith("/") && (
|
||||||
|
<Box flexDirection="column" paddingX={2} marginBottom={1}>
|
||||||
|
{SLASH_COMMANDS.filter((cmd: SlashCommand) =>
|
||||||
|
cmd.command.startsWith(input.trim()),
|
||||||
|
).map((cmd: SlashCommand, idx: number) => (
|
||||||
|
<Box key={cmd.command}>
|
||||||
|
<Text
|
||||||
|
backgroundColor={
|
||||||
|
idx === selectedSlashSuggestion ? "blackBright" : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text color="blueBright">{cmd.command}</Text>
|
||||||
|
<Text> {cmd.description}</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box paddingX={2} marginBottom={1}>
|
<Box paddingX={2} marginBottom={1}>
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
{isNew && !input ? (
|
{isNew && !input ? (
|
||||||
|
|||||||
27
codex-cli/src/utils/slash-commands.ts
Normal file
27
codex-cli/src/utils/slash-commands.ts
Normal file
@@ -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<SlashCommand> = [
|
||||||
|
{
|
||||||
|
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" },
|
||||||
|
];
|
||||||
38
codex-cli/tests/slash-commands.test.ts
Normal file
38
codex-cli/tests/slash-commands.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user