diff --git a/packages/app/src/runtime/extension/commands.tsx b/packages/app/src/runtime/extension/commands.tsx index f07a42a68e7..098b869923f 100644 --- a/packages/app/src/runtime/extension/commands.tsx +++ b/packages/app/src/runtime/extension/commands.tsx @@ -1,6 +1,6 @@ import { createMemo } from "solid-js" import { Command } from "@opencode/gui-extensions/sdk" -import { useCommand } from "@/shell/commands/command" +import { useCommand, type CommandSource } from "@/shell/commands/command" import { useSettings } from "@/settings/model" import { migrateKeybinds } from "@/settings/keybinds/migration" import { useExtensionHost } from "./host" @@ -33,13 +33,22 @@ export function ExtensionCommands() { disabled: item.value.enabled === false, hidden: item.value.hidden, editable: item.value.editable, - when: item.value.scope - ? (event: KeyboardEvent) => - event.target instanceof Element && !!event.target.closest(item.value.scope as string) - : undefined, - onSelect: (_source: unknown, input?: string) => item.value.run(input), + when: item.value.scope ? scopedTo(item.value.scope) : undefined, + onSelect: (_source?: CommandSource, input?: string) => item.value.run(input), })), ) return null } + +const scopedTo = (scope: string) => (event: KeyboardEvent) => + event.target instanceof Element && !!event.target.closest(scope) + +/** Focus inside an extension command's scope belongs to that extension, which binds its own shortcuts there. */ +export function useExtensionScoped() { + const host = useExtensionHost() + + return (target: EventTarget | null) => + target instanceof Element && + host.items(Command).some((item) => !!item.value.scope && !!target.closest(item.value.scope)) +} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 2e38dd1238b..d8ef59bfe6e 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -89,6 +89,7 @@ export const dict = { "command.session.export.description": "Export the full session transcript as JSON", "command.session.import": "Import session", "command.session.copyID": "Copy Session ID", + "command.session.rename": "Rename session", "palette.search.placeholder": "Search files, commands, and sessions", "palette.search.placeholder.home": "Search commands and sessions", diff --git a/packages/app/src/session/commands/use-session-commands.tsx b/packages/app/src/session/commands/use-session-commands.tsx index 559ff66dc4f..8a420a04c3d 100644 --- a/packages/app/src/session/commands/use-session-commands.tsx +++ b/packages/app/src/session/commands/use-session-commands.tsx @@ -15,8 +15,7 @@ import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/ import { usePlatform } from "@/runtime/platform/platform" import type { SessionModel } from "@/session/model" import type { SessionRevert } from "@/session/revert" -import { Command } from "@opencode/gui-extensions/sdk" -import { useExtensionHost } from "@/runtime/extension/host" +import { useExtensionScoped } from "@/runtime/extension/commands" import type { Region } from "@/runtime/extension/panels" import { createSideFocus } from "@/runtime/extension/closed-tabs" @@ -60,7 +59,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const settings = useSettings() const platform = usePlatform() const layout = useLayout() - const host = useExtensionHost() + const extensionScoped = useExtensionScoped() const openDialog = async (load: () => Promise, show: (value: T) => void) => { const owner = actions.session.ownership.capture() @@ -85,11 +84,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const inSide = createSideFocus() const cycleSideTabs = () => actions.session.layout.view().side.opened() && actions.region.keys().length > 1 - // Focus inside an extension command's scope belongs to that extension, which binds its own shortcuts there. - const extensionScoped = (target: EventTarget | null) => - target instanceof Element && - host.items(Command).some((item) => !!item.value.scope && !!target.closest(item.value.scope)) - const sideFocused = (target: EventTarget | null) => { const element = target instanceof Element && target !== document.body && target !== document.documentElement diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 20d89099626..8eccb4d0822 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -25,6 +25,7 @@ import { isWorkspaceDirectory } from "@/workspaces/paths" import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note" import { parseVoiceDelegation } from "./voice-delegation" import { useCommand } from "@/shell/commands/command" +import { useExtensionScoped } from "@/runtime/extension/commands" import { SessionAncestorTrail, SessionHeaderLayout, @@ -127,6 +128,8 @@ function MessageTimelineView( }, ) { const language = useLanguage() + const command = useCommand() + const extensionScoped = useExtensionScoped() const server = useServer() const data = server.ctx.data const sdk = useWorkspaceLocation() @@ -288,6 +291,22 @@ function MessageTimelineView( if (await props.action.rename(title.draft)) setTitle("editing", false) } + command.register(() => + props.active === false || props.hideHeader + ? [] + : [ + { + id: "session.rename", + category: language.t("command.category.session"), + title: language.t("command.session.rename"), + keybind: "ctrl+r", + disabled: !sessionID() || !!parentID() || title.editing, + skip: (event) => extensionScoped(event.target), + onSelect: openTitleEditor, + }, + ], + ) + createEffect(() => { if (props.active !== false) return setTitle({ draft: "", editing: false, menuOpen: false, pendingRename: false }) diff --git a/packages/app/src/shell/commands/command.test.ts b/packages/app/src/shell/commands/command.test.ts index c58195feb06..8ffad058060 100644 --- a/packages/app/src/shell/commands/command.test.ts +++ b/packages/app/src/shell/commands/command.test.ts @@ -5,6 +5,7 @@ import { addCommandRegistration, commandPaletteOptions, CommandCatalog, + commandKeymap, formatKeybind, matchKeybind, parseKeybind, @@ -68,6 +69,26 @@ test.each([true, false])("resolveKeybindOption prefers a contextual command only ) }) +test("commandKeymap puts a user's binding before a default on the same keys", () => { + const rename = { id: "session.rename", title: "Rename session", keybind: "ctrl+r" } + const focus = { id: "input.focus", title: "Focus input", keybind: "ctrl+r" } + const event = new KeyboardEvent("keydown", { key: "r", ctrlKey: true }) + const keymap = (custom: string[]) => commandKeymap([rename, focus], (id) => custom.includes(id)).get("r:1") + + expect(resolveKeybindOption(keymap([]), event)).toBe(rename) + expect(resolveKeybindOption(keymap(["input.focus"]), event)).toBe(focus) + expect(commandKeymap([{ ...rename, disabled: true }], () => false).size).toBe(0) +}) + +test.each([true, false])("resolveKeybindOption leaves the key to a skipping command's target: %p", (inside) => { + const rename = { id: "session.rename", title: "Rename session", skip: () => inside } + const focus = { id: "input.focus", title: "Focus input" } + const event = new KeyboardEvent("keydown") + + expect(resolveKeybindOption([rename], event)).toBe(inside ? undefined : rename) + expect(resolveKeybindOption([rename, focus], event)).toBe(inside ? focus : rename) +}) + describe("command keybinds", () => { test("parseKeybind handles aliases, multiple combos, and disabled bindings", () => { const keybinds = parseKeybind("control+option+k, mod+shift+comma") diff --git a/packages/app/src/shell/commands/command.tsx b/packages/app/src/shell/commands/command.tsx index 18197ef98ef..0625e73df34 100644 --- a/packages/app/src/shell/commands/command.tsx +++ b/packages/app/src/shell/commands/command.tsx @@ -2,7 +2,7 @@ import { createSimpleContext } from "@opencode/ui/context" import { useDialog } from "@opencode/ui/context/dialog" import { type Accessor, batch, createEffect, createMemo, onCleanup, onMount, untrack } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" -import { Schema } from "effect" +import { Predicate, Schema } from "effect" import { Persistence } from "@/runtime/persistence/schema" import { makeEventListener } from "@solid-primitives/event-listener" import { useLanguage } from "@/runtime/i18n/language" @@ -11,7 +11,7 @@ import { keybindRenames } from "@/settings/keybinds/migration" import en from "@/runtime/i18n/en" import { Persist, persisted } from "@/runtime/persistence/storage" -const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) +const IS_MAC = /(Mac|iPod|iPhone|iPad)/.test(globalThis.navigator?.platform ?? "") const PALETTE_ID = "command.palette" @@ -112,6 +112,8 @@ export interface CommandOption { /** The keybind also fires while a text field has focus. */ editable?: boolean when?: (event: KeyboardEvent) => boolean + /** The keybind leaves this event to its target, as if the command had no keybind. */ + skip?: (event: KeyboardEvent) => boolean onSelect?: (source?: "palette" | "keybind" | "slash", input?: string) => void | Promise onHighlight?: () => (() => void) | void } @@ -123,11 +125,38 @@ export function commandPaletteOptions(options: CommandOption[]) { ) } -export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) { - return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when) +/** Maps each keybind signature to its commands. A user's own binding comes before a default on the same keys. */ +export function commandKeymap(options: CommandOption[], custom: (id: string) => boolean) { + const map = new Map() + + for (const option of options) { + if (option.id.startsWith(SUGGESTED_PREFIX)) continue + + if (option.disabled) continue + + if (!option.keybind) continue + + const keybinds = parseKeybind(option.keybind) + + for (const kb of keybinds) { + if (!kb.key) continue + const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt) + const existing = map.get(sig) ?? [] + + map.set(sig, custom(actionId(option.id)) ? [option, ...existing] : [...existing, option]) + } + } + + return map } -type CommandSource = "palette" | "keybind" | "slash" +export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) { + const live = candidates?.filter((option) => !option.skip?.(event)) + + return live?.find((option) => option.when?.(event)) ?? live?.find((option) => !option.when) +} + +export type CommandSource = "palette" | "keybind" | "slash" export const CommandCatalogItem = Persistence.struct({ title: Schema.String, @@ -264,39 +293,37 @@ function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) { if (!kb.key) return parts - const keys: Record = { - arrowup: "↑", - arrowdown: "↓", - arrowleft: "←", - arrowright: "→", - comma: ",", - plus: "+", - } + const keys = new Map([ + ["arrowup", "↑"], + ["arrowdown", "↓"], + ["arrowleft", "←"], + ["arrowright", "→"], + ["comma", ","], + ["plus", "+"], + ]) - const named: Record = { - backspace: "common.key.backspace", - delete: "common.key.delete", - end: "common.key.end", - enter: "common.key.enter", - esc: "common.key.esc", - escape: "common.key.esc", - home: "common.key.home", - insert: "common.key.insert", - pagedown: "common.key.pageDown", - pageup: "common.key.pageUp", - space: "common.key.space", - tab: "common.key.tab", - } + const named = new Map([ + ["backspace", "common.key.backspace"], + ["delete", "common.key.delete"], + ["end", "common.key.end"], + ["enter", "common.key.enter"], + ["esc", "common.key.esc"], + ["escape", "common.key.esc"], + ["home", "common.key.home"], + ["insert", "common.key.insert"], + ["pagedown", "common.key.pageDown"], + ["pageup", "common.key.pageUp"], + ["space", "common.key.space"], + ["tab", "common.key.tab"], + ]) const key = kb.key.toLowerCase() + const label = named.get(key) + const displayKey = - keys[key] ?? - (named[key] - ? keyText(named[key], t) - : key.length === 1 - ? key.toUpperCase() - : key.charAt(0).toUpperCase() + key.slice(1)) + keys.get(key) ?? + (label ? keyText(label, t) : key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1)) parts.push(displayKey) @@ -337,8 +364,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const settings = useSettings() const language = useLanguage() - const [store, setStore] = createStore({ - registrations: [] as CommandRegistration[], + const [store, setStore] = createStore<{ registrations: CommandRegistration[]; suspendCount: number }>({ + registrations: [], suspendCount: 0, }) @@ -437,34 +464,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt))) }) - const keymap = createMemo(() => { - const map = new Map() - - for (const option of options()) { - if (option.id.startsWith(SUGGESTED_PREFIX)) continue - - if (option.disabled) continue - - if (!option.keybind) continue - - const keybinds = parseKeybind(option.keybind) - - for (const kb of keybinds) { - if (!kb.key) continue - const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt) - const existing = map.get(sig) - - if (existing) { - existing.push(option) - continue - } - - map.set(sig, [option]) - } - } - - return map - }) + const keymap = createMemo(() => commandKeymap(options(), (id) => settings.keybinds.get(id) !== undefined)) const optionMap = createMemo(() => { const map = new Map() @@ -519,8 +519,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex function register(cb: () => CommandOption[]): void function register(key: string, cb: () => CommandOption[]): void function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) { - const id = typeof key === "string" ? key : undefined - const next = typeof key === "function" ? key : cb + const id = Predicate.isString(key) ? key : undefined + const next = Predicate.isFunction(key) ? key : cb if (!next) return const options = createMemo(next) diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 824061a2af4..4babafbe516 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -59,6 +59,11 @@ export const registerIpcHandlers = Effect.gen(function* () { const wire = (win: BrowserWindow) => { win.webContents.on("before-input-event", (_event, input) => { + // Ctrl+R renames the session in the renderer, so the default menu's Reload must not take it. + win.webContents.setIgnoreMenuShortcuts( + input.control && !input.meta && !input.shift && !input.alt && input.key.toLowerCase() === "r", + ) + if (input.type !== "keyDown" || input.key !== "Escape") return win.webContents.send(DragCancelEvent) })