diff --git a/packages/plugin/src/v2/tui/context.ts b/packages/plugin/src/v2/tui/context.ts index 1f139fd772..fba5f934cc 100644 --- a/packages/plugin/src/v2/tui/context.ts +++ b/packages/plugin/src/v2/tui/context.ts @@ -19,6 +19,7 @@ import type { ShellInfo, SkillInfo, } from "@opencode-ai/client" +import type { Renderable } from "@opentui/core" import type { JSX } from "@opentui/solid" interface LocationCollection { @@ -105,6 +106,61 @@ export interface Page { export type Slot = (props: Record) => JSX.Element +export interface KeymapCommand { + /** Stable command and config keybind identifier. Omit for an inline command. */ + readonly id?: string + /** Optional label used by command discovery and keyboard-help UI. */ + readonly title?: string + /** Optional longer description. */ + readonly description?: string + /** Groups the command in discovery and keyboard-help UI. */ + readonly group?: string + /** Enables or disables the command. */ + readonly enabled?: boolean | (() => boolean) + /** Configures automatic binding, or disables it for a named command. */ + readonly bind?: false | string + /** Adds a named command to the command palette. */ + readonly palette?: true + /** Adds a named command to prompt slash completion. */ + readonly slash?: { + readonly name: string + readonly aliases?: string[] + } + /** Executes the command. Return false to let keymap dispatch continue. */ + readonly run: () => void | false | Promise +} + +export interface KeymapLayer { + /** Limits the layer to one OpenCode input mode. Use global to opt out; defaults to base. */ + readonly mode?: string + /** Enables or disables the complete layer. */ + readonly enabled?: boolean | (() => boolean) + /** Limits the layer to a focused renderable. */ + readonly target?: () => Renderable | null | undefined + /** Resolves conflicts with other active layers. */ + readonly priority?: number + /** Commands owned by this layer. */ + readonly commands?: readonly KeymapCommand[] + /** IDs of commands whose configured bindings should be active in this layer. */ + readonly bindings?: readonly string[] +} + +export interface Keymap { + /** Creates a reactive keymap layer owned by the calling component. */ + layer(input: () => KeymapLayer): void + /** Dispatches a reachable command by ID. */ + dispatch(id: string): void + /** Returns the formatted shortcut for a registered command. */ + shortcut(id: string): string | undefined + /** Controls mutually exclusive OpenCode input modes. */ + readonly mode: { + /** Returns the active mode. */ + current(): string + /** Pushes a mode until the returned cleanup is called. */ + push(mode: string): () => void + } +} + export interface UI { readonly router: { register(page: Page): () => void @@ -118,5 +174,6 @@ export interface Context { readonly options: Readonly> readonly client: OpenCodeClient readonly data: Data + readonly keymap: Keymap readonly ui: UI } diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index 4f6de9d911..16fdb9c8c5 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -1,4 +1,5 @@ -import { InputRenderable, TextareaRenderable, type Renderable } from "@opentui/core" +import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context" +import { InputRenderable, TextareaRenderable } from "@opentui/core" import { stringifyKeyStroke } from "@opentui/keymap" import { registerBackspacePopsPendingSequence, @@ -117,44 +118,7 @@ function Provider(props: ParentProps) { ) } -export interface KeymapCommand { - /** Stable command and config keybind identifier. Omit for an inline command. */ - readonly id?: string - /** Optional label used by command discovery and keyboard-help UI. */ - readonly title?: string - /** Optional longer description. */ - readonly description?: string - /** Groups the command in discovery and keyboard-help UI. */ - readonly group?: string - /** Enables or disables the command. */ - readonly enabled?: boolean | (() => boolean) - /** Configures automatic binding, or disables it for a named command. */ - readonly bind?: false | string - /** Adds a named command to the command palette. */ - readonly palette?: true - /** Adds a named command to prompt slash completion. */ - readonly slash?: { - readonly name: string - readonly aliases?: string[] - } - /** Executes the command. Return false to let keymap dispatch continue. */ - readonly run: () => void | false | Promise -} - -export interface KeymapLayer { - /** Limits the layer to one OpenCode input mode. Use global to opt out; defaults to base. */ - readonly mode?: string - /** Enables or disables the complete layer. */ - readonly enabled?: boolean | (() => boolean) - /** Limits the layer to a focused renderable. */ - readonly target?: () => Renderable | null | undefined - /** Resolves conflicts with other active layers. */ - readonly priority?: number - /** Commands owned by this layer. */ - readonly commands?: readonly KeymapCommand[] - /** IDs of commands whose configured bindings should be active in this layer. */ - readonly bindings?: readonly string[] -} +export type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context" export interface Keymap { /** Dispatches a reachable command by ID. */ diff --git a/packages/tui/src/feature-plugins/builtins.ts b/packages/tui/src/feature-plugins/builtins.ts index 8f4ffa50fd..1e051ae4d2 100644 --- a/packages/tui/src/feature-plugins/builtins.ts +++ b/packages/tui/src/feature-plugins/builtins.ts @@ -1,6 +1,5 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import type { PluginRuntime } from "../plugin/runtime" -import DiffViewer from "./system/diff-viewer" import Notifications from "./system/notifications" import PluginManager from "./system/plugins" import WhichKey from "./system/which-key" @@ -12,7 +11,7 @@ export type BuiltinTuiPlugin = Omit & { } export function createBuiltinPlugins(): BuiltinTuiPlugin[] { - return [Notifications, PluginManager, WhichKey, DiffViewer] + return [Notifications, PluginManager, WhichKey] } export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) { diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index 0b7b4be91a..cf69650202 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -1,6 +1,7 @@ /** @jsxImportSource @opentui/solid */ -import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui" import type { FileDiffInfo } from "@opencode-ai/client" +import { Plugin } from "@opencode-ai/plugin/v2/tui" +import type { KeymapCommand, Route } from "@opencode-ai/plugin/v2/tui/context" import { TextAttributes, type BorderSides, @@ -9,14 +10,13 @@ import { type ScrollBoxRenderable, } from "@opentui/core" import { LANGUAGE_EXTENSIONS } from "../../util/filetype" -import { useBindings, useCommandShortcut } from "../../keymap" import { useTheme } from "../../context/theme" -import { useClient } from "../../context/client" import { useTerminalDimensions } from "@opentui/solid" import path from "path" import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { DiffViewerFileTree } from "./diff-viewer-file-tree" import { Panel, PanelGroup, Separator } from "./diff-viewer-ui" +import { useDialog } from "../../ui/dialog" import { DialogSelect } from "../../ui/dialog-select" import { getScrollAcceleration } from "../../util/scroll" import { useConfig } from "../../config" @@ -80,32 +80,36 @@ function diffSourceLabel(mode: DiffMode) { return "working tree" } -function DiffViewer(props: { api: TuiPluginApi }) { +function DiffViewer(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() - const client = useClient() const config = useConfig() + const dialog = useDialog() const themeState = useTheme() - const theme = () => props.api.theme.current - const params = () => - ("params" in props.api.route.current ? props.api.route.current.params : undefined) as + const theme = () => themeState.theme + const params = () => { + const route = props.context.ui.router.current() + return (route.type === "plugin" ? route.data : undefined) as | { mode?: DiffMode sessionID?: string - returnRoute?: TuiRouteCurrent + returnRoute?: Route } | undefined + } const mode = () => params()?.mode ?? "working" const diffInput = createMemo(() => { const sessionID = params()?.sessionID return { mode: mode(), sessionID, - directory: sessionID ? props.api.state.session.get(sessionID)?.directory : undefined, + location: sessionID + ? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default()) + : props.context.data.location.default(), } }) const [diff] = createResource(diffInput, async (input) => { - const result = await client.api.vcs.diff({ - location: input.directory ? { directory: input.directory } : undefined, + const result = await props.context.client.vcs.diff({ + location: input.location, mode: input.mode, context: VCS_DIFF_CONTEXT_LINES, }) @@ -120,8 +124,8 @@ function DiffViewer(props: { api: TuiPluginApi }) { const patchLeftBorder = createMemo(() => (showFileTree() ? ["left"] : [])) const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH) const defaultView = createMemo(() => { - if (props.api.tuiConfig.diffs?.view === "unified") return "unified" - if (props.api.tuiConfig.diffs?.view === "split") return "split" + if (config.data.diffs?.view === "unified") return "unified" + if (config.data.diffs?.view === "split") return "split" return splitAvailable() ? "split" : "unified" }) const [viewOverride, setViewOverride] = createSignal(storedView(config.data.diffs?.view)) @@ -133,21 +137,22 @@ function DiffViewer(props: { api: TuiPluginApi }) { const [activePatchFileIndex, setActivePatchFileIndex] = createSignal() const [selectedFileIndex, setSelectedFileIndex] = createSignal() const [reviewedFileNames, setReviewedFileNames] = createSignal>(new Set()) - const patchScrollAcceleration = createMemo(() => getScrollAcceleration(props.api.tuiConfig)) + const patchScrollAcceleration = createMemo(() => getScrollAcceleration(config.data)) const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes())) const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree()))) const focusRunner = (input: Record void>) => () => input[focus()]() - const switchFocusShortcut = useCommandShortcut("diff.switch_focus") - const nextHunkShortcut = useCommandShortcut("diff.next_hunk") - const previousHunkShortcut = useCommandShortcut("diff.previous_hunk") - const nextFileShortcut = useCommandShortcut("diff.next_file") - const previousFileShortcut = useCommandShortcut("diff.previous_file") - const toggleFileTreeShortcut = useCommandShortcut("diff.toggle_file_tree") - const singlePatchShortcut = useCommandShortcut("diff.single_patch") - const switchSourceShortcut = useCommandShortcut("diff.switch_source") - const toggleViewShortcut = useCommandShortcut("diff.toggle_view") - const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed") - const helpShortcut = useCommandShortcut("diff.help") + const shortcut = (id: string) => () => props.context.keymap.shortcut(id) + const switchFocusShortcut = shortcut("diff.switch_focus") + const nextHunkShortcut = shortcut("diff.next_hunk") + const previousHunkShortcut = shortcut("diff.previous_hunk") + const nextFileShortcut = shortcut("diff.next_file") + const previousFileShortcut = shortcut("diff.previous_file") + const toggleFileTreeShortcut = shortcut("diff.toggle_file_tree") + const singlePatchShortcut = shortcut("diff.single_patch") + const switchSourceShortcut = shortcut("diff.switch_source") + const toggleViewShortcut = shortcut("diff.toggle_view") + const markReviewedShortcut = shortcut("diff.mark_reviewed") + const helpShortcut = shortcut("diff.help") let scroll: ScrollBoxRenderable | undefined const patchNodeByFileIndex = new Map() const diffNodeByFileIndex = new Map() @@ -155,7 +160,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal() const [patchFillerHeight, setPatchFillerHeight] = createSignal(0) - onCleanup(() => props.api.ui.dialog.clear()) + onCleanup(() => dialog.clear()) createEffect(() => { setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) @@ -412,25 +417,24 @@ function DiffViewer(props: { api: TuiPluginApi }) { }) } - const commands = [ - { - name: "diff.close", - title: "Close diff viewer", - category: "VCS", - run() { - const returnRoute = params()?.returnRoute - props.api.ui.dialog.clear() + const close = () => { + const returnRoute = params()?.returnRoute + dialog.clear() + props.context.ui.router.navigate(returnRoute ?? { type: "home" }) + } - props.api.route.navigate( - returnRoute?.name ?? "home", - returnRoute && "params" in returnRoute ? returnRoute.params : undefined, - ) - }, + const commands: KeymapCommand[] = [ + { + id: "diff.close", + title: "Close diff viewer", + group: "VCS", + run: close, }, { - name: "diff.down", + id: "diff.down", title: "Move diff viewer down", - category: "VCS", + group: "VCS", + bind: "j,down", run: focusRunner({ files() { moveFileSelection(1) @@ -442,9 +446,10 @@ function DiffViewer(props: { api: TuiPluginApi }) { }), }, { - name: "diff.up", + id: "diff.up", title: "Move diff viewer up", - category: "VCS", + group: "VCS", + bind: "k,up", run: focusRunner({ files() { moveFileSelection(-1) @@ -456,9 +461,10 @@ function DiffViewer(props: { api: TuiPluginApi }) { }), }, { - name: "diff.page.down", + id: "diff.page.down", title: "Page diff viewer down", - category: "VCS", + group: "VCS", + bind: "pagedown,ctrl+f", run: focusRunner({ files() { moveFileSelection(8) @@ -470,9 +476,10 @@ function DiffViewer(props: { api: TuiPluginApi }) { }), }, { - name: "diff.page.up", + id: "diff.page.up", title: "Page diff viewer up", - category: "VCS", + group: "VCS", + bind: "pageup,ctrl+b", run: focusRunner({ files() { moveFileSelection(-8) @@ -484,9 +491,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { }), }, { - name: "diff.toggle", + id: "diff.toggle", title: "Toggle diff viewer item", - category: "VCS", + group: "VCS", run: focusRunner({ files() { toggleSelectedFileTreeRow() @@ -495,9 +502,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { }), }, { - name: "diff.expand", + id: "diff.expand", title: "Expand diff viewer item", - category: "VCS", + group: "VCS", run: focusRunner({ files() { const highlighted = highlightedFileNode() @@ -513,9 +520,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { }), }, { - name: "diff.expand_all", + id: "diff.expand_all", title: "Expand all diff viewer folders", - category: "VCS", + group: "VCS", run: focusRunner({ files() { setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) @@ -524,9 +531,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { }), }, { - name: "diff.collapse", + id: "diff.collapse", title: "Collapse diff viewer item", - category: "VCS", + group: "VCS", run: focusRunner({ files() { const highlighted = highlightedFileNode() @@ -543,49 +550,50 @@ function DiffViewer(props: { api: TuiPluginApi }) { }), }, { - name: "diff.next_hunk", + id: "diff.next_hunk", title: "Jump to next diff hunk", - category: "VCS", + group: "VCS", run() { jumpRelativeHunk(1) }, }, { - name: "diff.previous_hunk", + id: "diff.previous_hunk", title: "Jump to previous diff hunk", - category: "VCS", + group: "VCS", run() { jumpRelativeHunk(-1) }, }, { - name: "diff.next_file", + id: "diff.next_file", title: "Jump to next diff file", - category: "VCS", + group: "VCS", run() { jumpRelativePatchFile(1) }, }, { - name: "diff.previous_file", + id: "diff.previous_file", title: "Jump to previous diff file", - category: "VCS", + group: "VCS", run() { jumpRelativePatchFile(-1) }, }, { - name: "diff.mark_reviewed", + id: "diff.mark_reviewed", title: "Toggle selected diff file reviewed", - category: "VCS", + group: "VCS", + bind: "m", run() { toggleSelectedFileReviewed() }, }, { - name: "diff.switch_focus", + id: "diff.switch_focus", title: "Switch diff viewer focus", - category: "VCS", + group: "VCS", run() { if (!showFileTree()) return setFocus((current) => { @@ -596,10 +604,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { }, }, { - name: "diff.toggle_file_tree", + id: "diff.toggle_file_tree", title: "Toggle diff viewer file tree", - category: "VCS", - hidden: true, + group: "VCS", run() { const next = !fileTreeEnabled() if (!next) setFocus("patches") @@ -612,10 +619,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { }, }, { - name: "diff.single_patch", + id: "diff.single_patch", title: "Toggle single patch view", - category: "VCS", - hidden: true, + group: "VCS", run() { setSelectedHunk(undefined) if (!singlePatch()) { @@ -648,18 +654,17 @@ function DiffViewer(props: { api: TuiPluginApi }) { }, }, { - name: "diff.switch_source", + id: "diff.switch_source", title: "Switch diff viewer source", - category: "VCS", + group: "VCS", run() { openSwitchDiffDialog() }, }, { - name: "diff.toggle_view", + id: "diff.toggle_view", title: "Toggle diff viewer split or unified view", - category: "VCS", - hidden: true, + group: "VCS", run() { if (!splitAvailable()) return setSelectedHunk(undefined) @@ -673,9 +678,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { }, }, { - name: "diff.help", + id: "diff.help", title: "Show more diff viewer shortcuts", - category: "VCS", + group: "VCS", run() { openHelpDialog() }, @@ -698,7 +703,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { }) const openSwitchDiffDialog = () => { - props.api.ui.dialog.replace(() => ( + dialog.replace(() => ( { - props.api.ui.dialog.replace(() => ) - props.api.ui.dialog.setSize("large") + dialog.replace(() => ) + dialog.setSize("large") } - useBindings(() => ({ + props.context.keymap.layer(() => ({ commands, - bindings: [ - { key: "j,down", cmd: "diff.down", desc: "Move diff viewer down" }, - { key: "k,up", cmd: "diff.up", desc: "Move diff viewer up" }, - { key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" }, - { key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" }, - { key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" }, - ...commands.flatMap((command) => props.api.tuiConfig.keybinds.get(command.name)), - ], })) return ( @@ -932,8 +933,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { ) } -function DiffViewerHelpDialog() { +function DiffViewerHelpDialog(props: { context: Plugin.Context }) { const { theme } = useTheme() + const shortcut = (id: string) => () => props.context.keymap.shortcut(id) const rows = [ { shortcut: () => "q", @@ -941,57 +943,57 @@ function DiffViewerHelpDialog() { description: "Quit the diff viewer", }, { - shortcut: useCommandShortcut("diff.switch_focus"), + shortcut: shortcut("diff.switch_focus"), action: "Focus file tree", description: "Move keyboard focus between the file tree and patch pane", }, { - shortcut: useCommandShortcut("diff.next_hunk"), + shortcut: shortcut("diff.next_hunk"), action: "Next hunk", description: "Jump to the next diff hunk", }, { - shortcut: useCommandShortcut("diff.previous_hunk"), + shortcut: shortcut("diff.previous_hunk"), action: "Previous hunk", description: "Jump to the previous diff hunk", }, { - shortcut: useCommandShortcut("diff.next_file"), + shortcut: shortcut("diff.next_file"), action: "Next file", description: "Select the next changed file in file-tree order", }, { - shortcut: useCommandShortcut("diff.previous_file"), + shortcut: shortcut("diff.previous_file"), action: "Previous file", description: "Select the previous changed file in file-tree order", }, { - shortcut: useCommandShortcut("diff.toggle_file_tree"), + shortcut: shortcut("diff.toggle_file_tree"), action: "Toggle file tree", description: "Show or hide the file tree sidebar", }, { - shortcut: useCommandShortcut("diff.single_patch"), + shortcut: shortcut("diff.single_patch"), action: "Toggle patches", description: "Switch between one selected patch and all patches", }, { - shortcut: useCommandShortcut("diff.switch_source"), + shortcut: shortcut("diff.switch_source"), action: "Switch source", description: "Choose working tree or main branch changes", }, { - shortcut: useCommandShortcut("diff.toggle_view"), + shortcut: shortcut("diff.toggle_view"), action: "Toggle view", description: "Switch between split and unified diff layout", }, { - shortcut: useCommandShortcut("diff.expand_all"), + shortcut: shortcut("diff.expand_all"), action: "Expand all folders", description: "Open every folder in the file tree", }, { - shortcut: useCommandShortcut("diff.mark_reviewed"), + shortcut: shortcut("diff.mark_reviewed"), action: "Mark reviewed", description: "Toggle reviewed state for the selected file", }, @@ -1031,36 +1033,54 @@ function DiffViewerHelpDialog() { ) } -const tui: TuiPlugin = async (api) => { - api.route.register([ - { - name: ROUTE, - render: () => , - }, - ]) - - api.keymap.registerLayer({ +function Commands(props: { context: Plugin.Context }) { + const dialog = useDialog() + props.context.keymap.layer(() => ({ + mode: "global", commands: [ { - name: "diff.open", + id: "diff.open", title: "Open diff viewer", slash: { name: "diff" }, - category: "VCS", - namespace: "palette", + group: "VCS", + palette: true, run() { - api.route.navigate(ROUTE, { - mode: "working", - sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined, - returnRoute: api.route.current, + const route = props.context.ui.router.current() + const returnRoute: Route = + route.type === "home" + ? { type: "home" } + : route.type === "session" + ? { type: "session", sessionID: route.sessionID } + : { + type: "plugin", + id: route.id, + name: route.name, + ...(route.data ? { data: { ...route.data } } : {}), + } + props.context.ui.router.navigate({ + type: "plugin", + name: ROUTE, + data: { + mode: "working", + sessionID: route.type === "session" ? route.sessionID : undefined, + returnRoute, + }, }) - api.ui.dialog.clear() + dialog.clear() }, }, ], - }) + })) + return null } -export default { +export default Plugin.define({ id: "diff-viewer", - tui, -} + setup(context) { + context.ui.router.register({ + name: ROUTE, + render: () => , + }) + context.ui.slot("app", () => ) + }, +}) diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 8be4671a5a..ef8a370095 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -4,6 +4,16 @@ import SidebarContext from "../feature-plugins/sidebar/context" import SidebarFooter from "../feature-plugins/sidebar/footer" import SidebarLsp from "../feature-plugins/sidebar/lsp" import SidebarMcp from "../feature-plugins/sidebar/mcp" +import DiffViewer from "../feature-plugins/system/diff-viewer" import Scrap from "../feature-plugins/system/scrap" -export const builtins = [HomeFooter, HomeTips, SidebarContext, SidebarMcp, SidebarLsp, SidebarFooter, Scrap] +export const builtins = [ + HomeFooter, + HomeTips, + SidebarContext, + SidebarMcp, + SidebarLsp, + SidebarFooter, + Scrap, + DiffViewer, +] diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index dc983ea843..a9f9577660 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -18,6 +18,7 @@ import { createStore, produce, reconcile as reconcileStore } from "solid-js/stor import { useConfig } from "../config" import { useClient } from "../context/client" import { useData } from "../context/data" +import { Keymap } from "../context/keymap" import { useRoute } from "../context/route" import { useTuiLifecycle } from "../context/runtime" import { builtins } from "./builtins" @@ -59,6 +60,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const data = useData() const route = useRoute() const config = useConfig() + const keymap = Keymap.use() + const shortcuts = Keymap.useShortcuts() const lifecycle = useTuiLifecycle() const directory = config.path ? path.dirname(config.path) : process.cwd() const [store, setStore] = createStore({ @@ -81,6 +84,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> options: item.options ?? {}, client: client.api, data, + keymap: { + layer: Keymap.createLayer, + dispatch: keymap.dispatch, + shortcut: shortcuts.get, + mode: keymap.mode, + }, ui: { router: { register(page) { diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index e644258766..f09e49aa0a 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -1,27 +1,38 @@ /** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core" -import { testRender, useRenderer } from "@opentui/solid" -import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui" +import { testRender } from "@opentui/solid" +import type { + Context, + Destination, + KeymapCommand, + KeymapLayer, + Page, + Route, + Slot, +} from "@opencode-ai/plugin/v2/tui/context" import { ThemeProvider } from "../../../src/context/theme" import { ConfigProvider } from "../../../src/config" -import { ClientProvider } from "../../../src/context/client" import { TuiKeybind } from "../../../src/config/keybind" -import { OpencodeKeymapProvider } from "../../../src/keymap" +import { Keymap } from "../../../src/context/keymap" import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer" -import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TestTuiContexts } from "../../fixture/tui-environment" import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" +import { DialogProvider } from "../../../src/ui/dialog" +import { ToastProvider } from "../../../src/ui/toast" test("closing the diff viewer returns to the route it opened from", async () => { const viewer = await renderDiffViewer([]) try { expect(viewer.current()).toEqual({ + type: "plugin", + id: "diff-viewer", name: "diff", - params: { mode: "working", sessionID: "session-1", returnRoute: startRoute }, + data: { mode: "working", sessionID: "session-1", returnRoute: startRoute }, }) + const route = viewer.current() + expect(route.type === "plugin" ? route.data?.returnRoute : undefined).not.toBe(startRoute) expect(viewer.vcsDiffInput()).toEqual({ location: { directory: "/repo/session" }, mode: "working", @@ -29,7 +40,7 @@ test("closing the diff viewer returns to the route it opened from", async () => }) expect(viewer.commands.has("diff.close")).toBe(true) - viewer.commands.get("diff.close")!.run?.({} as never) + viewer.commands.get("diff.close")!.run() expect(viewer.current()).toEqual(startRoute) } finally { viewer.app.renderer.destroy() @@ -46,6 +57,19 @@ test("shows an error instead of an empty diff when loading fails", async () => { } }) +test("uses the active location when opened outside a session", async () => { + const viewer = await renderDiffViewer([], 20, { type: "home" }) + try { + expect(viewer.vcsDiffInput()).toEqual({ + location: { directory: "/repo/default" }, + mode: "working", + context: "12", + }) + } finally { + viewer.app.renderer.destroy() + } +}) + test("brackets navigate diff hunks", async () => { const viewer = await renderDiffViewer( [ @@ -85,26 +109,26 @@ test("brackets navigate diff hunks", async () => { expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]") expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[") - viewer.commands.get("diff.next_hunk")!.run?.({} as never) + viewer.commands.get("diff.next_hunk")!.run() await viewer.app.renderOnce() const first = scroll.scrollTop expect(first).toBeGreaterThan(initial) - viewer.commands.get("diff.next_hunk")!.run?.({} as never) + viewer.commands.get("diff.next_hunk")!.run() await viewer.app.renderOnce() const second = scroll.scrollTop expect(second).toBeGreaterThan(first) - viewer.commands.get("diff.previous_hunk")!.run?.({} as never) + viewer.commands.get("diff.previous_hunk")!.run() await viewer.app.renderOnce() expect(scroll.scrollTop).toBe(first) - viewer.commands.get("diff.next_hunk")!.run?.({} as never) + viewer.commands.get("diff.next_hunk")!.run() await viewer.app.renderOnce() expect(scroll.scrollTop).toBe(second) scroll.scrollTo(initial) - viewer.commands.get("diff.next_hunk")!.run?.({} as never) + viewer.commands.get("diff.next_hunk")!.run() await viewer.app.renderOnce() expect(scroll.scrollTop).toBe(first) } finally { @@ -112,13 +136,11 @@ test("brackets navigate diff hunks", async () => { } }) -async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent, fail = false) { - const commands = new Map< - string, - NonNullable[0]["commands"]>[number] - >() +async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: Route, fail = false) { + const commands = new Map() let current = initialRoute ?? startRoute - let renderDiff: TuiRouteDefinition["render"] | undefined + let renderDiff: Page["render"] | undefined + let renderCommands: Slot | undefined let vcsDiffInput: unknown const config = createTuiResolvedConfig() const transport = createFetch((url) => { @@ -135,51 +157,68 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: }) }, createEventStream()) function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - const registerLayer = keymap.registerLayer.bind(keymap) - keymap.registerLayer = (layer) => { - layer.commands?.forEach((command) => commands.set(command.name, command)) - return registerLayer(layer) - } - const base = createTuiPluginApi({ - keymap, - state: { - session: { - get: () => session, - }, + const context = { + options: {}, + client: createApi(transport.fetch), + data: { + session: { get: () => session }, + location: { default: () => ({ directory: "/repo/default" }) }, }, - }) - const api = { - ...base, - route: { - register(routes) { - renderDiff = routes.find((route) => route.name === "diff")?.render + keymap: { + layer(input: () => KeymapLayer) { + input().commands?.forEach((command) => { + if (command.id) commands.set(command.id, command) + }) + }, + dispatch() {}, + shortcut: () => undefined, + mode: { current: () => "base", push: () => () => {} }, + }, + ui: { + router: { + register(page: Page) { + if (page.name === "diff") renderDiff = page.render + return () => {} + }, + navigate(destination: Destination) { + current = destination.type === "plugin" && !("id" in destination) + ? { ...destination, id: "diff-viewer" } + : destination + }, + current: () => current, + }, + slot(_name: string, render: Slot) { + renderCommands = render return () => {} }, - navigate(name, params) { - current = params ? { name, params } : { name } - }, - get current() { - return current - }, }, - } satisfies TuiPluginApi + } as unknown as Context - void diffViewerPlugin.tui(api, undefined, pluginMeta) - if (!initialRoute) commands.get("diff.open")?.run?.({} as never) + void diffViewerPlugin.setup(context) + function Content() { + const commandView = renderCommands?.({}) + if (current.type !== "plugin") commands.get("diff.open")?.run() + return ( + <> + {commandView} + {renderDiff?.({ data: current.type === "plugin" ? current.data : undefined })} + + ) + } return ( - - - + + + - {renderDiff?.({ params: "params" in current ? current.params : undefined })} + + + - - - + + + ) } @@ -194,7 +233,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: } } -const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } } +const startRoute: Route = { type: "session", sessionID: "session-1" } function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined { if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root @@ -208,26 +247,30 @@ function containsDiff(root: Renderable): boolean { const session = { id: "session-1", - slug: "session-1", projectID: "project-1", - directory: "/repo/session", + location: { directory: "/repo/session" }, title: "Session", - version: "1", + cost: { currency: "USD", amount: 0 }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 0, updated: 0, }, -} satisfies NonNullable> +} test("branch diff source requests branch VCS diff", async () => { const viewer = await renderDiffViewer([], 20, { + type: "plugin", + id: "diff-viewer", name: "diff", - params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, + data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, }) try { expect(viewer.current()).toEqual({ + type: "plugin", + id: "diff-viewer", name: "diff", - params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, + data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, }) expect(viewer.vcsDiffInput()).toEqual({ location: { directory: "/repo/session" }, @@ -250,16 +293,3 @@ async function waitForCommand( await new Promise((resolve) => setTimeout(resolve, 25)) } } - -const pluginMeta = { - id: "diff-viewer", - source: "internal", - spec: "diff-viewer", - target: "diff-viewer", - first_time: 0, - last_time: 0, - time_changed: 0, - load_count: 1, - fingerprint: "test", - state: "same", -} satisfies TuiPluginMeta