diff --git a/.opencode/plugins/tui/discovery-smoke.ts b/.opencode/plugins/tui/discovery-smoke.ts index f0144918f1..fb7edaf349 100644 --- a/.opencode/plugins/tui/discovery-smoke.ts +++ b/.opencode/plugins/tui/discovery-smoke.ts @@ -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, + // }) }, } diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 9d092f7245..e4087b5aa0 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -116,6 +116,10 @@ export interface Page { export interface SlotMap { readonly app: Readonly> readonly "home.footer": Readonly> + readonly "prompt.footer.end": { + readonly sessionID?: string + readonly mode: "normal" | "shell" + } readonly "sidebar.content": { readonly sessionID: string } diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 44e98f92d5..732359a4a7 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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) { )} - - - - 0}> - - - {(shortcut) => {shortcut()} } - - - {(label) => {label()}} - - - · - - - {(label) => {label()}} - - 0}> - · - - 0}> - {statusItems().join(" · ")} - - - - - - {agentShortcut()} agents - - - - - {paletteShortcut()} commands - - - - - esc exit shell mode - - - + { + 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 ( + + + + 0}> + + + {(value) => {value()} } + + {(value) => {value()}} + · + {(value) => {value()}} + 0}> · + 0}>{status().join(" · ")} + + + + + {shortcut("agent.cycle")} agents + + + + + {shortcut("command.palette.show")} commands + + + + + esc exit shell mode + + + + ) +} + +export default Plugin.define({ + id: "opencode.prompt-footer", + setup(context) { + context.ui.slot("prompt.footer.end", (props) => ( + + )) + }, +}) diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 79765b0658..0c18cdafa6 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -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, diff --git a/packages/tui/test/feature-plugins/prompt-footer.test.tsx b/packages/tui/test/feature-plugins/prompt-footer.test.tsx new file mode 100644 index 0000000000..313f7b8d27 --- /dev/null +++ b/packages/tui/test/feature-plugins/prompt-footer.test.tsx @@ -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(() => , { + 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() + } +})