From e3bc9471950b98121b77bbc4366f764ed9f3d160 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 24 Oct 2025 16:09:32 +0100 Subject: [PATCH] Implement double-escape session abort matching TUI behavior - Add double-escape debounce pattern with 1-second timeout - First escape shows warning, second escape aborts session - Fix session busy check to handle undefined timeCompleted field - Add abortSession API call to sessions store - Show visual feedback in prompt input during debounce - Remove Escape from filtered keys in prompt-input auto-focus --- src/App.tsx | 41 +++++++++++++++++++++++++++------ src/components/prompt-input.tsx | 27 ++++++++++++++-------- src/lib/shortcuts/escape.ts | 40 ++++++++++++++++++++++++++++++-- src/stores/sessions.ts | 20 ++++++++++++++++ 4 files changed, 109 insertions(+), 19 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 47845b6d..68414182 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { Component, onMount, onCleanup, Show, createMemo, createEffect } from "solid-js" +import { Component, onMount, onCleanup, Show, createMemo, createEffect, createSignal } from "solid-js" import type { Session } from "./types/session" import type { Attachment } from "./types/attachment" import EmptyState from "./components/empty-state" @@ -36,6 +36,7 @@ import { getParentSessions, loadMessages, sendMessage, + abortSession, updateSessionAgent, updateSessionModel, agents, @@ -45,7 +46,7 @@ import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette import { registerNavigationShortcuts } from "./lib/shortcuts/navigation" import { registerInputShortcuts } from "./lib/shortcuts/input" import { registerAgentShortcuts } from "./lib/shortcuts/agent" -import { registerEscapeShortcut } from "./lib/shortcuts/escape" +import { registerEscapeShortcut, setEscapeStateChangeHandler } from "./lib/shortcuts/escape" import { keyboardRegistry } from "./lib/keyboard-registry" const SessionView: Component<{ @@ -53,6 +54,7 @@ const SessionView: Component<{ activeSessions: Map instanceId: string instanceFolder: string + escapeInDebounce: boolean }> = (props) => { const session = () => props.activeSessions.get(props.sessionId) @@ -101,6 +103,7 @@ const SessionView: Component<{ model={s().model} onAgentChange={handleAgentChange} onModelChange={handleModelChange} + escapeInDebounce={props.escapeInDebounce} /> )} @@ -110,6 +113,7 @@ const SessionView: Component<{ const App: Component = () => { const commandRegistry = createCommandRegistry() + const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const activeInstance = createMemo(() => getActiveInstance()) @@ -477,6 +481,8 @@ const App: Component = () => { onMount(() => { initMarkdown(false).catch(console.error) + setEscapeStateChangeHandler(setEscapeInDebounce) + setupCommands() setupTabKeyboardShortcuts( @@ -538,15 +544,35 @@ const App: Component = () => { () => { const instance = activeInstance() if (!instance) return false - const sessions = getSessions(instance.id) + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "logs") return false + + const sessions = getSessions(instance.id) const session = sessions.find((s) => s.id === sessionId) - if (!session) return false + if (!session || session.messages.length === 0) return false + const lastMessage = session.messages[session.messages.length - 1] - return lastMessage?.status === "streaming" + const messageInfo = session.messagesInfo.get(lastMessage.id) + + const timeCompleted = messageInfo?.time?.completed + return ( + lastMessage.type === "assistant" && + messageInfo !== undefined && + (timeCompleted === undefined || timeCompleted === 0) + ) }, - () => { - console.log("Interrupt session (not implemented)") + async () => { + const instance = activeInstance() + const sessionId = activeSessionIdForInstance() + if (!instance || !sessionId || sessionId === "logs") return + + try { + await abortSession(instance.id, sessionId) + console.log("Session aborted successfully") + } catch (error) { + console.error("Failed to abort session:", error) + } }, () => { const active = document.activeElement as HTMLElement @@ -650,6 +676,7 @@ const App: Component = () => { activeSessions={activeSessions()} instanceId={activeInstance()!.id} instanceFolder={activeInstance()!.folder} + escapeInDebounce={escapeInDebounce()} /> } diff --git a/src/components/prompt-input.tsx b/src/components/prompt-input.tsx index 61b50317..dc38f463 100644 --- a/src/components/prompt-input.tsx +++ b/src/components/prompt-input.tsx @@ -21,6 +21,7 @@ interface PromptInputProps { model: { providerId: string; modelId: string } onAgentChange: (agent: string) => Promise onModelChange: (model: { providerId: string; modelId: string }) => Promise + escapeInDebounce?: boolean } export default function PromptInput(props: PromptInputProps) { @@ -200,12 +201,7 @@ export default function PromptInput(props: PromptInputProps) { if (isModifierKey) return const isSpecialKey = - e.key === "Escape" || - e.key === "Tab" || - e.key === "Enter" || - e.key.startsWith("Arrow") || - e.key === "Backspace" || - e.key === "Delete" + e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete" if (isSpecialKey) return if (e.key.length === 1 && textareaRef && !props.disabled) { @@ -738,10 +734,21 @@ export default function PromptInput(props: PromptInputProps) {
- Enter to send • Shift+Enter for new line • @ for files/agents • ↑↓{" "} - for history - 0}> - • {attachments().length} file(s) attached + + Enter to send • Shift+Enter for new line • @ for files/agents •{" "} + ↑↓ for history + 0}> + • {attachments().length} file(s) attached + + + } + > + + Press Esc again to abort session +
diff --git a/src/lib/shortcuts/escape.ts b/src/lib/shortcuts/escape.ts index c0477207..fa368675 100644 --- a/src/lib/shortcuts/escape.ts +++ b/src/lib/shortcuts/escape.ts @@ -1,8 +1,31 @@ import { keyboardRegistry } from "../keyboard-registry" +type EscapeKeyState = "idle" | "firstPress" + +const ESCAPE_DEBOUNCE_TIMEOUT = 1000 + +let escapeKeyState: EscapeKeyState = "idle" +let escapeTimeoutId: number | null = null +let onEscapeStateChange: ((inDebounce: boolean) => void) | null = null + +export function setEscapeStateChangeHandler(handler: (inDebounce: boolean) => void) { + onEscapeStateChange = handler +} + +function resetEscapeState() { + escapeKeyState = "idle" + if (escapeTimeoutId !== null) { + clearTimeout(escapeTimeoutId) + escapeTimeoutId = null + } + if (onEscapeStateChange) { + onEscapeStateChange(false) + } +} + export function registerEscapeShortcut( isSessionBusy: () => boolean, - interruptSession: () => void, + abortSession: () => Promise, blurInput: () => void, closeModal: () => void, ) { @@ -15,14 +38,27 @@ export function registerEscapeShortcut( if (hasOpenModal) { closeModal() + resetEscapeState() return } if (isSessionBusy()) { - interruptSession() + if (escapeKeyState === "idle") { + escapeKeyState = "firstPress" + if (onEscapeStateChange) { + onEscapeStateChange(true) + } + escapeTimeoutId = window.setTimeout(() => { + resetEscapeState() + }, ESCAPE_DEBOUNCE_TIMEOUT) + } else if (escapeKeyState === "firstPress") { + resetEscapeState() + abortSession() + } return } + resetEscapeState() blurInput() }, description: "cancel/close", diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index bc2c3a7d..9c3572e1 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -700,6 +700,25 @@ async function sendMessage( } } +async function abortSession(instanceId: string, sessionId: string): Promise { + const instance = instances().get(instanceId) + if (!instance || !instance.client) { + throw new Error("Instance not ready") + } + + console.log("[abortSession] Aborting session:", { instanceId, sessionId }) + + try { + await instance.client.session.abort({ + path: { id: sessionId }, + }) + console.log("[abortSession] Session aborted successfully") + } catch (error) { + console.error("[abortSession] Failed to abort session:", error) + throw error + } +} + async function updateSessionAgent(instanceId: string, sessionId: string, agent: string): Promise { const instanceSessions = sessions().get(instanceId) const session = instanceSessions?.get(sessionId) @@ -759,6 +778,7 @@ export { fetchProviders, loadMessages, sendMessage, + abortSession, setActiveSession, setActiveParentSession, clearActiveParentSession,