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
This commit is contained in:
Shantur Rathore
2025-10-24 16:09:32 +01:00
parent 6f31ffc467
commit e3bc947195
4 changed files with 109 additions and 19 deletions

View File

@@ -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<string, Session>
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}
/>
</div>
)}
@@ -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()}
/>
</Show>
}

View File

@@ -21,6 +21,7 @@ interface PromptInputProps {
model: { providerId: string; modelId: string }
onAgentChange: (agent: string) => Promise<void>
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
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) {
</div>
<div class="prompt-input-hints">
<HintRow>
<Kbd>Enter</Kbd> to send <Kbd>Shift+Enter</Kbd> for new line <Kbd>@</Kbd> for files/agents <Kbd></Kbd>{" "}
for history
<Show when={attachments().length > 0}>
<span class="ml-2 text-xs text-gray-500"> {attachments().length} file(s) attached</span>
<Show
when={props.escapeInDebounce}
fallback={
<>
<Kbd>Enter</Kbd> to send <Kbd>Shift+Enter</Kbd> for new line <Kbd>@</Kbd> for files/agents {" "}
<Kbd></Kbd> for history
<Show when={attachments().length > 0}>
<span class="ml-2 text-xs text-gray-500"> {attachments().length} file(s) attached</span>
</Show>
</>
}
>
<span class="text-orange-600 dark:text-orange-400 font-medium">
Press <Kbd>Esc</Kbd> again to abort session
</span>
</Show>
</HintRow>
<div class="flex items-center gap-2">

View File

@@ -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<void>,
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",

View File

@@ -700,6 +700,25 @@ async function sendMessage(
}
}
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
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<void> {
const instanceSessions = sessions().get(instanceId)
const session = instanceSessions?.get(sessionId)
@@ -759,6 +778,7 @@ export {
fetchProviders,
loadMessages,
sendMessage,
abortSession,
setActiveSession,
setActiveParentSession,
clearActiveParentSession,