feat(tui): add replaceable prompt footer slot

This commit is contained in:
Dax Raad 2026-07-29 10:16:34 -04:00
commit 247f14f955
6 changed files with 152 additions and 106 deletions

View file

@ -2,15 +2,12 @@ import type { Context } from "../../../packages/plugin/src/tui/context"
export default {
id: "test.tui-discovery-smoke",
setup(context: Context) {
const timer = setTimeout(() => {
context.ui.toast.show({
title: "TUI plugin discovery works",
message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
variant: "success",
duration: 30_000,
})
}, 1_000)
return () => clearTimeout(timer)
setup(_context: Context) {
// context.ui.toast.show({
// title: "TUI plugin discovery works",
// message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
// variant: "success",
// duration: 30_000,
// })
},
}

View file

@ -116,6 +116,10 @@ export interface Page {
export interface SlotMap {
readonly app: Readonly<Record<string, never>>
readonly "home.footer": Readonly<Record<string, never>>
readonly "prompt.footer.end": {
readonly sessionID?: string
readonly mode: "normal" | "shell"
}
readonly "sidebar.content": {
readonly sessionID: string
}

View file

@ -52,8 +52,8 @@ import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { contextUsage, formatContextUsage } from "../../util/session"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/context"
registerOpencodeSpinner()
@ -93,11 +93,6 @@ export type PromptRef = {
submit(): void
}
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
const DRAFT_RETENTION_MIN_CHARS = 20
function randomIndex(count: number) {
@ -170,22 +165,9 @@ export function Prompt(props: PromptProps) {
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const activeSubagents = createMemo(() => {
if (!props.sessionID) return 0
return data.session
.family(props.sessionID)
.filter((id) => id !== props.sessionID && data.session.status(id) === "running").length
})
const runningShells = createMemo(
() =>
data.shell.list(currentLocation.current).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
)
const history = usePromptHistory()
const stash = usePromptStash()
const keymap = Keymap.use()
const agentShortcut = Keymap.useShortcut("agent.cycle")
const paletteShortcut = Keymap.useShortcut("command.palette.show")
const liveWorkShortcut = Keymap.useShortcut("session.child.first")
const renderer = useRenderer()
const exit = useExit()
const dimensions = useTerminalDimensions()
@ -301,42 +283,6 @@ export function Prompt(props: PromptProps) {
if (!props.disabled) input.cursorColor = theme.text.default
})
const usage = createMemo(() => {
if (!props.sessionID) return
const session = data.session.get(props.sessionID)
if (!session) return
const cost = data.session.cost(props.sessionID)
const formattedCost = cost > 0 ? money.format(cost) : undefined
const context = contextUsage(
data.session.message.list(props.sessionID),
data.location.model.list(session.location),
session.revert?.messageID,
)
return {
context: context ? formatContextUsage(context.tokens, context.percent) : undefined,
cost: formattedCost,
}
})
const subagentStatusLabel = createMemo(() => {
const agents = activeSubagents()
if (!agents) return undefined
return `${agents} subagent${agents === 1 ? "" : "s"}`
})
const shellStatusLabel = createMemo(() => {
const shells = runningShells()
if (!shells) return undefined
return `${shells} shell${shells === 1 ? "" : "s"}`
})
const liveWorkStatusVisible = createMemo(() => Boolean(subagentStatusLabel() || shellStatusLabel()))
// Far-right footer cluster: live work counts lead, then context/cost usage.
// When empty, the cluster falls back to the hotkey hints.
const statusItems = createMemo(() => {
const stats = usage()
return [stats?.context, stats?.cost].filter(Boolean)
})
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
@ -1603,47 +1549,11 @@ export function Prompt(props: PromptProps) {
</text>
)}
</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
<text fg={theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={liveWorkStatusVisible() && liveWorkShortcut()}>
{(shortcut) => <span style={{ fg: theme.text.default }}>{shortcut()} </span>}
</Show>
<Show when={subagentStatusLabel()}>
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
</Show>
<Show when={subagentStatusLabel() && shellStatusLabel()}>
<span style={{ fg: theme.text.subdued }}> · </span>
</Show>
<Show when={shellStatusLabel()}>
{(label) => <span style={{ fg: theme.text.subdued }}>{label()}</span>}
</Show>
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
<span style={{ fg: theme.text.subdued }}> · </span>
</Show>
<Show when={statusItems().length > 0}>
<span style={{ fg: theme.text.subdued }}>{statusItems().join(" · ")}</span>
</Show>
</text>
</Match>
<Match when={true}>
<text fg={theme.text.default} flexShrink={0}>
{agentShortcut()} <span style={{ fg: theme.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<text fg={theme.text.default} flexShrink={0}>
{paletteShortcut()} <span style={{ fg: theme.text.subdued }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text.default} flexShrink={0}>
esc <span style={{ fg: theme.text.subdued }}>exit shell mode</span>
</text>
</Match>
</Switch>
<PluginSlot
name="prompt.footer.end"
input={{ sessionID: props.sessionID, mode: store.mode }}
mode="replace"
/>
</box>
</box>
<Autocomplete

View file

@ -0,0 +1,89 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { contextUsage, formatContextUsage } from "../../util/session"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
export function PromptFooter(props: { context: Plugin.Context; sessionID?: string; mode: "normal" | "shell" }) {
const activeSubagents = createMemo(() => {
if (!props.sessionID) return 0
return props.context.data.session
.family(props.sessionID)
.filter((id) => id !== props.sessionID && props.context.data.session.status(id) === "running").length
})
const runningShells = createMemo(() => {
if (!props.sessionID) return 0
return props.context.data.shell
.list(props.context.location)
.filter((shell) => shell.metadata.sessionID === props.sessionID).length
})
const subagents = createMemo(() => {
const count = activeSubagents()
return count ? `${count} subagent${count === 1 ? "" : "s"}` : undefined
})
const shells = createMemo(() => {
const count = runningShells()
return count ? `${count} shell${count === 1 ? "" : "s"}` : undefined
})
const status = createMemo(() => {
if (!props.sessionID) return []
const session = props.context.data.session.get(props.sessionID)
if (!session) return []
const usage = contextUsage(
props.context.data.session.message.list(props.sessionID),
props.context.data.location.model.list(session.location),
session.revert?.messageID,
)
const cost = props.context.data.session.cost(props.sessionID)
return [usage ? formatContextUsage(usage.tokens, usage.percent) : undefined, cost > 0 ? money.format(cost) : undefined]
.filter((item): item is string => Boolean(item))
})
const live = createMemo(() => Boolean(subagents() || shells()))
const shortcut = (id: string) => props.context.keymap.shortcuts(id)[0]
return (
<Switch>
<Match when={props.mode === "normal"}>
<Switch>
<Match when={live() || status().length > 0}>
<text fg={props.context.theme.text.subdued} wrapMode="none" truncate flexShrink={1}>
<Show when={live() && shortcut("session.child.first")}>
{(value) => <span style={{ fg: props.context.theme.text.default }}>{value()} </span>}
</Show>
<Show when={subagents()}>{(value) => <span>{value()}</span>}</Show>
<Show when={subagents() && shells()}> · </Show>
<Show when={shells()}>{(value) => <span>{value()}</span>}</Show>
<Show when={live() && status().length > 0}> · </Show>
<Show when={status().length > 0}>{status().join(" · ")}</Show>
</text>
</Match>
<Match when={true}>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.subdued }}>agents</span>
</text>
</Match>
</Switch>
<text fg={props.context.theme.text.default} flexShrink={0}>
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.subdued }}>commands</span>
</text>
</Match>
<Match when={props.mode === "shell"}>
<text fg={props.context.theme.text.default} flexShrink={0}>
esc <span style={{ fg: props.context.theme.text.subdued }}>exit shell mode</span>
</text>
</Match>
</Switch>
)
}
export default Plugin.define({
id: "opencode.prompt-footer",
setup(context) {
context.ui.slot("prompt.footer.end", (props) => (
<PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />
))
},
})

View file

@ -1,4 +1,5 @@
import HomeFooter from "../feature-plugins/home/footer"
import PromptFooter from "../feature-plugins/prompt/footer"
import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarFooter from "../feature-plugins/sidebar/footer"
import SidebarLsp from "../feature-plugins/sidebar/lsp"
@ -10,6 +11,7 @@ import Scrap from "../feature-plugins/system/scrap"
export const builtins = [
HomeFooter,
PromptFooter,
SidebarContext,
SidebarMcp,
SidebarLsp,

View file

@ -0,0 +1,44 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { Context } from "@opencode-ai/plugin/tui/context"
import { PromptFooter } from "../../src/feature-plugins/prompt/footer"
test("prompt footer separates simultaneous subagent, shell, and usage status", async () => {
const color = RGBA.fromInts(200, 200, 200)
const context = {
location: { directory: "/workspace" },
theme: { text: { default: color, subdued: color } },
keymap: {
shortcuts: (id: string) => (id === "session.child.first" ? ["ctrl+j"] : id === "command.palette.show" ? ["ctrl+p"] : []),
},
data: {
session: {
family: () => ["session", "child"],
status: (id: string) => (id === "child" ? "running" : "idle"),
get: () => ({ id: "session", location: { directory: "/workspace" } }),
cost: () => 1,
message: { list: () => [] },
},
shell: {
list: () => [{ metadata: { sessionID: "session" } }],
},
location: {
model: { list: () => [] },
},
},
} as unknown as Context
const app = await testRender(() => <PromptFooter context={context} sessionID="session" mode="normal" />, {
width: 80,
height: 2,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("ctrl+j 1 subagent · 1 shell · $1.00")
expect(app.captureCharFrame()).toContain("ctrl+p commands")
} finally {
app.renderer.destroy()
}
})