feat(tui): add plugin context hook

This commit is contained in:
Dax Raad 2026-07-28 14:57:28 -04:00
commit 5bcc0016a6
16 changed files with 216 additions and 148 deletions

View file

@ -11,7 +11,7 @@ function Directory(props: { context: Plugin.Context; maxWidth: number }) {
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.themeV2.text.subdued} />}
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />}
</Show>
)
}
@ -24,18 +24,16 @@ function Mcp(props: { context: Plugin.Context }) {
return (
<Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={props.context.theme.themeV2.text.default}>
<text fg={props.context.theme.text.default}>
<Switch>
<Match when={failed()}>
<span style={{ fg: props.context.theme.themeV2.text.feedback.error.default }}> </span>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
</Match>
<Match when={true}>
<span
style={{
fg:
count() > 0
? props.context.theme.themeV2.text.feedback.success.default
: props.context.theme.themeV2.text.subdued,
count() > 0 ? props.context.theme.text.feedback.success.default : props.context.theme.text.subdued,
}}
>
{" "}
@ -44,7 +42,7 @@ function Mcp(props: { context: Plugin.Context }) {
</Switch>
{count()} MCP
</text>
<text fg={props.context.theme.themeV2.text.subdued}>/status</text>
<text fg={props.context.theme.text.subdued}>/status</text>
</box>
</Show>
)
@ -77,7 +75,7 @@ function View(props: { context: Plugin.Context }) {
<Mcp context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.themeV2.text.subdued}>{props.context.app.version}</text>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
</box>
</box>
)

View file

@ -1,6 +1,5 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { contextUsage } from "../../util/session"
const money = new Intl.NumberFormat("en-US", {
@ -9,7 +8,7 @@ const money = new Intl.NumberFormat("en-US", {
})
function View(props: { context: Plugin.Context; sessionID: string }) {
const { themeV2 } = useTheme()
const theme = props.context.theme
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
const session = createMemo(() => props.context.data.session.get(props.sessionID))
const cost = createMemo(() => props.context.data.session.cost(props.sessionID))
@ -20,20 +19,20 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
return (
<box>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>Context</b>
</text>
<Show when={state()} fallback={<text fg={themeV2.text.subdued}>Not measured</text>}>
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
{(value) => (
<>
<text fg={themeV2.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
<Show when={value().percent !== undefined}>
<text fg={themeV2.text.subdued}>{value().percent}% used</text>
<text fg={theme.text.subdued}>{value().percent}% used</text>
</Show>
</>
)}
</Show>
<text fg={themeV2.text.subdued}>{money.format(cost())} spent</text>
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
</box>
)
}

View file

@ -8,7 +8,7 @@ function View(props: { context: Plugin.Context }) {
)
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.themeV2.text.subdued} />}
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}
</Show>
)
}

View file

@ -1,14 +1,14 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTheme } from "../../context/theme"
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
function View() {
const { themeV2 } = useTheme()
const context = usePlugin()
const theme = context.theme
return (
<box>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>LSP</b>
</text>
<text fg={themeV2.text.subdued}>LSP status unavailable</text>
<text fg={theme.text.subdued}>LSP status unavailable</text>
</box>
)
}

View file

@ -1,10 +1,9 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
import { useTheme } from "../../context/theme"
function View(props: { context: Plugin.Context; sessionID: string }) {
const [open, setOpen] = createSignal(true)
const { themeV2 } = useTheme()
const theme = props.context.theme
const session = createMemo(() => props.context.data.session.get(props.sessionID))
const list = createMemo(() => props.context.data.location.mcp.server.list(session()?.location) ?? [])
const on = createMemo(() => list().filter((item) => item.status.status === "connected").length)
@ -19,12 +18,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
)
const dot = (status: string) => {
if (status === "connected") return themeV2.text.feedback.success.default
if (status === "failed") return themeV2.text.feedback.error.default
if (status === "disabled") return themeV2.text.subdued
if (status === "needs_auth") return themeV2.text.feedback.warning.default
if (status === "needs_client_registration") return themeV2.text.feedback.error.default
return themeV2.text.subdued
if (status === "connected") return theme.text.feedback.success.default
if (status === "failed") return theme.text.feedback.error.default
if (status === "disabled") return theme.text.subdued
if (status === "needs_auth") return theme.text.feedback.warning.default
if (status === "needs_client_registration") return theme.text.feedback.error.default
return theme.text.subdued
}
return (
@ -32,12 +31,12 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={themeV2.text.default}>{open() ? "▼" : "▶"}</text>
<text fg={theme.text.default}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={themeV2.text.default}>
<text fg={theme.text.default}>
<b>MCP</b>
<Show when={!open()}>
<span style={{ fg: themeV2.text.subdued }}>
<span style={{ fg: theme.text.subdued }}>
{" "}
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
</span>
@ -56,9 +55,9 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
>
</text>
<text fg={themeV2.text.default} wrapMode="word">
<text fg={theme.text.default} wrapMode="word">
{item.name}{" "}
<span style={{ fg: themeV2.text.subdued }}>
<span style={{ fg: theme.text.subdued }}>
<Switch fallback={item.status.status}>
<Match when={item.status.status === "connected"}>Connected</Match>
<Match when={item.status.status === "failed"}>

View file

@ -1,15 +1,16 @@
/** @jsxImportSource @opentui/solid */
import type { ScrollBoxRenderable } from "@opentui/core"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { Locale } from "../../util/locale"
import { tint } from "../../theme/color"
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"
import { useTheme } from "../../context/theme"
const FILE_TREE_STATUS_WIDTH = 2
export type DiffViewerFileTreeProps = {
readonly context: Plugin.Context
readonly width: number
readonly files: readonly FileTreeItem[]
readonly loading: boolean
@ -23,7 +24,7 @@ export type DiffViewerFileTreeProps = {
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const { themeV2 } = useTheme()
const theme = props.context.theme
const tree = createMemo(() => buildFileTree(props.files))
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
let scroll: ScrollBoxRenderable | undefined
@ -38,10 +39,10 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
requestAnimationFrame(scrollSelectedIntoView)
})
const fadedColor = () => tint(themeV2.text.default, themeV2.background.default, 0.75)
const fadedColor = () => tint(theme.text.default, theme.background.default, 0.75)
return (
<Panel border="both" width={props.width}>
<Panel border="both" width={props.width} context={props.context}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
verticalScrollbarOptions={{ visible: false }}
@ -52,7 +53,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
<text />
</Match>
<Match when={props.files.length === 0}>
<text fg={themeV2.text.default}>No files</text>
<text fg={theme.text.default}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<For each={rows()}>
@ -71,11 +72,11 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
<box
flexDirection="row"
width="100%"
backgroundColor={highlighted() ? themeV2.background.action.primary.focused : undefined}
backgroundColor={highlighted() ? theme.background.action.primary.focused : undefined}
onMouseUp={() => props.onRowClick?.(row)}
>
<text
fg={highlighted() ? themeV2.text.action.primary.focused : fadedColor()}
fg={highlighted() ? theme.text.action.primary.focused : fadedColor()}
wrapMode="none"
flexShrink={0}
>
@ -85,12 +86,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
<text
fg={
highlighted()
? themeV2.text.action.primary.focused
? theme.text.action.primary.focused
: selected()
? themeV2.text.formfield.selected
? theme.text.formfield.selected
: reviewed() || row.kind === "directory"
? themeV2.text.subdued
: themeV2.text.default
? theme.text.subdued
: theme.text.default
}
wrapMode="none"
>
@ -98,7 +99,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
</text>
</box>
<text
fg={highlighted() ? themeV2.text.action.primary.focused : themeV2.text.subdued}
fg={highlighted() ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
flexShrink={0}
>

View file

@ -1,13 +1,13 @@
import type { BorderSides, ColorInput } from "@opentui/core"
import type { Plugin } from "@opencode-ai/plugin/tui"
import type { JSX } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import { createContext, Show, splitProps, useContext } from "solid-js"
export type Axis = "x" | "y"
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
export type PanelBorder = "start" | "end" | "both" | "none"
const PanelGroupContext = createContext<{ axis: Axis }>()
const PanelGroupContext = createContext<{ axis: Axis; context: Plugin.Context }>()
function crossAxis(axis: Axis) {
return axis === "x" ? "y" : "x"
@ -17,10 +17,10 @@ function usePanelGroup() {
return useContext(PanelGroupContext)
}
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) {
const [local, boxProps] = splitProps(props, ["axis", "children"])
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; context: Plugin.Context }) {
const [local, boxProps] = splitProps(props, ["axis", "context", "children"])
return (
<PanelGroupContext.Provider value={{ axis: local.axis }}>
<PanelGroupContext.Provider value={{ axis: local.axis, context: local.context }}>
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
{local.children}
</box>
@ -28,24 +28,28 @@ export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis })
)
}
export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder }) {
export function Panel(
props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder; context?: Plugin.Context },
) {
const group = usePanelGroup()
const { themeV2 } = useTheme()
const [local, boxProps] = splitProps(props, ["border"])
const [local, boxProps] = splitProps(props, ["border", "context"])
const context = local.context ?? group?.context
if (!context) throw new Error("Panel context is missing")
const theme = context.theme
const border = local.border ?? "start"
const borderProps =
border === "none"
? {}
: {
border: panelBorderSides(group?.axis ?? "y", border),
borderColor: themeV2.border.default,
borderColor: theme.border.default,
}
return (
<box
minWidth={0}
minHeight={0}
flexDirection={crossAxis(group?.axis || "y") === "x" ? "row" : "column"}
flexDirection={crossAxis(group?.axis ?? "y") === "x" ? "row" : "column"}
{...borderProps}
{...boxProps}
/>
@ -59,9 +63,10 @@ function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): Bor
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
const group = usePanelGroup()
const { themeV2 } = useTheme()
const color = () => props.color ?? themeV2.border.default
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
if (!group) throw new Error("PanelGroup is missing")
const theme = group.context.theme
const color = () => props.color ?? theme.border.default
const axis = () => props.axis ?? crossAxis(group.axis)
if (axis() === "y") {
return (
<Show

View file

@ -10,7 +10,6 @@ import {
type ScrollBoxRenderable,
} from "@opentui/core"
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
import { useTheme } from "../../context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
@ -83,8 +82,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const config = useConfig()
const dialog = props.context.ui.dialog
const themeState = useTheme()
const themeV2 = themeState.themeV2
const theme = props.context.theme
const params = () => {
const route = props.context.ui.router.current()
return (route.type === "plugin" ? route.data : undefined) as
@ -738,13 +736,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
return (
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
<PanelGroup axis="y" width="100%" height="100%">
<PanelGroup axis="y" context={props.context} width="100%" height="100%">
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
<text fg={themeV2.text.default}>Diff </text>
<text fg={themeV2.text.subdued}>{diffSourceLabel(mode())}</text>
<text fg={theme.text.default}>Diff </text>
<text fg={theme.text.subdued}>{diffSourceLabel(mode())}</text>
<box flexGrow={1} />
<Show when={!diff.loading && !diff.error}>
<text fg={themeV2.text.subdued}>
<text fg={theme.text.subdued}>
{files().length} {files().length === 1 ? "file" : "files"}
</text>
</Show>
@ -755,13 +753,13 @@ function DiffViewer(props: { context: Plugin.Context }) {
<Match when={diff.loading}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={themeV2.text.subdued}>Loading diff</text>
<text fg={theme.text.subdued}>Loading diff</text>
</box>
</Match>
<Match when={!diff.loading && diff.error}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={themeV2.text.feedback.error.default}>
<text fg={theme.text.feedback.error.default}>
Could not load diff. Reopen the diff viewer to try again.
</text>
</box>
@ -769,13 +767,14 @@ function DiffViewer(props: { context: Plugin.Context }) {
<Match when={!diff.loading && files().length === 0}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={themeV2.text.subdued}>No changes to show</text>
<text fg={theme.text.subdued}>No changes to show</text>
</box>
</Match>
<Match when={!diff.loading}>
<PanelGroup axis="x">
<PanelGroup axis="x" context={props.context}>
<Show when={showFileTree()}>
<DiffViewerFileTree
context={props.context}
files={files()}
loading={diff.loading}
error={diff.error}
@ -812,56 +811,52 @@ function DiffViewer(props: { context: Plugin.Context }) {
paddingLeft={1}
paddingRight={1}
border={patchLeftBorder()}
borderColor={themeV2.border.default}
borderColor={theme.border.default}
>
<text fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}>
{entry.file.file}
</text>
<text fg={reviewed() ? theme.text.subdued : theme.text.default}>{entry.file.file}</text>
<box flexGrow={1} />
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.added}>
<text fg={reviewed() ? theme.text.subdued : theme.diff.text.added}>
+{entry.file.additions}
</text>
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.removed}>
<text fg={reviewed() ? theme.text.subdued : theme.diff.text.removed}>
-{entry.file.deletions}
</text>
</box>
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
<Show
when={entry.file.patch}
fallback={<text fg={themeV2.text.subdued}>No patch available for this file.</text>}
fallback={<text fg={theme.text.subdued}>No patch available for this file.</text>}
>
{(patch) => (
<box border={patchLeftBorder()} borderColor={themeV2.border.default}>
<box border={patchLeftBorder()} borderColor={theme.border.default}>
<diff
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
diff={patch()}
view={view()}
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
syntaxStyle={themeState.syntax()}
syntaxStyle={theme.syntaxStyle()}
showLineNumbers={true}
width="100%"
wrapMode="char"
fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}
fg={reviewed() ? theme.text.subdued : theme.text.default}
addedBg={
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.added
reviewed() ? theme.background.surface.overlay : theme.diff.background.added
}
removedBg={
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.removed
reviewed() ? theme.background.surface.overlay : theme.diff.background.removed
}
addedSignColor={reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.added}
removedSignColor={
reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.removed
}
lineNumberFg={themeV2.diff.lineNumber.text}
addedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.added}
removedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.removed}
lineNumberFg={theme.diff.lineNumber.text}
addedLineNumberBg={
reviewed()
? themeV2.background.surface.overlay
: themeV2.diff.lineNumber.background.added
? theme.background.surface.overlay
: theme.diff.lineNumber.background.added
}
removedLineNumberBg={
reviewed()
? themeV2.background.surface.overlay
: themeV2.diff.lineNumber.background.removed
? theme.background.surface.overlay
: theme.diff.lineNumber.background.removed
}
/>
</box>
@ -872,11 +867,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
}}
</For>
<Show when={patchFillerHeight() > 0}>
<box
height={patchFillerHeight()}
border={patchLeftBorder()}
borderColor={themeV2.border.default}
/>
<box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme.border.default} />
</Show>
</scrollbox>
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
@ -889,57 +880,57 @@ function DiffViewer(props: { context: Plugin.Context }) {
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
<Show when={switchFocusShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>focus file tree</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>focus file tree</span>
</text>
)}
</Show>
<Show when={nextFileShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next file</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>next file</span>
</text>
)}
</Show>
<Show when={nextHunkShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next hunk</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>next hunk</span>
</text>
)}
</Show>
<Show when={previousHunkShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous hunk</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous hunk</span>
</text>
)}
</Show>
<Show when={previousFileShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous file</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>previous file</span>
</text>
)}
</Show>
<Show when={switchSourceShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>switch source</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>switch source</span>
</text>
)}
</Show>
<Show when={markReviewedShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>mark reviewed</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>mark reviewed</span>
</text>
)}
</Show>
<Show when={helpShortcut()}>
{(shortcut) => (
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>all</span>
<text fg={theme.text.default}>
{shortcut()} <span style={{ fg: theme.text.subdued }}>all</span>
</text>
)}
</Show>
@ -950,7 +941,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
}
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme().contextual("elevated")
const theme = props.context.theme.contextual("elevated")
const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0]
const rows = [
{
@ -1018,30 +1009,30 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Diff shortcuts
</text>
<text fg={themeV2.text.subdued}>esc</text>
<text fg={theme.text.subdued}>esc</text>
</box>
<box flexDirection="row">
<text fg={themeV2.text.subdued} width={5} wrapMode="none">
<text fg={theme.text.subdued} width={5} wrapMode="none">
Key
</text>
<text fg={themeV2.text.subdued} width={22} wrapMode="none">
<text fg={theme.text.subdued} width={22} wrapMode="none">
Action
</text>
<text fg={themeV2.text.subdued}>Description</text>
<text fg={theme.text.subdued}>Description</text>
</box>
<For each={rows}>
{(row) => (
<box flexDirection="row">
<text fg={themeV2.text.default} width={5} wrapMode="none">
<text fg={theme.text.default} width={5} wrapMode="none">
{row.shortcut() || "-"}
</text>
<text fg={themeV2.text.default} width={22} wrapMode="none">
<text fg={theme.text.default} width={22} wrapMode="none">
{row.action}
</text>
<text fg={themeV2.text.subdued}>{row.description}</text>
<text fg={theme.text.subdued}>{row.description}</text>
</box>
)}
</For>

View file

@ -1,6 +1,5 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../../context/theme"
function Commands(props: { context: Plugin.Context }) {
props.context.keymap.layer(() => ({
@ -23,8 +22,8 @@ function Commands(props: { context: Plugin.Context }) {
function Scrap(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const { themeV2 } = useTheme()
const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
const theme = props.context.theme
const elevatedTheme = props.context.theme.contextual("elevated")
props.context.keymap.layer(() => ({
commands: [
@ -40,7 +39,7 @@ function Scrap(props: { context: Plugin.Context }) {
}))
return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={themeV2.background.default}>
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
<box flexGrow={1} />
<box
height={1}

View file

@ -1,4 +1,4 @@
import type { Plugin } from "@opencode-ai/plugin/tui"
import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
import {
batch,
createContext,
@ -80,6 +80,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const paths = useTuiPaths()
const location = useLocation()
const theme = useTheme()
const pluginTheme = createPluginTheme(theme)
const dialog = useDialog()
const toast = useToast()
const attention = useAttention()
@ -100,18 +101,22 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
setStore("registrations", id, "cleanups", [])
})
const owned: Dispose[] = []
let context: Context
let activeDialog: symbol | undefined
const dialogApi: Dialog = {
show(render, onClose) {
const token = Symbol()
let closed = false
activeDialog = token
dialog.replace(render, () => {
if (closed) return
closed = true
if (activeDialog === token) activeDialog = undefined
onClose?.()
})
dialog.replace(
() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>,
() => {
if (closed) return
closed = true
if (activeDialog === token) activeDialog = undefined
onClose?.()
},
)
return () => {
if (closed || activeDialog !== token) return
dialog.clear()
@ -215,7 +220,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
},
}
owned.push(async () => dialogApi.clear())
const context: Context = {
context = {
options: item.options ?? {},
get location() {
return location.current
@ -225,7 +230,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
client: client.api,
data,
attention,
theme,
theme: pluginTheme,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
@ -245,7 +250,10 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
register(page) {
if (store.registrations[item.plugin.id]?.routes[page.name])
throw new Error(`Route already registered: ${page.name}`)
setStore("registrations", item.plugin.id, "routes", page.name, page)
setStore("registrations", item.plugin.id, "routes", page.name, {
...page,
render: (input) => <PluginContextProvider value={context}>{page.render(input)}</PluginContextProvider>,
})
let registered = true
const unregister = () => {
if (!registered) return
@ -275,7 +283,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
},
slot(name, render) {
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
setStore("registrations", item.plugin.id, "slots", name, () => render)
setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => (
<PluginContextProvider value={context}>{render(input)}</PluginContextProvider>
))
let registered = true
const unregister = () => {
if (!registered) return
@ -518,6 +528,23 @@ function isPlugin(value: unknown): value is Plugin.Definition {
)
}
type PluginTheme = ReturnType<typeof useTheme>["themeV2"] & {
contextual(context: "elevated" | "overlay"): PluginTheme
syntaxStyle(): ReturnType<ReturnType<typeof useTheme>["syntax"]>
}
export function createPluginTheme(theme: ReturnType<typeof useTheme>): PluginTheme {
return new Proxy(theme.themeV2 as PluginTheme, {
get(target, property, receiver) {
if (property === "contextual")
return (context: "elevated" | "overlay") => createPluginTheme(theme.contextual(context))
if (property === "syntaxStyle") return theme.syntax
if (Reflect.has(target, property)) return Reflect.get(target, property, receiver)
return Reflect.get(theme, property, theme)
},
})
}
export function usePlugin() {
const value = useContext(PluginContext)
if (!value) throw new Error("PluginProvider is missing")