mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 04:46:43 +08:00
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:
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user