refactor(tui): simplify location and command contexts

This commit is contained in:
Dax Raad 2026-07-14 23:01:03 -04:00
commit 2cb2f9d538
6 changed files with 78 additions and 108 deletions

View file

@ -135,6 +135,10 @@ export interface KeymapCommand {
readonly name: string readonly name: string
readonly aliases?: 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. */ /** Executes the command. Return false to let keymap dispatch continue. */
readonly run: () => void | false | Promise<void> readonly run: () => void | false | Promise<void>
} }

View file

@ -20,7 +20,6 @@
"./context/epilogue": "./src/context/epilogue.tsx", "./context/epilogue": "./src/context/epilogue.tsx",
"./context/exit": "./src/context/exit.tsx", "./context/exit": "./src/context/exit.tsx",
"./context/log": "./src/context/log.tsx", "./context/log": "./src/context/log.tsx",
"./context/project": "./src/context/project.tsx",
"./context/runtime": "./src/context/runtime.tsx", "./context/runtime": "./src/context/runtime.tsx",
"./context/client": "./src/context/client.tsx", "./context/client": "./src/context/client.tsx",
"./context/theme": "./src/context/theme.tsx", "./context/theme": "./src/context/theme.tsx",

View file

@ -1,63 +1,35 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { type DialogContext } from "../ui/dialog" import { type DialogContext } from "../ui/dialog"
import { import { COMMAND_PALETTE_COMMAND } from "../keymap"
COMMAND_PALETTE_COMMAND, import { Keymap, type KeymapCommand } from "../context/keymap"
formatKeyBindings,
type OpenTuiKeymap,
useKeymapSelector,
useOpencodeKeymap,
} from "../keymap"
import { useConfig } from "../config"
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number] function isSuggestedPaletteCommand(command: KeymapCommand) {
const suggested = command.suggested
function isVisiblePaletteCommand(command: PaletteCommandEntry["command"]) {
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
}
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
const suggested = entry.command.suggested
if (typeof suggested === "boolean") return suggested if (typeof suggested === "boolean") return suggested
if (typeof suggested === "function") return suggested() === true if (typeof suggested === "function") return suggested() === true
return false return false
} }
export function CommandPaletteDialog() { export function CommandPaletteDialog() {
const config = useConfig().data const commands = Keymap.useCommands()
const keymap = useOpencodeKeymap() const shortcuts = Keymap.useShortcuts()
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(() => const options = createMemo(() =>
entries().map((entry) => ({ commands().flatMap((command) => {
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, if (!command.id || !command.palette || command.hidden || command.id === COMMAND_PALETTE_COMMAND) return []
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, return {
category: typeof entry.command.category === "string" ? entry.command.category : undefined, title: command.title ?? command.id,
footer: formatKeyBindings(entry.bindings, config), description: command.description,
value: entry.command.name, category: command.group,
suggested: isSuggestedPaletteCommand(entry), footer: shortcuts.all(command.id),
onSelect: (dialog: DialogContext) => { value: command.id,
dialog.clear() suggested: isSuggestedPaletteCommand(command),
keymap.dispatchCommand(entry.command.name) onSelect: (dialog: DialogContext) => {
}, dialog.clear()
})), command.run()
},
}
}),
) )
let ref: DialogSelectRef<string> let ref: DialogSelectRef<string>

View file

@ -18,7 +18,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import type { PromptInfo, PromptPartRef } from "../../prompt/history" import type { PromptInfo, PromptPartRef } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency" import { useFrecency } from "../../prompt/frecency"
import { useBindings, useCommandSlashes } from "../../keymap" import { useBindings } from "../../keymap"
import { Keymap } from "../../context/keymap" import { Keymap } from "../../context/keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
import type { FileSystemEntry } from "@opencode-ai/client" import type { FileSystemEntry } from "@opencode-ai/client"
@ -86,8 +86,8 @@ export function Autocomplete(props: {
const editor = useEditorContext() const editor = useEditorContext()
const client = useClient() const client = useClient()
const data = useData() const data = useData()
const slashes = useCommandSlashes()
const keymap = Keymap.use() const keymap = Keymap.use()
const keymapCommands = Keymap.useCommands()
const { theme } = useTheme() const { theme } = useTheme()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const frecency = useFrecency() const frecency = useFrecency()
@ -428,7 +428,15 @@ export function Autocomplete(props: {
) )
const commands = createMemo((): AutocompleteOption[] => { 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<string>() const commandNames = new Set<string>()
for (const serverCommand of data.location.command.list(location.current) ?? []) { for (const serverCommand of data.location.command.list(location.current) ?? []) {

View file

@ -9,7 +9,7 @@ import {
registerManagedTextareaLayer, registerManagedTextareaLayer,
registerTimedLeader, registerTimedLeader,
} from "@opentui/keymap/addons/opentui" } 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 { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid" import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid"
import { useRenderer } from "@opentui/solid" import { useRenderer } from "@opentui/solid"
@ -19,6 +19,7 @@ import { TuiKeybind } from "../config/keybind"
declare module "@opentui/keymap" { declare module "@opentui/keymap" {
interface Command { interface Command {
opencode?: KeymapCommand
slash?: { slash?: {
name: string name: string
aliases?: string[] aliases?: string[]
@ -177,6 +178,7 @@ function createLayer(input: () => KeymapLayer) {
return { return {
...definition, ...definition,
name: id, name: id,
opencode: command,
...(description === undefined ? {} : { desc: description }), ...(description === undefined ? {} : { desc: description }),
...(group === undefined ? {} : { category: group }), ...(group === undefined ? {} : { category: group }),
...(palette === undefined ? {} : { namespace: "palette" }), ...(palette === undefined ? {} : { namespace: "palette" }),
@ -215,12 +217,21 @@ function useShortcuts() {
const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name) const commands = keymap.getCommands({ visibility: "registered" }).map((command) => command.name)
const bindings = keymap.getCommandBindings({ visibility: "registered", commands }) const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
return new Map( 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 { return {
get(id: string) { 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<readonly KeymapCommand[]> {
.getCommandEntries({ .getCommandEntries({
visibility: "reachable", visibility: "reachable",
}) })
.map((entry) => ({ .map((entry) => {
id: entry.command.name, const command = entry.command.opencode ?? {
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, id: entry.command.name,
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, title: typeof entry.command.title === "string" ? entry.command.title : undefined,
group: typeof entry.command.category === "string" ? entry.command.category : undefined, description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
palette: entry.command.namespace === "palette" ? true : undefined, group: typeof entry.command.category === "string" ? entry.command.category : undefined,
slash: entry.command.slash, enabled:
run: () => { typeof entry.command.enabled === "boolean" || typeof entry.command.enabled === "function"
value.keymap.dispatchCommand(entry.command.name) ? (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)
},
}
}),
) )
} }

View file

@ -13,12 +13,14 @@ import {
formatKeySequence as formatKeySequenceExtra, formatKeySequence as formatKeySequenceExtra,
} from "@opentui/keymap/extras" } from "@opentui/keymap/extras"
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid" 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 { useConfig } from "./config"
import { TuiKeybind } from "./config/keybind" import { TuiKeybind } from "./config/keybind"
import type { KeymapCommand } from "@opencode-ai/plugin/v2/tui/context"
declare module "@opentui/keymap" { declare module "@opentui/keymap" {
interface Command { interface Command {
opencode?: KeymapCommand
slash?: { slash?: {
name: string name: string
aliases?: string[] aliases?: string[]
@ -39,13 +41,6 @@ export const useOpencodeKeymap = useKeymap
export type OpenTuiKeymap = ReturnType<typeof useKeymap> export type OpenTuiKeymap = ReturnType<typeof useKeymap>
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack> type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
type CommandSlashEntry = {
display: string
description?: string
aliases?: string[]
onSelect: () => void
}
type RegisteredCommand = ReturnType<OpenTuiKeymap["getCommands"]>[number]
type BindingLookup = { type BindingLookup = {
get(command: string): readonly Binding<Renderable, KeyEvent>[] get(command: string): readonly Binding<Renderable, KeyEvent>[]
} }
@ -54,10 +49,6 @@ type ResolvedKeymapConfig = FormatConfig & ({ leader: { timeout: number } } | {
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>() const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
function isVisiblePaletteCommand(command: RegisteredCommand) {
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
}
export function createOpencodeModeStack(keymap: OpenTuiKeymap) { export function createOpencodeModeStack(keymap: OpenTuiKeymap) {
keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE) keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE)
@ -268,32 +259,3 @@ export function useCommandShortcut(command: string): Accessor<string> {
), ),
) )
} }
export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
const keymap = useOpencodeKeymap()
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
keymap.getCommandEntries({
visibility: "reachable",
namespace: "palette",
filter: isVisiblePaletteCommand,
}),
)
return createMemo<CommandSlashEntry[]>(() =>
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),
}
}),
)
}