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).