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 { Session } from "./types/session"
|
||||||
import type { Attachment } from "./types/attachment"
|
import type { Attachment } from "./types/attachment"
|
||||||
import EmptyState from "./components/empty-state"
|
import EmptyState from "./components/empty-state"
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
getParentSessions,
|
getParentSessions,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
abortSession,
|
||||||
updateSessionAgent,
|
updateSessionAgent,
|
||||||
updateSessionModel,
|
updateSessionModel,
|
||||||
agents,
|
agents,
|
||||||
@@ -45,7 +46,7 @@ import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette
|
|||||||
import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
|
import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
|
||||||
import { registerInputShortcuts } from "./lib/shortcuts/input"
|
import { registerInputShortcuts } from "./lib/shortcuts/input"
|
||||||
import { registerAgentShortcuts } from "./lib/shortcuts/agent"
|
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"
|
import { keyboardRegistry } from "./lib/keyboard-registry"
|
||||||
|
|
||||||
const SessionView: Component<{
|
const SessionView: Component<{
|
||||||
@@ -53,6 +54,7 @@ const SessionView: Component<{
|
|||||||
activeSessions: Map<string, Session>
|
activeSessions: Map<string, Session>
|
||||||
instanceId: string
|
instanceId: string
|
||||||
instanceFolder: string
|
instanceFolder: string
|
||||||
|
escapeInDebounce: boolean
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const session = () => props.activeSessions.get(props.sessionId)
|
const session = () => props.activeSessions.get(props.sessionId)
|
||||||
|
|
||||||
@@ -101,6 +103,7 @@ const SessionView: Component<{
|
|||||||
model={s().model}
|
model={s().model}
|
||||||
onAgentChange={handleAgentChange}
|
onAgentChange={handleAgentChange}
|
||||||
onModelChange={handleModelChange}
|
onModelChange={handleModelChange}
|
||||||
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -110,6 +113,7 @@ const SessionView: Component<{
|
|||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const commandRegistry = createCommandRegistry()
|
const commandRegistry = createCommandRegistry()
|
||||||
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
|
|
||||||
const activeInstance = createMemo(() => getActiveInstance())
|
const activeInstance = createMemo(() => getActiveInstance())
|
||||||
|
|
||||||
@@ -477,6 +481,8 @@ const App: Component = () => {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
initMarkdown(false).catch(console.error)
|
initMarkdown(false).catch(console.error)
|
||||||
|
|
||||||
|
setEscapeStateChangeHandler(setEscapeInDebounce)
|
||||||
|
|
||||||
setupCommands()
|
setupCommands()
|
||||||
|
|
||||||
setupTabKeyboardShortcuts(
|
setupTabKeyboardShortcuts(
|
||||||
@@ -538,15 +544,35 @@ const App: Component = () => {
|
|||||||
() => {
|
() => {
|
||||||
const instance = activeInstance()
|
const instance = activeInstance()
|
||||||
if (!instance) return false
|
if (!instance) return false
|
||||||
const sessions = getSessions(instance.id)
|
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId || sessionId === "logs") return false
|
||||||
|
|
||||||
|
const sessions = getSessions(instance.id)
|
||||||
const session = sessions.find((s) => s.id === sessionId)
|
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]
|
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)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
() => {
|
async () => {
|
||||||
console.log("Interrupt session (not implemented)")
|
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
|
const active = document.activeElement as HTMLElement
|
||||||
@@ -650,6 +676,7 @@ const App: Component = () => {
|
|||||||
activeSessions={activeSessions()}
|
activeSessions={activeSessions()}
|
||||||
instanceId={activeInstance()!.id}
|
instanceId={activeInstance()!.id}
|
||||||
instanceFolder={activeInstance()!.folder}
|
instanceFolder={activeInstance()!.folder}
|
||||||
|
escapeInDebounce={escapeInDebounce()}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface PromptInputProps {
|
|||||||
model: { providerId: string; modelId: string }
|
model: { providerId: string; modelId: string }
|
||||||
onAgentChange: (agent: string) => Promise<void>
|
onAgentChange: (agent: string) => Promise<void>
|
||||||
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
|
onModelChange: (model: { providerId: string; modelId: string }) => Promise<void>
|
||||||
|
escapeInDebounce?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PromptInput(props: PromptInputProps) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
@@ -200,12 +201,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
if (isModifierKey) return
|
if (isModifierKey) return
|
||||||
|
|
||||||
const isSpecialKey =
|
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 (isSpecialKey) return
|
||||||
|
|
||||||
if (e.key.length === 1 && textareaRef && !props.disabled) {
|
if (e.key.length === 1 && textareaRef && !props.disabled) {
|
||||||
@@ -738,10 +734,21 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="prompt-input-hints">
|
<div class="prompt-input-hints">
|
||||||
<HintRow>
|
<HintRow>
|
||||||
<Kbd>Enter</Kbd> to send • <Kbd>Shift+Enter</Kbd> for new line • <Kbd>@</Kbd> for files/agents • <Kbd>↑↓</Kbd>{" "}
|
<Show
|
||||||
for history
|
when={props.escapeInDebounce}
|
||||||
<Show when={attachments().length > 0}>
|
fallback={
|
||||||
<span class="ml-2 text-xs text-gray-500">• {attachments().length} file(s) attached</span>
|
<>
|
||||||
|
<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>
|
</Show>
|
||||||
</HintRow>
|
</HintRow>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
import { keyboardRegistry } from "../keyboard-registry"
|
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(
|
export function registerEscapeShortcut(
|
||||||
isSessionBusy: () => boolean,
|
isSessionBusy: () => boolean,
|
||||||
interruptSession: () => void,
|
abortSession: () => Promise<void>,
|
||||||
blurInput: () => void,
|
blurInput: () => void,
|
||||||
closeModal: () => void,
|
closeModal: () => void,
|
||||||
) {
|
) {
|
||||||
@@ -15,14 +38,27 @@ export function registerEscapeShortcut(
|
|||||||
|
|
||||||
if (hasOpenModal) {
|
if (hasOpenModal) {
|
||||||
closeModal()
|
closeModal()
|
||||||
|
resetEscapeState()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSessionBusy()) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetEscapeState()
|
||||||
blurInput()
|
blurInput()
|
||||||
},
|
},
|
||||||
description: "cancel/close",
|
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> {
|
async function updateSessionAgent(instanceId: string, sessionId: string, agent: string): Promise<void> {
|
||||||
const instanceSessions = sessions().get(instanceId)
|
const instanceSessions = sessions().get(instanceId)
|
||||||
const session = instanceSessions?.get(sessionId)
|
const session = instanceSessions?.get(sessionId)
|
||||||
@@ -759,6 +778,7 @@ export {
|
|||||||
fetchProviders,
|
fetchProviders,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
abortSession,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
setActiveParentSession,
|
setActiveParentSession,
|
||||||
clearActiveParentSession,
|
clearActiveParentSession,
|
||||||
|
|||||||
Reference in New Issue
Block a user