wip: shortcuts panel

desktop-shortcuts-panel
David Hill 2025-12-22 12:50:34 +00:00 committed by Adam
parent dad5dbc1cc
commit 0f398e612f
No known key found for this signature in database
GPG Key ID: 9CB48779AF150E75
6 changed files with 501 additions and 20 deletions

View File

@ -0,0 +1,214 @@
import { For, createSignal, Show, onMount, onCleanup } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { parseKeybind } from "@/context/command"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const SPECIAL_CHAR_NAMES: Record<string, string> = {
"^": "Control",
"⌥": "Option",
"⇧": "Shift",
"⌘": "Command",
"↑": "Arrow Up",
"↓": "Arrow Down",
"`": "Backtick",
"'": "Quote",
".": "Period",
",": "Comma",
"/": "Slash",
"\\": "Backslash",
"[": "Left Bracket",
"]": "Right Bracket",
"-": "Minus",
"=": "Equals",
";": "Semicolon",
}
const KEY_DISPLAY_MAP: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
backspace: "⌫",
}
interface Shortcut {
title: string
keybind: string
}
interface ShortcutCategory {
name: string
shortcuts: Shortcut[]
}
function isLetter(char: string): boolean {
return /^[A-Za-z]$/.test(char)
}
function getKeyChars(config: string): string[] {
const keybinds = parseKeybind(config)
if (keybinds.length === 0) return []
const kb = keybinds[0]
const chars: string[] = []
if (kb.ctrl) chars.push(IS_MAC ? "^" : "Ctrl")
if (kb.alt) chars.push(IS_MAC ? "⌥" : "Alt")
if (kb.shift) chars.push(IS_MAC ? "⇧" : "Shift")
if (kb.meta) chars.push(IS_MAC ? "⌘" : "Meta")
if (kb.key) {
const mapped = KEY_DISPLAY_MAP[kb.key.toLowerCase()]
if (mapped) {
chars.push(mapped)
} else {
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
for (const char of displayKey) {
chars.push(char)
}
}
}
return chars
}
const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{
name: "General",
shortcuts: [
{ title: "Command palette", keybind: "mod+shift+p" },
{ title: "Toggle sidebar", keybind: "mod+b" },
{ title: "Toggle shortcuts", keybind: "ctrl+/" },
{ title: "Open file", keybind: "mod+p" },
{ title: "Open project", keybind: "mod+o" },
],
},
{
name: "Session",
shortcuts: [
{ title: "New session", keybind: "mod+shift+s" },
{ title: "Previous session", keybind: "alt+arrowup" },
{ title: "Next session", keybind: "alt+arrowdown" },
{ title: "Archive session", keybind: "mod+shift+backspace" },
{ title: "Undo", keybind: "mod+z" },
{ title: "Redo", keybind: "mod+shift+z" },
],
},
{
name: "Navigation",
shortcuts: [
{ title: "Previous message", keybind: "mod+arrowup" },
{ title: "Next message", keybind: "mod+arrowdown" },
{ title: "Toggle steps", keybind: "mod+e" },
],
},
{
name: "Model and Agent",
shortcuts: [
{ title: "Choose model", keybind: "mod+'" },
{ title: "Cycle agent", keybind: "mod+." },
],
},
{
name: "Terminal",
shortcuts: [
{ title: "Toggle terminal", keybind: "ctrl+`" },
{ title: "New terminal", keybind: "ctrl+shift+`" },
],
},
]
const USED_SHORTCUTS_KEY = "opencode:used-shortcuts"
function getUsedShortcuts(): Set<string> {
const stored = localStorage.getItem(USED_SHORTCUTS_KEY)
return stored ? new Set(JSON.parse(stored)) : new Set()
}
const [usedShortcuts, setUsedShortcuts] = createSignal(getUsedShortcuts())
function formatKeybindForCopy(config: string): string {
const chars = getKeyChars(config)
return chars.join("")
}
function ShortcutItem(props: { shortcut: Shortcut }) {
const [copied, setCopied] = createSignal(false)
const used = () => usedShortcuts().has(props.shortcut.keybind)
function copyToClipboard() {
const text = formatKeybindForCopy(props.shortcut.keybind)
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<Tooltip value="Copy to clipboard" placement="top">
<button type="button" class="shortcut-item" classList={{ "shortcut-used": used() }} onClick={copyToClipboard}>
<span class="text-14-regular text-text-base">{props.shortcut.title}</span>
<Show
when={!copied()}
fallback={
<div class="shortcut-copied">
<Icon name="check" size="small" />
</div>
}
>
<div class="shortcut-keys">
<For each={getKeyChars(props.shortcut.keybind)}>
{(char) => {
const tooltip = SPECIAL_CHAR_NAMES[char]
return (
<Show when={tooltip && !isLetter(char)} fallback={<kbd class="shortcut-key">{char}</kbd>}>
<Tooltip value={tooltip} placement="top">
<kbd class="shortcut-key">{char}</kbd>
</Tooltip>
</Show>
)
}}
</For>
</div>
</Show>
</button>
</Tooltip>
)
}
export function ShortcutsPanel(props: { onClose: () => void }) {
const [activeTab, setActiveTab] = createSignal(SHORTCUT_CATEGORIES[0].name)
onMount(() => {
const handler = () => setUsedShortcuts(getUsedShortcuts())
window.addEventListener("shortcut-used", handler)
onCleanup(() => window.removeEventListener("shortcut-used", handler))
})
return (
<div class="shortcuts-panel" data-component="shortcuts-panel">
<Tabs value={activeTab()} onChange={setActiveTab}>
<div class="shortcuts-tabs-row">
<Tabs.List class="shortcuts-tabs-list">
<For each={SHORTCUT_CATEGORIES}>
{(category) => <Tabs.Trigger value={category.name}>{category.name}</Tabs.Trigger>}
</For>
</Tabs.List>
<IconButton icon="close" variant="ghost" onClick={props.onClose} />
</div>
<For each={SHORTCUT_CATEGORIES}>
{(category) => (
<Tabs.Content value={category.name} class="shortcuts-content">
<div class="shortcuts-grid">
<For each={category.shortcuts}>{(shortcut) => <ShortcutItem shortcut={shortcut} />}</For>
</div>
</Tabs.Content>
)}
</For>
</Tabs>
</div>
)
}

View File

@ -1,6 +1,8 @@
import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@ -120,6 +122,73 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+")
}
function DialogCommand(props: { options: CommandOption[] }) {
const dialog = useDialog()
const state = { cleanup: undefined as (() => void) | void, committed: false }
const handleMove = (option: CommandOption | undefined) => {
state.cleanup?.()
if (!option) return
state.cleanup = option.onHighlight?.()
}
const handleSelect = (option: CommandOption | undefined) => {
if (!option) return
state.committed = true
state.cleanup = undefined
dialog.close()
option.onSelect?.("palette")
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return (
<Dialog title="Commands">
<List
class="px-2.5"
search={{ placeholder: "Search commands", autofocus: true }}
emptyMessage="No commands found"
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
key={(x) => x?.id}
filterKeys={["title", "description", "category"]}
groupBy={(x) => x.category ?? ""}
onMove={handleMove}
onSelect={handleSelect}
>
{(option) => (
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
<Show when={option.description}>
<span class="text-14-regular text-text-weak truncate">{option.description}</span>
</Show>
</div>
<Show when={option.keybind}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
</Show>
</div>
)}
</List>
</Dialog>
)
}
const USED_SHORTCUTS_KEY = "opencode:used-shortcuts"
function getUsedShortcuts(): Set<string> {
const stored = localStorage.getItem(USED_SHORTCUTS_KEY)
return stored ? new Set(JSON.parse(stored)) : new Set()
}
function markShortcutUsed(keybind: string) {
const used = getUsedShortcuts()
used.add(keybind)
localStorage.setItem(USED_SHORTCUTS_KEY, JSON.stringify([...used]))
window.dispatchEvent(new CustomEvent("shortcut-used", { detail: keybind }))
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {
@ -163,7 +232,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
const showPalette = () => {
run("file.open", "palette")
if (dialog.active) return
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
}
const handleKeyDown = (event: KeyboardEvent) => {
@ -172,6 +242,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const paletteKeybinds = parseKeybind("mod+shift+p")
if (matchKeybind(paletteKeybinds, event)) {
event.preventDefault()
markShortcutUsed("mod+shift+p")
showPalette()
return
}
@ -183,6 +254,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const keybinds = parseKeybind(option.keybind)
if (matchKeybind(keybinds, event)) {
event.preventDefault()
markShortcutUsed(option.keybind)
option.onSelect?.("keybind")
return
}

View File

@ -36,3 +36,173 @@
}
}
}
/* Shortcuts panel */
[data-component="shortcuts-panel"] {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-background-base);
border-top: 1px solid var(--color-border-weak-base);
z-index: 100;
display: flex;
flex-direction: column;
height: 280px;
animation: slide-up 0.2s ease-out;
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.shortcuts-panel [data-component="tabs"] {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.shortcuts-tabs-row {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
position: relative;
padding: 16px 0;
}
.shortcuts-tabs-row > [data-component="icon-button"] {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
}
.shortcuts-panel [data-slot="tabs-list"] {
height: auto;
justify-content: center;
gap: 24px;
}
.shortcuts-panel [data-slot="tabs-list"]::after {
display: none;
}
.shortcuts-panel [data-slot="tabs-trigger-wrapper"] {
background: transparent;
border: 0.5px solid transparent;
font-weight: var(--font-weight-regular);
border-radius: 6px;
color: var(--color-text-weak);
}
.shortcuts-panel [data-slot="tabs-trigger-wrapper"]:hover {
background: var(--color-surface-base);
}
.shortcuts-panel [data-slot="tabs-trigger-wrapper"]:has([data-selected]) {
border: 1px solid var(--color-border-weak-base);
background: var(--color-surface-raised-base);
color: var(--color-text-strong);
}
.shortcuts-panel [data-slot="tabs-trigger"] {
padding: 4px 12px;
}
.shortcuts-panel [data-slot="tabs-content"] {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
justify-content: center;
}
.shortcuts-grid {
display: grid;
grid-template-columns: repeat(4, 240px);
gap: 4px 48px;
align-content: start;
}
.shortcut-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 6px 6px 12px;
margin: 0 -12px;
gap: 16px;
height: 36px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.1s ease;
text-align: left;
width: calc(100% + 24px);
box-sizing: border-box;
}
.shortcut-item:hover {
background: var(--color-surface-base);
}
.shortcut-item:hover .shortcut-key {
background: var(--color-background-stronger);
}
.shortcut-item:active {
background: var(--color-surface-raised-base);
}
.shortcut-item.shortcut-used span {
color: var(--color-text-interactive-base);
}
.shortcut-item.shortcut-used .shortcut-key {
color: var(--color-text-interactive-base);
border-color: var(--color-border-interactive-base);
background: var(--color-surface-interactive-base);
box-shadow: var(--shadow-xs-border-interactive);
}
.shortcut-keys {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.shortcut-copied {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-success-base);
margin-right: 4px;
}
.shortcut-key {
display: flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 6px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-subtle);
box-shadow: var(--shadow-xs-border-base);
border-radius: 4px;
white-space: nowrap;
}
/* Adjust main content when shortcuts panel is open */
main.shortcuts-open {
padding-bottom: 280px;
}

View File

@ -65,6 +65,7 @@ import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { ReleaseNotesHandler } from "@/components/release-notes-handler"
import { ShortcutsPanel } from "@/components/shortcuts-panel"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
@ -80,6 +81,7 @@ export default function Layout(props: ParentProps) {
workspaceExpanded: {} as Record<string, boolean>,
}),
)
const [shortcutsOpen, setShortcutsOpen] = createSignal(false)
const pageReady = createMemo(() => ready())
@ -750,6 +752,13 @@ export default function Layout(props: ParentProps) {
keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(),
},
{
id: "shortcuts.toggle",
title: "Toggle shortcuts panel",
category: "View",
keybind: "ctrl+/",
onSelect: () => setShortcutsOpen(!shortcutsOpen()),
},
{
id: "project.open",
title: "Open project",
@ -1928,14 +1937,19 @@ export default function Layout(props: ParentProps) {
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings" class="hidden">
<IconButton disabled icon="settings-gear" variant="ghost" size="large" />
</Tooltip>
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
<IconButton
icon="help"
variant="ghost"
size="large"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
/>
</Tooltip>
<DropdownMenu>
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
<DropdownMenu.Trigger as={IconButton} icon="question-mark" variant="ghost" size="large" />
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => platform.openLink("https://opencode.ai/desktop-feedback")}>
Submit feedback
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => setShortcutsOpen(true)}>Keyboard shortcuts</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
@ -2146,11 +2160,15 @@ export default function Layout(props: ParentProps) {
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
"xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(),
"shortcuts-open": shortcutsOpen(),
}}
>
{props.children}
</main>
</div>
<Show when={shortcutsOpen()}>
<ShortcutsPanel onClose={() => setShortcutsOpen(false)} />
</Show>
<Toast.Region />
<ReleaseNotesHandler />
</div>

View File

@ -66,6 +66,8 @@ const icons = {
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
"question-mark": `<path d="M6 3.9975V2H14V6.99376L10 9.99001V11.9875M10 17.98V18" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" />`
}
export interface IconProps extends ComponentProps<"svg"> {

View File

@ -73,6 +73,11 @@
0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.25),
0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak, #f1f0f0),
0 0 0 3px var(--border-selected, rgba(0, 74, 255, 0.99));
--shadow-xs-border-interactive:
0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)),
0 1px 2px -1px rgba(19, 16, 16, 0.25),
0 1px 2px 0 rgba(19, 16, 16, 0.08),
0 1px 3px 0 rgba(19, 16, 16, 0.12);
--shadow-lg-border-base:
0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)),
0 36px 80px 0 rgba(0, 0, 0, 0.03),
@ -372,10 +377,10 @@
--surface-raised-stronger-non-alpha: var(--smoke-dark-3);
--surface-brand-base: var(--yuzu-light-9);
--surface-brand-hover: var(--yuzu-light-10);
--surface-interactive-base: var(--cobalt-light-3);
--surface-interactive-hover: var(--cobalt-light-4);
--surface-interactive-weak: var(--cobalt-light-2);
--surface-interactive-weak-hover: var(--cobalt-light-3);
--surface-interactive-base: var(--cobalt-dark-3);
--surface-interactive-hover: var(--cobalt-dark-4);
--surface-interactive-weak: var(--cobalt-dark-2);
--surface-interactive-weak-hover: var(--cobalt-dark-3);
--surface-success-base: var(--apple-light-3);
--surface-success-weak: var(--apple-light-2);
--surface-success-strong: var(--apple-light-9);
@ -462,12 +467,12 @@
--border-weak-selected: var(--cobalt-dark-alpha-6);
--border-weak-disabled: var(--smoke-dark-alpha-6);
--border-weak-focus: var(--smoke-dark-alpha-8);
--border-interactive-base: var(--cobalt-light-7);
--border-interactive-hover: var(--cobalt-light-8);
--border-interactive-active: var(--cobalt-light-9);
--border-interactive-selected: var(--cobalt-light-9);
--border-interactive-disabled: var(--smoke-light-8);
--border-interactive-focus: var(--cobalt-light-9);
--border-interactive-base: var(--cobalt-dark-7);
--border-interactive-hover: var(--cobalt-dark-8);
--border-interactive-active: var(--cobalt-dark-9);
--border-interactive-selected: var(--cobalt-dark-9);
--border-interactive-disabled: var(--smoke-dark-8);
--border-interactive-focus: var(--cobalt-dark-9);
--border-success-base: var(--apple-light-6);
--border-success-hover: var(--apple-light-7);
--border-success-selected: var(--apple-light-9);