Skip to content

Commit 884de30

Browse files
authored
Adds TUI prompt traits, refs, and plugin slots (anomalyco#20741)
1 parent 7667b66 commit 884de30

17 files changed

Lines changed: 263 additions & 125 deletions

File tree

bun.lock

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/opencode/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@
104104
"@opencode-ai/sdk": "workspace:*",
105105
"@opencode-ai/util": "workspace:*",
106106
"@openrouter/ai-sdk-provider": "2.3.3",
107-
"@opentui/core": "0.1.95",
108-
"@opentui/solid": "0.1.95",
107+
"@opentui/core": "0.1.96",
108+
"@opentui/solid": "0.1.96",
109109
"@parcel/watcher": "2.5.1",
110110
"@pierre/diffs": "catalog:",
111111
"@solid-primitives/event-bus": "1.1.2",

packages/opencode/specs/tui-plugins.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,9 @@ That is what makes local config-scoped plugins able to import `@opencode-ai/plug
194194
Top-level API groups exposed to `tui(api, options, meta)`:
195195

196196
- `api.app.version`
197-
- `api.command.register(cb)` / `api.command.trigger(value)`
197+
- `api.command.register(cb)` / `api.command.trigger(value)` / `api.command.show()`
198198
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
199-
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Prompt`, `ui.toast`, `ui.dialog`
199+
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog`
200200
- `api.keybind.match`, `print`, `create`
201201
- `api.tuiConfig`
202202
- `api.kv.get`, `set`, `ready`
@@ -225,6 +225,7 @@ Command behavior:
225225
- Registrations are reactive.
226226
- Later registrations win for duplicate `value` and for keybind handling.
227227
- Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and `command.trigger(value)` if `enabled !== false`.
228+
- `api.command.show()` opens the host command dialog directly.
228229

229230
### Routes
230231

@@ -242,7 +243,8 @@ Command behavior:
242243

243244
- `ui.Dialog` is the base dialog wrapper.
244245
- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
245-
- `ui.Prompt` renders the same prompt component used by the host app.
246+
- `ui.Slot` renders host or plugin-defined slots by name from plugin JSX.
247+
- `ui.Prompt` renders the same prompt component used by the host app and accepts `sessionID`, `workspaceID`, `ref`, and `right` for the prompt meta row's right side.
246248
- `ui.toast(...)` shows a toast.
247249
- `ui.dialog` exposes the host dialog stack:
248250
- `replace(render, onClose?)`
@@ -315,8 +317,12 @@ Current host slot names:
315317

316318
- `app`
317319
- `home_logo`
318-
- `home_prompt` with props `{ workspace_id? }`
320+
- `home_prompt` with props `{ workspace_id?, ref? }`
321+
- `home_prompt_right` with props `{ workspace_id? }`
322+
- `session_prompt` with props `{ session_id, visible?, disabled?, on_submit?, ref? }`
323+
- `session_prompt_right` with props `{ session_id }`
319324
- `home_bottom`
325+
- `home_footer`
320326
- `sidebar_title` with props `{ session_id, title, share_url? }`
321327
- `sidebar_content` with props `{ session_id }`
322328
- `sidebar_footer` with props `{ session_id }`
@@ -328,8 +334,8 @@ Slot notes:
328334
- `api.slots.register(plugin)` does not return an unregister function.
329335
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
330336
- Plugin-provided `id` is not allowed.
331-
- The current host renders `home_logo` and `home_prompt` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
332-
- Plugins cannot define new slot names in this branch.
337+
- The current host renders `home_logo`, `home_prompt`, and `session_prompt` with `replace`, `home_footer`, `sidebar_title`, and `sidebar_footer` with `single_winner`, and `app`, `home_prompt_right`, `session_prompt_right`, `home_bottom`, and `sidebar_content` with the slot library default mode.
338+
- Plugins can define custom slot names in `api.slots.register(...)` and render them from plugin UI with `ui.Slot`.
333339

334340
### Plugin control and lifecycle
335341

@@ -425,5 +431,6 @@ The plugin manager is exposed as a command with title `Plugins` and value `plugi
425431
## Current in-repo examples
426432

427433
- Local smoke plugin: `.opencode/plugins/tui-smoke.tsx`
434+
- Local vim plugin: `.opencode/plugins/tui-vim.tsx`
428435
- Local smoke config: `.opencode/tui.json`
429436
- Local smoke theme: `.opencode/plugins/smoke-theme.json`

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes, t, dim, fg } from "@opentui/core"
2-
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
2+
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
33
import "opentui-spinner/solid"
44
import path from "path"
55
import { Filesystem } from "@/util/filesystem"
@@ -18,7 +18,7 @@ import { usePromptStash } from "./stash"
1818
import { DialogStash } from "../dialog-stash"
1919
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
2020
import { useCommandDialog } from "../dialog-command"
21-
import { useKeyboard, useRenderer } from "@opentui/solid"
21+
import { useKeyboard, useRenderer, type JSX } from "@opentui/solid"
2222
import { Editor } from "@tui/util/editor"
2323
import { useExit } from "../../context/exit"
2424
import { Clipboard } from "../../util/clipboard"
@@ -42,8 +42,9 @@ export type PromptProps = {
4242
visible?: boolean
4343
disabled?: boolean
4444
onSubmit?: () => void
45-
ref?: (ref: PromptRef) => void
45+
ref?: (ref: PromptRef | undefined) => void
4646
hint?: JSX.Element
47+
right?: JSX.Element
4748
showPlaceholder?: boolean
4849
placeholders?: {
4950
normal?: string[]
@@ -92,6 +93,7 @@ export function Prompt(props: PromptProps) {
9293
const kv = useKV()
9394
const list = createMemo(() => props.placeholders?.normal ?? [])
9495
const shell = createMemo(() => props.placeholders?.shell ?? [])
96+
const [auto, setAuto] = createSignal<AutocompleteRef>()
9597

9698
function promptModelWarning() {
9799
toast.show({
@@ -435,11 +437,24 @@ export function Prompt(props: PromptProps) {
435437
},
436438
}
437439

440+
onCleanup(() => {
441+
props.ref?.(undefined)
442+
})
443+
438444
createEffect(() => {
439445
if (props.visible !== false) input?.focus()
440446
if (props.visible === false) input?.blur()
441447
})
442448

449+
createEffect(() => {
450+
if (!input || input.isDestroyed) return
451+
input.traits = {
452+
capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined,
453+
suspend: !!props.disabled || store.mode === "shell",
454+
status: store.mode === "shell" ? "SHELL" : undefined,
455+
}
456+
})
457+
443458
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
444459
input.extmarks.clear()
445460
setStore("extmarkToPartIndex", new Map())
@@ -844,7 +859,10 @@ export function Prompt(props: PromptProps) {
844859
<>
845860
<Autocomplete
846861
sessionID={props.sessionID}
847-
ref={(r) => (autocomplete = r)}
862+
ref={(r) => {
863+
autocomplete = r
864+
setAuto(() => r)
865+
}}
848866
anchor={() => anchor}
849867
input={() => input}
850868
setPrompt={(cb) => {
@@ -1060,24 +1078,27 @@ export function Prompt(props: PromptProps) {
10601078
cursorColor={theme.text}
10611079
syntaxStyle={syntax()}
10621080
/>
1063-
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
1064-
<text fg={highlight()}>
1065-
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
1066-
</text>
1067-
<Show when={store.mode === "normal"}>
1068-
<box flexDirection="row" gap={1}>
1069-
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
1070-
{local.model.parsed().model}
1071-
</text>
1072-
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
1073-
<Show when={showVariant()}>
1074-
<text fg={theme.textMuted}>·</text>
1075-
<text>
1076-
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
1081+
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
1082+
<box flexDirection="row" gap={1}>
1083+
<text fg={highlight()}>
1084+
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
1085+
</text>
1086+
<Show when={store.mode === "normal"}>
1087+
<box flexDirection="row" gap={1}>
1088+
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
1089+
{local.model.parsed().model}
10771090
</text>
1078-
</Show>
1079-
</box>
1080-
</Show>
1091+
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
1092+
<Show when={showVariant()}>
1093+
<text fg={theme.textMuted}>·</text>
1094+
<text>
1095+
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
1096+
</text>
1097+
</Show>
1098+
</box>
1099+
</Show>
1100+
</box>
1101+
{props.right}
10811102
</box>
10821103
</box>
10831104
</box>

packages/opencode/src/cli/cmd/tui/plugin/api.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ParsedKey } from "@opentui/core"
2-
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
2+
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
33
import type { useCommandDialog } from "@tui/component/dialog-command"
44
import type { useKeybind } from "@tui/context/keybind"
55
import type { useRoute } from "@tui/context/route"
@@ -15,6 +15,7 @@ import { DialogConfirm } from "../ui/dialog-confirm"
1515
import { DialogPrompt } from "../ui/dialog-prompt"
1616
import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
1717
import { Prompt } from "../component/prompt"
18+
import { Slot as HostSlot } from "./slots"
1819
import type { useToast } from "../ui/toast"
1920
import { Installation } from "@/installation"
2021
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
@@ -244,6 +245,9 @@ export function createTuiApi(input: Input): TuiHostPluginApi {
244245
trigger(value) {
245246
input.command.trigger(value)
246247
},
248+
show() {
249+
input.command.show()
250+
},
247251
},
248252
route: {
249253
register(list) {
@@ -288,14 +292,20 @@ export function createTuiApi(input: Input): TuiHostPluginApi {
288292
/>
289293
)
290294
},
295+
Slot<Name extends string>(props: TuiSlotProps<Name>) {
296+
return <HostSlot {...props} />
297+
},
291298
Prompt(props) {
292299
return (
293300
<Prompt
301+
sessionID={props.sessionID}
294302
workspaceID={props.workspaceID}
295303
visible={props.visible}
296304
disabled={props.disabled}
297305
onSubmit={props.onSubmit}
306+
ref={props.ref}
298307
hint={props.hint}
308+
right={props.right}
299309
showPlaceholder={props.showPlaceholder}
300310
placeholders={props.placeholders}
301311
/>

packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type TuiPluginModule,
88
type TuiPluginMeta,
99
type TuiPluginStatus,
10+
type TuiSlotPlugin,
1011
type TuiTheme,
1112
} from "@opencode-ai/plugin/tui"
1213
import path from "path"
@@ -491,6 +492,9 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
491492
trigger(value) {
492493
api.command.trigger(value)
493494
},
495+
show() {
496+
api.command.show()
497+
},
494498
}
495499

496500
const route: TuiPluginApi["route"] = {
@@ -518,7 +522,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
518522
let count = 0
519523

520524
const slots: TuiPluginApi["slots"] = {
521-
register(plugin) {
525+
register(plugin: TuiSlotPlugin) {
522526
const id = count ? `${base}:${count}` : base
523527
count += 1
524528
scope.track(host.register({ ...plugin, id }))

packages/opencode/src/cli/cmd/tui/plugin/slots.tsx

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,37 @@
1-
import { type SlotMode, type TuiPluginApi, type TuiSlotContext, type TuiSlotMap } from "@opencode-ai/plugin/tui"
1+
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/tui"
22
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
33
import { isRecord } from "@/util/record"
44

5-
type SlotProps<K extends keyof TuiSlotMap> = {
6-
name: K
7-
mode?: SlotMode
8-
children?: JSX.Element
9-
} & TuiSlotMap[K]
5+
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
106

11-
type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
12-
export type HostSlotPlugin = SolidPlugin<TuiSlotMap, TuiSlotContext>
7+
type Slot = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
8+
export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
139

1410
export type HostPluginApi = TuiPluginApi
1511
export type HostSlots = {
16-
register: (plugin: HostSlotPlugin) => () => void
12+
register: {
13+
(plugin: HostSlotPlugin): () => void
14+
<Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
15+
}
1716
}
1817

19-
function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
18+
function empty<Name extends string>(_props: TuiSlotProps<Name>) {
2019
return null
2120
}
2221

2322
let view: Slot = empty
2423

2524
export const Slot: Slot = (props) => view(props)
2625

27-
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin {
26+
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
2827
if (!isRecord(value)) return false
2928
if (typeof value.id !== "string") return false
3029
if (!isRecord(value.slots)) return false
3130
return true
3231
}
3332

3433
export function setupSlots(api: HostPluginApi): HostSlots {
35-
const reg = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(
34+
const reg = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
3635
api.renderer,
3736
{
3837
theme: api.theme,
@@ -50,10 +49,10 @@ export function setupSlots(api: HostPluginApi): HostSlots {
5049
},
5150
)
5251

53-
const slot = createSlot<TuiSlotMap, TuiSlotContext>(reg)
52+
const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(reg)
5453
view = (props) => slot(props)
5554
return {
56-
register(plugin) {
55+
register(plugin: HostSlotPlugin) {
5756
if (!isHostSlotPlugin(plugin)) return () => {}
5857
return reg.register(plugin)
5958
},

0 commit comments

Comments
 (0)