From 8a8c6301f11eda5949279a6f954d995254264737 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 7 May 2026 16:16:55 +0200 Subject: [PATCH 1/4] STASH --- packages/opencode/src/cli/cmd/tui/app.tsx | 13 +++- .../cmd/tui/component/prompt/autocomplete.tsx | 12 ++-- .../cli/cmd/tui/component/prompt/index.tsx | 12 ++-- .../cli/cmd/tui/context/command-palette.tsx | 14 +---- packages/opencode/src/cli/cmd/tui/keymap.tsx | 60 ++++++++++++++++++- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- .../cli/cmd/tui/routes/session/permission.tsx | 9 +-- .../cli/cmd/tui/routes/session/question.tsx | 9 ++- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 11 +++- 9 files changed, 104 insertions(+), 40 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a8cc7946a9..5aed38bbb3 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -65,7 +65,14 @@ 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 { + OPENCODE_BASE_MODE, + OpencodeKeymapProvider, + createOpencodeModeStack, + registerOpencodeKeymap, + useBindings, + useOpencodeKeymap, +} from "./keymap" import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" @@ -133,6 +140,7 @@ export function tui(input: { const onBeforeExit = async () => { offKeymap() + modeStack.dispose() await TuiPluginRuntime.dispose() } @@ -142,6 +150,7 @@ export function tui(input: { const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" const keymap = createDefaultOpenTuiKeymap(renderer) + const modeStack = createOpencodeModeStack(keymap) const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config) await render(() => { @@ -747,7 +756,7 @@ function App(props: { onSnapshot?: () => Promise }) { })) useBindings(() => ({ - enabled: command.matcher, + opencodeMode: OPENCODE_BASE_MODE, bindings: sections.global, })) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 7a2548704d..de1af61521 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -17,7 +17,7 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" -import { useBindings } from "../../keymap" +import { useBindings, useOpencodeModeStack } from "../../keymap" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -83,6 +83,7 @@ export function Autocomplete(props: { const sdk = useSDK() const sync = useSync() const command = useCommandPalette() + const modeStack = useOpencodeModeStack() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() @@ -99,6 +100,12 @@ export function Autocomplete(props: { const [positionTick, setPositionTick] = createSignal(0) + createEffect(() => { + if (!store.visible) return + const popMode = modeStack.push("autocomplete") + onCleanup(popMode) + }) + createEffect(() => { if (store.visible) { let lastPos = { x: 0, y: 0, width: 0 } @@ -284,7 +291,6 @@ export function Autocomplete(props: { const { filename, part } = createFilePart(item, lineRange) const index = store.visible === "@" ? store.index : props.input().cursorOffset - command.suspend(false) setStore("visible", false) setStore("index", index) insertPart(filename, part) @@ -569,7 +575,6 @@ export function Autocomplete(props: { })) function show(mode: "@" | "/") { - command.suspend(true) setStore({ visible: mode, index: props.input().cursorOffset, @@ -586,7 +591,6 @@ export function Autocomplete(props: { draft.input = props.input().plainText }) } - command.suspend(false) setStore("visible", false) } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 898d14e979..977d8fdd36 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -59,8 +59,13 @@ import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" import { Flag } from "@opencode-ai/core/flag/flag" import { type WorkspaceStatus } from "../workspace-label" -import { useCommandPalette } from "../../context/command-palette" -import { useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" +import { + OPENCODE_BASE_MODE, + useBindings, + useCommandShortcut, + useLeaderActive, + useOpencodeKeymap, +} from "../../keymap" import { useTuiConfig } from "../../context/tui-config" export type PromptProps = { @@ -151,7 +156,6 @@ export function Prompt(props: PromptProps) { const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) const history = usePromptHistory() const stash = usePromptStash() - const command = useCommandPalette() const keymap = useOpencodeKeymap() const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") @@ -632,7 +636,7 @@ export function Prompt(props: PromptProps) { })) useBindings(() => ({ - enabled: command.matcher, + opencodeMode: OPENCODE_BASE_MODE, bindings: keymapConfig.pick("prompt", [ "prompt.submit", "prompt.editor", diff --git a/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx b/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx index 07cca99074..57cd86463c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx @@ -1,9 +1,8 @@ -import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps } from "solid-js" +import { createContext, createMemo, 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, @@ -21,9 +20,6 @@ type CommandPaletteContext = { run(command: string): void show(): void slashes: Accessor - suspend(enabled: boolean): void - readonly suspended: boolean - matcher: ReturnType } const COMMAND_PALETTE_DIALOG = "command.palette.show" @@ -44,7 +40,6 @@ function isSuggestedPaletteCommand(entry: PaletteCommandEntry) { export function CommandPaletteProvider(props: ParentProps) { const dialog = useDialog() const keymap = useOpencodeKeymap() - const [suspendCount, setSuspendCount] = createSignal(0) const entries = useKeymapSelector((keymap: OpenTuiKeymap) => keymap .getCommandEntries({ @@ -85,13 +80,6 @@ export function CommandPaletteProvider(props: ParentProps) { dialog.replace(() => ) }, 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 {props.children} diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index 0d65057d79..554f79ce54 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -6,7 +6,6 @@ import { } from "@opentui/keymap/extras" import { KeymapProvider, - reactiveMatcherFromSignal, useBindings, useKeymap, useKeymapSelector, @@ -16,13 +15,70 @@ import type { TuiConfig } from "./config/tui" import { useTuiConfig } from "./context/tui-config" export const LEADER_TOKEN = "leader" +export const OPENCODE_BASE_MODE = "base" + +const OPENCODE_MODE_KEY = "opencode.mode" export const OpencodeKeymapProvider = KeymapProvider export const useOpencodeKeymap = useKeymap -export { reactiveMatcherFromSignal, useBindings, useKeymapSelector } +export { useBindings, useKeymapSelector } export type OpenTuiKeymap = ReturnType +type OpencodeModeStack = ReturnType + +const modeStacks = new WeakMap() + +export function createOpencodeModeStack(keymap: OpenTuiKeymap) { + keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE) + + const offFields = keymap.registerLayerFields({ + opencodeMode(value, ctx) { + ctx.require(OPENCODE_MODE_KEY, value) + }, + }) + + const stack: { id: symbol; mode: string }[] = [] + let disposed = false + + const update = () => { + keymap.setData(OPENCODE_MODE_KEY, stack.at(-1)?.mode ?? OPENCODE_BASE_MODE) + } + + const stackApi = { + push(mode: string) { + if (disposed) return () => {} + const id = Symbol(mode) + let active = true + stack.push({ id, mode }) + update() + + return () => { + if (!active) return + active = false + const index = stack.findIndex((item) => item.id === id) + if (index !== -1) stack.splice(index, 1) + update() + } + }, + dispose() { + if (disposed) return + disposed = true + stack.length = 0 + offFields() + keymap.setData(OPENCODE_MODE_KEY, undefined) + }, + } + + modeStacks.set(keymap, stackApi) + return stackApi +} + +export function useOpencodeModeStack() { + const value = modeStacks.get(useOpencodeKeymap()) + if (!value) throw new Error("Opencode mode stack is not registered for this keymap") + return value +} function formatOptions(config: TuiConfig.Resolved) { return { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 9ba300ea14..62453395d6 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -89,7 +89,7 @@ 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" +import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" addDefaultParsers(parsers.parsers) @@ -991,7 +991,7 @@ export function Session() { })) useBindings(() => ({ - enabled: command.matcher, + opencodeMode: OPENCODE_BASE_MODE, bindings: sections.session, })) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 5e7e80b66a..9f7e834c45 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -13,10 +13,9 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" 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" +import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" type PermissionStage = "permission" | "always" | "reject" @@ -465,9 +464,8 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) - const dialog = useDialog() useBindings(() => ({ - enabled: dialog.stack.length === 0, + opencodeMode: OPENCODE_BASE_MODE, commands: [ { name: "permission.reject.cancel", @@ -553,11 +551,10 @@ function Prompt>(props: { expanded: false, }) const narrow = createMemo(() => dimensions().width < 80) - const dialog = useDialog() const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen") useBindings(() => ({ - enabled: dialog.stack.length === 0, + opencodeMode: OPENCODE_BASE_MODE, commands: [ { name: "permission.prompt.escape", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 811db7e82f..1f5f17172d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -5,9 +5,8 @@ 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 { useDialog } from "../../ui/dialog" import { useTuiConfig } from "../../context/tui-config" -import { useBindings } from "../../keymap" +import { OPENCODE_BASE_MODE, useBindings } from "../../keymap" export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() @@ -122,9 +121,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { pick(opt.label) } - const dialog = useDialog() - useBindings(() => ({ + opencodeMode: OPENCODE_BASE_MODE, enabled: store.editing && !confirm(), commands: [ { @@ -199,7 +197,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const max = Math.min(total, 9) return { - enabled: dialog.stack.length === 0 && !store.editing, + opencodeMode: OPENCODE_BASE_MODE, + enabled: !store.editing, commands: [ { name: "question.reject", diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 0dff8b5433..b26a43c662 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -1,12 +1,12 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" -import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js" +import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js" import { useTheme } from "@tui/context/theme" import { MouseButton, Renderable, RGBA } from "@opentui/core" 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" +import { useBindings, useOpencodeModeStack } from "../keymap" export function Dialog( props: ParentProps<{ @@ -73,6 +73,13 @@ function init() { }) const renderer = useRenderer() + const modeStack = useOpencodeModeStack() + + createEffect(() => { + if (store.stack.length === 0) return + const popMode = modeStack.push("modal") + onCleanup(popMode) + }) let focus: Renderable | null function refocus() { From bb68dcb637a9e0a957f819dbabae18b694ad82cf Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 7 May 2026 16:18:55 +0200 Subject: [PATCH 2/4] STASH 2 --- packages/opencode/src/cli/cmd/tui/app.tsx | 13 +-- .../cmd/tui/component/prompt/autocomplete.tsx | 6 +- .../cli/cmd/tui/context/command-palette.tsx | 108 ++++++++---------- .../src/cli/cmd/tui/routes/session/index.tsx | 8 +- .../tui/routes/session/subagent-footer.tsx | 11 +- 5 files changed, 61 insertions(+), 85 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5aed38bbb3..8ff30c5b14 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -64,7 +64,7 @@ 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 { CommandPaletteProvider } from "./context/command-palette" import { OPENCODE_BASE_MODE, OpencodeKeymapProvider, @@ -233,7 +233,6 @@ function App(props: { onSnapshot?: () => Promise }) { const dialog = useDialog() const local = useLocal() const kv = useKV() - const command = useCommandPalette() const keymap = useOpencodeKeymap() const event = useEvent() const sdk = useSDK() @@ -405,14 +404,6 @@ function App(props: { onSnapshot?: () => Promise }) { const connected = useConnected() const appCommands = createMemo(() => [ - { - name: "command.palette.show", - title: "Show command palette", - hidden: true, - run: () => { - command.show() - }, - }, { name: "session.list", title: "Switch session", @@ -761,7 +752,7 @@ function App(props: { onSnapshot?: () => Promise }) { })) event.on(TuiEvent.CommandExecute.type, (evt) => { - command.run(evt.properties.command) + keymap.dispatchCommand(evt.properties.command) }) event.on(TuiEvent.ToastShow.type, (evt) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index de1af61521..42da822f12 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -12,7 +12,7 @@ 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 { useCommandPalette } from "../../context/command-palette" +import { useCommandSlashes } from "../../context/command-palette" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" @@ -82,7 +82,7 @@ export function Autocomplete(props: { const editor = useEditorContext() const sdk = useSDK() const sync = useSync() - const command = useCommandPalette() + const slashes = useCommandSlashes() const modeStack = useOpencodeModeStack() const { theme } = useTheme() const dimensions = useTerminalDimensions() @@ -407,7 +407,7 @@ export function Autocomplete(props: { }) const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = [...command.slashes()] + const results: AutocompleteOption[] = [...slashes()] for (const serverCommand of sync.data.command) { if (serverCommand.source === "skill") continue diff --git a/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx b/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx index 57cd86463c..c901b70122 100644 --- a/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx @@ -1,9 +1,10 @@ -import { createContext, createMemo, useContext, type Accessor, type ParentProps } from "solid-js" +import { createMemo, 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, type OpenTuiKeymap, + useBindings, useKeymapSelector, useOpencodeKeymap, } from "../keymap" @@ -16,14 +17,7 @@ type SlashEntry = { onSelect: () => void } -type CommandPaletteContext = { - run(command: string): void - show(): void - slashes: Accessor -} - const COMMAND_PALETTE_DIALOG = "command.palette.show" -const ctx = createContext() type PaletteCommandEntry = ReturnType[number] function isVisiblePaletteCommand(entry: PaletteCommandEntry) { @@ -39,60 +33,25 @@ function isSuggestedPaletteCommand(entry: PaletteCommandEntry) { export function CommandPaletteProvider(props: ParentProps) { const dialog = useDialog() - const keymap = useOpencodeKeymap() - const entries = useKeymapSelector((keymap: OpenTuiKeymap) => - keymap - .getCommandEntries({ - visibility: "reachable", - namespace: "palette", - }) - .filter(isVisiblePaletteCommand), - ) + useBindings(() => ({ + commands: [ + { + name: COMMAND_PALETTE_DIALOG, + title: "Show command palette", + hidden: true, + run() { + dialog.replace(() => ) + }, + }, + ], + })) - const run = (command: string) => { - keymap.dispatchCommand(command) - } - - const slashes = createMemo(() => - 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(() => ) - }, - slashes, - } - - return {props.children} + return <>{props.children} } -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 }) { +function CommandPaletteDialog() { const config = useTuiConfig() + const keymap = useOpencodeKeymap() const entries = useKeymapSelector((keymap: OpenTuiKeymap) => { const query = { namespace: "palette", @@ -123,7 +82,7 @@ function CommandPaletteDialog(props: { run(command: string): void }) { suggested: isSuggestedPaletteCommand(entry), onSelect: (dialog: DialogContext) => { dialog.clear() - props.run(entry.command.name) + keymap.dispatchCommand(entry.command.name) }, })), ) @@ -147,5 +106,34 @@ function CommandPaletteDialog(props: { run(command: string): void }) { } export function useCommandSlashes(): Accessor { - return useCommandPalette().slashes + const keymap = useOpencodeKeymap() + const entries = useKeymapSelector((keymap: OpenTuiKeymap) => + keymap + .getCommandEntries({ + visibility: "reachable", + namespace: "palette", + }) + .filter(isVisiblePaletteCommand), + ) + + return createMemo(() => + 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: () => keymap.dispatchCommand(entry.command.name), + } + }), + ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 62453395d6..a8fbb5a9d9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -88,8 +88,7 @@ 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 { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" +import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" addDefaultParsers(parsers.parsers) @@ -253,7 +252,7 @@ export function Session() { seeded = true r.set(route.prompt) } - const command = useCommandPalette() + const keymap = useOpencodeKeymap() const dialog = useDialog() const renderer = useRenderer() @@ -1066,7 +1065,6 @@ export function Session() { {(function () { - const command = useCommandPalette() const redoShortcut = useCommandShortcut("session.redo") const [hover, setHover] = createSignal(false) const dialog = useDialog() @@ -1078,7 +1076,7 @@ export function Session() { "Are you sure you want to restore the reverted messages?", ) if (confirmed) { - command.run("session.redo") + keymap.dispatchCommand("session.redo") } } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx index 2a6813ffbe..fdf153cfd6 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx @@ -6,8 +6,7 @@ import { SplitBorder } from "@tui/component/border" import type { AssistantMessage } from "@opencode-ai/sdk/v2" import { Locale } from "@/util/locale" import { useTerminalDimensions } from "@opentui/solid" -import { useCommandPalette } from "../../context/command-palette" -import { useCommandShortcut } from "../../keymap" +import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" export function SubagentFooter() { const route = useRouteData("session") @@ -56,7 +55,7 @@ export function SubagentFooter() { }) const { theme } = useTheme() - const command = useCommandPalette() + const keymap = useOpencodeKeymap() const parentShortcut = useCommandShortcut("session.parent") const previousShortcut = useCommandShortcut("session.child.previous") const nextShortcut = useCommandShortcut("session.child.next") @@ -98,7 +97,7 @@ export function SubagentFooter() { setHover("parent")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.run("session.parent")} + onMouseUp={() => keymap.dispatchCommand("session.parent")} backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > @@ -108,7 +107,7 @@ export function SubagentFooter() { setHover("prev")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.run("session.child.previous")} + onMouseUp={() => keymap.dispatchCommand("session.child.previous")} backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} > @@ -118,7 +117,7 @@ export function SubagentFooter() { setHover("next")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.run("session.child.next")} + onMouseUp={() => keymap.dispatchCommand("session.child.next")} backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} > From 968b6962bdefc34129237497db3448aba238a732 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 7 May 2026 22:35:18 +0200 Subject: [PATCH 3/4] move command palette --- packages/opencode/src/cli/cmd/tui/app.tsx | 30 +++++++++------- .../command-palette.tsx | 36 ++++--------------- .../cmd/tui/component/prompt/autocomplete.tsx | 2 +- 3 files changed, 25 insertions(+), 43 deletions(-) rename packages/opencode/src/cli/cmd/tui/{context => component}/command-palette.tsx (81%) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8ff30c5b14..8c868db0bc 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -64,7 +64,7 @@ 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 } from "./context/command-palette" +import { COMMAND_PALETTE_DIALOG, CommandPaletteDialog } from "./component/command-palette" import { OPENCODE_BASE_MODE, OpencodeKeymapProvider, @@ -190,17 +190,15 @@ export function tui(input: { - - - - - - - - - - - + + + + + + + + + @@ -404,6 +402,14 @@ function App(props: { onSnapshot?: () => Promise }) { const connected = useConnected() const appCommands = createMemo(() => [ + { + name: COMMAND_PALETTE_DIALOG, + title: "Show command palette", + hidden: true, + run: () => { + dialog.replace(() => ) + }, + }, { name: "session.list", title: "Switch session", diff --git a/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx b/packages/opencode/src/cli/cmd/tui/component/command-palette.tsx similarity index 81% rename from packages/opencode/src/cli/cmd/tui/context/command-palette.tsx rename to packages/opencode/src/cli/cmd/tui/component/command-palette.tsx index c901b70122..684c408bdb 100644 --- a/packages/opencode/src/cli/cmd/tui/context/command-palette.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/command-palette.tsx @@ -1,14 +1,8 @@ -import { createMemo, type Accessor, type ParentProps } from "solid-js" +import { createMemo, type Accessor } from "solid-js" import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" -import { useDialog, type DialogContext } from "@tui/ui/dialog" -import { - formatKeyBindings, - type OpenTuiKeymap, - useBindings, - useKeymapSelector, - useOpencodeKeymap, -} from "../keymap" -import { useTuiConfig } from "./tui-config" +import { type DialogContext } from "@tui/ui/dialog" +import { formatKeyBindings, type OpenTuiKeymap, useKeymapSelector, useOpencodeKeymap } from "../keymap" +import { useTuiConfig } from "../context/tui-config" type SlashEntry = { display: string @@ -17,7 +11,7 @@ type SlashEntry = { onSelect: () => void } -const COMMAND_PALETTE_DIALOG = "command.palette.show" +export const COMMAND_PALETTE_DIALOG = "command.palette.show" type PaletteCommandEntry = ReturnType[number] function isVisiblePaletteCommand(entry: PaletteCommandEntry) { @@ -31,25 +25,7 @@ function isSuggestedPaletteCommand(entry: PaletteCommandEntry) { return false } -export function CommandPaletteProvider(props: ParentProps) { - const dialog = useDialog() - useBindings(() => ({ - commands: [ - { - name: COMMAND_PALETTE_DIALOG, - title: "Show command palette", - hidden: true, - run() { - dialog.replace(() => ) - }, - }, - ], - })) - - return <>{props.children} -} - -function CommandPaletteDialog() { +export function CommandPaletteDialog() { const config = useTuiConfig() const keymap = useOpencodeKeymap() const entries = useKeymapSelector((keymap: OpenTuiKeymap) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 42da822f12..052f1af63a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -12,7 +12,7 @@ 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 { useCommandSlashes } from "../../context/command-palette" +import { useCommandSlashes } from "../command-palette" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" From 29b78715528f3dff8398b2f8a94020f2d030e1f2 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 7 May 2026 22:42:45 +0200 Subject: [PATCH 4/4] structure --- packages/opencode/src/cli/cmd/tui/app.tsx | 5 +- .../cli/cmd/tui/component/command-palette.tsx | 53 ++++--------------- .../cmd/tui/component/prompt/autocomplete.tsx | 3 +- packages/opencode/src/cli/cmd/tui/keymap.tsx | 47 +++++++++++++++- 4 files changed, 59 insertions(+), 49 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8c868db0bc..1ed522e999 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -64,8 +64,9 @@ 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 { COMMAND_PALETTE_DIALOG, CommandPaletteDialog } from "./component/command-palette" +import { CommandPaletteDialog } from "./component/command-palette" import { + COMMAND_PALETTE_COMMAND, OPENCODE_BASE_MODE, OpencodeKeymapProvider, createOpencodeModeStack, @@ -403,7 +404,7 @@ function App(props: { onSnapshot?: () => Promise }) { const appCommands = createMemo(() => [ { - name: COMMAND_PALETTE_DIALOG, + name: COMMAND_PALETTE_COMMAND, title: "Show command palette", hidden: true, run: () => { diff --git a/packages/opencode/src/cli/cmd/tui/component/command-palette.tsx b/packages/opencode/src/cli/cmd/tui/component/command-palette.tsx index 684c408bdb..ba2b1ebdc7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/command-palette.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/command-palette.tsx @@ -1,21 +1,19 @@ -import { createMemo, type Accessor } from "solid-js" +import { createMemo } from "solid-js" import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select" import { type DialogContext } from "@tui/ui/dialog" -import { formatKeyBindings, type OpenTuiKeymap, useKeymapSelector, useOpencodeKeymap } from "../keymap" +import { + COMMAND_PALETTE_COMMAND, + formatKeyBindings, + type OpenTuiKeymap, + useKeymapSelector, + useOpencodeKeymap, +} from "../keymap" import { useTuiConfig } from "../context/tui-config" -type SlashEntry = { - display: string - description?: string - aliases?: string[] - onSelect: () => void -} - -export const COMMAND_PALETTE_DIALOG = "command.palette.show" type PaletteCommandEntry = ReturnType[number] function isVisiblePaletteCommand(entry: PaletteCommandEntry) { - return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_DIALOG + return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_COMMAND } function isSuggestedPaletteCommand(entry: PaletteCommandEntry) { @@ -80,36 +78,3 @@ export function CommandPaletteDialog() { return (ref = value)} title="Commands" options={list()} /> } - -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 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: () => keymap.dispatchCommand(entry.command.name), - } - }), - ) -} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 052f1af63a..d8f9d1f977 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -12,12 +12,11 @@ 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 { useCommandSlashes } from "../command-palette" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" -import { useBindings, useOpencodeModeStack } from "../../keymap" +import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index 554f79ce54..165fbf0085 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -10,12 +10,13 @@ import { useKeymap, useKeymapSelector, } from "@opentui/keymap/solid" -import type { Accessor } from "solid-js" +import { createMemo, type Accessor } from "solid-js" import type { TuiConfig } from "./config/tui" import { useTuiConfig } from "./context/tui-config" export const LEADER_TOKEN = "leader" export const OPENCODE_BASE_MODE = "base" +export const COMMAND_PALETTE_COMMAND = "command.palette.show" const OPENCODE_MODE_KEY = "opencode.mode" @@ -26,9 +27,20 @@ export { useBindings, useKeymapSelector } export type OpenTuiKeymap = ReturnType type OpencodeModeStack = ReturnType +type CommandSlashEntry = { + display: string + description?: string + aliases?: string[] + onSelect: () => void +} +type CommandEntry = ReturnType[number] const modeStacks = new WeakMap() +function isVisiblePaletteCommand(entry: CommandEntry) { + return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_COMMAND +} + export function createOpencodeModeStack(keymap: OpenTuiKeymap) { keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE) @@ -145,3 +157,36 @@ export function useCommandShortcut(command: string): Accessor { export function useLeaderActive(): Accessor { return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN) } + +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 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: () => keymap.dispatchCommand(entry.command.name), + } + }), + ) +}