diff --git a/.changeset/double-esc-abort.md b/.changeset/double-esc-abort.md new file mode 100644 index 0000000000..2e3658134f --- /dev/null +++ b/.changeset/double-esc-abort.md @@ -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. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 46ad590eb6..85cd37481b 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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 { + private async handleAbort(sessionID?: string): Promise { 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) diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 65e34a730b..5f4846c96c 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -623,7 +623,6 @@ interface ForkSessionIn { interface AbortIn { type: "abort" sessionID: string - queuedMessageIDs?: string[] } interface ContinueInWorktreeIn { diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts index e295fea545..8d3d00676e 100644 --- a/packages/kilo-vscode/src/kilo-provider/abort.ts +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -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)) - } } diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts index c9002a650c..126333e6a4 100644 --- a/packages/kilo-vscode/tests/unit/abort.test.ts +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -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([ { diff --git a/packages/kilo-vscode/tests/unit/session-abort-press.test.ts b/packages/kilo-vscode/tests/unit/session-abort-press.test.ts new file mode 100644 index 0000000000..44c997179d --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-abort-press.test.ts @@ -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 void>() + let nextId = 0 + return { + set: (fn: () => void) => { + nextId += 1 + queue.set(nextId, fn) + return nextId as unknown as ReturnType + }, + clear: (t: ReturnType) => { + queue.delete(t as unknown as number) + }, + expire: (t: ReturnType) => { + 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) + 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) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 7da3fa1dce..5114c5b441 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -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 = (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 diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index c9f6c2bec0..6148317b54 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -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 = (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) { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts b/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts new file mode 100644 index 0000000000..24b2c3e8bd --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts @@ -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 + clear: (t: ReturnType) => void +} + +function press(state: { count: number; timer: ReturnType | 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 | 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 | 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 + }, + } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 485f4ba964..0a1936d723 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -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, }) } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 1a29297159..a631d4178b 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -1703,7 +1703,6 @@ export interface SendMessageRequest { export interface AbortRequest { type: "abort" sessionID: string - queuedMessageIDs?: string[] } export interface RevertSessionRequest {