diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 1fea3f4b305..046c9945523 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,8 +1,22 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" -import { TextAttributes } from "@opentui/core" +import { RGBA, TextAttributes } from "@opentui/core" +import { Locale } from "@/util/locale" import { RouteProvider, useRoute } from "@tui/context/route" -import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" +import { + Switch, + Match, + createEffect, + untrack, + ErrorBoundary, + createSignal, + onMount, + batch, + Show, + on, + For, + createMemo, +} from "solid-js" import { Installation } from "@/installation" import { Flag } from "@/flag/flag" import { DialogProvider, useDialog } from "@tui/ui/dialog" @@ -18,7 +32,7 @@ import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" -import { KeybindProvider } from "@tui/context/keybind" +import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" @@ -195,6 +209,23 @@ function App() { const sync = useSync() const exit = useExit() const promptRef = usePromptRef() + const keybind = useKeybind() + const hints = createMemo(() => keybind.keybindHints) + const columnWidth = 36 + const columnGap = 2 + const keyLimit = Math.min(10, Math.max(1, columnWidth - 6)) + const hintColumns = createMemo(() => { + const list = hints() + const available = Math.max(1, dimensions().width - 4) + const maxColumns = Math.max(1, Math.floor((available + columnGap) / (columnWidth + columnGap))) + const total = Math.min(maxColumns, Math.max(1, list.length)) + const rows = Math.ceil(list.length / total) + const columns = Array.from({ length: total }, (_, index) => { + const start = index * rows + return list.slice(start, start + rows) + }) + return { columns, rows } + }) // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { @@ -647,6 +678,55 @@ function App() { + + + + + + {(column) => ( + + + {(item) => { + const desc = item.count > 1 ? `+${item.count} keymaps` : item.desc + const keyText = Locale.truncate(item.key, keyLimit) + const descLimit = Math.max(4, columnWidth - keyText.length - 1) + const descText = Locale.truncate(desc, descLimit) + return ( + + + {keyText} + + + {descText} + + + ) + }} + + + )} + + + + + Input {keybind.print("leader")} + + + + ) } diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 4c82e594c3e..d0b27e92022 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -1,4 +1,4 @@ -import { createMemo } from "solid-js" +import { createMemo, createEffect } from "solid-js" import { useSync } from "@tui/context/sync" import { Keybind } from "@/util/keybind" import { pipe, mapValues } from "remeda" @@ -7,6 +7,13 @@ import type { ParsedKey, Renderable } from "@opentui/core" import { createStore } from "solid-js/store" import { useKeyboard, useRenderer } from "@opentui/solid" import { createSimpleContext } from "./helper" +import { Config } from "@/config/config" + +type KeybindHint = { + key: string + desc: string + count: number +} export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({ name: "Keybind", @@ -18,16 +25,95 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex mapValues((value) => Keybind.parse(value)), ) }) + const hintConfig = createMemo(() => (sync.data.config as Config.Info).tui?.keybind_hint) + const hintEnabled = createMemo(() => hintConfig()?.enabled ?? true) + const hintDelay = createMemo(() => Math.max(0, hintConfig()?.delay_ms ?? 200)) + const descriptions = createMemo(() => { + const result: Partial> = {} + const fields = Config.Keybinds.shape + for (const [key, value] of Object.entries(fields)) { + const desc = (value as { description?: string }).description + if (!desc) continue + result[key as keyof KeybindsConfig] = desc + } + return result + }) const [store, setStore] = createStore({ leader: false, + keybindHint: false, + }) + const keybindHints = createMemo(() => { + if (!store.keybindHint || !hintEnabled()) return [] as KeybindHint[] + const lookup = descriptions() + const direct: KeybindHint[] = [] + const grouped = new Map() + const keys = sync.data.config.keybinds ?? {} + for (const [key, raw] of Object.entries(keys)) { + if (typeof raw !== "string" || raw === "none") continue + const desc = lookup[key as keyof KeybindsConfig] + if (!desc) continue + const combos = raw + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + for (const combo of combos) { + const steps = combo.split(/\s+/).filter(Boolean) + const first = steps[0] + if (!first) continue + const info = Keybind.parse(first).at(0) + if (!info?.leader) continue + const name = Keybind.toString({ ...info, leader: false }) + if (!name) continue + if (steps.length === 1) { + direct.push({ key: name, desc, count: 1 }) + continue + } + const current = grouped.get(name) + const count = (current?.count ?? 0) + 1 + grouped.set(name, { key: name, desc: current?.desc ?? desc, count }) + } + } + const directKeys = new Set(direct.map((item) => item.key)) + const groupedHints = Array.from(grouped.values()).filter((item) => !directKeys.has(item.key)) + const hints = [...direct, ...groupedHints] + return hints.sort((a, b) => { + if (a.count === b.count) return a.key.localeCompare(b.key) + if (a.count > 1 && b.count === 1) return 1 + if (a.count === 1 && b.count > 1) return -1 + return a.key.localeCompare(b.key) + }) }) const renderer = useRenderer() let focus: Renderable | null let timeout: NodeJS.Timeout + const timers = { + keybindHint: undefined as NodeJS.Timeout | undefined, + } + function clearKeybindHint() { + if (timers.keybindHint) clearTimeout(timers.keybindHint) + timers.keybindHint = undefined + setStore("keybindHint", false) + } + + createEffect(() => { + if (hintEnabled()) return + clearKeybindHint() + }) + function scheduleKeybindHint() { + if (!hintEnabled()) return + if (timers.keybindHint) clearTimeout(timers.keybindHint) + const delay = hintDelay() + timers.keybindHint = setTimeout(() => { + if (!store.leader) return + setStore("keybindHint", true) + }, delay) + } function leader(active: boolean) { if (active) { setStore("leader", true) + setStore("keybindHint", false) + scheduleKeybindHint() focus = renderer.currentFocusedRenderable focus?.blur() if (timeout) clearTimeout(timeout) @@ -42,6 +128,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex } if (!active) { + clearKeybindHint() if (focus && !renderer.currentFocusedRenderable) { focus.focus() } @@ -50,6 +137,9 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex } useKeyboard(async (evt) => { + if (store.leader) { + clearKeybindHint() + } if (!store.leader && result.match("leader", evt)) { leader(true) return @@ -72,6 +162,9 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex get leader() { return store.leader }, + get keybindHints() { + return keybindHints() + }, parse(evt: ParsedKey): Keybind.Info { // Handle special case for Ctrl+Underscore (represented as \x1F) if (evt.name === "\x1F") { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 322ce273ab8..74845ebefed 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -788,6 +788,13 @@ export namespace Config { .enum(["auto", "stacked"]) .optional() .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), + keybind_hint: z + .object({ + enabled: z.boolean().optional().default(true).describe("Enable keybind hint overlay"), + delay_ms: z.number().int().min(0).optional().default(200).describe("Delay before showing keybind hints (ms)"), + }) + .optional() + .describe("Keybind hint overlay settings"), }) export const Server = z diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts index 59318a31b09..f8aab426bd4 100644 --- a/packages/opencode/src/util/keybind.ts +++ b/packages/opencode/src/util/keybind.ts @@ -35,14 +35,32 @@ export namespace Keybind { export function toString(info: Info | undefined): string { if (!info) return "" const parts: string[] = [] + const symbols: Record = { + left: "←", + right: "→", + up: "↑", + down: "↓", + pageup: "⇞", + pagedown: "⇟", + home: "⤒", + end: "⤓", + escape: "⎋", + tab: "⇥", + backspace: "⌫", + delete: "⌦", + enter: "↵", + return: "↵", + space: "␣", + } if (info.ctrl) parts.push("ctrl") if (info.meta) parts.push("alt") if (info.super) parts.push("super") if (info.shift) parts.push("shift") if (info.name) { - if (info.name === "delete") parts.push("del") - else parts.push(info.name) + const symbol = symbols[info.name] + if (symbol) parts.push(symbol) + if (!symbol) parts.push(info.name) } let result = parts.join("+") diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 30edbbd2146..cad7078a6ae 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -169,7 +169,9 @@ Available options: - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.** - `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`. -- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column. +- `diff_style` - Control diff rendering. "auto" adapts to terminal width, "stacked" always shows single column. +- `keybind_hint.enabled` - Show keybind hints in the TUI (default: `true`). +- `keybind_hint.delay_ms` - Wait before showing keybind hints in milliseconds (default: `200`). [Learn more about using the TUI here](/docs/tui).