diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5105ee3c63..0451381d58 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -16,6 +16,7 @@ import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" +import { ShortcutsProvider, useShortcuts, ShortcutsPanel } from "./ui/dialog-shortcuts" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" @@ -124,11 +125,13 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise - - - - - + + + + + + + @@ -178,6 +181,7 @@ function App() { const sync = useSync() const exit = useExit() const promptRef = usePromptRef() + const shortcuts = useShortcuts() const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) @@ -411,6 +415,16 @@ function App() { }, category: "System", }, + { + title: "View shortcuts", + value: "shortcuts.view", + keybind: "shortcuts_view", + category: "System", + onSelect: () => { + dialog.clear() + shortcuts.toggle() + }, + }, { title: "Open docs", value: "docs.open", @@ -565,6 +579,7 @@ function App() { { if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) { @@ -585,14 +600,17 @@ function App() { } }} > - - - - - - - - + + + + + + + + + + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 4c82e594c3..d4efac4958 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -1,5 +1,6 @@ import { createMemo } from "solid-js" import { useSync } from "@tui/context/sync" +import { useKV } from "@tui/context/kv" import { Keybind } from "@/util/keybind" import { pipe, mapValues } from "remeda" import type { KeybindsConfig } from "@opencode-ai/sdk/v2" @@ -12,6 +13,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex name: "Keybind", init: () => { const sync = useSync() + const kv = useKV() const keybinds = createMemo(() => { return pipe( sync.data.config.keybinds ?? {}, @@ -49,8 +51,16 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex } } + function trackUsedShortcut(key: keyof KeybindsConfig) { + const used = kv.get("used_shortcuts", []) as string[] + if (!used.includes(key)) { + kv.set("used_shortcuts", [...used, key]) + } + } + useKeyboard(async (evt) => { if (!store.leader && result.match("leader", evt)) { + trackUsedShortcut("leader") leader(true) return } @@ -83,8 +93,9 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex const keybind = keybinds()[key] if (!keybind) return false const parsed: Keybind.Info = result.parse(evt) - for (const key of keybind) { - if (Keybind.match(key, parsed)) { + for (const k of keybind) { + if (Keybind.match(k, parsed)) { + trackUsedShortcut(key) return true } } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-shortcuts.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-shortcuts.tsx new file mode 100644 index 0000000000..ba65b94507 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-shortcuts.tsx @@ -0,0 +1,336 @@ +import { createContext, createMemo, createSignal, For, Show, useContext, type ParentProps } from "solid-js" +import { TextAttributes } from "@opentui/core" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useTheme } from "@tui/context/theme" +import { useKeybind } from "@tui/context/keybind" +import { useKV } from "@tui/context/kv" +import { entries, groupBy, pipe } from "remeda" +import type { KeybindsConfig } from "@opencode-ai/sdk/v2" + +type ShortcutInfo = { + key: keyof KeybindsConfig + title: string + category: string +} + +const SHORTCUTS: ShortcutInfo[] = [ + // General + { key: "leader", title: "Leader key", category: "General" }, + { key: "app_exit", title: "Exit the app", category: "General" }, + { key: "command_list", title: "Command list", category: "General" }, + { key: "shortcuts_view", title: "View shortcuts", category: "General" }, + { key: "status_view", title: "View status", category: "General" }, + { key: "theme_list", title: "Switch theme", category: "General" }, + { key: "editor_open", title: "Open external editor", category: "General" }, + { key: "terminal_suspend", title: "Suspend terminal", category: "General" }, + { key: "terminal_title_toggle", title: "Toggle terminal title", category: "General" }, + + // Session + { key: "session_new", title: "New session", category: "Session" }, + { key: "session_list", title: "Switch session", category: "Session" }, + { key: "session_timeline", title: "Jump to message", category: "Session" }, + { key: "session_fork", title: "Fork from message", category: "Session" }, + { key: "session_rename", title: "Rename session", category: "Session" }, + { key: "session_share", title: "Share session", category: "Session" }, + { key: "session_unshare", title: "Unshare session", category: "Session" }, + { key: "session_export", title: "Export session", category: "Session" }, + { key: "session_compact", title: "Compact session", category: "Session" }, + { key: "session_interrupt", title: "Interrupt session", category: "Session" }, + { key: "session_child_cycle", title: "Next child session", category: "Session" }, + { key: "session_child_cycle_reverse", title: "Previous child session", category: "Session" }, + { key: "session_parent", title: "Go to parent session", category: "Session" }, + { key: "sidebar_toggle", title: "Toggle sidebar", category: "Session" }, + { key: "scrollbar_toggle", title: "Toggle scrollbar", category: "Session" }, + { key: "username_toggle", title: "Toggle username", category: "Session" }, + { key: "tool_details", title: "Toggle tool details", category: "Session" }, + + // Messages + { key: "messages_page_up", title: "Page up", category: "Navigation" }, + { key: "messages_page_down", title: "Page down", category: "Navigation" }, + { key: "messages_half_page_up", title: "Half page up", category: "Navigation" }, + { key: "messages_half_page_down", title: "Half page down", category: "Navigation" }, + { key: "messages_first", title: "First message", category: "Navigation" }, + { key: "messages_last", title: "Last message", category: "Navigation" }, + { key: "messages_next", title: "Next message", category: "Navigation" }, + { key: "messages_previous", title: "Previous message", category: "Navigation" }, + { key: "messages_last_user", title: "Last user message", category: "Navigation" }, + { key: "messages_copy", title: "Copy last message", category: "Navigation" }, + { key: "messages_undo", title: "Undo message", category: "Navigation" }, + { key: "messages_redo", title: "Redo message", category: "Navigation" }, + { key: "messages_toggle_conceal", title: "Toggle code conceal", category: "Navigation" }, + + // Agent & Model + { key: "agent_list", title: "Switch agent", category: "Agent" }, + { key: "agent_cycle", title: "Next agent", category: "Agent" }, + { key: "agent_cycle_reverse", title: "Previous agent", category: "Agent" }, + { key: "model_list", title: "Switch model", category: "Agent" }, + { key: "model_cycle_recent", title: "Next recent model", category: "Agent" }, + { key: "model_cycle_recent_reverse", title: "Previous recent model", category: "Agent" }, + { key: "model_cycle_favorite", title: "Next favorite model", category: "Agent" }, + { key: "model_cycle_favorite_reverse", title: "Previous favorite model", category: "Agent" }, + + // Input + { key: "input_submit", title: "Submit", category: "Input" }, + { key: "input_newline", title: "New line", category: "Input" }, + { key: "input_clear", title: "Clear", category: "Input" }, + { key: "input_paste", title: "Paste", category: "Input" }, + { key: "input_undo", title: "Undo", category: "Input" }, + { key: "input_redo", title: "Redo", category: "Input" }, + { key: "input_move_left", title: "Move left", category: "Input" }, + { key: "input_move_right", title: "Move right", category: "Input" }, + { key: "input_move_up", title: "Move up", category: "Input" }, + { key: "input_move_down", title: "Move down", category: "Input" }, + { key: "input_word_forward", title: "Word forward", category: "Input" }, + { key: "input_word_backward", title: "Word backward", category: "Input" }, + { key: "input_line_home", title: "Line start", category: "Input" }, + { key: "input_line_end", title: "Line end", category: "Input" }, + { key: "input_visual_line_home", title: "Visual line start", category: "Input" }, + { key: "input_visual_line_end", title: "Visual line end", category: "Input" }, + { key: "input_buffer_home", title: "Buffer start", category: "Input" }, + { key: "input_buffer_end", title: "Buffer end", category: "Input" }, + { key: "input_backspace", title: "Backspace", category: "Input" }, + { key: "input_delete", title: "Delete", category: "Input" }, + { key: "input_delete_line", title: "Delete line", category: "Input" }, + { key: "input_delete_to_line_end", title: "Delete to line end", category: "Input" }, + { key: "input_delete_to_line_start", title: "Delete to line start", category: "Input" }, + { key: "input_delete_word_forward", title: "Delete word forward", category: "Input" }, + { key: "input_delete_word_backward", title: "Delete word backward", category: "Input" }, + { key: "input_select_left", title: "Select left", category: "Input" }, + { key: "input_select_right", title: "Select right", category: "Input" }, + { key: "input_select_up", title: "Select up", category: "Input" }, + { key: "input_select_down", title: "Select down", category: "Input" }, + { key: "input_select_word_forward", title: "Select word forward", category: "Input" }, + { key: "input_select_word_backward", title: "Select word backward", category: "Input" }, + { key: "input_select_line_home", title: "Select to line start", category: "Input" }, + { key: "input_select_line_end", title: "Select to line end", category: "Input" }, + { key: "input_select_visual_line_home", title: "Select to visual line start", category: "Input" }, + { key: "input_select_visual_line_end", title: "Select to visual line end", category: "Input" }, + { key: "input_select_buffer_home", title: "Select to buffer start", category: "Input" }, + { key: "input_select_buffer_end", title: "Select to buffer end", category: "Input" }, + + // History + { key: "history_previous", title: "Previous history", category: "History" }, + { key: "history_next", title: "Next history", category: "History" }, + + // Home + { key: "tips_toggle", title: "Toggle tips", category: "Home" }, +] + +const CATEGORY_ORDER = ["General", "Session", "Navigation", "Agent", "Input", "History", "Home"] + +function categorySort(a: string, b: string) { + const indexA = CATEGORY_ORDER.indexOf(a) + const indexB = CATEGORY_ORDER.indexOf(b) + if (indexA === -1 && indexB === -1) return a.localeCompare(b) + if (indexA === -1) return 1 + if (indexB === -1) return -1 + return indexA - indexB +} + +type ShortcutsContext = { + visible: () => boolean + show: () => void + hide: () => void + toggle: () => void +} + +const ctx = createContext() +const [globalVisible, setGlobalVisible] = createSignal(false) + +export function useShortcuts() { + const value = useContext(ctx) + if (!value) { + throw new Error("useShortcuts must be used within a ShortcutsProvider") + } + return value +} + +export function ShortcutsProvider(props: ParentProps) { + const value: ShortcutsContext = { + visible: globalVisible, + show: () => setGlobalVisible(true), + hide: () => setGlobalVisible(false), + toggle: () => setGlobalVisible((v) => !v), + } + + return {props.children} +} + +export function ShortcutsPanel() { + return ( + + setGlobalVisible(false)} /> + + ) +} + +export function DialogShortcuts(props: { onClose: () => void }) { + const { theme } = useTheme() + const keybind = useKeybind() + const kv = useKV() + const dimensions = useTerminalDimensions() + + const [activeTab, setActiveTab] = createSignal(0) + + useKeyboard((evt) => { + if (evt.ctrl && evt.name === "/") { + props.onClose() + } + if (evt.name === "left" || (evt.ctrl && evt.name === "h")) { + setActiveTab((prev) => Math.max(0, prev - 1)) + } + if (evt.name === "right" || (evt.ctrl && evt.name === "l")) { + setActiveTab((prev) => Math.min(tabs().length - 1, prev + 1)) + } + if (evt.name === "tab" && !evt.shift) { + setActiveTab((prev) => (prev + 1) % tabs().length) + } + if (evt.name === "tab" && evt.shift) { + setActiveTab((prev) => (prev - 1 + tabs().length) % tabs().length) + } + }) + + const shortcuts = createMemo(() => { + return SHORTCUTS.filter((s) => { + const kb = keybind.print(s.key) + return kb && kb !== "none" + }) + }) + + const grouped = createMemo(() => { + return pipe( + shortcuts(), + groupBy((x) => x.category), + entries(), + (arr) => arr.toSorted((a, b) => categorySort(a[0], b[0])), + ) + }) + + const tabs = createMemo(() => grouped().map(([category]) => category)) + + const currentShortcuts = createMemo(() => { + const tab = tabs()[activeTab()] + return grouped().find(([category]) => category === tab)?.[1] ?? [] + }) + + const columnCount = createMemo(() => { + const width = dimensions().width + if (width >= 150) return 3 + if (width >= 100) return 2 + return 1 + }) + + const maxContentRows = createMemo(() => { + const cols = columnCount() + let max = 0 + for (const [, items] of grouped()) { + const rows = Math.ceil(items.length / cols) + if (rows > max) max = rows + } + return max + }) + + const usedShortcuts = createMemo(() => kv.get("used_shortcuts", []) as string[]) + + const usedCount = createMemo(() => shortcuts().filter((s) => usedShortcuts().includes(s.key)).length) + + const totalCount = createMemo(() => shortcuts().length) + + const progressFilled = createMemo(() => (totalCount() > 0 ? Math.round((usedCount() / totalCount()) * 10) : 0)) + + const columns = createMemo(() => { + const items = currentShortcuts() + const cols = columnCount() + const result: ShortcutInfo[][] = Array.from({ length: cols }, () => []) + items.forEach((item, i) => { + result[i % cols].push(item) + }) + return result + }) + + return ( + + + + + + {(tab, index) => ( + setActiveTab(index())} + paddingLeft={1} + paddingRight={1} + backgroundColor={activeTab() === index() ? theme.backgroundElement : undefined} + > + + {tab} + + + )} + + + + + ctrl+/ + + + + + + + = 150 ? Math.floor((dimensions().width * 2) / 3) : undefined} + > + + {(column) => ( + + + {(shortcut) => { + const kb = keybind.print(shortcut.key) + const used = createMemo(() => usedShortcuts().includes(shortcut.key)) + return ( + + + + {shortcut.title} + + {kb} + + + ) + }} + + + )} + + + + + + + + {"━".repeat(progressFilled())} + {"━".repeat(10 - progressFilled())} + + + + {usedCount()}/{totalCount()} + + shortcuts used + + + + ) +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ba9d197302..ac5ac86f73 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -437,6 +437,7 @@ export namespace Config { scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), status_view: z.string().optional().default("s").describe("View status"), + shortcuts_view: z.string().optional().default("ctrl+/").describe("View keyboard shortcuts"), session_export: z.string().optional().default("x").describe("Export session to editor"), session_new: z.string().optional().default("n").describe("Create a new session"), session_list: z.string().optional().default("l").describe("List all sessions"), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0021ca1f5d..037457d884 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -826,6 +826,10 @@ export type KeybindsConfig = { * View status */ status_view?: string + /** + * View keyboard shortcuts + */ + shortcuts_view?: string /** * Export session to editor */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 10dd7365ce..144fb9a880 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7263,6 +7263,11 @@ "default": "s", "type": "string" }, + "shortcuts_view": { + "description": "View keyboard shortcuts", + "default": "?", + "type": "string" + }, "session_export": { "description": "Export session to editor", "default": "x",