From 2cb2f9d5387a7e3c771953d3c0d45c41ff5f8a58 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 14 Jul 2026 23:01:03 -0400 Subject: [PATCH] refactor(tui): simplify location and command contexts --- packages/plugin/src/v2/tui/context.ts | 4 ++ packages/tui/package.json | 1 - .../tui/src/component/command-palette.tsx | 70 ++++++------------- .../tui/src/component/prompt/autocomplete.tsx | 14 +++- packages/tui/src/context/keymap.tsx | 53 ++++++++++---- packages/tui/src/keymap.tsx | 44 +----------- 6 files changed, 78 insertions(+), 108 deletions(-) diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts index f4a2fb9a39..1e83ff35f2 100644 --- a/packages/plugin/src/v2/tui/context.ts +++ b/packages/plugin/src/v2/tui/context.ts @@ -135,6 +135,10 @@ export interface KeymapCommand { readonly name: string readonly aliases?: string[] } + /** Hides the command from discovery UI. */ + readonly hidden?: boolean + /** Promotes the command in discovery UI. */ + readonly suggested?: boolean | (() => boolean) /** Executes the command. Return false to let keymap dispatch continue. */ readonly run: () => void | false | Promise } diff --git a/packages/tui/package.json b/packages/tui/package.json index 162b3ddb94..e631731ee6 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -20,7 +20,6 @@ "./context/epilogue": "./src/context/epilogue.tsx", "./context/exit": "./src/context/exit.tsx", "./context/log": "./src/context/log.tsx", - "./context/project": "./src/context/project.tsx", "./context/runtime": "./src/context/runtime.tsx", "./context/client": "./src/context/client.tsx", "./context/theme": "./src/context/theme.tsx", diff --git a/packages/tui/src/component/command-palette.tsx b/packages/tui/src/component/command-palette.tsx index ff7b863678..b71d61495e 100644 --- a/packages/tui/src/component/command-palette.tsx +++ b/packages/tui/src/component/command-palette.tsx @@ -1,63 +1,35 @@ import { createMemo } from "solid-js" import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" import { type DialogContext } from "../ui/dialog" -import { - COMMAND_PALETTE_COMMAND, - formatKeyBindings, - type OpenTuiKeymap, - useKeymapSelector, - useOpencodeKeymap, -} from "../keymap" -import { useConfig } from "../config" +import { COMMAND_PALETTE_COMMAND } from "../keymap" +import { Keymap, type KeymapCommand } from "../context/keymap" -type PaletteCommandEntry = ReturnType[number] - -function isVisiblePaletteCommand(command: PaletteCommandEntry["command"]) { - return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND -} - -function isSuggestedPaletteCommand(entry: PaletteCommandEntry) { - const suggested = entry.command.suggested +function isSuggestedPaletteCommand(command: KeymapCommand) { + const suggested = command.suggested if (typeof suggested === "boolean") return suggested if (typeof suggested === "function") return suggested() === true return false } export function CommandPaletteDialog() { - const config = useConfig().data - const keymap = useOpencodeKeymap() - 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 commands = Keymap.useCommands() + const shortcuts = Keymap.useShortcuts() 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() - keymap.dispatchCommand(entry.command.name) - }, - })), + commands().flatMap((command) => { + if (!command.id || !command.palette || command.hidden || command.id === COMMAND_PALETTE_COMMAND) return [] + return { + title: command.title ?? command.id, + description: command.description, + category: command.group, + footer: shortcuts.all(command.id), + value: command.id, + suggested: isSuggestedPaletteCommand(command), + onSelect: (dialog: DialogContext) => { + dialog.clear() + command.run() + }, + } + }), ) let ref: DialogSelectRef diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index d046833653..e33f87b45f 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -18,7 +18,7 @@ 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 } from "../../keymap" +import { useBindings } from "../../keymap" import { Keymap } from "../../context/keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" import type { FileSystemEntry } from "@opencode-ai/client" @@ -86,8 +86,8 @@ export function Autocomplete(props: { const editor = useEditorContext() const client = useClient() const data = useData() - const slashes = useCommandSlashes() const keymap = Keymap.use() + const keymapCommands = Keymap.useCommands() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() @@ -428,7 +428,15 @@ export function Autocomplete(props: { ) const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = [...slashes()] + const results: AutocompleteOption[] = keymapCommands().flatMap((command) => { + if (!command.slash) return [] + return { + display: `/${command.slash.name}`, + description: command.description ?? command.title, + aliases: command.slash.aliases?.map((alias) => `/${alias}`), + onSelect: command.run, + } + }) const commandNames = new Set() for (const serverCommand of data.location.command.list(location.current) ?? []) { diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index 16fdb9c8c5..04e7dff924 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -9,7 +9,7 @@ import { registerManagedTextareaLayer, registerTimedLeader, } from "@opentui/keymap/addons/opentui" -import { formatKeySequence } from "@opentui/keymap/extras" +import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid" import { useRenderer } from "@opentui/solid" @@ -19,6 +19,7 @@ import { TuiKeybind } from "../config/keybind" declare module "@opentui/keymap" { interface Command { + opencode?: KeymapCommand slash?: { name: string aliases?: string[] @@ -177,6 +178,7 @@ function createLayer(input: () => KeymapLayer) { return { ...definition, name: id, + opencode: command, ...(description === undefined ? {} : { desc: description }), ...(group === undefined ? {} : { category: group }), ...(palette === undefined ? {} : { namespace: "palette" }), @@ -215,12 +217,21 @@ function useShortcuts() { const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name) const bindings = keymap.getCommandBindings({ visibility: "registered", commands }) return new Map( - commands.map((id) => [id, formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data))]), + commands.map((id) => [ + id, + { + first: formatKeySequence(bindings.get(id)?.[0]?.sequence, formatOptions(config.data)), + all: formatCommandBindings(bindings.get(id) ?? [], formatOptions(config.data)), + }, + ]), ) }) return { get(id: string) { - return shortcuts().get(id) + return shortcuts().get(id)?.first + }, + all(id: string) { + return shortcuts().get(id)?.all }, } } @@ -232,17 +243,31 @@ function useCommands(): Accessor { .getCommandEntries({ visibility: "reachable", }) - .map((entry) => ({ - id: entry.command.name, - title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, - description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, - group: typeof entry.command.category === "string" ? entry.command.category : undefined, - palette: entry.command.namespace === "palette" ? true : undefined, - slash: entry.command.slash, - run: () => { - value.keymap.dispatchCommand(entry.command.name) - }, - })), + .map((entry) => { + const command = entry.command.opencode ?? { + id: entry.command.name, + title: typeof entry.command.title === "string" ? entry.command.title : undefined, + description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, + group: typeof entry.command.category === "string" ? entry.command.category : undefined, + enabled: + typeof entry.command.enabled === "boolean" || typeof entry.command.enabled === "function" + ? (entry.command.enabled as boolean | (() => boolean)) + : undefined, + palette: entry.command.namespace === "palette" ? true : undefined, + slash: entry.command.slash, + hidden: typeof entry.command.hidden === "boolean" ? entry.command.hidden : undefined, + suggested: + typeof entry.command.suggested === "boolean" || typeof entry.command.suggested === "function" + ? (entry.command.suggested as boolean | (() => boolean)) + : undefined, + } + return { + ...command, + run: () => { + value.keymap.dispatchCommand(entry.command.name) + }, + } + }), ) } diff --git a/packages/tui/src/keymap.tsx b/packages/tui/src/keymap.tsx index 4278c67b63..0167f9f62a 100644 --- a/packages/tui/src/keymap.tsx +++ b/packages/tui/src/keymap.tsx @@ -13,12 +13,14 @@ import { formatKeySequence as formatKeySequenceExtra, } from "@opentui/keymap/extras" import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid" -import { createMemo, type Accessor } from "solid-js" +import type { Accessor } from "solid-js" import { useConfig } from "./config" import { TuiKeybind } from "./config/keybind" +import type { KeymapCommand } from "@opencode-ai/plugin/v2/tui/context" declare module "@opentui/keymap" { interface Command { + opencode?: KeymapCommand slash?: { name: string aliases?: string[] @@ -39,13 +41,6 @@ export const useOpencodeKeymap = useKeymap export type OpenTuiKeymap = ReturnType type OpencodeModeStack = ReturnType -type CommandSlashEntry = { - display: string - description?: string - aliases?: string[] - onSelect: () => void -} -type RegisteredCommand = ReturnType[number] type BindingLookup = { get(command: string): readonly Binding[] } @@ -54,10 +49,6 @@ type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | { const modeStacks = new WeakMap() -function isVisiblePaletteCommand(command: RegisteredCommand) { - return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND -} - export function createOpencodeModeStack(keymap: OpenTuiKeymap) { keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE) @@ -268,32 +259,3 @@ export function useCommandShortcut(command: string): Accessor { ), ) } - -export function useCommandSlashes(): Accessor { - const keymap = useOpencodeKeymap() - const entries = useKeymapSelector((keymap: OpenTuiKeymap) => - keymap.getCommandEntries({ - visibility: "reachable", - namespace: "palette", - filter: isVisiblePaletteCommand, - }), - ) - - return createMemo(() => - entries().flatMap((entry) => { - const slash = entry.command.slash - if (!slash) return [] - return { - display: `/${slash.name}`, - description: - typeof entry.command.desc === "string" - ? entry.command.desc - : typeof entry.command.title === "string" - ? entry.command.title - : undefined, - aliases: slash.aliases?.map((alias) => `/${alias}`), - onSelect: () => keymap.dispatchCommand(entry.command.name), - } - }), - ) -}