Add Assistant keyboard interrupt guard

This commit is contained in:
rcourtman
2026-06-06 04:36:35 +01:00
parent 2f19f13c4c
commit 4e2ae3109e
5 changed files with 89 additions and 9 deletions
@@ -247,6 +247,18 @@ runtime cost control, and shared AI transport surfaces.
those rows through the existing chat-runtime queue without aborting the
active model stream.
The referenced OpenCode source at fetched `origin/dev` commit
`09d9cf01f93798939c1284fbe974b6e1f4d2759d` registers the
`session.interrupt` command while a turn is non-idle in
`packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`, and its
direct-run footer implements the same two-press interrupt guard in
`packages/opencode/src/cli/cmd/run/footer.ts` while rendering the armed
state in `packages/opencode/src/cli/cmd/run/footer.view.tsx`. Pulse's
Assistant drawer adapts that ergonomics model by letting Escape from the
focused composer arm the visible Stop control first and letting the next
Escape confirm the same governed `chat.stop()` path as the Stop button,
including aborting the active stream, clearing queued follow-ups, preserving
partial text, and returning focus to the composer.
The referenced OpenCode source at fetched `origin/dev` commit
`fa2b63f850fc0a23bec2bdff9e660450d3fe7913` keeps prompt/footer status visible
only while the session is non-idle in
`packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`, and maps
+7 -2
View File
@@ -394,9 +394,14 @@ function App() {
// Setup escape handling for the assistant drawer.
onMount(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Escape to close
// Escape closes the drawer only after mounted drawer controls have had
// a chance to claim the key for local flows such as interrupt confirm.
if (e.key === 'Escape' && aiChatStore.isOpen) {
aiChatStore.close();
window.setTimeout(() => {
if (!e.defaultPrevented && aiChatStore.isOpen) {
aiChatStore.close();
}
}, 0);
}
};
@@ -173,6 +173,8 @@ describe('App architecture', () => {
'if (dialogStackHasBlockingDialog() && aiChatStore.isOpenSignal()) {',
);
expect(appSource).toContain("if (e.key === 'Escape' && aiChatStore.isOpen) {");
expect(appSource).toContain('window.setTimeout(() => {');
expect(appSource).toContain('if (!e.defaultPrevented && aiChatStore.isOpen) {');
expect(appSource).toContain('<AIChat onClose={() => aiChatStore.close()} />');
expect(appSource).toContain('showOrgSwitcher={runtime.showOrgSwitcher}');
expect(appSource).not.toContain('TrialBanner');
@@ -1476,6 +1476,22 @@ describe('AIChat', () => {
expect(mockChat.stop).toHaveBeenCalledTimes(1);
});
it('arms keyboard interruption on first Escape and stops on second Escape', async () => {
mockChat.isLoading.mockReturnValue(true);
renderChat();
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');
fireEvent.keyDown(textarea, { key: 'Escape' });
expect(mockChat.stop).not.toHaveBeenCalled();
expect(screen.getByTitle('Stop response armed')).toBeInTheDocument();
fireEvent.keyDown(textarea, { key: 'Escape' });
expect(mockChat.stop).toHaveBeenCalledTimes(1);
await waitFor(() => expect(document.activeElement).toBe(textarea));
});
it('returns focus to the composer after stopping a response', async () => {
mockChat.isLoading.mockReturnValue(true);
renderChat();
@@ -457,6 +457,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
const [editingQueuedFollowUp, setEditingQueuedFollowUp] = createSignal<QueuedFollowUp | null>(
null,
);
const [interruptArmed, setInterruptArmed] = createSignal(false);
const [promptHistory, setPromptHistory] = createSignal<PromptHistoryEntry[]>([]);
const [promptHistoryIndex, setPromptHistoryIndex] = createSignal(-1);
const [savedPromptDraft, setSavedPromptDraft] = createSignal<PromptHistoryEntry | null>(null);
@@ -497,6 +498,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
const [mentionResources, setMentionResources] = createSignal<MentionResource[]>([]);
const [accumulatedMentions, setAccumulatedMentions] = createSignal<MentionResource[]>([]);
let textareaRef: HTMLTextAreaElement | undefined;
let interruptArmTimeout: ReturnType<typeof setTimeout> | undefined;
const focusComposer = () => {
queueMicrotask(() => {
@@ -504,6 +506,23 @@ export const AIChat: Component<AIChatProps> = (props) => {
});
};
const clearInterruptArm = () => {
if (interruptArmTimeout) {
clearTimeout(interruptArmTimeout);
interruptArmTimeout = undefined;
}
setInterruptArmed(false);
};
const armKeyboardInterrupt = () => {
clearInterruptArm();
setInterruptArmed(true);
interruptArmTimeout = setTimeout(() => {
interruptArmTimeout = undefined;
setInterruptArmed(false);
}, 5000);
};
const resizeTextarea = () => {
if (!textareaRef) return;
textareaRef.style.height = 'auto';
@@ -756,6 +775,12 @@ export const AIChat: Component<AIChatProps> = (props) => {
onConversationChanged: refreshSessions,
});
const stopActiveResponse = () => {
clearInterruptArm();
chat.stop();
focusComposer();
};
const queuedFollowUpPreview = (prompt: string) => {
const firstLine = prompt
.split(/\r?\n/)
@@ -1183,6 +1208,12 @@ export const AIChat: Component<AIChatProps> = (props) => {
void initializeWhenOpen();
});
createEffect(() => {
if (!chat.isLoading() && interruptArmed()) {
clearInterruptArm();
}
});
createEffect(() => {
const open = isOpen();
const model = selectedChatModel().trim();
@@ -1259,6 +1290,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
onCleanup(() => {
document.removeEventListener('click', handleClickOutside);
aiChatStore.registerInput?.(null);
clearInterruptArm();
});
});
@@ -1714,6 +1746,18 @@ export const AIChat: Component<AIChatProps> = (props) => {
return;
}
if (e.key === 'Escape' && chat.isLoading()) {
e.preventDefault();
e.stopPropagation();
if (interruptArmed()) {
stopActiveResponse();
} else {
armKeyboardInterrupt();
focusComposer();
}
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
@@ -2516,13 +2560,14 @@ export const AIChat: Component<AIChatProps> = (props) => {
<Show when={chat.isLoading()}>
<button
type="button"
onClick={() => {
chat.stop();
focusComposer();
}}
class="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-surface text-base-content shadow-sm transition-colors hover:bg-surface-hover"
title="Stop"
aria-label="Stop response"
onClick={stopActiveResponse}
class={`flex h-9 w-9 items-center justify-center rounded-md border bg-surface text-base-content shadow-sm transition-colors hover:bg-surface-hover ${
interruptArmed()
? 'border-blue-400 ring-2 ring-blue-500/30'
: 'border-border'
}`}
title={interruptArmed() ? 'Stop response armed' : 'Stop'}
aria-label={interruptArmed() ? 'Stop response armed' : 'Stop response'}
>
<SquareIcon class="h-4 w-4" />
</button>