Răsfoiți Sursa

feat(tui): port diff viewer to v2 plugins

Dax Raad 1 lună în urmă
părinte
comite
b67bed061a

+ 57 - 0
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<Value> {
@@ -105,6 +106,61 @@ export interface Page {
 
 export type Slot = (props: Record<string, any>) => 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<void>
+}
+
+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<Record<string, any>>
   readonly client: OpenCodeClient
   readonly data: Data
+  readonly keymap: Keymap
   readonly ui: UI
 }

+ 3 - 39
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<void>
-}
-
-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. */

+ 1 - 2
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<TuiPluginModule, "id"> & {
 }
 
 export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
-  return [Notifications, PluginManager, WhichKey, DiffViewer]
+  return [Notifications, PluginManager, WhichKey]
 }
 
 export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {

+ 149 - 129
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<BorderSides[]>(() => (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<DiffView | undefined>(storedView(config.data.diffs?.view))
@@ -133,21 +137,22 @@ function DiffViewer(props: { api: TuiPluginApi }) {
   const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
   const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
   const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(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<DiffViewerFocus, () => 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<number, BoxRenderable>()
   const diffNodeByFileIndex = new Map<number, DiffRenderable>()
@@ -155,7 +160,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
   const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
   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 = [
+  const close = () => {
+    const returnRoute = params()?.returnRoute
+    dialog.clear()
+    props.context.ui.router.navigate(returnRoute ?? { type: "home" })
+  }
+
+  const commands: KeymapCommand[] = [
     {
-      name: "diff.close",
+      id: "diff.close",
       title: "Close diff viewer",
-      category: "VCS",
-      run() {
-        const returnRoute = params()?.returnRoute
-        props.api.ui.dialog.clear()
-
-        props.api.route.navigate(
-          returnRoute?.name ?? "home",
-          returnRoute && "params" in returnRoute ? returnRoute.params : undefined,
-        )
-      },
+      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(() => (
       <DialogSelect
         title="Switch source"
         skipFilter={true}
@@ -708,10 +713,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
           ...option,
           onSelect(dialog) {
             dialog.clear()
-            props.api.route.navigate(ROUTE, {
-              mode: option.value,
-              sessionID: params()?.sessionID,
-              returnRoute: params()?.returnRoute,
+            props.context.ui.router.navigate({
+              type: "plugin",
+              name: ROUTE,
+              data: {
+                mode: option.value,
+                sessionID: params()?.sessionID,
+                returnRoute: params()?.returnRoute,
+              },
             })
           },
         }))}
@@ -720,20 +729,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
   }
 
   const openHelpDialog = () => {
-    props.api.ui.dialog.replace(() => <DiffViewerHelpDialog />)
-    props.api.ui.dialog.setSize("large")
+    dialog.replace(() => <DiffViewerHelpDialog context={props.context} />)
+    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: () => <DiffViewer api={api} />,
-    },
-  ])
-
-  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: () => <DiffViewer context={context} />,
+    })
+    context.ui.slot("app", () => <Commands context={context} />)
+  },
+})

+ 11 - 1
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,
+]

+ 9 - 0
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) {

+ 102 - 72
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<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
-  >()
+async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: Route, fail = false) {
+  const commands = new Map<string, KeymapCommand>()
   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" }) },
+      },
+      keymap: {
+        layer(input: () => KeymapLayer) {
+          input().commands?.forEach((command) => {
+            if (command.id) commands.set(command.id, command)
+          })
         },
+        dispatch() {},
+        shortcut: () => undefined,
+        mode: { current: () => "base", push: () => () => {} },
       },
-    })
-    const api = {
-      ...base,
-      route: {
-        register(routes) {
-          renderDiff = routes.find((route) => route.name === "diff")?.render
+      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,
         },
-        navigate(name, params) {
-          current = params ? { name, params } : { name }
-        },
-        get current() {
-          return current
+        slot(_name: string, render: Slot) {
+          renderCommands = render
+          return () => {}
         },
       },
-    } 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 (
       <TestTuiContexts>
-        <ClientProvider api={createApi(transport.fetch)}>
-          <OpencodeKeymapProvider keymap={keymap}>
-            <ConfigProvider config={config}>
+        <ConfigProvider config={config}>
+          <Keymap.Provider>
+            <ToastProvider>
               <ThemeProvider mode="dark">
-                {renderDiff?.({ params: "params" in current ? current.params : undefined })}
+                <DialogProvider>
+                  <Content />
+                </DialogProvider>
               </ThemeProvider>
-            </ConfigProvider>
-          </OpencodeKeymapProvider>
-        </ClientProvider>
+            </ToastProvider>
+          </Keymap.Provider>
+        </ConfigProvider>
       </TestTuiContexts>
     )
   }
@@ -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<ReturnType<TuiPluginApi["state"]["session"]["get"]>>
+}
 
 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