introduce opentui keymap as sole key/cmd engine (#26053)

This commit is contained in:
Sebastian
2026-05-07 20:35:31 +02:00
committed by GitHub
parent 474e311f6f
commit 98f5e6e713
67 changed files with 3858 additions and 2977 deletions
+394 -424
View File
@@ -1,4 +1,5 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
@@ -11,6 +12,7 @@ import {
ErrorBoundary,
createSignal,
onMount,
onCleanup,
batch,
Show,
on,
@@ -36,11 +38,9 @@ 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 { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
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"
@@ -60,15 +60,17 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
return {
@@ -111,7 +113,7 @@ function errorMessage(error: unknown) {
export function tui(input: {
url: string
args: Args
config: TuiConfig.Info
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
@@ -130,6 +132,7 @@ export function tui(input: {
}
const onBeforeExit = async () => {
offKeymap()
await TuiPluginRuntime.dispose()
}
@@ -138,6 +141,9 @@ export function tui(input: {
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
const keymap = createDefaultOpenTuiKeymap(renderer)
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
await render(() => {
return (
<ErrorBoundary
@@ -145,37 +151,37 @@ export function tui(input: {
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
)}
>
<ArgsProvider {...input.args}>
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<SyncProviderV2>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<SyncProviderV2>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<CommandPaletteProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
@@ -185,22 +191,22 @@ export function tui(input: {
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</CommandPaletteProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProviderV2>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</LocalProvider>
</ThemeProvider>
</SyncProviderV2>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ErrorBoundary>
)
}, renderer)
@@ -209,14 +215,17 @@ export function tui(input: {
function App(props: { onSnapshot?: () => Promise<string[]> }) {
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
const kv = useKV()
const command = useCommandDialog()
const keybind = useKeybind()
const command = useCommandPalette()
const keymap = useOpencodeKeymap()
const event = useEvent()
const sdk = useSDK()
const toast = useToast()
@@ -233,10 +242,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
const api = createTuiApi({
command,
tuiConfig,
dialog,
keybind,
keymap,
kv,
route,
routes,
@@ -260,40 +268,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
setReady(true)
})
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
const sel = renderer.getSelection()
if (!sel) return
// Windows Terminal-like behavior:
// - Ctrl+C copies and dismisses selection
// - Esc dismisses selection
// - Most other key input dismisses selection and is passed through
if (evt.ctrl && evt.name === "c") {
if (!Selection.copy(renderer, toast)) {
renderer.clearSelection()
return
}
evt.preventDefault()
evt.stopPropagation()
return
}
if (evt.name === "escape") {
renderer.clearSelection()
evt.preventDefault()
evt.stopPropagation()
return
}
const focus = renderer.currentFocusedRenderable
if (focus?.hasSelection() && sel.selectedRenderables.includes(focus)) {
return
}
renderer.clearSelection()
})
// Let selection copy/dismiss win ahead of normal bindings when the feature flag is on.
const offSelectionKeys = keymap.intercept(
"key",
({ event }) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
Selection.handleSelectionKey(renderer, toast, event)
},
{ priority: 1 },
)
onCleanup(offSelectionKeys)
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
@@ -410,379 +394,365 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
)
const connected = useConnected()
command.register(() => [
{
title: "Switch session",
value: "session.list",
keybind: "session_list",
category: "Session",
suggested: sync.data.session.length > 0,
slash: {
name: "sessions",
aliases: ["resume", "continue"],
const appCommands = createMemo(() =>
[
{
name: "command.palette.show",
title: "Show command palette",
hidden: true,
run: () => {
command.show()
},
},
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
{
name: "session.list",
title: "Switch session",
category: "Session",
suggested: sync.data.session.length > 0,
slashName: "sessions",
slashAliases: ["resume", "continue"],
run: () => {
dialog.replace(() => <DialogSessionList />)
},
},
},
{
title: "New session",
suggested: route.data.type === "session",
value: "session.new",
keybind: "session_new",
category: "Session",
slash: {
name: "new",
aliases: ["clear"],
{
name: "session.new",
title: "New session",
suggested: route.data.type === "session",
category: "Session",
slashName: "new",
slashAliases: ["clear"],
run: () => {
route.navigate({
type: "home",
})
dialog.clear()
},
},
onSelect: () => {
route.navigate({
type: "home",
})
dialog.clear()
{
name: "model.list",
title: "Switch model",
suggested: true,
category: "Agent",
slashName: "models",
run: () => {
dialog.replace(() => <DialogModel />)
},
},
},
{
title: "Switch model",
value: "model.list",
keybind: "model_list",
suggested: true,
category: "Agent",
slash: {
name: "models",
{
name: "model.cycle_recent",
title: "Model cycle",
category: "Agent",
hidden: true,
run: () => {
local.model.cycle(1)
},
},
onSelect: () => {
dialog.replace(() => <DialogModel />)
{
name: "model.cycle_recent_reverse",
title: "Model cycle reverse",
category: "Agent",
hidden: true,
run: () => {
local.model.cycle(-1)
},
},
},
{
title: "Model cycle",
value: "model.cycle_recent",
keybind: "model_cycle_recent",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycle(1)
{
name: "model.cycle_favorite",
title: "Favorite cycle",
category: "Agent",
hidden: true,
run: () => {
local.model.cycleFavorite(1)
},
},
},
{
title: "Model cycle reverse",
value: "model.cycle_recent_reverse",
keybind: "model_cycle_recent_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycle(-1)
{
name: "model.cycle_favorite_reverse",
title: "Favorite cycle reverse",
category: "Agent",
hidden: true,
run: () => {
local.model.cycleFavorite(-1)
},
},
},
{
title: "Favorite cycle",
value: "model.cycle_favorite",
keybind: "model_cycle_favorite",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycleFavorite(1)
{
name: "agent.list",
title: "Switch agent",
category: "Agent",
slashName: "agents",
run: () => {
dialog.replace(() => <DialogAgent />)
},
},
},
{
title: "Favorite cycle reverse",
value: "model.cycle_favorite_reverse",
keybind: "model_cycle_favorite_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycleFavorite(-1)
{
name: "mcp.list",
title: "Toggle MCPs",
category: "Agent",
slashName: "mcps",
run: () => {
dialog.replace(() => <DialogMcp />)
},
},
},
{
title: "Switch agent",
value: "agent.list",
keybind: "agent_list",
category: "Agent",
slash: {
name: "agents",
{
name: "agent.cycle",
title: "Agent cycle",
category: "Agent",
hidden: true,
run: () => {
local.agent.move(1)
},
},
onSelect: () => {
dialog.replace(() => <DialogAgent />)
{
name: "variant.cycle",
title: "Variant cycle",
category: "Agent",
run: () => {
local.model.variant.cycle()
},
},
},
{
title: "Toggle MCPs",
value: "mcp.list",
category: "Agent",
slash: {
name: "mcps",
{
name: "variant.list",
title: "Switch model variant",
category: "Agent",
hidden: local.model.variant.list().length === 0,
slashName: "variants",
run: () => {
dialog.replace(() => <DialogVariant />)
},
},
onSelect: () => {
dialog.replace(() => <DialogMcp />)
{
name: "agent.cycle.reverse",
title: "Agent cycle reverse",
category: "Agent",
hidden: true,
run: () => {
local.agent.move(-1)
},
},
},
{
title: "Agent cycle",
value: "agent.cycle",
keybind: "agent_cycle",
category: "Agent",
hidden: true,
onSelect: () => {
local.agent.move(1)
{
name: "provider.connect",
title: "Connect provider",
suggested: !connected(),
slashName: "connect",
run: () => {
dialog.replace(() => <DialogProviderList />)
},
category: "Provider",
},
},
{
title: "Variant cycle",
value: "variant.cycle",
keybind: "variant_cycle",
category: "Agent",
onSelect: () => {
local.model.variant.cycle()
},
},
{
title: "Switch model variant",
value: "variant.list",
keybind: "variant_list",
category: "Agent",
hidden: local.model.variant.list().length === 0,
slash: {
name: "variants",
},
onSelect: () => {
dialog.replace(() => <DialogVariant />)
},
},
{
title: "Agent cycle reverse",
value: "agent.cycle.reverse",
keybind: "agent_cycle_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.agent.move(-1)
},
},
{
title: "Connect provider",
value: "provider.connect",
suggested: !connected(),
slash: {
name: "connect",
},
onSelect: () => {
dialog.replace(() => <DialogProviderList />)
},
category: "Provider",
},
...(sync.data.console_state.switchableOrgCount > 1
? [
{
title: "Switch org",
value: "console.org.switch",
suggested: Boolean(sync.data.console_state.activeOrgName),
slash: {
name: "org",
aliases: ["orgs", "switch-org"],
...(sync.data.console_state.switchableOrgCount > 1
? [
{
name: "console.org.switch",
title: "Switch org",
suggested: Boolean(sync.data.console_state.activeOrgName),
slashName: "org",
slashAliases: ["orgs", "switch-org"],
run: () => {
dialog.replace(() => <DialogConsoleOrg />)
},
category: "Provider",
},
onSelect: () => {
dialog.replace(() => <DialogConsoleOrg />)
},
category: "Provider",
},
]
: []),
{
title: "View status",
keybind: "status_view",
value: "opencode.status",
slash: {
name: "status",
]
: []),
{
name: "opencode.status",
title: "View status",
slashName: "status",
run: () => {
dialog.replace(() => <DialogStatus />)
},
category: "System",
},
onSelect: () => {
dialog.replace(() => <DialogStatus />)
{
name: "theme.switch",
title: "Switch theme",
slashName: "themes",
run: () => {
dialog.replace(() => <DialogThemeList />)
},
category: "System",
},
category: "System",
},
{
title: "Switch theme",
value: "theme.switch",
keybind: "theme_list",
slash: {
name: "themes",
{
name: "theme.switch_mode",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
run: () => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
},
category: "System",
},
onSelect: () => {
dialog.replace(() => <DialogThemeList />)
{
name: "theme.mode.lock",
title: locked() ? "Unlock theme mode" : "Lock theme mode",
run: () => {
if (locked()) unlock()
else lock()
dialog.clear()
},
category: "System",
},
category: "System",
},
{
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
value: "theme.switch_mode",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
{
name: "help.show",
title: "Help",
slashName: "help",
run: () => {
dialog.replace(() => <DialogHelp />)
},
category: "System",
},
category: "System",
},
{
title: locked() ? "Unlock theme mode" : "Lock theme mode",
value: "theme.mode.lock",
onSelect: (dialog) => {
if (locked()) unlock()
else lock()
dialog.clear()
{
name: "docs.open",
title: "Open docs",
run: () => {
open("https://opencode.ai/docs").catch(() => {})
dialog.clear()
},
category: "System",
},
category: "System",
},
{
title: "Help",
value: "help.show",
slash: {
name: "help",
{
name: "app.exit",
title: "Exit the app",
slashName: "exit",
slashAliases: ["quit", "q"],
enabled: () => {
const current = promptRef.current
if (!current?.focused) return true
return current.current.input === ""
},
run: () => exit(),
category: "System",
},
onSelect: () => {
dialog.replace(() => <DialogHelp />)
{
name: "app.debug",
title: "Toggle debug panel",
category: "System",
run: () => {
renderer.toggleDebugOverlay()
dialog.clear()
},
},
category: "System",
},
{
title: "Open docs",
value: "docs.open",
onSelect: () => {
open("https://opencode.ai/docs").catch(() => {})
dialog.clear()
{
name: "app.console",
title: "Toggle console",
category: "System",
run: () => {
renderer.console.toggle()
dialog.clear()
},
},
category: "System",
},
{
title: "Exit the app",
value: "app.exit",
slash: {
name: "exit",
aliases: ["quit", "q"],
{
name: "app.heap_snapshot",
title: "Write heap snapshot",
category: "System",
run: async () => {
const files = await props.onSnapshot?.()
toast.show({
variant: "info",
message: `Heap snapshot written to ${files?.join(", ")}`,
duration: 5000,
})
dialog.clear()
},
},
onSelect: () => exit(),
category: "System",
},
{
title: "Toggle debug panel",
category: "System",
value: "app.debug",
onSelect: (dialog) => {
renderer.toggleDebugOverlay()
dialog.clear()
},
},
{
title: "Toggle console",
category: "System",
value: "app.console",
onSelect: (dialog) => {
renderer.console.toggle()
dialog.clear()
},
},
{
title: "Write heap snapshot",
category: "System",
value: "app.heap_snapshot",
onSelect: async (dialog) => {
const files = await props.onSnapshot?.()
toast.show({
variant: "info",
message: `Heap snapshot written to ${files?.join(", ")}`,
duration: 5000,
})
dialog.clear()
},
},
{
title: "Suspend terminal",
value: "terminal.suspend",
keybind: "terminal_suspend",
category: "System",
hidden: true,
enabled: tuiConfig.keybinds?.terminal_suspend !== "none",
onSelect: () => {
process.once("SIGCONT", () => {
renderer.resume()
})
{
name: "terminal.suspend",
title: "Suspend terminal",
category: "System",
hidden: true,
enabled: process.platform !== "win32",
run: () => {
process.once("SIGCONT", () => {
renderer.resume()
})
renderer.suspend()
// pid=0 means send the signal to all processes in the process group
process.kill(0, "SIGTSTP")
renderer.suspend()
process.kill(0, "SIGTSTP")
},
},
},
{
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle",
keybind: "terminal_title_toggle",
category: "System",
onSelect: (dialog) => {
setTerminalTitleEnabled((prev) => {
const next = !prev
kv.set("terminal_title_enabled", next)
if (!next) renderer.setTerminalTitle("")
return next
})
dialog.clear()
{
name: "terminal.title.toggle",
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
category: "System",
run: () => {
setTerminalTitleEnabled((prev) => {
const next = !prev
kv.set("terminal_title_enabled", next)
if (!next) renderer.setTerminalTitle("")
return next
})
dialog.clear()
},
},
},
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
dialog.clear()
{
name: "app.toggle.animations",
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
category: "System",
run: () => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
dialog.clear()
},
},
},
{
title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
value: "app.toggle.file_context",
category: "System",
onSelect: (dialog) => {
kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
dialog.clear()
{
name: "app.toggle.file_context",
title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
category: "System",
run: () => {
kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
dialog.clear()
},
},
},
{
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
value: "app.toggle.paste_summary",
category: "System",
onSelect: (dialog) => {
setPasteSummaryEnabled((prev) => {
const next = !prev
kv.set("paste_summary_enabled", next)
return next
})
dialog.clear()
{
name: "app.toggle.diffwrap",
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
category: "System",
run: () => {
const current = kv.get("diff_wrap_mode", "word")
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
dialog.clear()
},
},
},
{
title: kv.get("session_directory_filter_enabled", true)
? "Disable session directory filtering"
: "Enable session directory filtering",
value: "app.toggle.session_directory_filter",
category: "System",
onSelect: async (dialog) => {
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
await sync.session.refresh()
dialog.clear()
{
name: "app.toggle.paste_summary",
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
category: "System",
run: () => {
setPasteSummaryEnabled((prev) => {
const next = !prev
kv.set("paste_summary_enabled", next)
return next
})
dialog.clear()
},
},
},
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
dialog.clear()
{
name: "app.toggle.session_directory_filter",
title: kv.get("session_directory_filter_enabled", true)
? "Disable session directory filtering"
: "Enable session directory filtering",
category: "System",
run: async () => {
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
await sync.session.refresh()
dialog.clear()
},
},
},
])
].map((command) => ({
namespace: "palette",
...command,
})),
)
useBindings(() => ({
commands: appCommands(),
}))
useBindings(() => ({
enabled: command.matcher,
bindings: sections.global,
}))
event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
command.run(evt.properties.command)
})
event.on(TuiEvent.ToastShow.type, (evt) => {
@@ -1,172 +0,0 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
import {
createContext,
createMemo,
createSignal,
getOwner,
onCleanup,
runWithOwner,
useContext,
type Accessor,
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
export type Slash = {
name: string
aliases?: string[]
}
export type CommandOption = DialogSelectOption<string> & {
keybind?: string
suggested?: boolean
slash?: Slash
hidden?: boolean
enabled?: boolean
}
function init() {
const root = getOwner()
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
const keybind = useKeybind()
const entries = createMemo(() => {
const all = registrations().flatMap((x) => x())
return all.map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}))
})
const isEnabled = (option: CommandOption) => option.enabled !== false
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
const suggestedOptions = createMemo(() =>
visibleOptions()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
)
const suspended = () => suspendCount() > 0
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
return
}
}
})
const result = {
trigger(name: string) {
for (const option of entries()) {
if (option.value === name) {
if (!isEnabled(option)) return
option.onSelect?.(dialog)
return
}
}
},
slashes() {
return visibleOptions().flatMap((option) => {
const slash = option.slash
if (!slash) return []
return {
display: "/" + slash.name,
description: option.description ?? option.title,
aliases: slash.aliases?.map((alias) => "/" + alias),
onSelect: () => result.trigger(option.value),
}
})
},
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
},
suspended,
show() {
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
},
register(cb: () => CommandOption[]) {
const owner = getOwner() ?? root
if (!owner) return () => {}
let list: Accessor<CommandOption[]> | undefined
// TUI plugins now register commands via an async store that runs outside an active reactive scope.
// runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
runWithOwner(owner, () => {
list = createMemo(cb)
const ref = list
if (!ref) return
setRegistrations((arr) => [ref, ...arr])
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== ref))
})
})
if (!list) return () => {}
let done = false
return () => {
if (done) return
done = true
const ref = list
if (!ref) return
setRegistrations((arr) => arr.filter((x) => x !== ref))
}
},
}
return result
}
export function useCommandDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider")
}
return value
}
export function CommandProvider(props: ParentProps) {
const value = init()
const dialog = useDialog()
const keybind = useKeybind()
useKeyboard((evt) => {
if (value.suspended()) return
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
if (keybind.match("command_list", evt)) {
evt.preventDefault()
value.show()
return
}
})
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
let ref: DialogSelectRef<string>
const list = () => {
if (ref?.filter) return props.options
return [...props.suggestedOptions, ...props.options]
}
return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
}
@@ -1,5 +1,4 @@
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import open from "open"
import { createSignal, onCleanup, onMount } from "solid-js"
import { selectedForeground, useTheme } from "@tui/context/theme"
@@ -7,6 +6,7 @@ import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { Link } from "@tui/ui/link"
import { GoLogo } from "./logo"
import { BgPulse, type BgPulseMask } from "./bg-pulse"
import { useBindings } from "../keymap"
const GO_URL = "https://opencode.ai/go"
const PAD_X = 3
@@ -71,18 +71,29 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
})
useKeyboard((evt) => {
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
return
}
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog)
}
})
useBindings(() => ({
bindings: [
{
key: "left",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
},
{
key: "right",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
},
{
key: "tab",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
},
{
key: "return",
cmd: () => {
if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog)
},
},
],
}))
return (
<box ref={(item: BoxRenderable) => (content = item)}>
@@ -4,7 +4,6 @@ import { useSync } from "@tui/context/sync"
import { map, pipe, entries, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useTheme } from "../context/theme"
import { Keybind } from "@/util/keybind"
import { TextAttributes } from "@opentui/core"
import { useSDK } from "@tui/context/sdk"
@@ -45,9 +44,9 @@ export function DialogMcp() {
)
})
const keybinds = createMemo(() => [
const actions = createMemo(() => [
{
keybind: Keybind.parse("space")[0],
command: "dialog.action.toggle",
title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress
@@ -77,7 +76,7 @@ export function DialogMcp() {
ref={setRef}
title="MCPs"
options={options()}
keybind={keybinds()}
actions={actions()}
onSelect={(_option) => {
// Don't close on select, only on escape
}}
@@ -6,15 +6,15 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useTuiConfig } from "../context/tui-config"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const keybind = useKeybind()
const tuiConfig = useTuiConfig()
const [query, setQuery] = createSignal("")
const connected = useConnected()
@@ -150,16 +150,16 @@ export function DialogModel(props: { providerID?: string }) {
return (
<DialogSelect<ReturnType<typeof options>[number]["value"]>
options={options()}
keybind={[
actions={[
{
keybind: keybind.all.model_provider_list?.[0],
command: "model.dialog.provider",
title: connected() ? "Connect provider" : "View all providers",
onTrigger() {
dialog.replace(() => <DialogProvider />)
},
},
{
keybind: keybind.all.model_favorite_toggle?.[0],
command: "model.dialog.favorite",
title: "Favorite",
disabled: !connected(),
onTrigger: (option) => {
@@ -167,6 +167,7 @@ export function DialogModel(props: { providerID?: string }) {
},
},
]}
bindings={tuiConfig.keymap.sections.model}
onFilter={setQuery}
flat={true}
skipFilter={true}
@@ -10,11 +10,11 @@ import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
import { useConnected } from "./use-connected"
import { useBindings } from "../keymap"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@@ -239,14 +239,19 @@ function AutoMethod(props: AutoMethodProps) {
const sync = useSync()
const toast = useToast()
useKeyboard((evt) => {
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
Clipboard.copy(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
}
})
useBindings(() => ({
bindings: [
{
key: "c",
cmd: () => {
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
Clipboard.copy(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
},
],
}))
onMount(async () => {
const result = await sdk.client.provider.oauth.callback({
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useBindings } from "../keymap"
export function DialogSessionDeleteFailed(props: {
session: string
@@ -40,19 +40,15 @@ export function DialogSessionDeleteFailed(props: {
if (!props.onDone) dialog.clear()
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
}
if (evt.name === "left" || evt.name === "up") {
setStore("active", "delete")
}
if (evt.name === "right" || evt.name === "down") {
setStore("active", "restore")
}
})
useBindings(() => ({
bindings: [
{ key: "return", cmd: () => void confirm() },
{ key: "left", cmd: () => setStore("active", "delete") },
{ key: "up", cmd: () => setStore("active", "delete") },
{ key: "right", cmd: () => setStore("active", "restore") },
{ key: "down", cmd: () => setStore("active", "restore") },
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
@@ -5,7 +5,6 @@ import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -17,18 +16,19 @@ import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { WorkspaceLabel } from "./workspace-label"
import { useCommandShortcut } from "../keymap"
export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const project = useProject()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("dialog.action.delete")
const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }),
@@ -156,7 +156,7 @@ export function DialogSessionList() {
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
@@ -187,9 +187,9 @@ export function DialogSessionList() {
})
dialog.clear()
}}
keybind={[
actions={[
{
keybind: keybind.all.session_delete?.[0],
command: "dialog.action.delete",
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
@@ -237,7 +237,7 @@ export function DialogSessionList() {
},
},
{
keybind: keybind.all.session_rename?.[0],
command: "dialog.action.rename",
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
@@ -3,8 +3,8 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "@/util/locale"
import { useTheme } from "../context/theme"
import { useKeybind } from "../context/keybind"
import { usePromptStash, type StashEntry } from "./prompt/stash"
import { useCommandShortcut } from "../keymap"
function getRelativeTime(timestamp: number): string {
const now = Date.now()
@@ -30,9 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const { theme } = useTheme()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<number>()
const deleteHint = useCommandShortcut("dialog.action.delete")
const options = createMemo(() => {
const entries = stash.list()
@@ -42,7 +42,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const isDeleting = toDelete() === index
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input),
title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input),
bg: isDeleting ? theme.error : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
@@ -68,9 +68,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
}
dialog.clear()
}}
keybind={[
actions={[
{
keybind: keybind.all.stash_delete?.[0],
command: "dialog.action.delete",
title: "delete",
onTrigger: (option) => {
if (toDelete() === option.value) {
@@ -1,9 +1,9 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useBindings } from "../keymap"
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
const dialog = useDialog()
@@ -23,25 +23,13 @@ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean |
if (result === false) return
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "cancel")
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "restore")
}
})
useBindings(() => ({
bindings: [
{ key: "return", cmd: () => void confirm() },
{ key: "left", cmd: () => setStore("active", "cancel") },
{ key: "right", cmd: () => setStore("active", "restore") },
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
@@ -1,4 +1,4 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun"
import fuzzysort from "fuzzysort"
import path from "path"
@@ -12,11 +12,12 @@ import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useCommandPalette } from "../../context/command-palette"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "@/util/locale"
import type { PromptInfo } from "./history"
import { useFrecency } from "./frecency"
import { useBindings } from "../../keymap"
function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
@@ -52,7 +53,6 @@ function extractLineRange(input: string) {
export type AutocompleteRef = {
onInput: (value: string) => void
onKeyDown: (e: KeyEvent) => void
visible: false | "@" | "/"
}
@@ -82,12 +82,14 @@ export function Autocomplete(props: {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const command = useCommandDialog()
const command = useCommandPalette()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const [store, setStore] = createStore({
index: 0,
selected: 0,
@@ -282,7 +284,7 @@ export function Autocomplete(props: {
const { filename, part } = createFilePart(item, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset
command.keybinds(true)
command.suspend(false)
setStore("visible", false)
setStore("index", index)
insertPart(filename, part)
@@ -520,8 +522,54 @@ export function Autocomplete(props: {
setStore("selected", 0)
}
useBindings(() => ({
target: props.input,
enabled: () => Boolean(store.visible),
commands: [
{
name: "prompt.autocomplete.prev",
run() {
setStore("input", "keyboard")
move(-1)
},
},
{
name: "prompt.autocomplete.next",
run() {
setStore("input", "keyboard")
move(1)
},
},
{
name: "prompt.autocomplete.hide",
run() {
hide()
},
},
{
name: "prompt.autocomplete.select",
run() {
select()
},
},
{
name: "prompt.autocomplete.complete",
run() {
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
return
}
select()
},
},
],
bindings: sections.autocomplete,
}))
function show(mode: "@" | "/") {
command.keybinds(false)
command.suspend(true)
setStore({
visible: mode,
index: props.input().cursorOffset,
@@ -538,7 +586,7 @@ export function Autocomplete(props: {
draft.input = props.input().plainText
})
}
command.keybinds(true)
command.suspend(false)
setStore("visible", false)
}
@@ -593,60 +641,6 @@ export function Autocomplete(props: {
setStore("index", idx)
}
},
onKeyDown(e: KeyEvent) {
if (store.visible) {
const name = e.name?.toLowerCase()
const ctrlOnly = e.ctrl && !e.meta && !e.shift
const isNavUp = name === "up" || (ctrlOnly && name === "p")
const isNavDown = name === "down" || (ctrlOnly && name === "n")
if (isNavUp) {
setStore("input", "keyboard")
move(-1)
e.preventDefault()
return
}
if (isNavDown) {
setStore("input", "keyboard")
move(1)
e.preventDefault()
return
}
if (name === "escape") {
hide()
e.preventDefault()
return
}
if (name === "return") {
select()
e.preventDefault()
return
}
if (name === "tab") {
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
} else {
select()
}
e.preventDefault()
return
}
}
if (!store.visible) {
if (e.name === "@") {
const cursorOffset = props.input().cursorOffset
const charBeforeCursor =
cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset)
const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor)
if (canTrigger) show("@")
}
if (e.name === "/") {
if (props.input().cursorOffset === 0) show("/")
}
}
},
})
})
@@ -1,4 +1,14 @@
import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
import {
BoxRenderable,
RGBA,
TextareaRenderable,
MouseEvent,
PasteEvent,
decodePasteBytes,
type KeyEvent,
type Renderable,
} from "@opentui/core"
import type { CommandContext } from "@opentui/keymap"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"
@@ -16,14 +26,12 @@ import { useEvent } from "@tui/context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits"
import { assign } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import * as Editor from "@tui/util/editor"
import { useExit } from "../../context/exit"
@@ -40,7 +48,6 @@ import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import {
confirmWorkspaceFileChanges,
@@ -51,7 +58,15 @@ import {
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
import { type WorkspaceStatus } from "../workspace-label"
import { useCommandPalette } from "../../context/command-palette"
import {
useBindings,
useCommandShortcut,
useLeaderActive,
useOpencodeKeymap,
} from "../../keymap"
import { useTuiConfig } from "../../context/tui-config"
export type PromptProps = {
sessionID?: string
@@ -124,9 +139,9 @@ let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
let autocomplete: AutocompleteRef
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const keybind = useKeybind()
const leader = useLeaderActive()
const local = useLocal()
const args = useArgs()
const sdk = useSDK()
@@ -134,12 +149,17 @@ export function Prompt(props: PromptProps) {
const route = useRoute()
const project = useProject()
const sync = useSync()
const tuiConfig = useTuiConfig()
const keymapConfig = tuiConfig.keymap
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
const stash = usePromptStash()
const command = useCommandDialog()
const command = useCommandPalette()
const keymap = useOpencodeKeymap()
const agentShortcut = useCommandShortcut("agent.cycle")
const paletteShortcut = useCommandShortcut("command.palette.show")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
@@ -184,6 +204,7 @@ export function Prompt(props: PromptProps) {
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
const [warpNotice, setWarpNotice] = createSignal<string>()
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current())
@@ -287,9 +308,6 @@ export function Prompt(props: PromptProps) {
setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
editor.clearSelection()
}
const textareaKeybindings = useTextareaKeybindings()
const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
@@ -391,26 +409,30 @@ export function Prompt(props: PromptProps) {
}
})
command.register(() => {
return [
const promptCommands = createMemo(() =>
[
{
title: "Clear prompt",
value: "prompt.clear",
name: "prompt.clear",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
input.extmarks.clear()
run: () => {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
},
{
title: "Submit prompt",
value: "prompt.submit",
keybind: "input_submit",
name: "prompt.submit",
category: "Prompt",
hidden: true,
onSelect: async (dialog) => {
run: async () => {
if (!input.focused) return
const handled = await submit()
if (!handled) return
@@ -420,21 +442,22 @@ export function Prompt(props: PromptProps) {
},
{
title: "Remove editor context",
value: "prompt.editor_context.clear",
name: "prompt.editor_context.clear",
category: "Prompt",
enabled: Boolean(editorContext()),
onSelect: (dialog) => {
run: () => {
dismissEditorContext()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
keybind: "input_paste",
name: "prompt.paste",
category: "Prompt",
hidden: true,
onSelect: async () => {
run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
ctx.event.preventDefault()
ctx.event.stopPropagation()
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
@@ -442,18 +465,21 @@ export function Prompt(props: PromptProps) {
mime: content.mime,
content: content.data,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data)
}
},
},
{
title: "Interrupt session",
value: "session.interrupt",
keybind: "session_interrupt",
name: "session.interrupt",
category: "Session",
hidden: true,
enabled: status().type !== "idle",
onSelect: (dialog) => {
if (autocomplete.visible) return
run: () => {
if (auto()?.visible) return
if (!input.focused) return
// TODO: this should be its own command
if (store.mode === "shell") {
@@ -480,12 +506,9 @@ export function Prompt(props: PromptProps) {
{
title: "Open editor",
category: "Session",
keybind: "editor_open",
value: "prompt.editor",
slash: {
name: "editor",
},
onSelect: async (dialog) => {
name: "prompt.editor",
slashName: "editor",
run: async () => {
dialog.clear()
// replace summarized text parts with the actual text
@@ -566,12 +589,10 @@ export function Prompt(props: PromptProps) {
},
{
title: "Skills",
value: "prompt.skills",
name: "prompt.skills",
category: "Prompt",
slash: {
name: "skills",
},
onSelect: () => {
slashName: "skills",
run: () => {
dialog.replace(() => (
<DialogSkill
onSelect={(skill) => {
@@ -588,14 +609,12 @@ export function Prompt(props: PromptProps) {
},
{
title: "Warp",
description: "Change the workspace for the session",
value: "workspace.set",
desc: "Change the workspace for the session",
name: "workspace.set",
category: "Session",
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slash: {
name: "warp",
},
onSelect: (dialog) => {
slashName: "warp",
run: () => {
void openWorkspaceSelect({
dialog,
sdk,
@@ -607,8 +626,29 @@ export function Prompt(props: PromptProps) {
})
},
},
]
})
].map((entry) => ({
namespace: "palette",
...entry,
})),
)
useBindings(() => ({
commands: promptCommands(),
}))
useBindings(() => ({
enabled: command.matcher,
bindings: keymapConfig.pick("prompt", [
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
"session.interrupt",
"workspace.set",
]),
}))
const ref: PromptRef = {
get focused() {
@@ -659,6 +699,7 @@ export function Prompt(props: PromptProps) {
if (store.prompt.input) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
setInputTarget(undefined)
props.ref?.(undefined)
})
@@ -676,11 +717,14 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
input.traits = computePromptTraits({
mode: store.mode,
disabled: !!props.disabled,
autocompleteVisible: !!auto()?.visible,
})
input.traits = {
...input.traits,
...computePromptTraits({
mode: store.mode,
disabled: !!props.disabled,
autocompleteVisible: !!auto()?.visible,
}),
}
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
@@ -761,60 +805,195 @@ export function Prompt(props: PromptProps) {
)
}
command.register(() => [
{
title: "Stash prompt",
value: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.input,
onSelect: (dialog) => {
if (!store.prompt.input) return
stash.push({
input: store.prompt.input,
parts: store.prompt.parts,
})
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
dialog.clear()
const stashCommands = createMemo(() =>
[
{
title: "Stash prompt",
name: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.input,
run: () => {
if (!store.prompt.input) return
stash.push({
input: store.prompt.input,
parts: store.prompt.parts,
})
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
},
},
{
title: "Stash pop",
value: "prompt.stash.pop",
category: "Prompt",
enabled: stash.list().length > 0,
onSelect: (dialog) => {
const entry = stash.pop()
if (entry) {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}
dialog.clear()
{
title: "Stash pop",
name: "prompt.stash.pop",
category: "Prompt",
enabled: stash.list().length > 0,
run: () => {
const entry = stash.pop()
if (entry) {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}
dialog.clear()
},
},
},
{
title: "Stash list",
value: "prompt.stash.list",
category: "Prompt",
enabled: stash.list().length > 0,
onSelect: (dialog) => {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}}
/>
))
{
title: "Stash list",
name: "prompt.stash.list",
category: "Prompt",
enabled: stash.list().length > 0,
run: () => {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}}
/>
))
},
},
},
])
].map((entry) => ({
namespace: "palette",
...entry,
})),
)
useBindings(() => ({
commands: stashCommands(),
}))
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
bindings: keymapConfig.pick("prompt", ["prompt.paste"]),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
bindings: keymapConfig.pick("prompt", ["prompt.clear"]),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0
})(),
bindings: [
{
key: "!",
cmd: () => {
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
},
},
],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!auto()?.visible &&
input !== undefined &&
(input.cursorOffset === 0 || input.visualCursor.visualRow === 0)
)
})(),
commands: [
{
name: "prompt.history.previous",
run() {
if (input.cursorOffset !== 0) {
input.cursorOffset = 0
return
}
const item = history.move(-1, input.plainText)
if (!item) return
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
input.cursorOffset = 0
},
},
],
bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!auto()?.visible &&
input !== undefined &&
(input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1)
)
})(),
commands: [
{
name: "prompt.history.next",
run() {
if (input.cursorOffset !== input.plainText.length) {
input.cursorOffset = input.plainText.length
return
}
const item = history.move(1, input.plainText)
if (!item) return
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
input.cursorOffset = input.plainText.length
},
},
],
bindings: keymapConfig.pick("prompt", ["prompt.history.next"]),
}
})
async function submit() {
setWarpNotice(undefined)
@@ -828,7 +1007,7 @@ export function Prompt(props: PromptProps) {
}
if (props.disabled) return false
if (workspaceCreating()) return false
if (autocomplete?.visible) return false
if (auto()?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
if (!agent) return false
@@ -1068,6 +1247,66 @@ export function Prompt(props: PromptProps) {
)
}
async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = iife(() => {
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (process.platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
})
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const mime = await Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
if (mime === "image/svg+xml") {
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (mime.startsWith("image/") || mime === "application/pdf") {
const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteAttachment({
filename,
filepath,
mime,
content,
})
return
}
}
} catch {}
}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
input.insertText(normalizedText)
setTimeout(() => {
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
}
async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
@@ -1117,7 +1356,7 @@ export function Prompt(props: PromptProps) {
}
const highlight = createMemo(() => {
if (keybind.leader) return theme.border
if (leader()) return theme.border
if (store.mode === "shell") return theme.primary
const agent = local.agent.current()
if (!agent) return theme.border
@@ -1206,30 +1445,7 @@ export function Prompt(props: PromptProps) {
return (
<>
<Autocomplete
sessionID={props.sessionID}
ref={(r) => {
autocomplete = r
setAuto(() => r)
}}
anchor={() => anchor}
input={() => input}
setPrompt={(cb) => {
setStore("prompt", produce(cb))
}}
setExtmark={(partIndex, extmarkId) => {
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
}}
value={store.prompt.input}
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={borderHighlight()}
@@ -1249,94 +1465,23 @@ export function Prompt(props: PromptProps) {
<textarea
placeholder={placeholderText()}
placeholderColor={theme.textMuted}
textColor={keybind.leader ? theme.textMuted : theme.text}
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
textColor={leader() ? theme.textMuted : theme.text}
focusedTextColor={leader() ? theme.textMuted : theme.text}
minHeight={1}
maxHeight={6}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "input", value)
autocomplete.onInput(value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
keyBindings={textareaKeybindings()}
onKeyDown={async (e) => {
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
e.preventDefault()
return
}
// Check clipboard for images before terminal-handled paste runs.
// This helps terminals that forward Ctrl+V to the app; Windows
// Terminal 1.25+ usually handles Ctrl+V before this path.
if (keybind.match("input_paste", e)) {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
e.preventDefault()
await pasteAttachment({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
return
}
// If no image, let the default paste behavior continue
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
return
}
if (keybind.match("app_exit", e)) {
if (store.prompt.input === "") {
await exit()
// Don't preventDefault - let textarea potentially handle the event
e.preventDefault()
return
}
}
if (e.name === "!" && input.visualCursor.offset === 0) {
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
e.preventDefault()
return
}
if (store.mode === "shell") {
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
setStore("mode", "normal")
e.preventDefault()
return
}
}
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (!autocomplete.visible) {
if (
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
(keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
) {
const direction = keybind.match("history_previous", e) ? -1 : 1
const item = history.move(direction, input.plainText)
if (item) {
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
e.preventDefault()
if (direction === -1) input.cursorOffset = 0
if (direction === 1) input.cursorOffset = input.plainText.length
}
return
}
if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
input.cursorOffset = input.plainText.length
}
}}
onSubmit={() => {
// IME: double-defer so the last composed character (e.g. Korean
@@ -1358,7 +1503,7 @@ export function Prompt(props: PromptProps) {
// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
command.trigger("prompt.paste")
keymap.dispatchCommand("prompt.paste")
return
}
@@ -1366,67 +1511,11 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
const filepath = iife(() => {
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (process.platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
})
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const mime = await Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") {
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (mime.startsWith("image/") || mime === "application/pdf") {
const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteAttachment({
filename,
filepath,
mime,
content,
})
return
}
}
} catch {}
}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
input.insertText(normalizedText)
// Force layout update and render for the pasted content
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
await pasteInputText(normalizedText)
}}
ref={(r: TextareaRenderable) => {
input = r
setInputTarget(r)
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
@@ -1455,7 +1544,7 @@ export function Prompt(props: PromptProps) {
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
fg={fadeColor(leader() ? theme.textMuted : theme.text, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
@@ -1646,12 +1735,12 @@ export function Prompt(props: PromptProps) {
</Match>
<Match when={true}>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
@@ -1664,6 +1753,28 @@ export function Prompt(props: PromptProps) {
</Show>
</box>
</box>
<Autocomplete
sessionID={props.sessionID}
ref={(r) => {
setAuto(() => r)
}}
anchor={() => anchor}
input={() => input}
setPrompt={(cb) => {
setStore("prompt", produce(cb))
}}
setExtmark={(partIndex, extmarkId) => {
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
}}
value={store.prompt.input}
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
</>
)
}
@@ -8,6 +8,11 @@ export interface PromptTraitsInput {
autocompleteVisible: boolean
}
export type PromptTraits = EditorTraits & {
owner: "opencode"
role: "prompt"
}
/**
* Compute the textarea editor traits for the prompt.
*
@@ -16,7 +21,7 @@ export interface PromptTraitsInput {
* editing mode — only `disabled` should suspend the textarea, otherwise
* users can type in shell mode but cannot delete or move the cursor.
*/
export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
const capture =
input.mode === "normal"
? input.autocompleteVisible
@@ -27,5 +32,7 @@ export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
capture,
suspend: input.disabled,
status: input.mode === "shell" ? "SHELL" : undefined,
owner: "opencode",
role: "prompt",
}
}
@@ -1,73 +0,0 @@
import { createMemo } from "solid-js"
import type { KeyBinding } from "@opentui/core"
import { useKeybind } from "../context/keybind"
import { Keybind } from "@/util/keybind"
const TEXTAREA_ACTIONS = [
"submit",
"newline",
"move-left",
"move-right",
"move-up",
"move-down",
"select-left",
"select-right",
"select-up",
"select-down",
"line-home",
"line-end",
"select-line-home",
"select-line-end",
"visual-line-home",
"visual-line-end",
"select-visual-line-home",
"select-visual-line-end",
"buffer-home",
"buffer-end",
"select-buffer-home",
"select-buffer-end",
"delete-line",
"delete-to-line-end",
"delete-to-line-start",
"backspace",
"delete",
"undo",
"redo",
"word-forward",
"word-backward",
"select-word-forward",
"select-word-backward",
"delete-word-forward",
"delete-word-backward",
] as const
function mapTextareaKeybindings(
keybinds: Record<string, Keybind.Info[]>,
action: (typeof TEXTAREA_ACTIONS)[number],
): KeyBinding[] {
const configKey = `input_${action.replace(/-/g, "_")}`
const bindings = keybinds[configKey]
if (!bindings) return []
return bindings.map((binding) => ({
name: binding.name,
ctrl: binding.ctrl || undefined,
meta: binding.meta || undefined,
shift: binding.shift || undefined,
super: binding.super || undefined,
action,
}))
}
export function useTextareaKeybindings() {
const keybind = useKeybind()
return createMemo(() => {
const keybinds = keybind.all
return [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)),
] satisfies KeyBinding[]
})
}
@@ -0,0 +1,177 @@
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { BindingValue } from "@opentui/keymap/extras"
import { ConfigKeybinds } from "@/config/keybinds"
import { type KeymapConfigInput, type KeymapSection } from "./tui-schema"
type LegacyKeybinds = Partial<ConfigKeybinds.Keybinds>
type SectionsConfig = Record<string, Record<string, BindingValue<Renderable, KeyEvent>>>
const inputCommands = {
input_submit: "input.submit",
input_newline: "input.newline",
input_move_left: "input.move.left",
input_move_right: "input.move.right",
input_move_up: "input.move.up",
input_move_down: "input.move.down",
input_select_left: "input.select.left",
input_select_right: "input.select.right",
input_select_up: "input.select.up",
input_select_down: "input.select.down",
input_line_home: "input.line.home",
input_line_end: "input.line.end",
input_select_line_home: "input.select.line.home",
input_select_line_end: "input.select.line.end",
input_visual_line_home: "input.visual.line.home",
input_visual_line_end: "input.visual.line.end",
input_select_visual_line_home: "input.select.visual.line.home",
input_select_visual_line_end: "input.select.visual.line.end",
input_buffer_home: "input.buffer.home",
input_buffer_end: "input.buffer.end",
input_select_buffer_home: "input.select.buffer.home",
input_select_buffer_end: "input.select.buffer.end",
input_delete_line: "input.delete.line",
input_delete_to_line_end: "input.delete.to.line.end",
input_delete_to_line_start: "input.delete.to.line.start",
input_backspace: "input.backspace",
input_delete: "input.delete",
input_undo: "input.undo",
input_redo: "input.redo",
input_word_forward: "input.word.forward",
input_word_backward: "input.word.backward",
input_select_word_forward: "input.select.word.forward",
input_select_word_backward: "input.select.word.backward",
input_delete_word_forward: "input.delete.word.forward",
input_delete_word_backward: "input.delete.word.backward",
input_select_all: "input.select.all",
} as const satisfies Partial<Record<keyof LegacyKeybinds, string>>
function add(config: SectionsConfig, section: KeymapSection, command: string, binding: BindingValue<Renderable, KeyEvent> | undefined) {
if (binding === undefined) return
config[section] ??= {}
config[section][command] = binding
}
function bindingWith(key: string | undefined, input: Omit<Binding<Renderable, KeyEvent>, "key" | "cmd">) {
if (!key) return undefined
if (key === "none") return "none"
return { ...input, key }
}
function combineBindings(...keys: (string | undefined)[]) {
const result = Array.from(
new Set(
keys.flatMap((key) => {
if (!key || key === "none") return []
return key
.split(",")
.map((part) => part.trim())
.filter((part) => part && part !== "none")
}),
),
)
if (result.length) return result.join(",")
if (keys.some((key) => key === "none")) return "none"
return undefined
}
export function create(keybinds: LegacyKeybinds): KeymapConfigInput {
const config: SectionsConfig = {}
add(config, "global", "command.palette.show", keybinds.command_list)
add(config, "global", "session.list", keybinds.session_list)
add(config, "global", "session.new", keybinds.session_new)
add(config, "global", "model.list", keybinds.model_list)
add(config, "global", "model.cycle_recent", keybinds.model_cycle_recent)
add(config, "global", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse)
add(config, "global", "model.cycle_favorite", keybinds.model_cycle_favorite)
add(config, "global", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse)
add(config, "global", "agent.list", keybinds.agent_list)
add(config, "global", "agent.cycle", keybinds.agent_cycle)
add(config, "global", "agent.cycle.reverse", keybinds.agent_cycle_reverse)
add(config, "global", "variant.cycle", keybinds.variant_cycle)
add(config, "global", "variant.list", keybinds.variant_list)
add(config, "prompt", "prompt.editor", keybinds.editor_open)
add(config, "global", "opencode.status", keybinds.status_view)
add(config, "global", "theme.switch", keybinds.theme_list)
add(config, "global", "app.exit", keybinds.app_exit)
add(config, "global", "terminal.suspend", keybinds.terminal_suspend)
add(config, "global", "terminal.title.toggle", keybinds.terminal_title_toggle)
add(config, "session", "session.share", keybinds.session_share)
add(config, "session", "session.rename", keybinds.session_rename)
add(config, "session", "session.timeline", keybinds.session_timeline)
add(config, "session", "session.fork", keybinds.session_fork)
add(config, "session", "session.compact", keybinds.session_compact)
add(config, "session", "session.unshare", keybinds.session_unshare)
add(config, "session", "session.undo", keybinds.messages_undo)
add(config, "session", "session.redo", keybinds.messages_redo)
add(config, "session", "session.sidebar.toggle", keybinds.sidebar_toggle)
add(config, "session", "session.toggle.conceal", keybinds.messages_toggle_conceal)
add(config, "session", "session.toggle.thinking", keybinds.display_thinking)
add(config, "session", "session.toggle.actions", keybinds.tool_details)
add(config, "session", "session.toggle.scrollbar", keybinds.scrollbar_toggle)
add(config, "session", "session.page.up", keybinds.messages_page_up)
add(config, "session", "session.page.down", keybinds.messages_page_down)
add(config, "session", "session.line.up", keybinds.messages_line_up)
add(config, "session", "session.line.down", keybinds.messages_line_down)
add(config, "session", "session.half.page.up", keybinds.messages_half_page_up)
add(config, "session", "session.half.page.down", keybinds.messages_half_page_down)
add(config, "session", "session.first", keybinds.messages_first)
add(config, "session", "session.last", keybinds.messages_last)
add(config, "session", "session.messages_last_user", keybinds.messages_last_user)
add(config, "session", "session.message.next", keybinds.messages_next)
add(config, "session", "session.message.previous", keybinds.messages_previous)
add(config, "session", "messages.copy", keybinds.messages_copy)
add(config, "session", "session.export", keybinds.session_export)
add(config, "session", "session.child.first", keybinds.session_child_first)
add(config, "session", "session.parent", keybinds.session_parent)
add(config, "session", "session.child.next", keybinds.session_child_cycle)
add(config, "session", "session.child.previous", keybinds.session_child_cycle_reverse)
add(config, "prompt", "session.interrupt", keybinds.session_interrupt)
add(config, "prompt", "prompt.clear", keybinds.input_clear)
add(config, "prompt", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false }))
add(config, "prompt", "prompt.history.previous", keybinds.history_previous)
add(config, "prompt", "prompt.history.next", keybinds.history_next)
add(config, "autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"])
add(config, "autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"])
add(config, "autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"])
add(config, "autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"])
add(config, "autocomplete", "prompt.autocomplete.complete", keybinds["prompt.autocomplete.complete"])
for (const [legacy, command] of Object.entries(inputCommands) as [keyof typeof inputCommands, string][]) {
add(config, "input", command, keybinds[legacy])
}
add(config, "dialog_select", "dialog.select.prev", keybinds["dialog.select.prev"])
add(config, "dialog_select", "dialog.select.next", keybinds["dialog.select.next"])
add(config, "dialog_select", "dialog.select.page_up", keybinds["dialog.select.page_up"])
add(config, "dialog_select", "dialog.select.page_down", keybinds["dialog.select.page_down"])
add(config, "dialog_select", "dialog.select.home", keybinds["dialog.select.home"])
add(config, "dialog_select", "dialog.select.end", keybinds["dialog.select.end"])
add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"])
add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete))
add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename)
add(config, "dialog_actions", "dialog.action.toggle", combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"]))
add(config, "model", "model.dialog.provider", keybinds.model_provider_list)
add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle)
add(config, "permission", "permission.reject.cancel", keybinds.app_exit)
add(config, "permission", "permission.prompt.escape", keybinds.app_exit)
add(config, "permission", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"])
add(config, "question", "question.reject", keybinds.app_exit)
add(config, "question", "question.edit.clear", keybinds.input_clear)
add(config, "plugins", "plugins.list", keybinds.plugin_manager)
add(config, "plugins", "plugin.dialog.install", keybinds["dialog.plugins.install"])
add(config, "home_tips", "tips.toggle", keybinds.tips_toggle)
return {
...(keybinds.leader && keybinds.leader !== "none" && { leader: keybinds.leader }),
sections: config,
}
}
export * as LegacyKeymapTransform from "./legacy-keymap-transform"
@@ -1,4 +1,7 @@
import z from "zod"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { ResolvedBindingSections } from "@opentui/keymap/extras"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
@@ -11,6 +14,303 @@ const KeybindOverride = z
)
.strict()
const KeyStroke = z
.object({
name: z.string(),
ctrl: z.boolean().optional(),
shift: z.boolean().optional(),
meta: z.boolean().optional(),
super: z.boolean().optional(),
hyper: z.boolean().optional(),
})
.strict()
const KeymapBindingObject = z
.object({
key: z.union([z.string(), KeyStroke]),
event: z.enum(["press", "release"]).optional(),
preventDefault: z.boolean().optional(),
fallthrough: z.boolean().optional(),
})
.passthrough()
const KeymapBindingItem = z.union([z.string(), KeyStroke, KeymapBindingObject])
const KeymapBindingValue = z.union([z.literal(false), z.literal("none"), KeymapBindingItem, z.array(KeymapBindingItem)])
const keymapBinding = (value: z.input<typeof KeymapBindingValue> | (() => z.input<typeof KeymapBindingValue>)) =>
KeymapBindingValue.prefault(value)
const keymapSection = <Shape extends z.ZodRawShape>(shape: Shape) => {
const schema = z.object(shape).strict()
return schema.prefault({} as z.input<typeof schema>)
}
const keymapSectionInput = <Shape extends z.ZodRawShape>(shape: Shape) =>
z
.object(
Object.fromEntries(Object.keys(shape).map((key) => [key, KeymapBindingValue.optional()])) as {
[Key in keyof Shape]: z.ZodOptional<typeof KeymapBindingValue>
},
)
.strict()
const GlobalKeymapSection = {
"command.palette.show": keymapBinding("ctrl+p"),
"session.list": keymapBinding("<leader>l"),
"session.new": keymapBinding("<leader>n"),
"model.list": keymapBinding("<leader>m"),
"model.cycle_recent": keymapBinding("f2"),
"model.cycle_recent_reverse": keymapBinding("shift+f2"),
"model.cycle_favorite": keymapBinding("none"),
"model.cycle_favorite_reverse": keymapBinding("none"),
"agent.list": keymapBinding("<leader>a"),
"mcp.list": keymapBinding("none"),
"agent.cycle": keymapBinding("tab"),
"agent.cycle.reverse": keymapBinding("shift+tab"),
"variant.cycle": keymapBinding("ctrl+t"),
"variant.list": keymapBinding("none"),
"provider.connect": keymapBinding("none"),
"console.org.switch": keymapBinding("none"),
"opencode.status": keymapBinding("<leader>s"),
"theme.switch": keymapBinding("<leader>t"),
"theme.switch_mode": keymapBinding("none"),
"theme.mode.lock": keymapBinding("none"),
"help.show": keymapBinding("none"),
"docs.open": keymapBinding("none"),
"app.exit": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
"app.debug": keymapBinding("none"),
"app.console": keymapBinding("none"),
"app.heap_snapshot": keymapBinding("none"),
"app.toggle.animations": keymapBinding("none"),
"app.toggle.file_context": keymapBinding("none"),
"app.toggle.diffwrap": keymapBinding("none"),
"app.toggle.paste_summary": keymapBinding("none"),
"app.toggle.session_directory_filter": keymapBinding("none"),
"terminal.suspend": keymapBinding(() => (process.platform === "win32" ? "none" : "ctrl+z")),
"terminal.title.toggle": keymapBinding("none"),
}
const SessionKeymapSection = {
"session.share": keymapBinding("none"),
"session.rename": keymapBinding("ctrl+r"),
"session.timeline": keymapBinding("<leader>g"),
"session.fork": keymapBinding("none"),
"session.compact": keymapBinding("<leader>c"),
"session.unshare": keymapBinding("none"),
"session.undo": keymapBinding("<leader>u"),
"session.redo": keymapBinding("<leader>r"),
"session.sidebar.toggle": keymapBinding("<leader>b"),
"session.toggle.conceal": keymapBinding("<leader>h"),
"session.toggle.timestamps": keymapBinding("none"),
"session.toggle.thinking": keymapBinding("none"),
"session.toggle.actions": keymapBinding("none"),
"session.toggle.scrollbar": keymapBinding("none"),
"session.toggle.generic_tool_output": keymapBinding("none"),
"session.page.up": keymapBinding("pageup,ctrl+alt+b"),
"session.page.down": keymapBinding("pagedown,ctrl+alt+f"),
"session.line.up": keymapBinding("ctrl+alt+y"),
"session.line.down": keymapBinding("ctrl+alt+e"),
"session.half.page.up": keymapBinding("ctrl+alt+u"),
"session.half.page.down": keymapBinding("ctrl+alt+d"),
"session.first": keymapBinding("ctrl+g,home"),
"session.last": keymapBinding("ctrl+alt+g,end"),
"session.messages_last_user": keymapBinding("none"),
"session.message.next": keymapBinding("none"),
"session.message.previous": keymapBinding("none"),
"messages.copy": keymapBinding("<leader>y"),
"session.copy": keymapBinding("none"),
"session.export": keymapBinding("<leader>x"),
"session.child.first": keymapBinding("<leader>down"),
"session.parent": keymapBinding("up"),
"session.child.next": keymapBinding("right"),
"session.child.previous": keymapBinding("left"),
}
const PromptKeymapSection = {
"prompt.submit": keymapBinding("none"),
"prompt.editor": keymapBinding("<leader>e"),
"prompt.editor_context.clear": keymapBinding("none"),
"prompt.skills": keymapBinding("none"),
"prompt.stash": keymapBinding("none"),
"prompt.stash.pop": keymapBinding("none"),
"prompt.stash.list": keymapBinding("none"),
"workspace.set": keymapBinding("none"),
"session.interrupt": keymapBinding("escape"),
"prompt.clear": keymapBinding("ctrl+c"),
"prompt.paste": keymapBinding({ key: "ctrl+v", preventDefault: false }),
"prompt.history.previous": keymapBinding("up"),
"prompt.history.next": keymapBinding("down"),
}
const AutocompleteKeymapSection = {
"prompt.autocomplete.prev": keymapBinding("up,ctrl+p"),
"prompt.autocomplete.next": keymapBinding("down,ctrl+n"),
"prompt.autocomplete.hide": keymapBinding("escape"),
"prompt.autocomplete.select": keymapBinding("return"),
"prompt.autocomplete.complete": keymapBinding("tab"),
}
const InputKeymapSection = {
"input.submit": keymapBinding("return"),
"input.newline": keymapBinding("shift+return,ctrl+return,alt+return,ctrl+j"),
"input.move.left": keymapBinding("left,ctrl+b"),
"input.move.right": keymapBinding("right,ctrl+f"),
"input.move.up": keymapBinding("up"),
"input.move.down": keymapBinding("down"),
"input.select.left": keymapBinding("shift+left"),
"input.select.right": keymapBinding("shift+right"),
"input.select.up": keymapBinding("shift+up"),
"input.select.down": keymapBinding("shift+down"),
"input.line.home": keymapBinding("ctrl+a"),
"input.line.end": keymapBinding("ctrl+e"),
"input.select.line.home": keymapBinding("ctrl+shift+a"),
"input.select.line.end": keymapBinding("ctrl+shift+e"),
"input.visual.line.home": keymapBinding("alt+a"),
"input.visual.line.end": keymapBinding("alt+e"),
"input.select.visual.line.home": keymapBinding("alt+shift+a"),
"input.select.visual.line.end": keymapBinding("alt+shift+e"),
"input.buffer.home": keymapBinding("home"),
"input.buffer.end": keymapBinding("end"),
"input.select.buffer.home": keymapBinding("shift+home"),
"input.select.buffer.end": keymapBinding("shift+end"),
"input.delete.line": keymapBinding("ctrl+shift+d"),
"input.delete.to.line.end": keymapBinding("ctrl+k"),
"input.delete.to.line.start": keymapBinding("ctrl+u"),
"input.backspace": keymapBinding("backspace,shift+backspace"),
"input.delete": keymapBinding("ctrl+d,delete,shift+delete"),
"input.undo": keymapBinding(() => (process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")),
"input.redo": keymapBinding("ctrl+.,super+shift+z"),
"input.word.forward": keymapBinding("alt+f,alt+right,ctrl+right"),
"input.word.backward": keymapBinding("alt+b,alt+left,ctrl+left"),
"input.select.word.forward": keymapBinding("alt+shift+f,alt+shift+right"),
"input.select.word.backward": keymapBinding("alt+shift+b,alt+shift+left"),
"input.delete.word.forward": keymapBinding("alt+d,alt+delete,ctrl+delete"),
"input.delete.word.backward": keymapBinding("ctrl+w,ctrl+backspace,alt+backspace"),
"input.select.all": keymapBinding("super+a"),
}
const DialogSelectKeymapSection = {
"dialog.select.prev": keymapBinding("up,ctrl+p"),
"dialog.select.next": keymapBinding("down,ctrl+n"),
"dialog.select.page_up": keymapBinding("pageup"),
"dialog.select.page_down": keymapBinding("pagedown"),
"dialog.select.home": keymapBinding("home"),
"dialog.select.end": keymapBinding("end"),
"dialog.select.submit": keymapBinding("return"),
}
const DialogActionsKeymapSection = {
"dialog.action.toggle": keymapBinding("space"),
"dialog.action.delete": keymapBinding("ctrl+d"),
"dialog.action.rename": keymapBinding("ctrl+r"),
}
const ModelKeymapSection = {
"model.dialog.provider": keymapBinding("ctrl+a"),
"model.dialog.favorite": keymapBinding("ctrl+f"),
}
const PermissionKeymapSection = {
"permission.reject.cancel": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
"permission.prompt.escape": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
"permission.prompt.fullscreen": keymapBinding("ctrl+f"),
}
const QuestionKeymapSection = {
"question.reject": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
"question.edit.clear": keymapBinding("ctrl+c"),
}
const PluginsKeymapSection = {
"plugins.list": keymapBinding("none"),
"plugins.install": keymapBinding("none"),
"plugin.dialog.install": keymapBinding("shift+i"),
}
const HomeTipsKeymapSection = {
"tips.toggle": keymapBinding("<leader>h"),
}
const KeymapSectionsShape = {
global: keymapSection(GlobalKeymapSection),
session: keymapSection(SessionKeymapSection),
prompt: keymapSection(PromptKeymapSection),
autocomplete: keymapSection(AutocompleteKeymapSection),
input: keymapSection(InputKeymapSection),
dialog_select: keymapSection(DialogSelectKeymapSection),
dialog_actions: keymapSection(DialogActionsKeymapSection),
model: keymapSection(ModelKeymapSection),
permission: keymapSection(PermissionKeymapSection),
question: keymapSection(QuestionKeymapSection),
plugins: keymapSection(PluginsKeymapSection),
home_tips: keymapSection(HomeTipsKeymapSection),
}
const KeymapSectionsInputShape = {
global: keymapSectionInput(GlobalKeymapSection).optional(),
session: keymapSectionInput(SessionKeymapSection).optional(),
prompt: keymapSectionInput(PromptKeymapSection).optional(),
autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(),
input: keymapSectionInput(InputKeymapSection).optional(),
dialog_select: keymapSectionInput(DialogSelectKeymapSection).optional(),
dialog_actions: keymapSectionInput(DialogActionsKeymapSection).optional(),
model: keymapSectionInput(ModelKeymapSection).optional(),
permission: keymapSectionInput(PermissionKeymapSection).optional(),
question: keymapSectionInput(QuestionKeymapSection).optional(),
plugins: keymapSectionInput(PluginsKeymapSection).optional(),
home_tips: keymapSectionInput(HomeTipsKeymapSection).optional(),
}
export const KeymapSections = z.object(KeymapSectionsShape).strict().prefault({})
export type KeymapSections = z.output<typeof KeymapSections>
export type KeymapSection = keyof KeymapSections
export const KeymapSectionNames = Object.keys(KeymapSectionsShape) as KeymapSection[]
export const KeymapLeaderTimeoutDefault = 2000
export type KeymapInfo = {
leader: string
leader_timeout: number
} & ResolvedBindingSections<Renderable, KeyEvent, KeymapSection>
export const KeymapSectionGroups = {
global: "Global",
session: "Session",
prompt: "Prompt",
autocomplete: "Autocomplete",
input: "Text Editing",
dialog_select: "Dialog",
dialog_actions: "Dialog",
model: "Model",
permission: "Permission",
question: "Question",
plugins: "Plugins",
home_tips: "Home",
} satisfies Record<KeymapSection, string>
export function keymapBindingDefaults(input: { section: string; binding: Readonly<Binding<Renderable, KeyEvent>> }) {
if (input.binding.group !== undefined) return
if (!Object.hasOwn(KeymapSectionGroups, input.section)) return
return { group: KeymapSectionGroups[input.section as KeymapSection] }
}
export const KeymapConfig = z
.object({
leader: z.string().prefault("ctrl+x"),
leader_timeout: z.number().int().positive().prefault(KeymapLeaderTimeoutDefault).describe("Leader key timeout in milliseconds"),
sections: KeymapSections,
})
.strict()
.describe("TUI keymap configuration")
export type KeymapConfig = z.output<typeof KeymapConfig>
const KeymapSectionsInput = z.object(KeymapSectionsInputShape).strict().optional()
export const KeymapConfigInput = z
.object({
leader: z.string().optional(),
leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"),
sections: KeymapSectionsInput,
})
.strict()
.describe("TUI keymap configuration")
export type KeymapConfigInput = z.output<typeof KeymapConfigInput>
export const TuiOptions = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
@@ -30,9 +330,17 @@ export const TuiInfo = z
.object({
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
keybinds: KeybindOverride.optional().meta({
deprecated: true,
description: "Use keymap instead. This will be removed in opencode v2.0.",
}),
keymap: KeymapConfigInput.optional(),
plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})
.extend(TuiOptions.shape)
.strict()
export const TuiJsonSchemaInfo = TuiInfo.extend({
keymap: KeymapConfig.optional(),
}).strict()
+46 -14
View File
@@ -1,12 +1,14 @@
export * as TuiConfig from "./tui"
import z from "zod"
import type z from "zod"
import type { KeyEvent, Renderable } from "@opentui/core"
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@opencode-ai/core/global"
@@ -20,27 +22,34 @@ import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm"
import { LegacyKeymapTransform } from "./legacy-keymap-transform"
import {
KeymapSectionNames,
keymapBindingDefaults,
type KeymapInfo,
type KeymapSection,
} from "./tui-schema"
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
export const JsonSchemaInfo = TuiJsonSchemaInfo
export type Info = z.output<typeof Info>
type Acc = {
result: Info
plugin_origins: ConfigPlugin.Origin[]
}
type State = {
config: Info
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
export type Info = z.output<typeof Info> & {
export type Resolved = Omit<Info, "keybinds" | "keymap"> & {
keybinds: ConfigKeybinds.Keybinds
keymap: KeymapInfo
// Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[]
}
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly get: () => Effect.Effect<Resolved>
readonly waitForDependencies: () => Effect.Effect<void>
}
@@ -128,11 +137,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...acc.plugin_origins,
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
acc.plugin_origins = plugins
})
// Every config dir we may read from: global config dir, any `.opencode`
@@ -144,6 +153,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const acc: Acc = {
result: {},
plugin_origins: [],
}
// 1. Global tui config (lowest precedence).
@@ -184,11 +194,33 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
}
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds)
const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {})
const keymapConfig = KeymapConfig.parse(keymapInput)
const keymap = {
leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader,
leader_timeout: keymapConfig.leader_timeout,
...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
keymapConfig.sections,
{
sections: KeymapSectionNames,
bindingDefaults: keymapBindingDefaults,
},
),
}
const result: Resolved = {
...acc.result,
keybinds: parsedKeybinds,
plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined,
// `keybinds` is deprecated and will be removed in opencode v2.0. Keep it
// only as the legacy fallback; once `keymap` is configured, ignore
// `keybinds` for keymap resolution.
keymap,
}
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
config: result,
dirs: result.plugin?.length ? dirs : [],
}
})
@@ -0,0 +1,163 @@
import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps } from "solid-js"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import {
formatKeyBindings,
reactiveMatcherFromSignal,
type OpenTuiKeymap,
useKeymapSelector,
useOpencodeKeymap,
} from "../keymap"
import { useTuiConfig } from "./tui-config"
type SlashEntry = {
display: string
description?: string
aliases?: string[]
onSelect: () => void
}
type CommandPaletteContext = {
run(command: string): void
show(): void
slashes: Accessor<readonly SlashEntry[]>
suspend(enabled: boolean): void
readonly suspended: boolean
matcher: ReturnType<typeof reactiveMatcherFromSignal>
}
const COMMAND_PALETTE_DIALOG = "command.palette.show"
const ctx = createContext<CommandPaletteContext>()
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
function isVisiblePaletteCommand(entry: PaletteCommandEntry) {
return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_DIALOG
}
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
const suggested = entry.command.suggested
if (typeof suggested === "boolean") return suggested
if (typeof suggested === "function") return suggested() === true
return false
}
export function CommandPaletteProvider(props: ParentProps) {
const dialog = useDialog()
const keymap = useOpencodeKeymap()
const [suspendCount, setSuspendCount] = createSignal(0)
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
keymap
.getCommandEntries({
visibility: "reachable",
namespace: "palette",
})
.filter(isVisiblePaletteCommand),
)
const run = (command: string) => {
keymap.dispatchCommand(command)
}
const slashes = createMemo<SlashEntry[]>(() =>
entries().flatMap((entry) => {
const slashName = entry.command.slashName
if (typeof slashName !== "string" || !slashName) return []
const slashAliases = entry.command.slashAliases
return {
display: `/${slashName}`,
description:
typeof entry.command.desc === "string"
? entry.command.desc
: typeof entry.command.title === "string"
? entry.command.title
: undefined,
aliases: Array.isArray(slashAliases)
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
: undefined,
onSelect: () => run(entry.command.name),
}
}),
)
const value: CommandPaletteContext = {
run,
show() {
dialog.replace(() => <CommandPaletteDialog run={run} />)
},
slashes,
suspend(enabled: boolean) {
setSuspendCount((count) => Math.max(0, count + (enabled ? 1 : -1)))
},
get suspended() {
return suspendCount() > 0 || dialog.stack.length > 0
},
matcher: reactiveMatcherFromSignal(() => suspendCount() === 0 && dialog.stack.length === 0),
}
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useCommandPalette() {
const value = useContext(ctx)
if (!value) throw new Error("CommandPalette context must be used within a CommandPaletteProvider")
return value
}
function CommandPaletteDialog(props: { run(command: string): void }) {
const config = useTuiConfig()
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
const query = {
namespace: "palette",
}
const reachable = keymap
.getCommandEntries({
...query,
visibility: "reachable",
})
.filter(isVisiblePaletteCommand)
const registeredBindings = keymap.getCommandBindings({
visibility: "registered",
commands: reachable.map((entry) => entry.command.name),
})
return reachable.map((entry) => ({
...entry,
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
}))
})
const options = createMemo(() =>
entries().map((entry) => ({
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
footer: formatKeyBindings(entry.bindings, config),
value: entry.command.name,
suggested: isSuggestedPaletteCommand(entry),
onSelect: (dialog: DialogContext) => {
dialog.clear()
props.run(entry.command.name)
},
})),
)
let ref: DialogSelectRef<string>
const list = () => {
if (ref?.filter) return options()
return [
...options()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
...options(),
]
}
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
}
export function useCommandSlashes(): Accessor<readonly SlashEntry[]> {
return useCommandPalette().slashes
}
@@ -1,105 +0,0 @@
import { createMemo } from "solid-js"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { useTuiConfig } from "./tui-config"
export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: () => {
const config = useTuiConfig()
const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
return pipe(
(config.keybinds ?? {}) as Record<string, string>,
mapValues((value) => Keybind.parse(value)),
)
})
const [store, setStore] = createStore({
leader: false,
})
const renderer = useRenderer()
let focus: Renderable | null
let timeout: NodeJS.Timeout
function leader(active: boolean) {
if (active) {
setStore("leader", true)
focus = renderer.currentFocusedRenderable
focus?.blur()
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
if (!store.leader) return
leader(false)
if (!focus || focus.isDestroyed) return
focus.focus()
}, 2000)
return
}
if (!active) {
if (focus && !renderer.currentFocusedRenderable) {
focus.focus()
}
setStore("leader", false)
}
}
useKeyboard(async (evt) => {
if (!store.leader && result.match("leader", evt)) {
leader(true)
return
}
if (store.leader && evt.name) {
setImmediate(() => {
if (focus && renderer.currentFocusedRenderable === focus) {
focus.focus()
}
leader(false)
})
}
})
const result = {
get all() {
return keybinds()
},
get leader() {
return store.leader
},
parse(evt: ParsedKey): Keybind.Info {
// Handle special case for Ctrl+Underscore (represented as \x1F)
if (evt.name === "\x1F") {
return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader)
}
return Keybind.fromParsedKey(evt, store.leader)
},
match(key: string, evt: ParsedKey) {
const list = keybinds()[key] ?? Keybind.parse(key)
if (!list.length) return false
const parsed: Keybind.Info = result.parse(evt)
for (const item of list) {
if (Keybind.match(item, parsed)) {
return true
}
}
return false
},
print(key: string) {
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
if (!first) return ""
const text = Keybind.toString(first)
const lead = keybinds().leader?.[0]
if (!lead) return text
return text.replace("<leader>", Keybind.toString(lead))
},
}
return result
},
})
@@ -1,41 +0,0 @@
import type { ParsedKey } from "@opentui/core"
export type PluginKeybindMap = Record<string, string>
type Base = {
match: (key: string, evt: ParsedKey) => boolean
print: (key: string) => string
}
export type PluginKeybind = {
readonly all: PluginKeybindMap
get: (name: string) => string
match: (name: string, evt: ParsedKey) => boolean
print: (name: string) => string
}
const txt = (value: unknown) => {
if (typeof value !== "string") return
if (!value.trim()) return
return value
}
export function createPluginKeybind(
base: Base,
defaults: PluginKeybindMap,
overrides?: Record<string, unknown>,
): PluginKeybind {
const all = Object.freeze(
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
)
const get = (name: string) => all[name] ?? name
return {
get all() {
return all
},
get,
match: (name, evt) => base.match(get(name), evt),
print: (name) => base.print(get(name)),
}
}
@@ -3,7 +3,7 @@ import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
name: "TuiConfig",
init: (props: { config: TuiConfig.Info }) => {
init: (props: { config: TuiConfig.Resolved }) => {
return props.config
},
})
@@ -1,10 +1,27 @@
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { Tips } from "./tips-view"
import { useBindings } from "../../keymap"
const id = "internal:home-tips"
function View(props: { show: boolean; connected: boolean }) {
function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) {
useBindings(() => ({
commands: [
{
name: "tips.toggle",
title: props.hidden ? "Show tips" : "Hide tips",
category: "System",
namespace: "palette",
run() {
props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false))
props.api.ui.dialog.clear()
},
},
],
bindings: props.api.tuiConfig.keymap.sections.home_tips,
}))
return (
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={props.show}>
@@ -15,20 +32,6 @@ function View(props: { show: boolean; connected: boolean }) {
}
const tui: TuiPlugin = async (api) => {
api.command.register(() => [
{
title: api.kv.get("tips_hidden", false) ? "Show tips" : "Hide tips",
value: "tips.toggle",
keybind: "tips_toggle",
category: "System",
hidden: api.route.current.name !== "home",
onSelect() {
api.kv.set("tips_hidden", !api.kv.get("tips_hidden", false))
api.ui.dialog.clear()
},
},
])
api.slots.register({
order: 100,
slots: {
@@ -41,7 +44,7 @@ const tui: TuiPlugin = async (api) => {
),
)
const show = createMemo(() => (!first() || !connected()) && !hidden())
return <View show={show()} connected={connected()} />
return <View api={api} hidden={hidden()} show={show()} connected={connected()} />
},
},
})
@@ -1,14 +1,11 @@
import { Keybind } from "@/util/keybind"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { Show, createEffect, createMemo, createSignal } from "solid-js"
import { useBindings } from "../../keymap"
const id = "internal:plugin-manager"
const key = Keybind.parse("space").at(0)
const add = Keybind.parse("shift+i").at(0)
const tab = Keybind.parse("tab").at(0)
function state(api: TuiPluginApi, item: TuiPluginStatus) {
if (!item.enabled) {
@@ -41,13 +38,10 @@ function Install(props: { api: TuiPluginApi }) {
const [global, setGlobal] = createSignal(false)
const [busy, setBusy] = createSignal(false)
useKeyboard((evt) => {
if (evt.name !== "tab") return
evt.preventDefault()
evt.stopPropagation()
if (busy()) return
setGlobal((x) => !x)
})
useBindings(() => ({
enabled: !busy(),
bindings: [{ key: "tab", cmd: () => setGlobal((value) => !value) }],
}))
return (
<props.api.ui.DialogPrompt
@@ -62,7 +56,7 @@ function Install(props: { api: TuiPluginApi }) {
{global() ? "global" : "local"}
</text>
<Show when={!busy()}>
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text>
<text fg={props.api.theme.current.textMuted}>(tab toggle)</text>
</Show>
</box>
)}
@@ -209,10 +203,10 @@ function View(props: { api: TuiPluginApi }) {
options={rows()}
current={cur()}
onMove={(item) => setCur(item.value)}
keybind={[
actions={[
{
title: "toggle",
keybind: key,
command: "dialog.action.toggle",
disabled: lock(),
onTrigger: (item) => {
setCur(item.value)
@@ -221,13 +215,14 @@ function View(props: { api: TuiPluginApi }) {
},
{
title: "install",
keybind: add,
command: "plugin.dialog.install",
disabled: lock(),
onTrigger: () => {
showInstall(props.api)
},
},
]}
bindings={props.api.tuiConfig.keymap.pick("plugins", ["plugin.dialog.install"])}
onSelect={(item) => {
setCur(item.value)
flip(item.value)
@@ -241,25 +236,29 @@ function show(api: TuiPluginApi) {
}
const tui: TuiPlugin = async (api) => {
api.command.register(() => [
{
title: "Plugins",
value: "plugins.list",
keybind: "plugin_manager",
category: "System",
onSelect() {
show(api)
api.keymap.registerLayer({
commands: [
{
name: "plugins.list",
title: "Plugins",
category: "System",
namespace: "palette",
run() {
show(api)
},
},
},
{
title: "Install plugin",
value: "plugins.install",
category: "System",
onSelect() {
showInstall(api)
{
name: "plugins.install",
title: "Install plugin",
category: "System",
namespace: "palette",
run() {
showInstall(api)
},
},
},
])
],
bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]),
})
}
const plugin: TuiPluginModule & { id: string } = {
@@ -4,8 +4,9 @@ import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { useBindings } from "../../keymap"
import { Locale } from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import path from "path"
@@ -53,12 +54,16 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
void sync.session.message.sync(props.sessionID)
})
useKeyboard((event) => {
if (event.name !== "escape") return
event.preventDefault()
event.stopPropagation()
props.api.route.navigate("session", { sessionID: props.sessionID })
})
useBindings(() => ({
bindings: [
{
key: "escape",
cmd() {
props.api.route.navigate("session", { sessionID: props.sessionID })
},
},
],
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
@@ -1113,21 +1118,24 @@ const tui: TuiPlugin = async (api) => {
},
])
api.command.register(() => [
{
title: "View v2 session messages",
value: route,
category: "Debug",
suggested: api.route.current.name === "session",
enabled: api.route.current.name === "session",
onSelect() {
const sessionID = currentSessionID(api)
if (!sessionID) return
api.route.navigate(route, { sessionID })
api.ui.dialog.clear()
api.keymap.registerLayer({
commands: [
{
name: route,
title: "View v2 session messages",
category: "Debug",
namespace: "palette",
suggested: () => api.route.current.name === "session",
enabled: () => api.route.current.name === "session",
run() {
const sessionID = currentSessionID(api)
if (!sessionID) return
api.route.navigate(route, { sessionID })
api.ui.dialog.clear()
},
},
},
])
],
})
}
const plugin: TuiPluginModule & { id: string } = {
@@ -0,0 +1,91 @@
import { type CliRenderer } from "@opentui/core"
import * as addons from "@opentui/keymap/addons/opentui"
import {
formatCommandBindings as formatCommandBindingsExtra,
formatKeySequence as formatKeySequenceExtra,
} from "@opentui/keymap/extras"
import {
KeymapProvider,
reactiveMatcherFromSignal,
useBindings,
useKeymap,
useKeymapSelector,
} from "@opentui/keymap/solid"
import type { Accessor } from "solid-js"
import type { TuiConfig } from "./config/tui"
import { useTuiConfig } from "./context/tui-config"
export const LEADER_TOKEN = "leader"
export const OpencodeKeymapProvider = KeymapProvider
export const useOpencodeKeymap = useKeymap
export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
function formatOptions(config: TuiConfig.Resolved) {
return {
tokenDisplay: {
[LEADER_TOKEN]: config.keymap.leader,
},
keyNameAliases: {
pageup: "pgup",
pagedown: "pgdn",
delete: "del",
},
modifierAliases: {
meta: "alt",
},
} as const
}
export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: TuiConfig.Resolved) {
return formatKeySequenceExtra(parts, formatOptions(config))
}
export function formatKeyBindings(
bindings: Parameters<typeof formatCommandBindingsExtra>[0],
config: TuiConfig.Resolved,
) {
return formatCommandBindingsExtra(bindings, formatOptions(config))
}
export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) {
const offCommaBindings = addons.registerCommaBindings(keymap)
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
const offLeader = addons.registerTimedLeader(keymap, {
trigger: config.keymap.leader,
name: LEADER_TOKEN,
timeoutMs: config.keymap.leader_timeout,
})
const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
enabled: () => renderer.currentFocusedEditor !== null,
bindings: config.keymap.sections.input,
})
return () => {
offInputBindings()
offBackspace()
offEscape()
offLeader()
offBaseLayout()
offCommaBindings()
}
}
export function useCommandShortcut(command: string): Accessor<string> {
const config = useTuiConfig()
return useKeymapSelector((keymap) =>
formatKeySequence(
keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence,
config,
),
)
}
export function useLeaderActive(): Accessor<boolean> {
return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
}
@@ -1,15 +1,12 @@
import type { ParsedKey } from "@opentui/core"
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { useCommandDialog } from "@tui/component/dialog-command"
import type { useEvent } from "@tui/context/event"
import type { useKeybind } from "@tui/context/keybind"
import type { useRoute } from "@tui/context/route"
import type { useSDK } from "@tui/context/sdk"
import type { useSync } from "@tui/context/sync"
import type { useTheme } from "@tui/context/theme"
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createPluginKeybind } from "../context/plugin-keybinds"
import type { useOpencodeKeymap } from "../keymap"
import type { useKV } from "../context/kv"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
@@ -19,6 +16,7 @@ import { Prompt } from "../component/prompt"
import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Keymap from "../keymap"
type RouteEntry = {
key: symbol
@@ -28,10 +26,9 @@ type RouteEntry = {
export type RouteMap = Map<string, RouteEntry[]>
type Input = {
command: ReturnType<typeof useCommandDialog>
tuiConfig: TuiConfig.Info
tuiConfig: TuiConfig.Resolved
dialog: ReturnType<typeof useDialog>
keybind: ReturnType<typeof useKeybind>
keymap: ReturnType<typeof useOpencodeKeymap>
kv: ReturnType<typeof useKV>
route: ReturnType<typeof useRoute>
routes: RouteMap
@@ -201,20 +198,17 @@ export function createTuiApi(input: Input): TuiPluginApi {
return () => {}
},
}
return {
app: appApi(),
command: {
register(cb) {
return input.command.register(() => cb())
keys: {
formatSequence(parts) {
return Keymap.formatKeySequence(parts, input.tuiConfig)
},
trigger(value) {
input.command.trigger(value)
},
show() {
input.command.show()
formatBindings(bindings) {
return Keymap.formatKeyBindings(bindings, input.tuiConfig)
},
},
keymap: input.keymap,
route: {
register(list) {
return routeRegister(input.routes, list, input.bump)
@@ -306,17 +300,6 @@ export function createTuiApi(input: Input): TuiPluginApi {
},
},
},
keybind: {
match(key, evt: ParsedKey) {
return input.keybind.match(key, evt)
},
print(key) {
return input.keybind.print(key)
},
create(defaults, overrides) {
return createPluginKeybind(input.keybind, defaults, overrides)
},
},
get tuiConfig() {
return input.tuiConfig
},
@@ -1,4 +1,5 @@
import "@opentui/solid/runtime-plugin-support"
import { runtimeModules as keymapRuntimeModules } from "@opentui/keymap/runtime-modules"
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
import {
type TuiDispose,
type TuiPlugin,
@@ -39,6 +40,8 @@ import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots"
import { ConfigPlugin } from "@/config/plugin"
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
type PluginLoad = {
options: ConfigPlugin.Options | undefined
spec: string
@@ -70,6 +73,36 @@ type PluginEntry = {
scope?: PluginScope
}
const ScopedKeymapMethods = new Set<PropertyKey>([
"acquireResource",
"registerLayer",
"registerLayerFields",
"prependLayerBindingsTransformer",
"appendLayerBindingsTransformer",
"prependBindingTransformer",
"appendBindingTransformer",
"prependBindingParser",
"appendBindingParser",
"registerToken",
"registerSequencePattern",
"prependBindingExpander",
"appendBindingExpander",
"registerBindingFields",
"registerCommandFields",
"prependCommandTransformer",
"appendCommandTransformer",
"prependCommandResolver",
"appendCommandResolver",
"prependLayerAnalyzer",
"appendLayerAnalyzer",
"intercept",
"on",
"prependEventMatchResolver",
"appendEventMatchResolver",
"prependDisambiguationResolver",
"appendDisambiguationResolver",
])
type RuntimeState = {
directory: string
api: Api
@@ -104,6 +137,25 @@ function warn(message: string, data: Record<string, unknown>) {
console.warn(`[tui.plugin] ${message}`, data)
}
function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): TuiPluginApi["keymap"] {
const cache = new Map<PropertyKey, unknown>()
return new Proxy(keymap, {
get(target, prop) {
const value = Reflect.get(target, prop, target)
if (typeof value !== "function") return value
if (cache.has(prop)) return cache.get(prop)
const fn = ScopedKeymapMethods.has(prop)
? (...args: unknown[]) => {
const dispose = (value as (...args: unknown[]) => unknown).apply(target, args)
return scope.track(typeof dispose === "function" ? (dispose as () => void) : undefined)
}
: (...args: unknown[]) => (value as (...args: unknown[]) => unknown).apply(target, args)
cache.set(prop, fn)
return fn
},
})
}
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
@@ -327,14 +379,16 @@ function createPluginScope(load: PluginLoad, id: string) {
const track = (fn: (() => void) | undefined) => {
if (!fn) return () => {}
const off = onDispose(fn)
let drop = false
return () => {
let off = () => {}
const wrapped = () => {
if (drop) return
drop = true
off()
fn()
}
off = onDispose(wrapped)
return wrapped
}
const lifecycle: TuiPluginApi["lifecycle"] = {
@@ -395,7 +449,7 @@ function readPluginEnabledMap(value: unknown) {
)
}
function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
function pluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
return {
...readPluginEnabledMap(config.plugin_enabled),
...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
@@ -484,17 +538,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
const api = runtime.api
const host = runtime.slots
const load = plugin.load
const command: TuiPluginApi["command"] = {
register(cb) {
return scope.track(api.command.register(cb))
},
trigger(value) {
api.command.trigger(value)
},
show() {
api.command.show()
},
}
const route: TuiPluginApi["route"] = {
register(list) {
@@ -518,6 +561,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
},
}
const keymap = createScopedKeymap(api.keymap, scope)
let count = 0
const slots: TuiPluginApi["slots"] = {
@@ -531,10 +576,10 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
return {
app: api.app,
command,
keys: api.keys,
keymap,
route,
ui: api.ui,
keybind: api.keybind,
tuiConfig: api.tuiConfig,
kv: api.kv,
state: api.state,
@@ -580,7 +625,7 @@ function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
return true
}
function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
const map = pluginEnabledState(state, config)
for (const plugin of state.plugins) {
const enabled = map[plugin.id]
@@ -923,7 +968,7 @@ let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined
export const Slot = View
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
@@ -972,7 +1017,7 @@ export async function dispose() {
}
}
async function load(input: { api: Api; config: TuiConfig.Info }) {
async function load(input: { api: Api; config: TuiConfig.Resolved }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
@@ -49,12 +49,10 @@ import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { useDialog } from "../../ui/dialog"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
@@ -90,6 +88,8 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut } from "../../keymap"
addDefaultParsers(parsers.parsers)
@@ -124,6 +124,9 @@ export function Session() {
const event = useEvent()
const project = useProject()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const kv = useKV()
const { theme } = useTheme()
const promptRef = usePromptRef()
@@ -250,7 +253,7 @@ export function Session() {
seeded = true
r.set(route.prompt)
}
const keybind = useKeybind()
const command = useCommandPalette()
const dialog = useDialog()
const renderer = useRenderer()
@@ -271,7 +274,6 @@ export function Session() {
})
})
// Allow exit when in child session (prompt is hidden)
const exit = useExit()
createEffect(() => {
@@ -293,13 +295,6 @@ export function Session() {
)
})
useKeyboard((evt) => {
if (!session()?.parentID) return
if (keybind.match("app_exit", evt)) {
void exit()
}
})
// Helper: Find next visible message boundary in direction
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
const children = scroll.getChildren()
@@ -382,26 +377,24 @@ export function Session() {
}
}
function childSessionHandler(func: (dialog: DialogContext) => void) {
return (dialog: DialogContext) => {
function childSessionHandler(func: () => void) {
return () => {
if (!session()?.parentID || dialog.stack.length > 0) return
func(dialog)
func()
}
}
const command = useCommandDialog()
command.register(() => [
const sessionCommandList = createMemo(() => [
{
title: session()?.share?.url ? "Copy share link" : "Share session",
value: "session.share",
suggested: route.type === "session",
keybind: "session_share",
category: "Session",
enabled: sync.data.config.share !== "disabled",
slash: {
name: "share",
},
onSelect: async (dialog) => {
run: async () => {
const copy = (url: string) =>
Clipboard.copy(url)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
@@ -434,24 +427,22 @@ export function Session() {
{
title: "Rename session",
value: "session.rename",
keybind: "session_rename",
category: "Session",
slash: {
name: "rename",
},
onSelect: (dialog) => {
run: () => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
},
},
{
title: "Jump to message",
value: "session.timeline",
keybind: "session_timeline",
category: "Session",
slash: {
name: "timeline",
},
onSelect: (dialog) => {
run: () => {
dialog.replace(() => (
<DialogTimeline
onMove={(messageID) => {
@@ -469,12 +460,11 @@ export function Session() {
{
title: "Fork session",
value: "session.fork",
keybind: "session_fork",
category: "Session",
slash: {
name: "fork",
},
onSelect: (dialog) => {
run: () => {
dialog.replace(() => (
<DialogForkFromTimeline
onMove={(messageID) => {
@@ -492,13 +482,12 @@ export function Session() {
{
title: "Compact session",
value: "session.compact",
keybind: "session_compact",
category: "Session",
slash: {
name: "compact",
aliases: ["summarize"],
},
onSelect: (dialog) => {
run: () => {
const selectedModel = local.model.current()
if (!selectedModel) {
toast.show({
@@ -519,13 +508,12 @@ export function Session() {
{
title: "Unshare session",
value: "session.unshare",
keybind: "session_unshare",
category: "Session",
enabled: !!session()?.share?.url,
slash: {
name: "unshare",
},
onSelect: async (dialog) => {
run: async () => {
await sdk.client.session
.unshare({
sessionID: route.sessionID,
@@ -543,12 +531,11 @@ export function Session() {
{
title: "Undo previous message",
value: "session.undo",
keybind: "messages_undo",
category: "Session",
slash: {
name: "undo",
},
onSelect: async (dialog) => {
run: async () => {
const status = sync.data.session_status?.[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
const revert = session()?.revert?.messageID
@@ -581,13 +568,12 @@ export function Session() {
{
title: "Redo",
value: "session.redo",
keybind: "messages_redo",
category: "Session",
enabled: !!session()?.revert?.messageID,
slash: {
name: "redo",
},
onSelect: (dialog) => {
run: () => {
dialog.clear()
const messageID = session()?.revert?.messageID
if (!messageID) return
@@ -608,9 +594,8 @@ export function Session() {
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle",
keybind: "sidebar_toggle",
category: "Session",
onSelect: (dialog) => {
run: () => {
batch(() => {
const isVisible = sidebarVisible()
setSidebar(() => (isVisible ? "hide" : "auto"))
@@ -622,9 +607,8 @@ export function Session() {
{
title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal",
keybind: "messages_toggle_conceal",
category: "Session",
onSelect: (dialog) => {
run: () => {
setConceal((prev) => !prev)
dialog.clear()
},
@@ -637,7 +621,7 @@ export function Session() {
name: "timestamps",
aliases: ["toggle-timestamps"],
},
onSelect: (dialog) => {
run: () => {
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
dialog.clear()
},
@@ -645,13 +629,12 @@ export function Session() {
{
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
keybind: "display_thinking",
category: "Session",
slash: {
name: "thinking",
aliases: ["toggle-thinking"],
},
onSelect: (dialog) => {
run: () => {
setShowThinking((prev) => !prev)
dialog.clear()
},
@@ -659,9 +642,8 @@ export function Session() {
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
keybind: "tool_details",
category: "Session",
onSelect: (dialog) => {
run: () => {
setShowDetails((prev) => !prev)
dialog.clear()
},
@@ -669,9 +651,8 @@ export function Session() {
{
title: "Toggle session scrollbar",
value: "session.toggle.scrollbar",
keybind: "scrollbar_toggle",
category: "Session",
onSelect: (dialog) => {
run: () => {
setShowScrollbar((prev) => !prev)
dialog.clear()
},
@@ -680,7 +661,7 @@ export function Session() {
title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output",
value: "session.toggle.generic_tool_output",
category: "Session",
onSelect: (dialog) => {
run: () => {
setShowGenericToolOutput((prev) => !prev)
dialog.clear()
},
@@ -688,10 +669,9 @@ export function Session() {
{
title: "Page up",
value: "session.page.up",
keybind: "messages_page_up",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
},
@@ -699,10 +679,9 @@ export function Session() {
{
title: "Page down",
value: "session.page.down",
keybind: "messages_page_down",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
@@ -710,10 +689,9 @@ export function Session() {
{
title: "Line up",
value: "session.line.up",
keybind: "messages_line_up",
category: "Session",
disabled: true,
onSelect: (dialog) => {
enabled: false,
run: () => {
scroll.scrollBy(-1)
dialog.clear()
},
@@ -721,10 +699,9 @@ export function Session() {
{
title: "Line down",
value: "session.line.down",
keybind: "messages_line_down",
category: "Session",
disabled: true,
onSelect: (dialog) => {
enabled: false,
run: () => {
scroll.scrollBy(1)
dialog.clear()
},
@@ -732,10 +709,9 @@ export function Session() {
{
title: "Half page up",
value: "session.half.page.up",
keybind: "messages_half_page_up",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
},
@@ -743,10 +719,9 @@ export function Session() {
{
title: "Half page down",
value: "session.half.page.down",
keybind: "messages_half_page_down",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(scroll.height / 4)
dialog.clear()
},
@@ -754,10 +729,9 @@ export function Session() {
{
title: "First message",
value: "session.first",
keybind: "messages_first",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollTo(0)
dialog.clear()
},
@@ -765,10 +739,9 @@ export function Session() {
{
title: "Last message",
value: "session.last",
keybind: "messages_last",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
},
@@ -776,10 +749,9 @@ export function Session() {
{
title: "Jump to last user message",
value: "session.messages_last_user",
keybind: "messages_last_user",
category: "Session",
hidden: true,
onSelect: () => {
run: () => {
const messages = sync.data.message[route.sessionID]
if (!messages || !messages.length) return
@@ -808,25 +780,22 @@ export function Session() {
{
title: "Next message",
value: "session.message.next",
keybind: "messages_next",
category: "Session",
hidden: true,
onSelect: (dialog) => scrollToMessage("next", dialog),
run: () => scrollToMessage("next", dialog),
},
{
title: "Previous message",
value: "session.message.previous",
keybind: "messages_previous",
category: "Session",
hidden: true,
onSelect: (dialog) => scrollToMessage("prev", dialog),
run: () => scrollToMessage("prev", dialog),
},
{
title: "Copy last assistant message",
value: "messages.copy",
keybind: "messages_copy",
category: "Session",
onSelect: (dialog) => {
run: () => {
const revertID = session()?.revert?.messageID
const lastAssistantMessage = messages().findLast(
(msg) => msg.role === "assistant" && (!revertID || msg.id < revertID),
@@ -871,7 +840,7 @@ export function Session() {
slash: {
name: "copy",
},
onSelect: async (dialog) => {
run: async () => {
try {
const sessionData = session()
if (!sessionData) return
@@ -897,12 +866,11 @@ export function Session() {
{
title: "Export session transcript",
value: "session.export",
keybind: "session_export",
category: "Session",
slash: {
name: "export",
},
onSelect: async (dialog) => {
run: async () => {
try {
const sessionData = session()
if (!sessionData) return
@@ -959,10 +927,9 @@ export function Session() {
{
title: "Go to child session",
value: "session.child.first",
keybind: "session_child_first",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
moveFirstChild()
dialog.clear()
},
@@ -970,11 +937,10 @@ export function Session() {
{
title: "Go to parent session",
value: "session.parent",
keybind: "session_parent",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => {
run: childSessionHandler(() => {
const parentID = session()?.parentID
if (parentID) {
navigate({
@@ -988,11 +954,10 @@ export function Session() {
{
title: "Next child session",
value: "session.child.next",
keybind: "session_child_cycle",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => {
run: childSessionHandler(() => {
moveChild(1)
dialog.clear()
}),
@@ -1000,17 +965,36 @@ export function Session() {
{
title: "Previous child session",
value: "session.child.previous",
keybind: "session_child_cycle_reverse",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => {
run: childSessionHandler(() => {
moveChild(-1)
dialog.clear()
}),
},
])
const sessionCommands = createMemo(() =>
sessionCommandList().map((command) => ({
namespace: "palette",
name: command.value,
desc: "description" in command ? command.description : undefined,
slashName: "slash" in command ? command.slash?.name : undefined,
slashAliases: "slash" in command ? command.slash?.aliases : undefined,
...command,
})),
)
useBindings(() => ({
commands: sessionCommands(),
}))
useBindings(() => ({
enabled: command.matcher,
bindings: sections.session,
}))
const revertInfo = createMemo(() => session()?.revert)
const revertMessageID = createMemo(() => revertInfo()?.messageID)
@@ -1082,7 +1066,8 @@ export function Session() {
<Switch>
<Match when={message.id === revert()?.messageID}>
{(function () {
const command = useCommandDialog()
const command = useCommandPalette()
const redoShortcut = useCommandShortcut("session.redo")
const [hover, setHover] = createSignal(false)
const dialog = useDialog()
@@ -1093,7 +1078,7 @@ export function Session() {
"Are you sure you want to restore the reverted messages?",
)
if (confirmed) {
command.trigger("session.redo")
command.run("session.redo")
}
}
@@ -1116,7 +1101,7 @@ export function Session() {
>
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
<text fg={theme.textMuted}>
<span style={{ fg: theme.text }}>{keybind.print("messages_redo")}</span> or /redo to
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to
restore
</text>
<Show when={revert()!.diffFiles?.length}>
@@ -1370,7 +1355,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return props.message.time.completed - user.time.created
})
const keybind = useKeybind()
const childShortcut = useCommandShortcut("session.child.first")
return (
<>
@@ -1392,7 +1377,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
<box paddingTop={1} paddingLeft={3}>
<text fg={theme.text}>
{keybind.print("session_child_first")}
{childShortcut()}
<span style={{ fg: theme.textMuted }}> view subagents</span>
</text>
</box>
@@ -1,24 +1,22 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme, selectedForeground } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useProject } from "../../context/project"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@opencode-ai/core/global"
import { ShellID } from "@/tool/shell/id"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings, useCommandShortcut } from "../../keymap"
type PermissionStage = "permission" | "always" | "reject"
@@ -463,25 +461,27 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable
const { theme } = useTheme()
const keybind = useKeybind()
const textareaKeybindings = useTextareaKeybindings()
const tuiConfig = useTuiConfig()
const keymapConfig = tuiConfig.keymap
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
props.onCancel()
return
}
if (evt.name === "return") {
evt.preventDefault()
props.onConfirm(input.plainText)
}
})
useBindings(() => ({
enabled: dialog.stack.length === 0,
commands: [
{
name: "permission.reject.cancel",
run() {
props.onCancel()
},
},
],
bindings: [
{ key: "escape", cmd: () => props.onCancel() },
...keymapConfig.pick("permission", ["permission.reject.cancel"]),
{ key: "return", cmd: () => props.onConfirm(input.plainText) },
],
}))
return (
<box
@@ -520,7 +520,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={textareaKeybindings()}
/>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={theme.text}>
@@ -545,50 +544,75 @@ function Prompt<const T extends Record<string, string>>(props: {
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const keybind = useKeybind()
const tuiConfig = useTuiConfig()
const keymapConfig = tuiConfig.keymap
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
expanded: false,
})
const diffKey = Keybind.parse("ctrl+f")[0]
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen")
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "left" || evt.name == "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
}
if (evt.name === "right" || evt.name == "l") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
}
if (evt.name === "return") {
evt.preventDefault()
props.onSelect(store.selected)
}
if (props.escapeKey && (evt.name === "escape" || keybind.match("app_exit", evt))) {
evt.preventDefault()
props.onSelect(props.escapeKey)
}
if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) {
evt.preventDefault()
evt.stopPropagation()
setStore("expanded", (v) => !v)
}
})
useBindings(() => ({
enabled: dialog.stack.length === 0,
commands: [
{
name: "permission.prompt.escape",
run() {
if (!props.escapeKey) return
props.onSelect(props.escapeKey)
},
},
{
name: "permission.prompt.fullscreen",
run() {
if (!props.fullscreen) return
setStore("expanded", (v) => !v)
},
},
],
bindings: [
{
key: "left",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
},
},
{
key: "h",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
},
},
{
key: "right",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{
key: "l",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{ key: "return", cmd: () => props.onSelect(store.selected) },
...(props.escapeKey ? [{ key: "escape", cmd: () => props.onSelect(props.escapeKey!) }] : []),
...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []),
...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []),
],
}))
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
useRenderer()
@@ -661,7 +685,7 @@ function Prompt<const T extends Record<string, string>>(props: {
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={theme.text}>
{"ctrl+f"} <span style={{ fg: theme.textMuted }}>{hint()}</span>
{fullscreenHint()} <span style={{ fg: theme.textMuted }}>{hint()}</span>
</text>
</Show>
<text fg={theme.text}>
@@ -1,20 +1,22 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useDialog } from "../../ui/dialog"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings } from "../../keymap"
export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const bindings = useTextareaKeybindings()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const keymapConfig = tuiConfig.keymap
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
@@ -122,131 +124,124 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const dialog = useDialog()
useKeyboard((evt) => {
// Skip processing if a dialog (e.g., command palette) is open
if (dialog.stack.length > 0) return
// When editing custom answer textarea
if (store.editing && !confirm()) {
if (evt.name === "escape") {
evt.preventDefault()
setStore("editing", false)
return
}
if (keybind.match("input_clear", evt)) {
evt.preventDefault()
const text = textarea?.plainText ?? ""
if (!text) {
useBindings(() => ({
enabled: store.editing && !confirm(),
commands: [
{
name: "question.edit.clear",
run() {
const text = textarea?.plainText ?? ""
if (!text) {
setStore("editing", false)
return
}
textarea?.setText("")
},
},
],
bindings: [
{
key: "escape",
cmd: () => {
setStore("editing", false)
return
}
textarea?.setText("")
return
}
if (evt.name === "return") {
evt.preventDefault()
const text = textarea?.plainText?.trim() ?? ""
const prev = store.custom[store.tab]
},
},
...keymapConfig.pick("question", ["question.edit.clear"]),
{
key: "return",
cmd: () => {
const text = textarea?.plainText?.trim() ?? ""
const prev = store.custom[store.tab]
if (!text) {
if (prev) {
if (!text) {
if (prev) {
const inputs = [...store.custom]
inputs[store.tab] = ""
setStore("custom", inputs)
const answers = [...store.answers]
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
setStore("answers", answers)
}
setStore("editing", false)
return
}
if (multi()) {
const inputs = [...store.custom]
inputs[store.tab] = ""
inputs[store.tab] = text
setStore("custom", inputs)
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (prev) {
const index = next.indexOf(prev)
if (index !== -1) next.splice(index, 1)
}
if (!next.includes(text)) next.push(text)
const answers = [...store.answers]
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(text, true)
setStore("editing", false)
return
}
},
},
],
}))
if (multi()) {
const inputs = [...store.custom]
inputs[store.tab] = text
setStore("custom", inputs)
useBindings(() => {
const opts = options()
const total = opts.length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (prev) {
const index = next.indexOf(prev)
if (index !== -1) next.splice(index, 1)
}
if (!next.includes(text)) next.push(text)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(text, true)
setStore("editing", false)
return
}
// Let textarea handle all other keys
return
}
if (evt.name === "left" || evt.name === "h") {
evt.preventDefault()
selectTab((store.tab - 1 + tabs()) % tabs())
}
if (evt.name === "right" || evt.name === "l") {
evt.preventDefault()
selectTab((store.tab + 1) % tabs())
}
if (evt.name === "tab") {
evt.preventDefault()
const direction = evt.shift ? -1 : 1
selectTab((store.tab + direction + tabs()) % tabs())
}
if (confirm()) {
if (evt.name === "return") {
evt.preventDefault()
submit()
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
}
} else {
const opts = options()
const total = opts.length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
const digit = Number(evt.name)
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
evt.preventDefault()
const index = digit - 1
moveTo(index)
selectOption()
return
}
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
moveTo((store.selected - 1 + total) % total)
}
if (evt.name === "down" || evt.name === "j") {
evt.preventDefault()
moveTo((store.selected + 1) % total)
}
if (evt.name === "return") {
evt.preventDefault()
selectOption()
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
}
return {
enabled: dialog.stack.length === 0 && !store.editing,
commands: [
{
name: "question.reject",
run() {
reject()
},
},
],
bindings: [
{ key: "left", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
{ key: "h", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
{ key: "right", cmd: () => selectTab((store.tab + 1) % tabs()) },
{ key: "l", cmd: () => selectTab((store.tab + 1) % tabs()) },
{
key: "tab",
cmd: ({ event }: { event: { shift: boolean } }) => {
selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
},
},
...(confirm()
? [
{ key: "return", cmd: () => submit() },
{ key: "escape", cmd: () => reject() },
...sections.question,
]
: [
...Array.from({ length: max }, (_, index) => ({
key: String(index + 1),
cmd: () => {
moveTo(index)
selectOption()
},
})),
{ key: "up", cmd: () => moveTo((store.selected - 1 + total) % total) },
{ key: "k", cmd: () => moveTo((store.selected - 1 + total) % total) },
{ key: "down", cmd: () => moveTo((store.selected + 1) % total) },
{ key: "j", cmd: () => moveTo((store.selected + 1) % total) },
{ key: "return", cmd: () => selectOption() },
{ key: "escape", cmd: () => reject() },
...sections.question,
]),
],
}
})
@@ -394,7 +389,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={bindings()}
/>
</box>
</Show>
@@ -4,10 +4,10 @@ import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
import { Locale } from "@/util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { useCommandPalette } from "../../context/command-palette"
import { useCommandShortcut } from "../../keymap"
export function SubagentFooter() {
const route = useRouteData("session")
@@ -56,8 +56,10 @@ export function SubagentFooter() {
})
const { theme } = useTheme()
const keybind = useKeybind()
const command = useCommandDialog()
const command = useCommandPalette()
const parentShortcut = useCommandShortcut("session.parent")
const previousShortcut = useCommandShortcut("session.child.previous")
const nextShortcut = useCommandShortcut("session.child.next")
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
useTerminalDimensions()
@@ -96,31 +98,31 @@ export function SubagentFooter() {
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.parent")}
onMouseUp={() => command.run("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.previous")}
onMouseUp={() => command.run("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
Prev <span style={{ fg: theme.textMuted }}>{previousShortcut()}</span>
</text>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.next")}
onMouseUp={() => command.run("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
Next <span style={{ fg: theme.textMuted }}>{nextShortcut()}</span>
</text>
</box>
</box>
@@ -1,7 +1,7 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { useKeyboard } from "@opentui/solid"
import { useBindings } from "../keymap"
export type DialogAlertProps = {
title: string
@@ -13,14 +13,17 @@ export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const { theme } = useTheme()
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
props.onConfirm?.()
dialog.clear()
}
})
useBindings(() => ({
bindings: [
{
key: "return",
cmd: () => {
props.onConfirm?.()
dialog.clear()
},
},
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -3,8 +3,8 @@ import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Locale } from "@/util/locale"
import { useBindings } from "../keymap"
export type DialogConfirmProps = {
title: string
@@ -23,19 +23,30 @@ export function DialogConfirm(props: DialogConfirmProps) {
active: "confirm" as "confirm" | "cancel",
})
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
if (store.active === "confirm") props.onConfirm?.()
if (store.active === "cancel") props.onCancel?.()
dialog.clear()
}
if (evt.name === "left" || evt.name === "right") {
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
}
})
useBindings(() => ({
bindings: [
{
key: "return",
cmd: () => {
if (store.active === "confirm") props.onConfirm?.()
if (store.active === "cancel") props.onCancel?.()
dialog.clear()
},
},
{
key: "left",
cmd: () => {
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
},
},
{
key: "right",
cmd: () => {
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
},
},
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -56,7 +67,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
paddingLeft={1}
paddingRight={1}
backgroundColor={key === store.active ? theme.primary : undefined}
onMouseUp={(_evt) => {
onMouseUp={() => {
if (key === "confirm") props.onConfirm?.()
if (key === "cancel") props.onCancel?.()
dialog.clear()
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { onMount, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useBindings } from "../keymap"
export type DialogExportOptionsProps = {
defaultFilename: string
@@ -33,39 +33,40 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
})
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
props.onConfirm?.({
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
assistantMetadata: store.assistantMetadata,
openWithoutSaving: store.openWithoutSaving,
})
}
if (evt.name === "tab") {
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
"filename",
"thinking",
"toolDetails",
"assistantMetadata",
"openWithoutSaving",
]
const currentIndex = order.indexOf(store.active)
const nextIndex = (currentIndex + 1) % order.length
setStore("active", order[nextIndex])
evt.preventDefault()
}
if (evt.name === "space" || evt.name === " ") {
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
evt.preventDefault()
}
})
useBindings(() => ({
bindings: [
{
key: "tab",
cmd: () => {
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
"filename",
"thinking",
"toolDetails",
"assistantMetadata",
"openWithoutSaving",
]
const currentIndex = order.indexOf(store.active)
const nextIndex = (currentIndex + 1) % order.length
setStore("active", order[nextIndex])
},
},
],
}))
useBindings(() => ({
enabled: store.active !== "filename",
bindings: [
{
key: "space",
cmd: () => {
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
},
},
],
}))
onMount(() => {
dialog.setSize("medium")
@@ -101,7 +102,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
})
}}
height={3}
keyBindings={[{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => {
textarea = val
val.traits = { status: "FILENAME" }
@@ -1,21 +1,19 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme"
import { useDialog } from "./dialog"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
import { useBindings, useCommandShortcut } from "../keymap"
export function DialogHelp() {
const dialog = useDialog()
const { theme } = useTheme()
const keybind = useKeybind()
const commandShortcut = useCommandShortcut("command.palette.show")
useKeyboard((evt) => {
if (evt.name === "return" || evt.name === "escape") {
evt.preventDefault()
evt.stopPropagation()
dialog.clear()
}
})
useBindings(() => ({
bindings: [
{ key: "return", cmd: () => dialog.clear() },
{ key: "escape", cmd: () => dialog.clear() },
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
@@ -29,7 +27,7 @@ export function DialogHelp() {
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>
Press {keybind.print("command_list")} to see all available actions and commands in any context.
Press {commandShortcut()} to see all available actions and commands in any context.
</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
@@ -2,7 +2,6 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Spinner } from "../component/spinner"
export type DialogPromptProps = {
@@ -21,20 +20,6 @@ export function DialogPrompt(props: DialogPromptProps) {
const { theme } = useTheme()
let textarea: TextareaRenderable
useKeyboard((evt) => {
if (props.busy) {
if (evt.name === "escape") return
evt.preventDefault()
evt.stopPropagation()
return
}
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
props.onConfirm?.(textarea.plainText)
}
})
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
@@ -79,7 +64,6 @@ export function DialogPrompt(props: DialogPromptProps) {
props.onConfirm?.(textarea.plainText)
}}
height={3}
keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => {
textarea = val
}}
@@ -1,17 +1,24 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import {
InputRenderable,
RGBA,
ScrollBoxRenderable,
TextAttributes,
type KeyEvent,
type Renderable,
} from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../context/tui-config"
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
export interface DialogSelectProps<T> {
title: string
@@ -24,13 +31,14 @@ export interface DialogSelectProps<T> {
onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean
renderFilter?: boolean
keybind?: {
keybind?: Keybind.Info
actions?: {
command: string
title: string
side?: "left" | "right"
disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void
}[]
bindings?: readonly Binding<Renderable, KeyEvent>[]
current?: T
}
@@ -57,6 +65,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({
@@ -81,6 +92,25 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable
const actions = createMemo(() => props.actions ?? [])
const actionBindings = useKeymapSelector((keymap) =>
keymap.getCommandBindings({
visibility: "registered",
commands: actions().map((item) => item.command),
}),
)
const actionLabels = createMemo(() => {
const labels = new Map<string, string>()
for (const action of actions()) {
const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig)
if (label) labels.set(action.command, label)
}
return labels
})
const filtered = createMemo(() => {
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase()
@@ -171,7 +201,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const option = selected()
if (option) props.onMove?.(option)
if (!scroll) return
const target = scroll.getChildren().find((child) => {
const target = scroll.getChildren().find((child: { id?: string }) => {
return child.id === JSON.stringify(selected()?.value)
})
if (!target) return
@@ -192,36 +222,86 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
}
const keybind = useKeybind()
useKeyboard((evt) => {
function submit() {
setStore("input", "keyboard")
const option = selected()
if (!option) return
option.onSelect?.(dialog)
props.onSelect?.(option)
}
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)
if (evt.name === "home") moveTo(0)
if (evt.name === "end") moveTo(flat().length - 1)
useBindings(() => {
const enabledActions = actions().filter((item) => !item.disabled)
if (evt.name === "return") {
const option = selected()
if (option) {
evt.preventDefault()
evt.stopPropagation()
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
}
}
for (const item of props.keybind ?? []) {
if (item.disabled || !item.keybind) continue
if (Keybind.match(item.keybind, keybind.parse(evt))) {
const s = selected()
if (s) {
evt.preventDefault()
item.onTrigger(s)
}
}
return {
commands: [
{
name: "dialog.select.prev",
run() {
setStore("input", "keyboard")
move(-1)
},
},
{
name: "dialog.select.next",
run() {
setStore("input", "keyboard")
move(1)
},
},
{
name: "dialog.select.page_up",
run() {
setStore("input", "keyboard")
move(-10)
},
},
{
name: "dialog.select.page_down",
run() {
setStore("input", "keyboard")
move(10)
},
},
{
name: "dialog.select.home",
run() {
setStore("input", "keyboard")
moveTo(0)
},
},
{
name: "dialog.select.end",
run() {
setStore("input", "keyboard")
moveTo(flat().length - 1)
},
},
{
name: "dialog.select.submit",
run: submit,
},
...enabledActions.map((item) => ({
name: item.command,
run() {
setStore("input", "keyboard")
const option = selected()
if (!option) return
item.onTrigger(option)
},
})),
],
bindings: [
...sections.dialog_select,
...tuiConfig.keymap.pick(
"dialog_actions",
enabledActions.map((item) => item.command),
),
...(props.bindings ?? []).filter((binding) => {
if (typeof binding.cmd !== "string") return true
return enabledActions.some((item) => item.command === binding.cmd)
}),
],
}
})
@@ -236,9 +316,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
const visibleActions = createMemo(() =>
actions()
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
.filter((item) => !item.disabled && item.label),
)
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
return (
<box gap={1} paddingBottom={1}>
@@ -365,7 +449,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</For>
</scrollbox>
</Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
<Show when={visibleActions().length} fallback={<box flexShrink={0} />}>
<box
paddingRight={2}
paddingLeft={4}
@@ -381,7 +465,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
<span style={{ fg: theme.textMuted }}>{item.label}</span>
</text>
)}
</For>
@@ -393,7 +477,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
<span style={{ fg: theme.textMuted }}>{item.label}</span>
</text>
)}
</For>
+35 -20
View File
@@ -1,4 +1,4 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Selection from "@tui/util/selection"
import { useBindings } from "../keymap"
export function Dialog(
props: ParentProps<{
@@ -47,7 +48,7 @@ export function Dialog(
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={(e) => {
onMouseUp={(e: { stopPropagation(): void }) => {
dismiss = false
e.stopPropagation()
}}
@@ -73,23 +74,6 @@ function init() {
const renderer = useRenderer()
useKeyboard((evt) => {
if (store.stack.length === 0) return
if (evt.defaultPrevented) return
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
evt.preventDefault()
evt.stopPropagation()
refocus()
}
})
let focus: Renderable | null
function refocus() {
setTimeout(() => {
@@ -108,6 +92,36 @@ function init() {
}, 1)
}
useBindings(() => ({
enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(),
bindings: [
{
key: "escape",
cmd: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)
current?.onClose?.()
setStore("stack", store.stack.slice(0, -1))
refocus()
},
},
{
key: "ctrl+c",
cmd: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)
current?.onClose?.()
setStore("stack", store.stack.slice(0, -1))
refocus()
},
},
],
}))
return {
clear() {
for (const item of store.stack) {
@@ -155,13 +169,14 @@ export function DialogProvider(props: ParentProps) {
const value = init()
const renderer = useRenderer()
const toast = useToast()
return (
<ctx.Provider value={value}>
{props.children}
<box
position="absolute"
zIndex={3000}
onMouseDown={(evt) => {
onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
@@ -11,7 +11,9 @@ export class CustomSpeedScroll implements ScrollAcceleration {
reset(): void {}
}
export function getScrollAcceleration(tuiConfig?: TuiConfig.Info): ScrollAcceleration {
export function getScrollAcceleration(
tuiConfig?: Pick<TuiConfig.Info, "scroll_acceleration" | "scroll_speed">,
): ScrollAcceleration {
if (tuiConfig?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
@@ -5,9 +5,21 @@ type Toast = {
error: (err: unknown) => void
}
type FocusableSelectionTarget = {
hasSelection: () => boolean
}
type Renderer = {
getSelection: () => { getSelectedText: () => string } | null
getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null
clearSelection: () => void
currentFocusedRenderable?: FocusableSelectionTarget | null
}
type SelectionKeyEvent = {
ctrl?: boolean
name: string
preventDefault: () => void
stopPropagation: () => void
}
export function copy(renderer: Renderer, toast: Toast): boolean {
@@ -22,4 +34,32 @@ export function copy(renderer: Renderer, toast: Toast): boolean {
return true
}
export function handleSelectionKey(renderer: Renderer, toast: Toast, event: SelectionKeyEvent) {
const selection = renderer.getSelection()
if (!selection) return
if (event.ctrl && event.name === "c") {
if (!copy(renderer, toast)) {
renderer.clearSelection()
return
}
event.preventDefault()
event.stopPropagation()
return
}
if (event.name === "escape") {
renderer.clearSelection()
event.preventDefault()
event.stopPropagation()
return
}
const focus = renderer.currentFocusedRenderable
if (focus?.hasSelection() && selection.selectedRenderables.includes(focus)) return
renderer.clearSelection()
}
export * as Selection from "./selection"