Compare commits

...

1 Commits

Author SHA1 Message Date
David Hill
929776beaa wip: tui shortcuts panel 2025-12-24 00:17:31 +00:00
6 changed files with 390 additions and 15 deletions

View File

@@ -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>
)
}

View File

@@ -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
}
}

View File

@@ -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>
)
}

View File

@@ -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"),

View File

@@ -826,6 +826,10 @@ export type KeybindsConfig = {
* View status
*/
status_view?: string
/**
* View keyboard shortcuts
*/
shortcuts_view?: string
/**
* Export session to editor
*/

View File

@@ -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",