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
28 changes: 28 additions & 0 deletions packages/app/src/app-desktop-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"

const appSource = readFileSync(join(import.meta.dir, "app.tsx"), "utf8")

describe("DesktopCommands agent-cycle bridge (amicode#878)", () => {
test("DesktopCommands listens for agent-cycle messages from the extension", () => {
expect(appSource).toContain('"agent-cycle"')
expect(appSource).toContain('"amicode"')
})

test("the handler triggers the agent.cycle command", () => {
const handlerBlock = appSource.match(
/kind.*===.*"agent-cycle"[\s\S]{0,200}?trigger\(\s*"([^"]+)"/,
)
expect(handlerBlock).toBeTruthy()
expect(handlerBlock![1]).toBe("agent.cycle")
})

test("the handler is gated on being inside a frame (window.parent !== window)", () => {
expect(appSource).toContain("window.parent !== window")
})

test("the handler cleans up on component disposal", () => {
expect(appSource).toContain("removeEventListener")
})
})
14 changes: 14 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,20 @@ function DesktopCommands() {
return commands
})

// amicode#878: VS Code intercepts Tab keys before they reach the webview,
// so the extension catches Shift+Tab and bridges it here as an agent-cycle
// message. Trigger the same command the in-app keybind would.
if (window.parent !== window) {
const onAgentCycle = (e: MessageEvent) => {
const d = e.data as { source?: string; kind?: string } | undefined
if (d?.source === "amicode" && d.kind === "agent-cycle") {
command.trigger("agent.cycle", "keybind")
}
}
window.addEventListener("message", onAgentCycle)
onCleanup(() => window.removeEventListener("message", onAgentCycle))
}

return null
}

Expand Down
15 changes: 14 additions & 1 deletion packages/app/src/context/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,20 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex

type CommandCatalog = Record<string, CommandCatalogItem>
const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"),
{
...Persist.global("command.catalog.v1"),
migrate: (value: unknown) => {
// #878: agent.cycle keybind changed from "mod+." to "shift+tab".
// Clear the stale catalog entry so the live registration overwrites it.
if (value && typeof value === "object") {
const v = value as Record<string, { keybind?: string }>
if (v.agent_cycle?.keybind === "mod+.") {
delete v.agent_cycle
}
}
return value
},
},
createStore<CommandCatalog>({}),
)

Expand Down
18 changes: 17 additions & 1 deletion packages/app/src/context/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,23 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
gate: false,
init: () => {
const platform = usePlatform()
const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const [store, setStore, settingsInit, ready] = persisted(
{
key: "settings.v3",
migrate: (value: unknown) => {
// #878: agent.cycle keybind changed from "mod+." to "shift+tab".
// Strip the stale override so the new default takes effect.
if (value && typeof value === "object" && "keybinds" in value) {
const kb = (value as Record<string, unknown>).keybinds
if (kb && typeof kb === "object" && (kb as Record<string, unknown>).agent_cycle === "mod+.") {
delete (kb as Record<string, string>).agent_cycle
}
}
return value
},
},
createStore<Settings>(defaultSettings),
)
const [launch, setLaunch, , launchReady] = persisted(
"app-version.v1",
createStore<{ version?: string }>({ version: undefined }),
Expand Down
26 changes: 26 additions & 0 deletions packages/app/src/pages/session/use-composer-commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "fs"
import { join } from "path"

const SRC = readFileSync(join(__dirname, "use-composer-commands.tsx"), "utf-8")

describe("use-composer-commands keybind declarations (#878)", () => {
test('agent.cycle is bound to "shift+tab", not "mod+." (the old Cmd+Period)', () => {
const agentCycleBlock = SRC.match(/id:\s*"agent\.cycle"[\s\S]*?keybind:\s*"([^"]+)"/)
expect(agentCycleBlock).toBeTruthy()
expect(agentCycleBlock![1]).toBe("shift+tab")
expect(SRC).not.toContain('"mod+."')
expect(SRC).not.toContain('"mod."')
})

test('agent.cycle.reverse is explicitly disabled ("none")', () => {
const reverseBlock = SRC.match(/id:\s*"agent\.cycle\.reverse"[\s\S]*?keybind:\s*"([^"]+)"/)
expect(reverseBlock).toBeTruthy()
expect(reverseBlock![1]).toBe("none")
})

test("no command in the file uses the mod+period keybind", () => {
const modPeriodPattern = /keybind:\s*"[^"]*mod[+.]?\."[^"]*"/
expect(modPeriodPattern.test(SRC)).toBe(false)
})
})
4 changes: 2 additions & 2 deletions packages/app/src/pages/session/use-composer-commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
keybind: "shift+tab",
slash: "agent",
disabled: !local.agent.visible(),
onSelect: () => local.agent.move(1),
Expand All @@ -75,7 +75,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
keybind: "none",
disabled: !local.agent.visible(),
onSelect: () => local.agent.move(-1),
}),
Expand Down
91 changes: 91 additions & 0 deletions packages/app/test-browser/keybind-migration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"

// amicode#878: Verify the settings and catalog persist migrations strip the
// stale "mod+." keybind for agent.cycle so the new "shift+tab" default takes
// effect for existing users.

const settingsSource = readFileSync(join(import.meta.dir, "../src/context/settings.tsx"), "utf8")
const commandSource = readFileSync(join(import.meta.dir, "../src/context/command.tsx"), "utf8")

describe("agent.cycle keybind migration (#878)", () => {
// ── structural guards ──────────────────────────────────────────────────
test("settings.v3 has a migrate that targets agent_cycle", () => {
expect(settingsSource).toContain('agent_cycle === "mod+."')
expect(settingsSource).toContain("delete")
})

test("command.catalog.v1 has a migrate that targets agent_cycle", () => {
expect(commandSource).toContain('agent_cycle?.keybind === "mod+."')
expect(commandSource).toContain("delete")
})

// ── functional tests of the migrate logic ──────────────────────────────
// Extracted verbatim from the source so the test stays honest even if the
// implementation is later refactored into a shared helper.

const migrateSettings = (value: unknown) => {
if (value && typeof value === "object" && "keybinds" in value) {
const kb = (value as Record<string, unknown>).keybinds
if (kb && typeof kb === "object" && (kb as Record<string, unknown>).agent_cycle === "mod+.") {
delete (kb as Record<string, string>).agent_cycle
}
}
return value
}

const migrateCatalog = (value: unknown) => {
if (value && typeof value === "object") {
const v = value as Record<string, { keybind?: string }>
if (v.agent_cycle?.keybind === "mod+.") {
delete v.agent_cycle
}
}
return value
}

test("settings migrate: no keybinds key → no-op", () => {
expect(migrateSettings({ general: {} })).toEqual({ general: {} })
})

test("settings migrate: empty keybinds → no-op", () => {
expect(migrateSettings({ keybinds: {} })).toEqual({ keybinds: {} })
})

test("settings migrate: stale mod+. override is removed", () => {
expect(migrateSettings({ keybinds: { agent_cycle: "mod+." } })).toEqual({ keybinds: {} })
})

test("settings migrate: custom override is preserved", () => {
const input = { keybinds: { agent_cycle: "ctrl+shift+a" } }
expect(migrateSettings(input)).toEqual({ keybinds: { agent_cycle: "ctrl+shift+a" } })
})

test("settings migrate: other keybinds untouched", () => {
const input = { keybinds: { agent_cycle: "mod+.", model_choose: "mod+m" } }
expect(migrateSettings(input)).toEqual({ keybinds: { model_choose: "mod+m" } })
})

test("settings migrate: null input → null", () => {
expect(migrateSettings(null)).toBeNull()
})

test("catalog migrate: stale entry is removed", () => {
const input = { agent_cycle: { keybind: "mod+.", title: "Agent" } }
expect(migrateCatalog(input)).toEqual({})
})

test("catalog migrate: entry with different keybind is preserved", () => {
const input = { agent_cycle: { keybind: "shift+tab", title: "Agent" } }
expect(migrateCatalog(input)).toEqual(input)
})

test("catalog migrate: other entries untouched", () => {
const input = {
agent_cycle: { keybind: "mod+.", title: "Agent" },
model_choose: { keybind: "mod+'", title: "Model" },
}
expect(migrateCatalog(input)).toEqual({ model_choose: { keybind: "mod+'", title: "Model" } })
})
})
2 changes: 1 addition & 1 deletion packages/storybook/.storybook/mocks/app/context/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const keybinds: Record<string, string> = {
"prompt.mode.shell": "mod+shift+x",
"prompt.mode.normal": "mod+shift+e",
"permissions.autoaccept": "mod+shift+a",
"agent.cycle": "mod+.",
"agent.cycle": "shift+tab",
"model.choose": "mod+m",
"model.variant.cycle": "mod+shift+m",
}
Expand Down
Loading