Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/kill-command-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fix: "Kill Command" button now reliably terminates processes on all platforms, including those running in the background.
10 changes: 8 additions & 2 deletions src/core/tools/ExecuteCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,20 @@ export async function executeCommandInTerminal(
clearTimeout(timeoutId)
}

task.terminalProcess = undefined
// Don't clear if running in background - user may still want to kill it
if (!runInBackground) {
task.terminalProcess = undefined
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why keep task.terminalProcess when running in background

When the user clicks “Proceed/Continue,” we allow the command to keep running in the terminal. At that point the promise resolves and we enter finally, which used to clear task.terminalProcess.
That wiped the only handle the kill button uses, so later aborts did nothing even though the process was still alive.

By only clearing the reference when not running in background, we keep the handle around so the kill button can still terminate the process after “Proceed/Continue.”

}
} else {
// No timeout - just wait for the process to complete.
try {
await process
} finally {
task.terminalProcess = undefined
// Don't clear if running in background - user may still want to kill it
if (!runInBackground) {
task.terminalProcess = undefined
}
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/integrations/terminal/TerminalProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,10 @@ export class TerminalProcess extends BaseTerminalProcess {
}

public override abort() {
if (this.isListening) {
// Send SIGINT using CTRL+C
this.terminal.terminal.sendText("\x03")
}
// User-initiated kill must always attempt to interrupt the process.
// Listening state does not reflect process liveness.
const terminal = this.terminalRef.deref()
terminal?.terminal?.sendText("\x03")
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this change is needed
When a user clicks “Proceed/Continue,” we intentionally stop listening to terminal output so the UI stays quiet. That sets isListening = false and removes the line listeners, but the process is still running.
The kill button calls abort(), and the old guard (if (isListening)) meant we never sent Ctrl‑C after Continue. That’s why the kill button looked like a no‑op.

What this fixes

We now send Ctrl‑C unconditionally on user‑initiated abort. Listening state is about UI streaming, not process liveness. This makes “Kill” work even after “Proceed/Continue,” which matches user intent and doesn’t affect normal execution.


public override hasUnretrievedOutput(): boolean {
Expand Down
30 changes: 30 additions & 0 deletions src/integrations/terminal/__tests__/TerminalProcess.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ describe("TerminalProcess", () => {
let mockStream: AsyncIterableIterator<string>

beforeEach(() => {
vi.clearAllMocks()

// Create properly typed mock terminal
mockTerminal = {
shellIntegration: {
Expand Down Expand Up @@ -186,6 +188,34 @@ describe("TerminalProcess", () => {
})
})

describe("abort", () => {
it("sends CTRL+C even after continue has been called", () => {
// Simulate user clicking "Proceed While Running"
terminalProcess.continue()

// User later clicks "Kill Command"
terminalProcess.abort()

// CTRL+C should still be sent to terminate the process
expect(mockTerminal.sendText).toHaveBeenCalledWith("\x03")
expect(mockTerminal.sendText).toHaveBeenCalledTimes(1)
})

it("sends CTRL+C when still listening", () => {
terminalProcess.abort()

expect(mockTerminal.sendText).toHaveBeenCalledWith("\x03")
expect(mockTerminal.sendText).toHaveBeenCalledTimes(1)
})

it("does not throw if terminal is already gone", () => {
// Simulate terminal being garbage collected
vi.spyOn(terminalProcess["terminalRef"], "deref").mockReturnValue(undefined)

expect(() => terminalProcess.abort()).not.toThrow()
})
})

describe("getUnretrievedOutput", () => {
it("returns and clears unretrieved output", () => {
terminalProcess["fullOutput"] = `\x1b]633;C\x07previous\nnew output\x1b]633;D\x07`
Expand Down
Loading