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:
41
src/App.tsx
41
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<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>
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user