diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts index 2b956287c549..4297dd89bd37 100644 --- a/packages/app/src/context/command.test.ts +++ b/packages/app/src/context/command.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { upsertCommandRegistration } from "./command" +import { resolveKeybindOption, upsertCommandRegistration } from "./command" describe("upsertCommandRegistration", () => { test("replaces keyed registrations", () => { @@ -23,3 +23,19 @@ describe("upsertCommandRegistration", () => { expect(next[1]?.options).toBe(one) }) }) + +describe("resolveKeybindOption", () => { + test("prefers a matching contextual command over the global fallback", () => { + const fallback = { id: "tab.close", title: "Close tab" } + const contextual = { id: "terminal.close", title: "Close terminal", when: () => true } + + expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(contextual) + }) + + test("uses the global fallback outside the command context", () => { + const fallback = { id: "tab.close", title: "Close tab" } + const contextual = { id: "terminal.close", title: "Close terminal", when: () => false } + + expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback) + }) +}) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 0c935307ade1..467a1115b55f 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -82,10 +82,15 @@ export interface CommandOption { suggested?: boolean disabled?: boolean hidden?: boolean + when?: (event: KeyboardEvent) => boolean onSelect?: (source?: "palette" | "keybind" | "slash") => void onHighlight?: () => (() => void) | void } +export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) { + return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when) +} + type CommandSource = "palette" | "keybind" | "slash" export type CommandCatalogItem = { @@ -334,7 +339,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }) const keymap = createMemo(() => { - const map = new Map() + const map = new Map() for (const option of options()) { if (option.id.startsWith(SUGGESTED_PREFIX)) continue if (option.disabled) continue @@ -344,8 +349,12 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex for (const kb of keybinds) { if (!kb.key) continue const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt) - if (map.has(sig)) continue - map.set(sig, option) + const existing = map.get(sig) + if (existing) { + existing.push(option) + continue + } + map.set(sig, [option]) } } return map @@ -374,7 +383,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const sig = signatureFromEvent(event) const isPalette = palette().has(sig) - const option = keymap().get(sig) + const option = resolveKeybindOption(keymap().get(sig), event) const modified = event.ctrlKey || event.metaKey || event.altKey const isTab = event.key === "Tab" @@ -383,17 +392,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex if (isPalette) { event.preventDefault() + event.stopPropagation() showPalette() return } if (!option) return event.preventDefault() + event.stopPropagation() option.onSelect?.("keybind") } onMount(() => { - makeEventListener(document, "keydown", handleKeyDown) + makeEventListener(document, "keydown", handleKeyDown, { capture: true }) }) function register(cb: () => CommandOption[]): void diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index fd2e29d31c69..0f2e76b45fb4 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -269,6 +269,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => { view().terminal.open() } + const closeTerminal = () => { + const id = terminal.active() + if (!id) return + const last = terminal.all().length === 1 + void terminal.close(id) + if (last) view().terminal.close() + } + const chooseMcp = () => { void openDialog( () => import("@/components/dialog-select-mcp"), @@ -518,6 +526,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => { ] const terminalCmds = () => [ + terminalCommand({ + id: "terminal.close", + title: language.t("terminal.close"), + keybind: "mod+w", + hidden: true, + when: (event) => + event.target instanceof Element && !!event.target.closest('[data-component="terminal"]'), + onSelect: closeTerminal, + }), terminalCommand({ id: "terminal.new", title: language.t("command.terminal.new"),