mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user