diff --git a/packages/app/src/components/shortcuts-panel.tsx b/packages/app/src/components/shortcuts-panel.tsx new file mode 100644 index 0000000000..9823e0315e --- /dev/null +++ b/packages/app/src/components/shortcuts-panel.tsx @@ -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 = { + "^": "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 = { + 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 { + 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 ( + + + + ) +} + +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 ( +
+ +
+ + + {(category) => {category.name}} + + + +
+ + {(category) => ( + +
+ {(shortcut) => } +
+
+ )} +
+
+
+ ) +} diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index d8dc13e234..fd3beddb84 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -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 ( + + 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) => ( +
+
+ {option.title} + + {option.description} + +
+ + {formatKeybind(option.keybind!)} + +
+ )} +
+
+ ) +} + +const USED_SHORTCUTS_KEY = "opencode:used-shortcuts" + +function getUsedShortcuts(): Set { + 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(() => !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 } diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 0201894212..cb25d1c703 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -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; +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 51adcff2da..e53530fa22 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -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, }), ) + 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) { - - platform.openLink("https://opencode.ai/desktop-feedback")} - /> - + + + + + + + platform.openLink("https://opencode.ai/desktop-feedback")}> + Submit feedback + + setShortcutsOpen(true)}>Keyboard shortcuts + + + @@ -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} + + setShortcutsOpen(false)} /> + diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 98f96c8e80..0e52f5f0be 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -66,6 +66,8 @@ const icons = { dash: ``, "cloud-upload": ``, trash: ``, + "question-mark": `` + } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 6634663fb4..b459205f6f 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -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);