David Hill 3 месяцев назад
Родитель
Сommit
929776beaa

+ 31 - 13
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<voi
                                 <PromptStashProvider>
                                   <DialogProvider>
                                     <CommandProvider>
-                                      <PromptHistoryProvider>
-                                        <PromptRefProvider>
-                                          <App />
-                                        </PromptRefProvider>
-                                      </PromptHistoryProvider>
+                                      <ShortcutsProvider>
+                                        <PromptHistoryProvider>
+                                          <PromptRefProvider>
+                                            <App />
+                                          </PromptRefProvider>
+                                        </PromptHistoryProvider>
+                                      </ShortcutsProvider>
                                     </CommandProvider>
                                   </DialogProvider>
                                 </PromptStashProvider>
@@ -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() {
     <box
       width={dimensions().width}
       height={dimensions().height}
+      flexDirection="column"
       backgroundColor={theme.background}
       onMouseUp={async () => {
         if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
@@ -585,14 +600,17 @@ function App() {
         }
       }}
     >
-      <Switch>
-        <Match when={route.data.type === "home"}>
-          <Home />
-        </Match>
-        <Match when={route.data.type === "session"}>
-          <Session />
-        </Match>
-      </Switch>
+      <box flexGrow={1} flexDirection="column">
+        <Switch>
+          <Match when={route.data.type === "home"}>
+            <Home />
+          </Match>
+          <Match when={route.data.type === "session"}>
+            <Session />
+          </Match>
+        </Switch>
+      </box>
+      <ShortcutsPanel />
     </box>
   )
 }

+ 13 - 2
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
           }
         }

+ 336 - 0
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<ShortcutsContext>()
+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 <ctx.Provider value={value}>{props.children}</ctx.Provider>
+}
+
+export function ShortcutsPanel() {
+  return (
+    <Show when={globalVisible()}>
+      <DialogShortcuts onClose={() => setGlobalVisible(false)} />
+    </Show>
+  )
+}
+
+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 (
+    <box
+      flexDirection="column"
+      width={dimensions().width}
+      backgroundColor={theme.backgroundPanel}
+      paddingLeft={2}
+      paddingRight={2}
+      paddingTop={1}
+      paddingBottom={1}
+    >
+      <box flexDirection="row" paddingBottom={2} alignItems="center">
+        <box flexGrow={1} />
+        <box flexDirection="row" gap={2} alignItems="center">
+          <For each={tabs()}>
+            {(tab, index) => (
+              <box
+                onMouseUp={() => setActiveTab(index())}
+                paddingLeft={1}
+                paddingRight={1}
+                backgroundColor={activeTab() === index() ? theme.backgroundElement : undefined}
+              >
+                <text
+                  fg={activeTab() === index() ? theme.text : theme.textMuted}
+                  attributes={activeTab() === index() ? TextAttributes.BOLD : undefined}
+                >
+                  {tab}
+                </text>
+              </box>
+            )}
+          </For>
+        </box>
+        <box flexGrow={1} />
+        <text fg={theme.textMuted} paddingRight={2}>
+          ctrl+/
+        </text>
+      </box>
+
+      <scrollbox height={Math.min(8, maxContentRows())} scrollbarOptions={{ visible: false }}>
+        <box flexDirection="row" height={maxContentRows()}>
+          <box flexGrow={1} />
+          <box
+            flexDirection="row"
+            gap={8}
+            width={dimensions().width >= 150 ? Math.floor((dimensions().width * 2) / 3) : undefined}
+          >
+            <For each={columns()}>
+              {(column) => (
+                <box flexDirection="column" flexGrow={1} flexBasis={0}>
+                  <For each={column}>
+                    {(shortcut) => {
+                      const kb = keybind.print(shortcut.key)
+                      const used = createMemo(() => usedShortcuts().includes(shortcut.key))
+                      return (
+                        <Show when={kb}>
+                          <box flexDirection="row" gap={2}>
+                            <text fg={used() ? theme.success : theme.textMuted} flexGrow={1}>
+                              {shortcut.title}
+                            </text>
+                            <text fg={used() ? theme.success : theme.text}>{kb}</text>
+                          </box>
+                        </Show>
+                      )
+                    }}
+                  </For>
+                </box>
+              )}
+            </For>
+          </box>
+          <box flexGrow={1} />
+        </box>
+      </scrollbox>
+      <box paddingTop={2} flexDirection="row" justifyContent="center" gap={2}>
+        <text>
+          <span style={{ fg: theme.success }}>{"━".repeat(progressFilled())}</span>
+          <span style={{ fg: theme.textMuted }}>{"━".repeat(10 - progressFilled())}</span>
+        </text>
+        <text>
+          <span style={{ fg: theme.text }}>
+            {usedCount()}/{totalCount()}
+          </span>
+          <span style={{ fg: theme.textMuted }}> shortcuts used</span>
+        </text>
+      </box>
+    </box>
+  )
+}

+ 1 - 0
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("<leader>s").describe("View status"),
+      shortcuts_view: z.string().optional().default("ctrl+/").describe("View keyboard shortcuts"),
       session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
       session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
       session_list: z.string().optional().default("<leader>l").describe("List all sessions"),

+ 4 - 0
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
    */

+ 5 - 0
packages/sdk/openapi.json

@@ -7263,6 +7263,11 @@
             "default": "<leader>s",
             "type": "string"
           },
+          "shortcuts_view": {
+            "description": "View keyboard shortcuts",
+            "default": "?",
+            "type": "string"
+          },
           "session_export": {
             "description": "Export session to editor",
             "default": "<leader>x",