Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/agent-manager-terminal-shortcut-platform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Match the Agent Manager terminal shortcut fallback to the platform modifier (Cmd on macOS, Ctrl elsewhere) and consume the extension echo once per keypress so unrelated invocations are no longer swallowed.
73 changes: 55 additions & 18 deletions packages/kilo-vscode/tests/unit/agent-manager-terminal-side.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function scene(
saved?: "vscode" | "agentManager"
visible?: boolean
focusedId?: string
mac?: boolean
} = {},
) {
const calls = {
Expand Down Expand Up @@ -50,6 +51,7 @@ function scene(
openVscode: () => calls.openVscode++,
saved: opts.saved,
save: (destination) => calls.persisted.push(destination),
mac: opts.mac,
})
if (opts.destination) ctl.syncDefault(opts.destination)
return { ctl, calls }
Expand Down Expand Up @@ -99,32 +101,67 @@ describe("Agent Manager side terminal controller", () => {
expect(panelFirst.calls.openVscode).toBe(0)
})

it("handles Cmd/Ctrl+/ presses locally and dedupes the extension echo", () => {
const press = (key: string, opts: Partial<KeyboardEvent> = {}) =>
({ key, metaKey: true, ctrlKey: false, shiftKey: false, altKey: false, ...opts }) as KeyboardEvent
it("handles the platform terminal shortcut locally and dedupes the extension echo", () => {
const press = (opts: Partial<KeyboardEvent> = {}) =>
({ key: "/", metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...opts }) as KeyboardEvent

const item = scene({ destination: "agentManager" })
expect(item.ctl.press(press("/"))).toBe(true)
// macOS: the workbench binding is Cmd+/, so only Cmd is accepted.
const mac = scene({ destination: "agentManager", mac: true })
expect(mac.ctl.press(press({ metaKey: true }))).toBe(true)
expect(mac.calls.requestSide).toBe(1)
expect(mac.ctl.press(press({ ctrlKey: true }))).toBe(false)
expect(mac.ctl.press(press({ metaKey: true, ctrlKey: true }))).toBe(false)
expect(mac.calls.requestSide).toBe(1)

// Windows/Linux: the workbench binding is Ctrl+/, so only Ctrl is accepted.
const win = scene({ destination: "agentManager", mac: false })
expect(win.ctl.press(press({ ctrlKey: true }))).toBe(true)
expect(win.calls.requestSide).toBe(1)
expect(win.ctl.press(press({ metaKey: true }))).toBe(false)
expect(win.calls.requestSide).toBe(1)

// Unrelated keys and modifier combinations are not the shortcut.
expect(win.ctl.press(press({ key: "?" }))).toBe(false)
expect(win.ctl.press(press({ ctrlKey: true, shiftKey: true }))).toBe(false)
expect(win.ctl.press(press({ ctrlKey: true, altKey: true }))).toBe(false)
expect(win.calls.requestSide).toBe(1)

// The extension echoes each locally handled keypress back as an action
// message; one echo is consumed per press, then invocations run again.
expect(mac.ctl.echo()).toBe(true)
expect(mac.ctl.echo()).toBe(false)
})

it("consumes one echo per press, even for rapid repeated presses", () => {
const item = scene({ destination: "agentManager", mac: true })
const press = () => item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
press()
press()
// Two presses toggled the panel open and closed again; both echoes
// must still be consumed so neither press toggles a third time.
expect(item.calls.requestSide).toBe(1)
// The extension echoes the same keypress back as an action message;
// it must be ignored so the panel does not toggle twice.
expect(item.calls.hide).toBe(1)
expect(item.ctl.echo()).toBe(true)
expect(item.ctl.echo()).toBe(true)
expect(item.ctl.echo()).toBe(false)
})

// Unrelated keys and modifier combinations are not the shortcut.
const other = scene({ destination: "agentManager" })
expect(other.ctl.press(press("?"))).toBe(false)
expect(other.ctl.press(press("/", { shiftKey: true }))).toBe(false)
expect(other.ctl.press(press("/", { altKey: true }))).toBe(false)
expect(other.ctl.press(press("/", { metaKey: false }))).toBe(false)
expect(other.ctl.press(press("/", { metaKey: false, ctrlKey: true }))).toBe(true)
expect(other.calls.requestSide).toBe(1)
it("drops a never-arriving echo after the timeout safety valve", async () => {
const item = scene({ destination: "agentManager", mac: true })
item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
await new Promise((resolve) => setTimeout(resolve, 550))
expect(item.ctl.echo()).toBe(false)
expect(item.ctl.echo()).toBe(false)
})

it("stops deduping after the echo window passes", async () => {
const item = scene({ destination: "agentManager" })
it("expires a dropped echo's backlog at the next spaced press", async () => {
const item = scene({ destination: "agentManager", mac: true })
// First press's echo never arrives (dropped forwarding); its backlog
// must not outlive the echo window into the next press.
item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
expect(item.ctl.echo()).toBe(true)
await new Promise((resolve) => setTimeout(resolve, 550))
item.ctl.press({ key: "/", metaKey: true } as KeyboardEvent)
expect(item.ctl.echo()).toBe(true)
expect(item.ctl.echo()).toBe(false)
})

Expand Down
34 changes: 29 additions & 5 deletions packages/kilo-vscode/webview-ui/agent-manager/terminal/side.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ export interface SideTerminalDeps {
saved: TerminalDestination | undefined
/** Persist the panel-local choice so it survives webview reloads. */
save: (destination: TerminalDestination) => void
/** Platform override for tests; the workbench keybinding is Cmd on
* macOS and Ctrl elsewhere, and the local fallback must match it. */
mac?: boolean
Comment thread
marius-kilocode marked this conversation as resolved.
}

export function createSideTerminal(deps: SideTerminalDeps) {
Expand Down Expand Up @@ -162,23 +165,44 @@ export function createSideTerminal(deps: SideTerminalDeps) {
/**
* Cmd/Ctrl+/ pressed while the webview holds DOM focus. VS Code normally
* forwards the keybinding to the workbench too, and the extension echoes
* it back as a showTerminal action message; `echo()` lets the action
* handler skip that duplicate so one keypress never toggles twice.
* it back as a showTerminal action message; `echo()` consumes one pending
* echo per press so one keypress never toggles twice while an unrelated
* invocation (command palette, terminal-focused press) still runs.
* Handling the key locally keeps the shortcut working when the
* forwarding path drops it (e.g. the chat prompt input is focused).
* The modifier matches the declared keybinding: Cmd on macOS, Ctrl
* elsewhere — accepting both would hijack the other platform's combo
* (and any user keybinding on it) without a matching workbench binding.
*/
const mac = deps.mac ?? (typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent))
let pending = 0
let lastPress = 0
const ECHO_MS = 500

const press = (e: KeyboardEvent): boolean => {
if (e.key !== "/" || !(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return false
const mod = mac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey
if (e.key !== "/" || e.shiftKey || e.altKey || !mod) return false
// An echo either arrives promptly or never; drop the backlog of a
// dropped echo so it cannot swallow a later unrelated invocation.
if (Date.now() - lastPress > ECHO_MS) pending = 0
pending++
Comment thread
marius-kilocode marked this conversation as resolved.
lastPress = Date.now()
openPreferred("keyboard_shortcut")
return true
}

/** True while an incoming showTerminal action is the echo of `press`. */
const echo = () => Date.now() - lastPress < ECHO_MS
/** Consumes one pending extension echo of a local `press`. The timeout is
* only a safety valve for an echo that never arrives; an echo that
* outlasts it is indistinguishable from a real invocation and must run. */
const echo = () => {
if (pending === 0) return false
if (Date.now() - lastPress > ECHO_MS) {
pending = 0
return false
}
pending--
return true
}

return { destination, syncDefault, toggle, close, openPreferred, choose, press, echo }
}
Loading