Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 83 additions & 3 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -647,6 +678,55 @@ function App() {
<Session />
</Match>
</Switch>
<Show when={hints().length}>
<box position="absolute" left={0} right={0} bottom={0} alignItems="center">
<box
width={dimensions().width}
flexDirection="column"
paddingLeft={4}
paddingRight={4}
paddingTop={2}
paddingBottom={2}
backgroundColor={RGBA.fromInts(
theme.backgroundPanel.r,
theme.backgroundPanel.g,
theme.backgroundPanel.b,
200,
)}
>
<box flexDirection="row" gap={columnGap}>
<For each={hintColumns().columns}>
{(column) => (
<box flexDirection="column" width={columnWidth}>
<For each={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 (
<box flexDirection="row" width={columnWidth} gap={1}>
<text fg={theme.text} wrapMode="none">
{keyText}
</text>
<text fg={theme.textMuted} wrapMode="none">
{descText}
</text>
</box>
)
}}
</For>
</box>
)}
</For>
</box>
<box height={1} />
<text fg={theme.textMuted}>
Input <span style={{ fg: theme.primary }}>{keybind.print("leader")}</span>
</text>
</box>
</box>
</Show>
</box>
)
}
Expand Down
95 changes: 94 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/keybind.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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",
Expand All @@ -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<Record<keyof KeybindsConfig, string>> = {}
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<string, KeybindHint>()
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)
Expand All @@ -42,6 +128,7 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
}

if (!active) {
clearKeybindHint()
if (focus && !renderer.currentFocusedRenderable) {
focus.focus()
}
Expand All @@ -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
Expand All @@ -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") {
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions packages/opencode/src/util/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,32 @@ export namespace Keybind {
export function toString(info: Info | undefined): string {
if (!info) return ""
const parts: string[] = []
const symbols: Record<string, string> = {
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("+")
Expand Down
4 changes: 3 additions & 1 deletion packages/web/src/content/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Loading