feat(app): rename the current session with ctrl+r (#57423)

This commit is contained in:
James Long
2026-10-20 11:48:52 -04:00
committed by GitHub
Unverified
parent 5c95abcf34
commit 71a1b10ba4
7 changed files with 128 additions and 79 deletions
@@ -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))
}
+1
View File
@@ -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",
@@ -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 <T,>(load: () => Promise<T>, 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
@@ -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 })
@@ -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")
+65 -65
View File
@@ -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<void>
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<string, CommandOption[]>()
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<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
comma: ",",
plus: "+",
}
const keys = new Map([
["arrowup", "↑"],
["arrowdown", "↓"],
["arrowleft", "←"],
["arrowright", "→"],
["comma", ","],
["plus", "+"],
])
const named: Record<string, KeyLabel> = {
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<string, KeyLabel>([
["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<string, CommandOption[]>()
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<string, CommandOption>()
@@ -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)
+5
View File
@@ -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)
})