feat(tui): add v2 plugin runtime

This commit is contained in:
Dax Raad 2026-07-14 12:43:53 -04:00
commit 4a93972a78
63 changed files with 1722 additions and 1701 deletions

View file

@ -281,16 +281,16 @@ export function DialogConfig() {
footerHints={[{ title: "←/→", label: "change" }]}
bindings={[
{
key: "left",
desc: "Previous value",
bind: "left",
title: "Previous value",
group: "Settings",
cmd: () => void change(-1),
run: () => void change(-1),
},
{
key: "right",
desc: "Next value",
bind: "right",
title: "Next value",
group: "Settings",
cmd: () => void change(1),
run: () => void change(1),
},
]}
/>

View file

@ -1,13 +1,13 @@
import { TextAttributes } from "@opentui/core"
import { createMemo, createSignal, For } from "solid-js"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useRoute } from "../context/route"
import { useLocal } from "../context/local"
import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { useBindings } from "../keymap"
import { describeOS, describeTerminal } from "../util/system"
export function DialogDebug() {
@ -46,8 +46,9 @@ export function DialogDebug() {
.catch(toast.error)
}
useBindings(() => ({
bindings: [{ key: "return", desc: "Copy debug info", group: "Dialog", cmd: copy }],
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "return", title: "Copy debug info", group: "Dialog", run: copy }],
}))
return (

View file

@ -9,8 +9,8 @@ import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useBindings } from "../keymap"
import { useDialog } from "../ui/dialog"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
@ -278,13 +278,14 @@ function OAuthAuto(props: {
let timer: ReturnType<typeof setTimeout> | undefined
let settled = false
useBindings(() => ({
bindings: [
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{
key: "c",
desc: "Copy authorization details",
bind: "c",
title: "Copy authorization details",
group: "Dialog",
cmd: () => {
run: () => {
const value = props.attempt.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.attempt.url
clipboard
.write?.(value)

View file

@ -1,5 +1,6 @@
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
@ -11,7 +12,6 @@ import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import { useBindings } from "../keymap"
// Sort by how much attention a server needs: auth prompts first, then failures,
// then healthy servers, and intentionally-off servers last.
@ -134,8 +134,9 @@ function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
.catch(toast.error)
}
useBindings(() => ({
bindings: [{ key: "escape", desc: "Back to MCP servers", group: "Dialog", cmd: props.onBack }],
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }],
}))
useKeyboard((event) => {

View file

@ -5,6 +5,7 @@ import path from "path"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useData } from "../context/data"
import { abbreviateHome } from "../runtime"
@ -13,7 +14,6 @@ import { Locale } from "../util/locale"
import { errorMessage } from "../util/error"
import { isRecord } from "../util/record"
import { useToast } from "../ui/toast"
import { useCommandShortcut } from "../keymap"
import { useProject } from "../context/project"
import { Spinner } from "./spinner"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
@ -45,12 +45,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const route = useRoute()
const toast = useToast()
const paths = useTuiPaths()
const shortcuts = Keymap.useShortcuts()
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving)
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
const [loadError, setLoadError] = createSignal<unknown>()
const deleteHint = useCommandShortcut("dialog.move_session.delete")
onMount(() => dialog.setSize("xlarge"))
function reopen(initialRemoving?: string) {
@ -175,7 +175,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
titleView: isRemoving ? (
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
) : deleting ? (
<span style={{ fg: theme.text }}>Press {deleteHint()} again to confirm</span>
<span style={{ fg: theme.text }}>Press {shortcuts.get("dialog.move_session.delete")} again to confirm</span>
) : suffix ? (
<>
{visible.slice(0, split)}

View file

@ -1,16 +1,14 @@
import { InputRenderable, TextAttributes } from "@opentui/core"
import { Slug } from "@opencode-ai/core/util/slug"
import { createSignal, onMount } from "solid-js"
import { useConfig } from "../config"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useBindings, useCommandShortcut } from "../keymap"
import { useDialog, type DialogContext } from "../ui/dialog"
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
const dialog = useDialog()
const { theme } = useTheme()
const config = useConfig().data
const generateShortcut = useCommandShortcut("dialog.project_copy.generate")
const shortcuts = Keymap.useShortcuts()
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
let input: InputRenderable
@ -23,19 +21,19 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
props.onConfirm(slugify(input.value) || Slug.create())
}
useBindings(() => ({
Keymap.createLayer(() => ({
mode: "modal",
target: inputTarget,
enabled: inputTarget() !== undefined,
priority: 1,
commands: [
{
name: "dialog.project_copy.generate",
id: "dialog.project_copy.generate",
title: "Generate project copy name",
category: "Dialog",
group: "Dialog",
run: generate,
},
],
bindings: config.keybinds.get("dialog.project_copy.generate"),
}))
onMount(() => {
@ -73,7 +71,7 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
enter <span style={{ fg: theme.textMuted }}>submit</span>
</text>
<text fg={theme.text}>
{generateShortcut()} <span style={{ fg: theme.textMuted }}>generate one</span>
{shortcuts.get("dialog.project_copy.generate")} <span style={{ fg: theme.textMuted }}>generate one</span>
</text>
</box>
</box>
@ -82,7 +80,10 @@ export function DialogProjectCopyName(props: { onConfirm: (name: string) => void
DialogProjectCopyName.show = (dialog: DialogContext) =>
new Promise<string | null>((resolve) => {
dialog.replace(() => <DialogProjectCopyName onConfirm={resolve} />, () => resolve(null))
dialog.replace(
() => <DialogProjectCopyName onConfirm={resolve} />,
() => resolve(null),
)
})
function slugify(input: string) {

View file

@ -1,11 +1,11 @@
import { RGBA, TextAttributes } from "@opentui/core"
import open from "open"
import { createSignal } from "solid-js"
import { Keymap } from "../context/keymap"
import { selectedForeground, useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse"
import { useBindings } from "../keymap"
const GO_URL = "https://opencode.ai/go"
const PAD_X = 3
@ -44,31 +44,32 @@ export function DialogRetryAction(props: DialogRetryActionProps) {
const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined)
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
useBindings(() => ({
bindings: [
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{
key: "left",
desc: "Previous retry option",
bind: "left",
title: "Previous retry option",
group: "Dialog",
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
key: "right",
desc: "Next retry option",
bind: "right",
title: "Next retry option",
group: "Dialog",
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
key: "tab",
desc: "Next retry option",
bind: "tab",
title: "Next retry option",
group: "Dialog",
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
key: "return",
desc: "Confirm retry option",
bind: "return",
title: "Confirm retry option",
group: "Dialog",
cmd: () => {
run: () => {
if (selected() === "action") runAction(props, dialog)
else dismiss(props, dialog)
},

View file

@ -1,9 +1,9 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useBindings } from "../keymap"
export function DialogSessionDeleteFailed(props: {
session: string
@ -40,13 +40,24 @@ export function DialogSessionDeleteFailed(props: {
if (!props.onDone) dialog.clear()
}
useBindings(() => ({
bindings: [
{ key: "return", desc: "Confirm recovery option", group: "Dialog", cmd: () => void confirm() },
{ key: "left", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") },
{ key: "up", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") },
{ key: "right", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") },
{ key: "down", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") },
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "return", title: "Confirm recovery option", group: "Dialog", run: () => void confirm() },
{ bind: "left", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
{ bind: "up", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
{
bind: "right",
title: "Restore broken session",
group: "Dialog",
run: () => setStore("active", "restore"),
},
{
bind: "down",
title: "Restore broken session",
group: "Dialog",
run: () => setStore("active", "restore"),
},
],
}))

View file

@ -5,6 +5,7 @@ import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale"
import { useProject } from "../context/project"
import { useTheme } from "../context/theme"
@ -12,7 +13,6 @@ import { useClient } from "../context/client"
import { useLocal } from "../context/local"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { useCommandShortcut } from "../keymap"
import { DialogSessionRename } from "./dialog-session-rename"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
@ -27,11 +27,9 @@ export function DialogSessionList() {
const local = useLocal()
const toast = useToast()
const [filter, setFilter] = createSignal("")
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
const deleteHint = useCommandShortcut("session.delete")
const [searchResults] = createResource(search, async (query) => {
if (!query) return
@ -80,8 +78,8 @@ export function DialogSessionList() {
})
const quickSwitchHint = createMemo(() => {
const first = quickSwitch1()
const last = quickSwitch9()
const first = shortcuts.get("session.quick_switch.1")
const last = shortcuts.get("session.quick_switch.9")
if (!first || !last) return
return quickSwitchRange(first, last)
})
@ -107,7 +105,7 @@ export function DialogSessionList() {
const slot = slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
title: deleting ? `Press ${deleteHint()} again to confirm` : session.title,
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
value: session.id,
category,
footer,

View file

@ -2,9 +2,9 @@ import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "../util/locale"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
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 shortcuts = Keymap.useShortcuts()
const [toDelete, setToDelete] = createSignal<number>()
const deleteHint = useCommandShortcut("stash.delete")
const options = createMemo(() => {
const entries = stash.list()
@ -42,7 +42,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const isDeleting = toDelete() === index
const lineCount = (entry.prompt.text.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.prompt.text),
title: isDeleting
? `Press ${shortcuts.get("stash.delete")} again to confirm`
: getStashPreview(entry.prompt.text),
bg: isDeleting ? theme.error : undefined,
value: index,
description: getRelativeTime(entry.timestamp),

View file

@ -1,11 +1,13 @@
import { useTheme } from "../context/theme"
export function PluginRouteMissing(props: { id: string; onHome: () => void }) {
export function PluginRouteMissing(props: { id: string; name: string; onHome: () => void }) {
const { theme } = useTheme()
return (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
<text fg={theme.warning}>
Unknown plugin route: {props.id}/{props.name}
</text>
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={theme.text}>go home</text>
</box>

View file

@ -19,7 +19,8 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
import type { PromptInfo, PromptPartRef } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency"
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
import { useBindings, useCommandSlashes } from "../../keymap"
import { Keymap } from "../../context/keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client"
@ -88,7 +89,7 @@ export function Autocomplete(props: {
const data = useData()
const project = useProject()
const slashes = useCommandSlashes()
const modeStack = useOpencodeModeStack()
const keymap = Keymap.use()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
@ -106,7 +107,7 @@ export function Autocomplete(props: {
createEffect(() => {
if (!store.visible) return
const popMode = modeStack.push("autocomplete")
const popMode = keymap.mode.push("autocomplete")
onCleanup(popMode)
})
@ -627,13 +628,13 @@ export function Autocomplete(props: {
},
},
],
bindings: config.keybinds.gather("prompt.autocomplete", [
bindings: [
"prompt.autocomplete.prev",
"prompt.autocomplete.next",
"prompt.autocomplete.hide",
"prompt.autocomplete.select",
"prompt.autocomplete.complete",
]),
].flatMap((command) => config.keybinds.get(command)),
}))
function show(mode: "@" | "/") {

View file

@ -450,7 +450,7 @@ export function Prompt(props: PromptProps) {
title: "Open editor",
category: "Session",
name: "prompt.editor",
slashName: "editor",
slash: { name: "editor" },
run: async () => {
dialog.clear()
@ -498,7 +498,7 @@ export function Prompt(props: PromptProps) {
title: "Skills",
name: "prompt.skills",
category: "Prompt",
slashName: "skills",
slash: { name: "skills" },
run: () => {
dialog.replace(() => (
<DialogSkill
@ -520,7 +520,7 @@ export function Prompt(props: PromptProps) {
desc: "Move to another project dir",
name: "session.move",
category: "Session",
slashName: "move",
slash: { name: "move" },
run: () => {
move.open()
},
@ -537,7 +537,7 @@ export function Prompt(props: PromptProps) {
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: config.keybinds.gather("prompt.palette", [
bindings: [
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
@ -548,7 +548,7 @@ export function Prompt(props: PromptProps) {
"session.interrupt",
"session.background",
"session.move",
]),
].flatMap((command) => config.keybinds.get(command)),
}))
const ref: PromptRef = {
@ -1188,10 +1188,7 @@ export function Prompt(props: PromptProps) {
}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
config.prompt?.paste !== "full"
) {
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
@ -1298,10 +1295,7 @@ export function Prompt(props: PromptProps) {
})
const spinnerDef = createMemo(() => {
const agent =
status() === "running"
? local.agent.current()
: local.agent.current()
const agent = status() === "running" ? local.agent.current() : local.agent.current()
const color = agent ? local.agent.color(agent.id) : theme.border
return {
frames: createFrames({