Clear transient slash command drafts

Clear slash-only Assistant command drafts when autocomplete is dismissed with Escape or an outside click, so closing the popup cannot leave an executable command token behind.
This commit is contained in:
rcourtman
2026-06-07 02:13:46 +01:00
parent 789358fa37
commit 746914cc2c
4 changed files with 80 additions and 7 deletions
@@ -214,6 +214,16 @@ runtime cost control, and shared AI transport surfaces.
such as New session, session history, collapse/close, autonomous-warning
recovery, and the control-mode selector instead of depending on title-only
icon controls or ambiguous short labels.
Slash autocomplete close semantics are owned by that same prompt command
surface. The referenced OpenCode source at fetched `origin/dev` commit
`4519a1da329c1a4fc384054e7203ba7d06928205` clears a transient slash
command query when `hide()` closes prompt autocomplete in
`packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx`
(lines 668-678), so Pulse's browser slash command popup must close Escape
and outside-click locally, clear only slash-only command drafts, return focus
to the composer, and leave ordinary prompts or already-submitted local
command actions untouched. Closing the popup must not leave `/mo`, `/new`, or
another executable command token behind for the next Enter press.
Failed-turn retry is part of that same local chat-runtime boundary: a
retryable in-memory assistant error may replay the original user turn's
structured mentions, finding id, approval override, handoff resources,
@@ -72,22 +72,27 @@ export function SlashCommandAutocomplete(props: SlashCommandAutocompleteProps) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
event.stopPropagation();
setSelectedIndex((index) => Math.min(index + 1, Math.max(0, options.length - 1)));
break;
case 'ArrowUp':
event.preventDefault();
event.stopPropagation();
setSelectedIndex((index) => Math.max(index - 1, 0));
break;
case 'Enter':
event.preventDefault();
event.stopPropagation();
selectCommand(options[selectedIndex()]);
break;
case 'Tab':
event.preventDefault();
event.stopPropagation();
selectCommand(options[selectedIndex()]);
break;
case 'Escape':
event.preventDefault();
event.stopPropagation();
props.onClose();
break;
}
@@ -1702,13 +1702,14 @@ describe('AIChat', () => {
expect(mockChat.sendMessage).not.toHaveBeenCalled();
});
it('closes slash command suggestions with Escape without submitting', async () => {
renderChat();
it('clears a transient slash command draft with Escape without submitting', async () => {
const onClose = vi.fn();
renderChat(onClose);
const textarea = screen.getByPlaceholderText(
'Ask about your infrastructure...',
) as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: '/' } });
fireEvent.input(textarea, { target: { value: '/mo' } });
expect(screen.getByRole('listbox', { name: 'Assistant commands' })).toBeInTheDocument();
fireEvent.keyDown(textarea, { key: 'Escape' });
@@ -1718,7 +1719,30 @@ describe('AIChat', () => {
screen.queryByRole('listbox', { name: 'Assistant commands' }),
).not.toBeInTheDocument();
});
expect(textarea.value).toBe('/');
expect(textarea.value).toBe('');
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-open-request', '0');
expect(onClose).not.toHaveBeenCalled();
expect(mockChat.sendMessage).not.toHaveBeenCalled();
});
it('clears a transient slash command draft when autocomplete closes from outside click', async () => {
renderChat();
const textarea = screen.getByPlaceholderText(
'Ask about your infrastructure...',
) as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: '/new' } });
expect(screen.getByRole('listbox', { name: 'Assistant commands' })).toBeInTheDocument();
fireEvent.click(document.body);
await waitFor(() => {
expect(
screen.queryByRole('listbox', { name: 'Assistant commands' }),
).not.toBeInTheDocument();
});
expect(textarea.value).toBe('');
expect(mockChat.newSession).not.toHaveBeenCalled();
expect(mockChat.sendMessage).not.toHaveBeenCalled();
});
@@ -850,6 +850,34 @@ export const AIChat: Component<AIChatProps> = (props) => {
setSavedPromptDraft(null);
};
const isTransientSlashCommandDraft = () => {
const text = input();
const cursor = textareaRef?.selectionStart ?? text.length;
const textBeforeCursor = text.slice(0, cursor);
const textAfterCursor = text.slice(cursor);
return (
textBeforeCursor.startsWith('/') &&
!/\s/.test(textBeforeCursor) &&
!textAfterCursor.trim()
);
};
const closeSlashCommandAutocomplete = (options?: { clearTransientDraft?: boolean }) => {
const shouldClearDraft = Boolean(options?.clearTransientDraft && isTransientSlashCommandDraft());
setSlashCommandActive(false);
setSlashCommandQuery('');
if (!shouldClearDraft) return;
setInput('');
setAccumulatedMentions([]);
resetPromptHistoryNavigation();
queueMicrotask(() => {
resizeTextarea();
textareaRef?.focus();
textareaRef?.setSelectionRange(0, 0);
});
};
const stashComposerDraftForRemount = () => {
const text = input();
const mentions = accumulatedMentions();
@@ -2271,7 +2299,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
setMentionActive(false);
}
if (!target.closest('[data-slash-command-autocomplete]') && !target.closest('textarea')) {
setSlashCommandActive(false);
closeSlashCommandAutocomplete({ clearTransientDraft: true });
}
};
document.addEventListener('click', handleClickOutside);
@@ -2888,7 +2916,13 @@ export const AIChat: Component<AIChatProps> = (props) => {
}
}
if (slashCommandActive()) {
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(e.key)) {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closeSlashCommandAutocomplete({ clearTransientDraft: true });
return;
}
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab'].includes(e.key)) {
// These are handled by SlashCommandAutocomplete.
return;
}
@@ -4182,7 +4216,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
query={slashCommandQuery()}
position={{ top: 58, left: 0 }}
onSelect={handleSlashCommandSelect}
onClose={() => setSlashCommandActive(false)}
onClose={() => closeSlashCommandAutocomplete({ clearTransientDraft: true })}
visible={slashCommandActive()}
/>
<div class="absolute bottom-2 right-2 flex items-center gap-1.5">