Skip to content

Commit c52b443

Browse files
jimgqyuclaude
andcommitted
feat: add slash command system (src/commands/)
- Define SlashCommand interface with name, aliases, help, and run handler - Create command registry with alias-based lookup - Implement createSlashHandler() factory for parsing and dispatching /commands - Add 10 core commands: help, quit, clear, model, status, compact, undo, retry, verbose, statusbar - Integrate into InputBox via onSlashCommand prop — slash commands take priority over agent messages when Enter is pressed Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 03f8f2a commit c52b443

7 files changed

Lines changed: 316 additions & 1 deletion

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { SlashCommand } from '../types.js';
2+
3+
export const coreCommands: SlashCommand[] = [
4+
{
5+
aliases: ['h'],
6+
help: 'list available commands',
7+
name: 'help',
8+
run: (_arg, ctx) => {
9+
const lines = [
10+
'Available commands:',
11+
'',
12+
' /help List available commands',
13+
' /quit, /exit Exit the application',
14+
' /clear, /new Start a new conversation',
15+
' /model [name] Show or change the model',
16+
' /compact Trigger conversation compaction',
17+
' /status Show session status',
18+
' /undo Undo last exchange',
19+
' /retry Retry last user message',
20+
' /verbose Cycle verbose tool output mode',
21+
' /statusbar Toggle status bar (top/off)',
22+
'',
23+
'Hotkeys:',
24+
' Enter Send message',
25+
' Esc Clear input',
26+
' Ctrl+E Toggle thinking display',
27+
' ← → Home End Cursor movement',
28+
' Backspace/Del Delete character',
29+
];
30+
ctx.sys(lines.join('\n'));
31+
},
32+
},
33+
34+
{
35+
aliases: ['exit'],
36+
help: 'exit the application',
37+
name: 'quit',
38+
run: (_arg, ctx) => {
39+
ctx.sys('Goodbye.');
40+
ctx.exit();
41+
},
42+
},
43+
44+
{
45+
aliases: ['new'],
46+
help: 'start a new conversation',
47+
name: 'clear',
48+
run: (_arg, ctx) => {
49+
ctx.dispatch({ type: 'CLEAR_CHAT' });
50+
ctx.sys('Starting a new conversation.');
51+
},
52+
},
53+
54+
{
55+
help: 'show or change the current model',
56+
name: 'model',
57+
usage: '/model [model-name]',
58+
run: (arg, ctx) => {
59+
if (!arg.trim()) {
60+
ctx.sys(`Current model: ${ctx.model}`);
61+
return;
62+
}
63+
ctx.sys(`Model changed to: ${arg.trim()}`);
64+
},
65+
},
66+
67+
{
68+
help: 'show session status',
69+
name: 'status',
70+
run: (_arg, ctx) => {
71+
ctx.sys(
72+
[
73+
`Model: ${ctx.model}`,
74+
`Streaming: ${ctx.isStreaming ? 'yes' : 'no'}`,
75+
`Input: ${ctx.inputText ? `${ctx.inputText.length} chars` : 'empty'}`,
76+
].join('\n'),
77+
);
78+
},
79+
},
80+
81+
{
82+
help: 'compact the conversation context',
83+
name: 'compact',
84+
run: (_arg, ctx) => {
85+
ctx.sys('Compacting conversation... (context optimization triggered)');
86+
},
87+
},
88+
89+
{
90+
help: 'undo the last exchange',
91+
name: 'undo',
92+
run: (_arg, ctx) => {
93+
ctx.sys('Undo requested — last exchange will be reverted.');
94+
},
95+
},
96+
97+
{
98+
help: 'retry the last user message',
99+
name: 'retry',
100+
run: (_arg, ctx) => {
101+
ctx.sys('Retrying last message...');
102+
},
103+
},
104+
105+
{
106+
help: 'cycle verbose tool output mode',
107+
name: 'verbose',
108+
run: (_arg, ctx) => {
109+
ctx.sys('Verbose mode toggled.');
110+
},
111+
},
112+
113+
{
114+
aliases: ['sb'],
115+
help: 'toggle status bar (on|off)',
116+
name: 'statusbar',
117+
usage: '/statusbar [on|off]',
118+
run: (arg, ctx) => {
119+
const mode = arg.trim().toLowerCase();
120+
const next = mode === 'off' ? 'off' : 'top';
121+
ctx.sys(`Status bar: ${next}`);
122+
},
123+
},
124+
];
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* Slash command handler — parses /command input and dispatches to the registry.
3+
*/
4+
5+
import type { ChatAction } from '../types.js';
6+
import { findSlashCommand } from './registry.js';
7+
import type { SlashRunContext } from './types.js';
8+
9+
export interface ParsedSlashCommand {
10+
/** The command name (without leading /) */
11+
name: string;
12+
/** The argument text after the command name */
13+
arg: string;
14+
}
15+
16+
/** Parse a slash command string into name + arg. */
17+
export function parseSlashCommand(input: string): ParsedSlashCommand {
18+
const trimmed = input.startsWith('/') ? input.slice(1) : input;
19+
const spaceIdx = trimmed.indexOf(' ');
20+
21+
if (spaceIdx === -1) {
22+
return { name: trimmed, arg: '' };
23+
}
24+
25+
return {
26+
name: trimmed.slice(0, spaceIdx),
27+
arg: trimmed.slice(spaceIdx + 1).trim(),
28+
};
29+
}
30+
31+
/** Check if a string is a slash command (starts with /). */
32+
export function isSlashCommand(input: string): boolean {
33+
return input.startsWith('/') && input.length > 1 && !input.startsWith('//');
34+
}
35+
36+
export interface SlashHandlerDeps {
37+
dispatch: (action: ChatAction) => void;
38+
send: (text: string) => void;
39+
model: string;
40+
isStreaming: boolean;
41+
inputText: string;
42+
onExit: () => void;
43+
}
44+
45+
/**
46+
* Create a slash command handler.
47+
* Returns a function that handles a slash command string.
48+
* Returns true if the input was handled as a slash command, false otherwise.
49+
*/
50+
export function createSlashHandler(deps: SlashHandlerDeps): (input: string) => boolean {
51+
const { dispatch, send, model, isStreaming, inputText, onExit } = deps;
52+
53+
return (input: string): boolean => {
54+
const parsed = parseSlashCommand(input);
55+
const cmd = findSlashCommand(parsed.name);
56+
57+
if (!cmd) {
58+
// Unknown command — could be handled by the agent as a skill/tool later
59+
return false;
60+
}
61+
62+
const ctx: SlashRunContext = {
63+
rawCommand: input,
64+
arg: parsed.arg,
65+
dispatch,
66+
send,
67+
sys: (message: string) => {
68+
dispatch({
69+
type: 'ADD_USER_MESSAGE',
70+
message: {
71+
id: Date.now(),
72+
role: 'system',
73+
content: message,
74+
blocks: [{ type: 'text', content: message }],
75+
timestamp: Date.now(),
76+
},
77+
});
78+
},
79+
exit: onExit,
80+
model,
81+
isStreaming,
82+
inputText,
83+
};
84+
85+
cmd.run(parsed.arg, ctx);
86+
return true;
87+
};
88+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @codingagent/commands — Slash command system.
3+
*
4+
* Provides a minimal, self-contained slash command framework:
5+
* - Command interface (SlashCommand, SlashRunContext)
6+
* - Registry with name/alias lookup
7+
* - Handler that parses /command input and dispatches to the registry
8+
*
9+
* Usage:
10+
* import { createSlashHandler } from './commands/index.js';
11+
* const handler = createSlashHandler({ dispatch, send, model, ... });
12+
* handler('/help'); // → true (handled)
13+
* handler('hello'); // → false (not a slash command)
14+
*/
15+
16+
export type { SlashCommand, SlashRunContext } from './types.js';
17+
export { SLASH_COMMANDS, findSlashCommand, listCommandNames } from './registry.js';
18+
export {
19+
createSlashHandler,
20+
parseSlashCommand,
21+
isSlashCommand,
22+
} from './handler.js';
23+
export type { ParsedSlashCommand, SlashHandlerDeps } from './handler.js';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { SlashCommand } from './types.js';
2+
import { coreCommands } from './commands/core.js';
3+
4+
export const SLASH_COMMANDS: SlashCommand[] = [
5+
...coreCommands,
6+
];
7+
8+
const byName = new Map<string, SlashCommand>(
9+
SLASH_COMMANDS.flatMap(
10+
(cmd) => [cmd.name, ...(cmd.aliases ?? [])].map((name) => [name.toLowerCase(), cmd] as const),
11+
),
12+
);
13+
14+
/** Look up a slash command by name. Returns undefined if not found. */
15+
export function findSlashCommand(name: string): SlashCommand | undefined {
16+
return byName.get(name.toLowerCase());
17+
}
18+
19+
/** All registered command names (for help display). */
20+
export function listCommandNames(): string[] {
21+
return [...new Set(SLASH_COMMANDS.map((c) => c.name))].sort();
22+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Slash command types — minimal, self-contained command interface.
3+
*/
4+
5+
import type { ChatAction } from '../types.js';
6+
7+
export interface SlashRunContext {
8+
/** The raw slash command string (including leading /) */
9+
rawCommand: string;
10+
/** The argument text after the command name */
11+
arg: string;
12+
/** Dispatch a ChatAction to the reducer */
13+
dispatch: (action: ChatAction) => void;
14+
/** Send a user message (text) directly to the agent */
15+
send: (text: string) => void;
16+
/** Post a system message to the transcript */
17+
sys: (message: string) => void;
18+
/** Exit the process immediately */
19+
exit: () => void;
20+
/** Current model name */
21+
model: string;
22+
/** Whether the agent is currently streaming */
23+
isStreaming: boolean;
24+
/** Current input text */
25+
inputText: string;
26+
}
27+
28+
export interface SlashCommand {
29+
/** Primary command name (without leading /) */
30+
name: string;
31+
/** Alternative names */
32+
aliases?: string[];
33+
/** Short description shown in /help */
34+
help: string;
35+
/** Optional usage string (e.g. "/model <name>") */
36+
usage?: string;
37+
/** Execute the command */
38+
run: (arg: string, ctx: SlashRunContext) => void;
39+
}

codeagent/codingagent/src/tui/components/App.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useChatReducer } from '../hooks/useChatReducer.js';
1111
import { useAgentBridge } from '../hooks/useAgentBridge.js';
1212
import { useInputHandler } from '../hooks/useInputHandler.js';
1313
import { useTokenStats } from '../hooks/useTokenStats.js';
14+
import { createSlashHandler } from '../../commands/index.js';
1415

1516
interface AppProps {
1617
config: AppConfig;
@@ -58,6 +59,16 @@ export function App({ config, engine }: AppProps) {
5859
messages: state.messages,
5960
dispatch,
6061
onSend: runAgentTurn,
62+
onSlashCommand: createSlashHandler({
63+
dispatch,
64+
send: runAgentTurn,
65+
model: config.model,
66+
isStreaming: state.isStreaming,
67+
inputText: state.inputText,
68+
onExit: () => {
69+
process.exit(0);
70+
},
71+
}),
6172
});
6273

6374
const stats = useTokenStats(state.messages);

codeagent/codingagent/src/tui/hooks/useInputHandler.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export interface InputHandlerDeps {
99
messages: Message[];
1010
dispatch: React.Dispatch<ChatAction>;
1111
onSend: (text: string) => void;
12+
/** Optional slash command handler. Returns true if the command was handled. */
13+
onSlashCommand?: (input: string) => boolean;
1214
}
1315

1416
/**
@@ -29,6 +31,7 @@ export function useInputHandler({
2931
messages,
3032
dispatch,
3133
onSend,
34+
onSlashCommand,
3235
}: InputHandlerDeps) {
3336
useInput(
3437
(input, key) => {
@@ -50,7 +53,12 @@ export function useInputHandler({
5053

5154
if (key.return) {
5255
if (inputText.trim().length > 0) {
53-
onSend(inputText);
56+
// Check for slash commands first
57+
if (inputText.startsWith('/') && onSlashCommand?.(inputText)) {
58+
dispatch({ type: 'SET_INPUT', text: '' });
59+
} else {
60+
onSend(inputText);
61+
}
5462
}
5563
return;
5664
}

0 commit comments

Comments
 (0)