Skip to content

Commit d9ac4b8

Browse files
committed
feat(cli): improve slash command menu - require explicit arg handling, execute on selection, show aliases
1 parent fe7cf86 commit d9ac4b8

File tree

7 files changed

+469
-81
lines changed

7 files changed

+469
-81
lines changed

cli/src/chat.tsx

Lines changed: 59 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -700,31 +700,7 @@ export const Chat = ({
700700
})
701701
})
702702

703-
// Click handlers for suggestion menu items
704-
const handleSlashItemClick = useCallback(
705-
(index: number) => {
706-
const selected = slashMatches[index]
707-
if (!selected || slashContext.startIndex < 0) return
708-
const before = inputValue.slice(0, slashContext.startIndex)
709-
const after = inputValue.slice(
710-
slashContext.startIndex + 1 + slashContext.query.length,
711-
)
712-
const replacement = `/${selected.id} `
713-
setInputValue({
714-
text: before + replacement + after,
715-
cursorPosition: before.length + replacement.length,
716-
lastEditDueToNav: false,
717-
})
718-
setSlashSelectedIndex(0)
719-
},
720-
[
721-
slashMatches,
722-
slashContext,
723-
inputValue,
724-
setInputValue,
725-
setSlashSelectedIndex,
726-
],
727-
)
703+
// handleSlashItemClick is defined later after openPublishMode is available
728704

729705
const handleMentionItemClick = useCallback(
730706
(index: number) => {
@@ -803,6 +779,26 @@ export const Chat = ({
803779

804780
const publishMutation = usePublishMutation()
805781

782+
// Click handler for slash menu items (defined here after openPublishMode is available)
783+
const handleSlashItemClick = useCallback(
784+
async (index: number) => {
785+
const selected = slashMatches[index]
786+
if (!selected) return
787+
// Execute the selected slash command immediately
788+
const commandString = `/${selected.id}`
789+
setSlashSelectedIndex(0)
790+
const result = await onSubmitPrompt(commandString, agentMode)
791+
if (result?.openFeedbackMode) {
792+
saveCurrentInput('', 0)
793+
openFeedbackForMessage(null)
794+
}
795+
if (result?.openPublishMode) {
796+
openPublishMode()
797+
}
798+
},
799+
[slashMatches, setSlashSelectedIndex, onSubmitPrompt, agentMode, saveCurrentInput, openFeedbackForMessage, openPublishMode],
800+
)
801+
806802
const inputValueRef = useRef(inputValue)
807803
const cursorPositionRef = useRef(cursorPosition)
808804
useEffect(() => {
@@ -1032,26 +1028,48 @@ export const Chat = ({
10321028
},
10331029
onSlashMenuDown: () => setSlashSelectedIndex((prev) => prev + 1),
10341030
onSlashMenuUp: () => setSlashSelectedIndex((prev) => prev - 1),
1035-
onSlashMenuTab: () =>
1036-
setSlashSelectedIndex((prev) => (prev + 1) % slashMatches.length),
1031+
onSlashMenuTab: async () => {
1032+
// If only one match, execute it immediately (unless the command opts out)
1033+
if (slashMatches.length === 1) {
1034+
const selected = slashMatches[0]
1035+
if (!selected) return
1036+
// Don't auto-execute commands that opt out (e.g. /exit)
1037+
if (selected.noTabAutoExecute) {
1038+
return
1039+
}
1040+
const commandString = `/${selected.id}`
1041+
setSlashSelectedIndex(0)
1042+
const result = await onSubmitPrompt(commandString, agentMode)
1043+
if (result?.openFeedbackMode) {
1044+
saveCurrentInput('', 0)
1045+
openFeedbackForMessage(null)
1046+
}
1047+
if (result?.openPublishMode) {
1048+
openPublishMode()
1049+
}
1050+
return
1051+
}
1052+
// Otherwise cycle through options
1053+
setSlashSelectedIndex((prev) => (prev + 1) % slashMatches.length)
1054+
},
10371055
onSlashMenuShiftTab: () =>
10381056
setSlashSelectedIndex(
10391057
(prev) => (slashMatches.length + prev - 1) % slashMatches.length,
10401058
),
1041-
onSlashMenuSelect: () => {
1059+
onSlashMenuSelect: async () => {
10421060
const selected = slashMatches[slashSelectedIndex] || slashMatches[0]
1043-
if (!selected || slashContext.startIndex < 0) return
1044-
const before = inputValue.slice(0, slashContext.startIndex)
1045-
const after = inputValue.slice(
1046-
slashContext.startIndex + 1 + slashContext.query.length,
1047-
)
1048-
const replacement = `/${selected.id} `
1049-
setInputValue({
1050-
text: before + replacement + after,
1051-
cursorPosition: before.length + replacement.length,
1052-
lastEditDueToNav: false,
1053-
})
1061+
if (!selected) return
1062+
// Execute the selected slash command immediately
1063+
const commandString = `/${selected.id}`
10541064
setSlashSelectedIndex(0)
1065+
const result = await onSubmitPrompt(commandString, agentMode)
1066+
if (result?.openFeedbackMode) {
1067+
saveCurrentInput('', 0)
1068+
openFeedbackForMessage(null)
1069+
}
1070+
if (result?.openPublishMode) {
1071+
openPublishMode()
1072+
}
10551073
},
10561074
onMentionMenuDown: () => setAgentSelectedIndex((prev) => prev + 1),
10571075
onMentionMenuUp: () => setAgentSelectedIndex((prev) => prev - 1),
@@ -1169,8 +1187,8 @@ export const Chat = ({
11691187
setSlashSelectedIndex,
11701188
slashMatches,
11711189
slashSelectedIndex,
1172-
slashContext,
1173-
inputValue,
1190+
onSubmitPrompt,
1191+
agentMode,
11741192
setAgentSelectedIndex,
11751193
agentMatches,
11761194
fileMatches,
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { describe, test, expect, mock, beforeEach } from 'bun:test'
2+
3+
import {
4+
COMMAND_REGISTRY,
5+
findCommand,
6+
defineCommand,
7+
defineCommandWithArgs,
8+
} from '../command-registry'
9+
10+
import type { RouterParams } from '../command-registry'
11+
12+
/**
13+
* Tests for the command factory pattern.
14+
*
15+
* The factory pattern ensures commands handle arguments correctly:
16+
* - defineCommand: creates commands that reject arguments automatically
17+
* - defineCommandWithArgs: creates commands that receive and handle arguments
18+
*/
19+
describe('command factory pattern', () => {
20+
const createMockParams = (
21+
overrides: Partial<RouterParams> = {},
22+
): RouterParams => ({
23+
abortControllerRef: { current: null },
24+
agentMode: 'DEFAULT',
25+
inputRef: { current: null },
26+
inputValue: '/test',
27+
isChainInProgressRef: { current: false },
28+
isStreaming: false,
29+
logoutMutation: {} as any,
30+
streamMessageIdRef: { current: null },
31+
addToQueue: mock(() => {}),
32+
clearMessages: mock(() => {}),
33+
saveToHistory: mock(() => {}),
34+
scrollToLatest: mock(() => {}),
35+
sendMessage: mock(async () => {}),
36+
setCanProcessQueue: mock(() => {}),
37+
setInputFocused: mock(() => {}),
38+
setInputValue: mock(() => {}),
39+
setIsAuthenticated: mock(() => {}),
40+
setMessages: mock(() => {}),
41+
setUser: mock(() => {}),
42+
stopStreaming: mock(() => {}),
43+
...overrides,
44+
})
45+
46+
describe('defineCommand (no args)', () => {
47+
test('creates a command that calls handler when no args provided', () => {
48+
const handler = mock(() => {})
49+
const cmd = defineCommand({
50+
name: 'test',
51+
handler,
52+
})
53+
54+
const params = createMockParams()
55+
cmd.handler(params, '')
56+
57+
expect(handler).toHaveBeenCalledWith(params)
58+
})
59+
60+
test('creates a command that rejects args with error message', () => {
61+
const handler = mock(() => {})
62+
const setMessages = mock(() => {})
63+
const cmd = defineCommand({
64+
name: 'test',
65+
handler,
66+
})
67+
68+
const params = createMockParams({ setMessages })
69+
cmd.handler(params, 'unexpected args')
70+
71+
// Handler should NOT be called
72+
expect(handler).not.toHaveBeenCalled()
73+
// Error message should be shown
74+
expect(setMessages).toHaveBeenCalled()
75+
})
76+
77+
test('sets aliases correctly', () => {
78+
const cmd = defineCommand({
79+
name: 'test',
80+
aliases: ['t', 'tst'],
81+
handler: () => {},
82+
})
83+
84+
expect(cmd.aliases).toEqual(['t', 'tst'])
85+
})
86+
87+
test('defaults to empty aliases when not provided', () => {
88+
const cmd = defineCommand({
89+
name: 'test',
90+
handler: () => {},
91+
})
92+
93+
expect(cmd.aliases).toEqual([])
94+
})
95+
96+
test('truncates long args in error message', () => {
97+
const handler = mock(() => {})
98+
let capturedMessage = ''
99+
const setMessages = mock((fn: any) => {
100+
const messages = fn([])
101+
capturedMessage = messages[1]?.content || ''
102+
})
103+
const cmd = defineCommand({
104+
name: 'test',
105+
handler,
106+
})
107+
108+
const longArgs = 'a'.repeat(100)
109+
const params = createMockParams({ setMessages })
110+
cmd.handler(params, longArgs)
111+
112+
expect(capturedMessage).toContain('...')
113+
expect(capturedMessage.length).toBeLessThan(longArgs.length + 100)
114+
})
115+
})
116+
117+
describe('defineCommandWithArgs', () => {
118+
test('creates a command that passes args to handler', () => {
119+
const handler = mock(() => {})
120+
const cmd = defineCommandWithArgs({
121+
name: 'test',
122+
handler,
123+
})
124+
125+
const params = createMockParams()
126+
cmd.handler(params, 'some args')
127+
128+
expect(handler).toHaveBeenCalledWith(params, 'some args')
129+
})
130+
131+
test('creates a command that passes empty args to handler', () => {
132+
const handler = mock(() => {})
133+
const cmd = defineCommandWithArgs({
134+
name: 'test',
135+
handler,
136+
})
137+
138+
const params = createMockParams()
139+
cmd.handler(params, '')
140+
141+
expect(handler).toHaveBeenCalledWith(params, '')
142+
})
143+
144+
test('sets aliases correctly', () => {
145+
const cmd = defineCommandWithArgs({
146+
name: 'test',
147+
aliases: ['t', 'tst'],
148+
handler: () => {},
149+
})
150+
151+
expect(cmd.aliases).toEqual(['t', 'tst'])
152+
})
153+
})
154+
155+
describe('COMMAND_REGISTRY commands', () => {
156+
// Derive command categories from the acceptsArgs flag (set by factories)
157+
const noArgsCommands = COMMAND_REGISTRY.filter((cmd) => !cmd.acceptsArgs)
158+
const withArgsCommands = COMMAND_REGISTRY.filter((cmd) => cmd.acceptsArgs)
159+
160+
test('there are commands that reject args', () => {
161+
expect(noArgsCommands.length).toBeGreaterThan(0)
162+
})
163+
164+
test('there are commands that accept args', () => {
165+
expect(withArgsCommands.length).toBeGreaterThan(0)
166+
})
167+
168+
for (const cmd of noArgsCommands) {
169+
test(`/${cmd.name} rejects arguments (acceptsArgs=false)`, () => {
170+
const setMessages = mock(() => {})
171+
const params = createMockParams({
172+
inputValue: `/${cmd.name} extra args`,
173+
setMessages,
174+
})
175+
176+
cmd.handler(params, 'extra args')
177+
178+
// Should show error message
179+
expect(setMessages).toHaveBeenCalled()
180+
})
181+
}
182+
183+
for (const cmd of withArgsCommands) {
184+
test(`/${cmd.name} accepts arguments (acceptsArgs=true)`, () => {
185+
// Verify the command has acceptsArgs=true
186+
expect(cmd.acceptsArgs).toBe(true)
187+
// The actual behavior is tested in specific command tests
188+
expect(typeof cmd.handler).toBe('function')
189+
})
190+
}
191+
})
192+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, test, expect } from 'bun:test'
2+
3+
import { COMMAND_REGISTRY } from '../command-registry'
4+
import { SLASH_COMMANDS } from '../../data/slash-commands'
5+
6+
/**
7+
* These tests ensure that SLASH_COMMANDS (UI metadata) and COMMAND_REGISTRY (execution)
8+
* stay in sync. They serve different purposes but should reference the same commands.
9+
*/
10+
describe('SLASH_COMMANDS and COMMAND_REGISTRY sync', () => {
11+
// Commands that are intentionally hidden from the autocomplete menu
12+
// These exist in COMMAND_REGISTRY but not in SLASH_COMMANDS
13+
const HIDDEN_COMMANDS = ['login']
14+
15+
test('every SLASH_COMMAND has a corresponding COMMAND_REGISTRY entry', () => {
16+
for (const slashCmd of SLASH_COMMANDS) {
17+
const registryCmd = COMMAND_REGISTRY.find((c) => c.name === slashCmd.id)
18+
expect(
19+
registryCmd,
20+
`SLASH_COMMAND "${slashCmd.id}" has no matching COMMAND_REGISTRY entry`,
21+
).toBeDefined()
22+
}
23+
})
24+
25+
test('every non-hidden COMMAND_REGISTRY entry has a corresponding SLASH_COMMAND', () => {
26+
for (const registryCmd of COMMAND_REGISTRY) {
27+
if (HIDDEN_COMMANDS.includes(registryCmd.name)) continue
28+
29+
const slashCmd = SLASH_COMMANDS.find((s) => s.id === registryCmd.name)
30+
expect(
31+
slashCmd,
32+
`COMMAND_REGISTRY "${registryCmd.name}" has no matching SLASH_COMMAND entry. ` +
33+
`If this command should be hidden from autocomplete, add it to HIDDEN_COMMANDS.`,
34+
).toBeDefined()
35+
}
36+
})
37+
38+
test('aliases match between SLASH_COMMANDS and COMMAND_REGISTRY', () => {
39+
for (const slashCmd of SLASH_COMMANDS) {
40+
const registryCmd = COMMAND_REGISTRY.find((c) => c.name === slashCmd.id)
41+
if (!registryCmd) continue
42+
43+
const slashAliases = slashCmd.aliases ?? []
44+
const registryAliases = registryCmd.aliases
45+
46+
expect(
47+
slashAliases.sort(),
48+
`Aliases mismatch for "${slashCmd.id}"`,
49+
).toEqual(registryAliases.sort())
50+
}
51+
})
52+
53+
test('exit command exists with its aliases and noTabAutoExecute flag', () => {
54+
const exitCmd = SLASH_COMMANDS.find((cmd) => cmd.id === 'exit')
55+
expect(exitCmd).toBeDefined()
56+
expect(exitCmd?.aliases).toContain('quit')
57+
expect(exitCmd?.aliases).toContain('q')
58+
// /exit opts out of Tab auto-execute (too dangerous)
59+
expect(exitCmd?.noTabAutoExecute).toBe(true)
60+
})
61+
})

0 commit comments

Comments
 (0)