Skip to content

Commit 29f7dc0

Browse files
authored
Adds TUI prompt traits, refs, and plugin slots (#20741)
1 parent 5e1b513 commit 29f7dc0

File tree

18 files changed

+316
-132
lines changed

18 files changed

+316
-132
lines changed

.opencode/plugins/tui-smoke.tsx

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -653,31 +653,77 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
653653
const skin = look(ctx.theme.current)
654654
type Prompt = (props: {
655655
workspaceID?: string
656+
visible?: boolean
657+
disabled?: boolean
658+
onSubmit?: () => void
656659
hint?: JSX.Element
660+
right?: JSX.Element
661+
showPlaceholder?: boolean
657662
placeholders?: {
658663
normal?: string[]
659664
shell?: string[]
660665
}
661666
}) => JSX.Element
662-
if (!("Prompt" in api.ui)) return null
663-
const view = api.ui.Prompt
664-
if (typeof view !== "function") return null
665-
const Prompt = view as Prompt
667+
type Slot = (
668+
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
669+
) => JSX.Element | null
670+
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
671+
const Prompt = ui.Prompt
672+
const Slot = ui.Slot
666673
const normal = [
667674
`[SMOKE] route check for ${input.label}`,
668675
"[SMOKE] confirm home_prompt slot override",
669-
"[SMOKE] verify api.ui.Prompt rendering",
676+
"[SMOKE] verify prompt-right slot passthrough",
670677
]
671678
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
672-
const Hint = (
679+
const hint = (
673680
<box flexShrink={0} flexDirection="row" gap={1}>
674681
<text fg={skin.muted}>
675682
<span style={{ fg: skin.accent }}></span> smoke home prompt
676683
</text>
677684
</box>
678685
)
679686

680-
return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
687+
return (
688+
<Prompt
689+
workspaceID={value.workspace_id}
690+
hint={hint}
691+
right={
692+
<box flexDirection="row" gap={1}>
693+
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
694+
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
695+
</box>
696+
}
697+
placeholders={{ normal, shell }}
698+
/>
699+
)
700+
},
701+
home_prompt_right(ctx, value) {
702+
const skin = look(ctx.theme.current)
703+
const id = value.workspace_id?.slice(0, 8) ?? "none"
704+
return (
705+
<text fg={skin.muted}>
706+
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
707+
</text>
708+
)
709+
},
710+
session_prompt_right(ctx, value) {
711+
const skin = look(ctx.theme.current)
712+
return (
713+
<text fg={skin.muted}>
714+
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
715+
</text>
716+
)
717+
},
718+
smoke_prompt_right(ctx, value) {
719+
const skin = look(ctx.theme.current)
720+
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
721+
const label = typeof value.label === "string" ? value.label : input.label
722+
return (
723+
<text fg={skin.muted}>
724+
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
725+
</text>
726+
)
681727
},
682728
home_bottom(ctx) {
683729
const skin = look(ctx.theme.current)

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
/>

0 commit comments

Comments
 (0)