vscode: require double Esc to stop a turn and keep queued messages visible

Pressing Escape once no longer cancels the running turn or clears queued follow-up messages. Press Escape twice within 5 seconds to stop the current turn, matching the CLI, so users don't lose their queued messages to an accidental tap.
This commit is contained in:
Alex Alecu
2026-04-23 14:15:51 +03:00
parent 1d0318b294
commit 06ce7ee866
11 changed files with 173 additions and 72 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Pressing Escape once in the Kilo Code sidebar no longer aborts the running turn or clears queued follow-up messages. Press Escape twice within 5 seconds to stop the current turn, matching the CLI. Queued messages stay visible so you can see what was waiting in the queue.
+3 -4
View File
@@ -54,7 +54,7 @@ import { clearCommandsCache, loadCommands } from "./kilo-provider/commands"
import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page"
import { childID } from "./kilo-provider/task-session"
import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network"
import { abortSession, parseQueued } from "./kilo-provider/abort"
import { abortSession } from "./kilo-provider/abort"
import * as ModelState from "./kilo-provider/model-state"
import { handleForkSession } from "./kilo-provider/fork-session"
import { retryable, backoff, MAX_RETRIES } from "./util/retry"
@@ -613,7 +613,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
case "abort":
this.cancelRetry(message.sessionID ?? "")
await this.handleAbort(message.sessionID, parseQueued(message.queuedMessageIDs))
await this.handleAbort(message.sessionID)
break
case "revertSession":
this.handleRevertSession(message.sessionID, message.messageID).catch((e) =>
@@ -2558,7 +2558,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
private async handleAbort(sessionID?: string, queuedMessageIDs: string[] = []): Promise<void> {
private async handleAbort(sessionID?: string): Promise<void> {
if (!this.client) {
return
}
@@ -2573,7 +2573,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
client: this.client,
sessionID: targetSessionID,
dir: this.getWorkspaceDirectory(targetSessionID),
queuedMessageIDs,
})
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to abort session:", error)
@@ -623,7 +623,6 @@ interface ForkSessionIn {
interface AbortIn {
type: "abort"
sessionID: string
queuedMessageIDs?: string[]
}
interface ContinueInWorktreeIn {
@@ -1,21 +1,5 @@
import type { KiloClient } from "@kilocode/sdk/v2/client"
export function parseQueued(value: unknown) {
if (!Array.isArray(value)) return []
return value.filter((id): id is string => typeof id === "string")
}
export async function abortSession(input: {
client: KiloClient
sessionID: string
dir: string
queuedMessageIDs: string[]
}) {
export async function abortSession(input: { client: KiloClient; sessionID: string; dir: string }) {
await input.client.session.abort({ sessionID: input.sessionID, directory: input.dir }, { throwOnError: true })
for (const mid of new Set(input.queuedMessageIDs)) {
await input.client.session
.deleteMessage({ sessionID: input.sessionID, messageID: mid, directory: input.dir }, { throwOnError: true })
.catch((err) => console.error("[Kilo New] KiloProvider: Failed to remove queued message:", err))
}
}
+7 -42
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { abortSession, parseQueued } from "../../src/kilo-provider/abort"
import { abortSession } from "../../src/kilo-provider/abort"
function client(calls: unknown[], fail = false) {
return {
@@ -10,35 +10,15 @@ function client(calls: unknown[], fail = false) {
if (fail) throw new Error("abort failed")
return { data: true }
},
deleteMessage: async (params: unknown, opts: unknown) => {
calls.push({ type: "delete", params, opts })
return { data: true }
},
},
} as unknown as KiloClient
}
describe("parseQueued", () => {
it("keeps only string queued message ids", () => {
expect(parseQueued(["message_1", 2, null, "message_2", {}])).toEqual(["message_1", "message_2"])
})
it("returns empty ids for invalid payloads", () => {
expect(parseQueued(undefined)).toEqual([])
expect(parseQueued({ queuedMessageIDs: ["message_1"] })).toEqual([])
})
})
describe("abortSession", () => {
it("aborts before removing queued follow-up messages", async () => {
it("calls session.abort with the session id and directory", async () => {
const calls: unknown[] = []
await abortSession({
client: client(calls),
sessionID: "session_1",
dir: "/repo",
queuedMessageIDs: ["message_2", "message_3", "message_2"],
})
await abortSession({ client: client(calls), sessionID: "session_1", dir: "/repo" })
expect(calls).toEqual([
{
@@ -46,30 +26,15 @@ describe("abortSession", () => {
params: { sessionID: "session_1", directory: "/repo" },
opts: { throwOnError: true },
},
{
type: "delete",
params: { sessionID: "session_1", messageID: "message_2", directory: "/repo" },
opts: { throwOnError: true },
},
{
type: "delete",
params: { sessionID: "session_1", messageID: "message_3", directory: "/repo" },
opts: { throwOnError: true },
},
])
})
it("does not remove queued messages when abort fails", async () => {
it("rejects when the abort request fails", async () => {
const calls: unknown[] = []
await expect(
abortSession({
client: client(calls, true),
sessionID: "session_1",
dir: "/repo",
queuedMessageIDs: ["message_2"],
}),
).rejects.toThrow("abort failed")
await expect(abortSession({ client: client(calls, true), sessionID: "session_1", dir: "/repo" })).rejects.toThrow(
"abort failed",
)
expect(calls).toEqual([
{
@@ -0,0 +1,84 @@
import { describe, expect, it } from "bun:test"
import { createAbortPressForTest } from "../../webview-ui/src/context/session-abort-press"
// Minimal fake timer harness: `set` returns an incrementing id and stores the
// callback; `advance` runs the callback if called. Matches the subset of timer
// behavior the helper depends on (set, clear, expiry).
function fakeTimers() {
const queue = new Map<number, () => void>()
let nextId = 0
return {
set: (fn: () => void) => {
nextId += 1
queue.set(nextId, fn)
return nextId as unknown as ReturnType<typeof setTimeout>
},
clear: (t: ReturnType<typeof setTimeout>) => {
queue.delete(t as unknown as number)
},
expire: (t: ReturnType<typeof setTimeout>) => {
const fn = queue.get(t as unknown as number)
if (!fn) return false
queue.delete(t as unknown as number)
fn()
return true
},
pending: () => queue.size,
}
}
describe("session-abort-press", () => {
it("requires two presses to trigger", () => {
const timers = fakeTimers()
const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear })
expect(gate.press()).toBe(false)
expect(gate.count).toBe(1)
expect(gate.hasTimer).toBe(true)
expect(gate.press()).toBe(true)
expect(gate.count).toBe(0)
expect(gate.hasTimer).toBe(false)
})
it("resets after the window elapses", () => {
const timers = fakeTimers()
const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear })
expect(gate.press()).toBe(false)
expect(timers.pending()).toBe(1)
// Expire the timer — simulates the 5s window elapsing with no second press.
timers.expire(1 as unknown as ReturnType<typeof setTimeout>)
expect(gate.count).toBe(0)
expect(gate.hasTimer).toBe(false)
// Next press restarts the counter from 1.
expect(gate.press()).toBe(false)
expect(gate.count).toBe(1)
})
it("re-arms the timer on every press", () => {
const timers = fakeTimers()
const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear })
gate.press()
// Press again before the window closes — prior timer is cleared, new one started.
// Since this is the second press, it triggers and clears (no pending timer).
gate.press()
expect(timers.pending()).toBe(0)
})
it("reset() clears count and timer", () => {
const timers = fakeTimers()
const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear })
gate.press()
expect(gate.hasTimer).toBe(true)
gate.reset()
expect(gate.count).toBe(0)
expect(gate.hasTimer).toBe(false)
expect(timers.pending()).toBe(0)
})
})
@@ -19,6 +19,7 @@ import { useVSCode } from "../../context/vscode"
import { useLanguage } from "../../context/language"
import { useWorktreeMode } from "../../context/worktree-mode"
import { useServer } from "../../context/server"
import { registerAbortPress, resetAbortPress } from "../../context/session-abort-press"
import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-utils"
interface ChatViewProps {
@@ -89,10 +90,13 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "Escape" || session.status() === "idle" || e.defaultPrevented) return
e.preventDefault()
session.abort()
if (registerAbortPress()) session.abort()
}
document.addEventListener("keydown", handler)
onCleanup(() => document.removeEventListener("keydown", handler))
onCleanup(() => {
document.removeEventListener("keydown", handler)
resetAbortPress()
})
})
// Listen for "Continue in Worktree" progress messages
@@ -17,6 +17,7 @@ import { useServer } from "../../context/server"
import { useLanguage } from "../../context/language"
import { useVSCode } from "../../context/vscode"
import { useWorktreeMode } from "../../context/worktree-mode"
import { registerAbortPress } from "../../context/session-abort-press"
import { ModelSelector } from "../shared/ModelSelector"
import { ModeSwitcher } from "../shared/ModeSwitcher"
import { ThinkingSelector } from "../shared/ThinkingSelector"
@@ -553,7 +554,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (e.key === "Escape" && isBusy()) {
e.preventDefault()
e.stopPropagation()
session.abort()
if (registerAbortPress()) session.abort()
return
}
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
@@ -0,0 +1,65 @@
// Shared counter for the double-Esc-to-abort gesture.
// Mirrors the CLI's `store.interrupt` in packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:
// press increments a counter and (re)starts a 5s reset timer; the second press within the window
// triggers abort and resets.
const WINDOW_MS = 5000
interface Timers {
set: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>
clear: (t: ReturnType<typeof setTimeout>) => void
}
function press(state: { count: number; timer: ReturnType<typeof setTimeout> | undefined }, timers: Timers): boolean {
state.count += 1
if (state.timer) timers.clear(state.timer)
if (state.count >= 2) {
state.count = 0
state.timer = undefined
return true
}
state.timer = timers.set(() => {
state.count = 0
state.timer = undefined
}, WINDOW_MS)
return false
}
const defaults: Timers = {
set: (fn, ms) => setTimeout(fn, ms),
clear: (t) => clearTimeout(t),
}
const shared: { count: number; timer: ReturnType<typeof setTimeout> | undefined } = { count: 0, timer: undefined }
// Registers an Esc press; returns true when this press is the second within the
// 5s window (and the caller should trigger abort).
export function registerAbortPress(): boolean {
return press(shared, defaults)
}
// Resets the counter. Safe to call from anywhere (idle transitions, tests, etc.).
export function resetAbortPress(): void {
shared.count = 0
if (shared.timer) defaults.clear(shared.timer)
shared.timer = undefined
}
// Test-only factory: creates an isolated press-state with caller-supplied timers.
export function createAbortPressForTest(timers: Timers) {
const state = { count: 0, timer: undefined as ReturnType<typeof setTimeout> | undefined }
return {
press: () => press(state, timers),
reset: () => {
state.count = 0
if (state.timer) timers.clear(state.timer)
state.timer = undefined
},
get count() {
return state.count
},
get hasTimer() {
return state.timer !== undefined
},
}
}
@@ -46,7 +46,6 @@ import {
import { Identifier } from "../utils/id"
import { resolveModelSelection } from "./model-selection"
import { resolveSessionAgent } from "./session-agent"
import { queuedUserMessageIDs } from "./session-queue"
import { PartStash } from "./part-stash"
import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model"
@@ -1748,12 +1747,9 @@ export const SessionProvider: ParentComponent = (props) => {
return
}
const queuedMessageIDs = queuedUserMessageIDs(messages(), statusInfo())
vscode.postMessage({
type: "abort",
sessionID,
queuedMessageIDs,
})
}
@@ -1703,7 +1703,6 @@ export interface SendMessageRequest {
export interface AbortRequest {
type: "abort"
sessionID: string
queuedMessageIDs?: string[]
}
export interface RevertSessionRequest {