feat(tui): add replaceable prompt footer slot
This commit is contained in:
parent
bd906d468d
commit
247f14f955
6 changed files with 152 additions and 106 deletions
|
|
@ -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,
|
||||
// })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
89
packages/tui/src/feature-plugins/prompt/footer.tsx
Normal file
89
packages/tui/src/feature-plugins/prompt/footer.tsx
Normal 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} />
|
||||
))
|
||||
},
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
44
packages/tui/test/feature-plugins/prompt-footer.test.tsx
Normal file
44
packages/tui/test/feature-plugins/prompt-footer.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue