diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 373251a1b49..5afb61ae181 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -133,6 +133,9 @@ export const Info = Schema.Struct({ turn_summary: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ description: "Show or hide the agent, model, and duration summary in scrollback", }), + footer: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ + description: "Show or hide persistent activity, model, usage, and context details in the footer", + }), mono: Schema.optional(Schema.Boolean).annotate({ description: "Use monochrome ASCII output", }), diff --git a/packages/tui/src/mini/footer.command.tsx b/packages/tui/src/mini/footer.command.tsx index 869d6b342b9..5b3998413de 100644 --- a/packages/tui/src/mini/footer.command.tsx +++ b/packages/tui/src/mini/footer.command.tsx @@ -26,6 +26,7 @@ type CommandEntry = | (PanelEntry & { action: "skill" }) | (PanelEntry & { action: "queued" }) | (PanelEntry & { action: "subagent" }) + | (PanelEntry & { action: "status" }) | (PanelEntry & { action: "variant.cycle" }) | (PanelEntry & { action: "variant.list" }) | (PanelEntry & { action: "settings" }) @@ -363,6 +364,7 @@ export function RunCommandMenuBody(props: { onQueued: () => void onVariant: () => void onVariantCycle: () => void + onStatus: () => void onSettings: () => void onCommand: (name: string) => void onNew: () => void @@ -381,6 +383,12 @@ export function RunCommandMenuBody(props: { footer: "/editor", keywords: "editor compose draft external editor", }, + { + action: "status", + category: "Session", + display: "Show status", + keywords: "status activity model context usage footer", + }, ...(props.subagents().length > 0 ? [ { @@ -526,6 +534,11 @@ export function RunCommandMenuBody(props: { return } + if (item.action === "status") { + props.onStatus() + return + } + if (item.action === "settings") { props.onSettings() return @@ -613,6 +626,13 @@ export function RunSettingsBody(props: { keywords: `turn summary agent model duration ${props.settings().turn_summary}`, key: "turn_summary", }, + { + category: "Terminal", + display: "Footer details", + footer: saving() === "footer" ? "saving" : props.settings().footer, + keywords: `footer status activity model context usage ${props.settings().footer}`, + key: "footer", + }, { category: "Terminal", display: "Monochrome UI", diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index 68bb0432432..c1cf4cc8953 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -56,6 +56,8 @@ import type { RunTheme } from "./theme" registerOpencodeSpinner() +const FOOTER_DETAIL_DURATION = 3000 + const EMPTY_BORDER = { topLeft: "", bottomLeft: "", @@ -189,6 +191,7 @@ export function RunFooterView(props: RunFooterViewProps) { const armed = createMemo(() => props.state().interrupt > 0) const exiting = createMemo(() => props.state().exit > 0) const usage = createMemo(() => props.state().usage) + const footerDetails = createMemo(() => props.miniSettings().footer === "show") const interruptLabel = createMemo(() => { if (!interrupt()) { return @@ -217,6 +220,33 @@ export function RunFooterView(props: RunFooterViewProps) { color: createColors(options), } }) + const footerStatus = createMemo(() => { + const current = model() ?? props.state().model.trim() + const variant = props.currentVariant() + const details = [busy() ? "running" : "idle"] + if (current) details.push(variant ? `${current} ${variant}` : current) + if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage()) + if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`) + if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`) + return details.join(props.mono ? " - " : " · ") + }) + const [footerNotice, setFooterNotice] = createSignal("") + let footerNoticeTimeout: ReturnType | undefined + let previousFooterStatus: string | undefined + const showFooterStatus = () => { + if (footerNoticeTimeout) clearTimeout(footerNoticeTimeout) + setFooterNotice(footerStatus()) + footerNoticeTimeout = setTimeout(() => { + footerNoticeTimeout = undefined + setFooterNotice("") + }, FOOTER_DETAIL_DURATION) + } + + createEffect(() => { + const current = footerStatus() + if (previousFooterStatus !== undefined && previousFooterStatus !== current && !footerDetails()) showFooterStatus() + previousFooterStatus = current + }) const permission = createMemo | undefined>(() => { const view = active() return view.type === "permission" ? view : undefined @@ -375,9 +405,11 @@ export function RunFooterView(props: RunFooterViewProps) { return `Press ${clearShortcut() || "ctrl+c"} again to exit` } - if (busy()) { - return armed() ? "again to interrupt" : "interrupt" - } + if (busy() && armed()) return "again to interrupt" + + if (footerNotice()) return footerNotice() + + if (busy()) return "interrupt" if (stateStatus().length > 0) { return stateStatus() @@ -386,7 +418,7 @@ export function RunFooterView(props: RunFooterViewProps) { return shell() ? "Shell mode" : "" }) const activityMeta = createMemo(() => { - if (!responsive().statusline.showActivityMeta || usage().length === 0) { + if (!footerDetails() || !responsive().statusline.showActivityMeta || usage().length === 0) { return "" } @@ -394,7 +426,7 @@ export function RunFooterView(props: RunFooterViewProps) { }) const modelStatus = createMemo(() => { const current = model() - if (!prompt() || !responsive().statusline.showModel || !current) return + if (!footerDetails() || !prompt() || !responsive().statusline.showModel || !current) return return { model: current, variant: responsive().statusline.showModelVariant ? props.currentVariant() : undefined, @@ -409,7 +441,7 @@ export function RunFooterView(props: RunFooterViewProps) { return theme().highlight } - if (busy() || stateStatus().length > 0) { + if (busy() || footerNotice().length > 0 || stateStatus().length > 0) { return theme().text } @@ -419,7 +451,7 @@ export function RunFooterView(props: RunFooterViewProps) { const hasActivityMeta = createMemo(() => activityMeta().length > 0) const hasModelStatus = createMemo(() => Boolean(modelStatus())) const contextHints = createMemo(() => { - if (!prompt() || shell() || !responsive().statusline.showContextHints) { + if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showContextHints) { return [] } @@ -459,6 +491,7 @@ export function RunFooterView(props: RunFooterViewProps) { onCleanup(() => { props.onRequestExit?.(undefined) + if (footerNoticeTimeout) clearTimeout(footerNoticeTimeout) }) Keymap.createLayer(() => ({ @@ -699,6 +732,10 @@ export function RunFooterView(props: RunFooterViewProps) { props.onCycle() closePanel() }} + onStatus={() => { + showFooterStatus() + closePanel() + }} onCommand={(name) => { composer.submitText(`/${name}`) closePanel() @@ -857,7 +894,7 @@ export function RunFooterView(props: RunFooterViewProps) { paddingRight={1} backgroundColor="transparent" > - + diff --git a/packages/tui/src/mini/runtime.boot.ts b/packages/tui/src/mini/runtime.boot.ts index 3828e9a4546..2f5e807d5e6 100644 --- a/packages/tui/src/mini/runtime.boot.ts +++ b/packages/tui/src/mini/runtime.boot.ts @@ -89,6 +89,7 @@ export function resolveMiniSettings(config?: { mini?: Partial }): thinking: config?.mini?.thinking ?? "hide", shell_output: config?.mini?.shell_output ?? "hide", turn_summary: config?.mini?.turn_summary ?? "show", + footer: config?.mini?.footer ?? "show", mono: config?.mini?.mono ?? false, } } diff --git a/packages/tui/src/mini/types.ts b/packages/tui/src/mini/types.ts index 2ed619abe43..232f74c1e5b 100644 --- a/packages/tui/src/mini/types.ts +++ b/packages/tui/src/mini/types.ts @@ -398,6 +398,7 @@ export type MiniSettings = { thinking: "show" | "hide" shell_output: "show" | "hide" turn_summary: "show" | "hide" + footer: "show" | "hide" mono: boolean } diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index 4e1cf95ac6b..15ddbaacbfa 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/packages/tui/test/mini/footer-keymap.test.tsx @@ -55,7 +55,7 @@ test("down opens subagents from an empty prompt", async () => { subagent={subagents} theme={() => RUN_THEME_FALLBACK} tuiConfig={config} - miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false })} + miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })} mono={false} onSubmit={() => true} onPermissionReply={() => {}} diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index c77cc50c768..adbb4050ad4 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -101,7 +101,7 @@ function footerState(input: Partial = {}) { interrupt: 0, exit: 0, ...input, - })[0] + }) } async function renderFooter( @@ -129,10 +129,10 @@ async function renderFooter( const [subagents] = createSignal( input.subagents ?? { tabs: [], details: {}, permissions: [], forms: [] }, ) - const state = footerState(input.state) + const [state, setState] = footerState(input.state) const config = input.tuiConfig ?? tuiConfig const [miniSettings] = createSignal( - input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false }, + input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false }, ) function Harness() { return ( @@ -186,6 +186,7 @@ async function renderFooter( return { ...app, setView, + setState, cleanup() { app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedEditor?.blur() @@ -362,6 +363,7 @@ test("direct command panel renders grouped command palette", async () => { ]) const [subagents] = createSignal([]) const [variants] = createSignal(["high", "minimal"]) + let status = 0 const app = await testRender( () => ( @@ -381,6 +383,7 @@ test("direct command panel renders grouped command palette", async () => { onQueued={() => {}} onVariant={() => {}} onVariantCycle={() => {}} + onStatus={() => status++} onSettings={() => {}} onCommand={() => {}} onNew={() => {}} @@ -406,6 +409,7 @@ test("direct command panel renders grouped command palette", async () => { expect(frame).toContain("Prompt") expect(frame).toContain("Open editor") expect(frame).toContain("/editor") + expect(frame).toContain("Show status") expect(frame).toContain("Switch model") expect(frame).toContain("Skills") expect(frame).toContain("/skills") @@ -417,6 +421,12 @@ test("direct command panel renders grouped command palette", async () => { expect(frame).not.toContain("Cycle reasoning effort for future turns") expect(frame).not.toContain("Review code") expect(frame).not.toContain("Commands 8") + + await app.mockInput.typeText("status") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Show status") + app.mockInput.pressEnter() + expect(status).toBe(1) } finally { app.renderer.destroy() } @@ -427,6 +437,7 @@ test("direct settings panel changes Mini transcript preferences", async () => { thinking: "hide", shell_output: "hide", turn_summary: "show", + footer: "show", mono: false, }) const app = await testRender( @@ -454,6 +465,7 @@ test("direct settings panel changes Mini transcript preferences", async () => { expect(frame).toContain("Thinking") expect(frame).toContain("Shell") expect(frame).toContain("Turn summary") + expect(frame).toContain("Footer details") expect(frame).toContain("Monochrome UI") expect(frame).toContain("left/right change") expect(frame).not.toMatch(/[^\x00-\x7F]/) @@ -461,15 +473,16 @@ test("direct settings panel changes Mini transcript preferences", async () => { app.mockInput.pressKey("ARROW_RIGHT") await app.renderOnce() - expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show", mono: false }) + expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show", footer: "show", mono: false }) app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_RIGHT") await app.renderOnce() - expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide", mono: false }) + expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide", footer: "show", mono: false }) + app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_DOWN") app.mockInput.pressKey("ARROW_RIGHT") await app.renderOnce() @@ -589,6 +602,7 @@ test("direct command panel shows subagent entry when available", async () => { onQueued={() => {}} onVariant={() => {}} onVariantCycle={() => {}} + onStatus={() => {}} onSettings={() => {}} onCommand={() => {}} onNew={() => {}} @@ -638,6 +652,7 @@ test("direct command panel keeps completed subagents available", async () => { onQueued={() => {}} onVariant={() => {}} onVariantCycle={() => {}} + onStatus={() => {}} onSettings={() => {}} onCommand={() => {}} onNew={() => {}} @@ -1127,7 +1142,7 @@ test("direct footer shows authoritative pending work while running", async () => ]} theme={() => RUN_THEME_FALLBACK} tuiConfig={tuiConfig} - miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false })} + miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })} mono={false} onSubmit={() => true} onPermissionReply={() => {}} @@ -1301,6 +1316,39 @@ test("direct footer shows full usage metadata when room is available", async () } }) +test("direct footer can hide persistent details and briefly reveal changes", async () => { + const app = await renderFooter({ + state: { usage: "159.6K (16%) · $4.23" }, + miniSettings: { + thinking: "hide", + shell_output: "hide", + turn_summary: "show", + footer: "hide", + mono: true, + }, + mono: true, + width: 160, + }) + + try { + await app.renderOnce() + const initial = app.captureCharFrame() + expect(initial).toContain("ctrl+p cmd") + expect(initial).not.toContain("gpt-5") + expect(initial).not.toContain("159.6K") + + app.setState((state) => ({ ...state, phase: "running" })) + await app.renderOnce() + const changed = app.captureCharFrame() + const statusline = footerStatusline(app.renderer.root) + + expect(changed).toContain("running - gpt-5 - 159.6K (16%) - $4.23") + expect(boxPath(statusline, "SpinnerRenderable")).toBeUndefined() + } finally { + app.cleanup() + } +}) + test("direct footer does not label normal mode as build", async () => { const app = await renderFooter() diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index 3fbf5580125..8d85cfa1f0f 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -107,14 +107,18 @@ describe("run runtime boot", () => { thinking: "hide", shell_output: "hide", turn_summary: "show", + footer: "show", mono: false, }) expect( - resolveMiniSettings({ mini: { thinking: "show", shell_output: "show", turn_summary: "hide", mono: true } }), + resolveMiniSettings({ + mini: { thinking: "show", shell_output: "show", turn_summary: "hide", footer: "hide", mono: true }, + }), ).toEqual({ thinking: "show", shell_output: "show", turn_summary: "hide", + footer: "hide", mono: true, }) })