mini: add hideable footer details (#38192)

This commit is contained in:
Simon Klee 2026-07-21 23:28:40 +02:00 committed by GitHub
commit 2ed8fe5960
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 130 additions and 16 deletions

View file

@ -133,6 +133,9 @@ export const Info = Schema.Struct({
turn_summary: Schema.optional(Schema.Literals(["show", "hide"])).annotate({ turn_summary: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide the agent, model, and duration summary in scrollback", 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({ mono: Schema.optional(Schema.Boolean).annotate({
description: "Use monochrome ASCII output", description: "Use monochrome ASCII output",
}), }),

View file

@ -26,6 +26,7 @@ type CommandEntry =
| (PanelEntry & { action: "skill" }) | (PanelEntry & { action: "skill" })
| (PanelEntry & { action: "queued" }) | (PanelEntry & { action: "queued" })
| (PanelEntry & { action: "subagent" }) | (PanelEntry & { action: "subagent" })
| (PanelEntry & { action: "status" })
| (PanelEntry & { action: "variant.cycle" }) | (PanelEntry & { action: "variant.cycle" })
| (PanelEntry & { action: "variant.list" }) | (PanelEntry & { action: "variant.list" })
| (PanelEntry & { action: "settings" }) | (PanelEntry & { action: "settings" })
@ -363,6 +364,7 @@ export function RunCommandMenuBody(props: {
onQueued: () => void onQueued: () => void
onVariant: () => void onVariant: () => void
onVariantCycle: () => void onVariantCycle: () => void
onStatus: () => void
onSettings: () => void onSettings: () => void
onCommand: (name: string) => void onCommand: (name: string) => void
onNew: () => void onNew: () => void
@ -381,6 +383,12 @@ export function RunCommandMenuBody(props: {
footer: "/editor", footer: "/editor",
keywords: "editor compose draft external 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 ...(props.subagents().length > 0
? [ ? [
{ {
@ -526,6 +534,11 @@ export function RunCommandMenuBody(props: {
return return
} }
if (item.action === "status") {
props.onStatus()
return
}
if (item.action === "settings") { if (item.action === "settings") {
props.onSettings() props.onSettings()
return return
@ -613,6 +626,13 @@ export function RunSettingsBody(props: {
keywords: `turn summary agent model duration ${props.settings().turn_summary}`, keywords: `turn summary agent model duration ${props.settings().turn_summary}`,
key: "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", category: "Terminal",
display: "Monochrome UI", display: "Monochrome UI",

View file

@ -56,6 +56,8 @@ import type { RunTheme } from "./theme"
registerOpencodeSpinner() registerOpencodeSpinner()
const FOOTER_DETAIL_DURATION = 3000
const EMPTY_BORDER = { const EMPTY_BORDER = {
topLeft: "", topLeft: "",
bottomLeft: "", bottomLeft: "",
@ -189,6 +191,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const armed = createMemo(() => props.state().interrupt > 0) const armed = createMemo(() => props.state().interrupt > 0)
const exiting = createMemo(() => props.state().exit > 0) const exiting = createMemo(() => props.state().exit > 0)
const usage = createMemo(() => props.state().usage) const usage = createMemo(() => props.state().usage)
const footerDetails = createMemo(() => props.miniSettings().footer === "show")
const interruptLabel = createMemo(() => { const interruptLabel = createMemo(() => {
if (!interrupt()) { if (!interrupt()) {
return return
@ -217,6 +220,33 @@ export function RunFooterView(props: RunFooterViewProps) {
color: createColors(options), 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<typeof setTimeout> | 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<Extract<FooterView, { type: "permission" }> | undefined>(() => { const permission = createMemo<Extract<FooterView, { type: "permission" }> | undefined>(() => {
const view = active() const view = active()
return view.type === "permission" ? view : undefined return view.type === "permission" ? view : undefined
@ -375,9 +405,11 @@ export function RunFooterView(props: RunFooterViewProps) {
return `Press ${clearShortcut() || "ctrl+c"} again to exit` return `Press ${clearShortcut() || "ctrl+c"} again to exit`
} }
if (busy()) { if (busy() && armed()) return "again to interrupt"
return armed() ? "again to interrupt" : "interrupt"
} if (footerNotice()) return footerNotice()
if (busy()) return "interrupt"
if (stateStatus().length > 0) { if (stateStatus().length > 0) {
return stateStatus() return stateStatus()
@ -386,7 +418,7 @@ export function RunFooterView(props: RunFooterViewProps) {
return shell() ? "Shell mode" : "" return shell() ? "Shell mode" : ""
}) })
const activityMeta = createMemo(() => { const activityMeta = createMemo(() => {
if (!responsive().statusline.showActivityMeta || usage().length === 0) { if (!footerDetails() || !responsive().statusline.showActivityMeta || usage().length === 0) {
return "" return ""
} }
@ -394,7 +426,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}) })
const modelStatus = createMemo(() => { const modelStatus = createMemo(() => {
const current = model() const current = model()
if (!prompt() || !responsive().statusline.showModel || !current) return if (!footerDetails() || !prompt() || !responsive().statusline.showModel || !current) return
return { return {
model: current, model: current,
variant: responsive().statusline.showModelVariant ? props.currentVariant() : undefined, variant: responsive().statusline.showModelVariant ? props.currentVariant() : undefined,
@ -409,7 +441,7 @@ export function RunFooterView(props: RunFooterViewProps) {
return theme().highlight return theme().highlight
} }
if (busy() || stateStatus().length > 0) { if (busy() || footerNotice().length > 0 || stateStatus().length > 0) {
return theme().text return theme().text
} }
@ -419,7 +451,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const hasActivityMeta = createMemo(() => activityMeta().length > 0) const hasActivityMeta = createMemo(() => activityMeta().length > 0)
const hasModelStatus = createMemo(() => Boolean(modelStatus())) const hasModelStatus = createMemo(() => Boolean(modelStatus()))
const contextHints = createMemo(() => { const contextHints = createMemo(() => {
if (!prompt() || shell() || !responsive().statusline.showContextHints) { if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showContextHints) {
return [] return []
} }
@ -459,6 +491,7 @@ export function RunFooterView(props: RunFooterViewProps) {
onCleanup(() => { onCleanup(() => {
props.onRequestExit?.(undefined) props.onRequestExit?.(undefined)
if (footerNoticeTimeout) clearTimeout(footerNoticeTimeout)
}) })
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
@ -699,6 +732,10 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onCycle() props.onCycle()
closePanel() closePanel()
}} }}
onStatus={() => {
showFooterStatus()
closePanel()
}}
onCommand={(name) => { onCommand={(name) => {
composer.submitText(`/${name}`) composer.submitText(`/${name}`)
closePanel() closePanel()
@ -857,7 +894,7 @@ export function RunFooterView(props: RunFooterViewProps) {
paddingRight={1} paddingRight={1}
backgroundColor="transparent" backgroundColor="transparent"
> >
<Show when={busy() && !exiting()}> <Show when={footerDetails() && busy() && !exiting()}>
<box flexShrink={0}> <box flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} /> <spinner color={spin().color} frames={spin().frames} interval={40} />
</box> </box>

View file

@ -89,6 +89,7 @@ export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }):
thinking: config?.mini?.thinking ?? "hide", thinking: config?.mini?.thinking ?? "hide",
shell_output: config?.mini?.shell_output ?? "hide", shell_output: config?.mini?.shell_output ?? "hide",
turn_summary: config?.mini?.turn_summary ?? "show", turn_summary: config?.mini?.turn_summary ?? "show",
footer: config?.mini?.footer ?? "show",
mono: config?.mini?.mono ?? false, mono: config?.mini?.mono ?? false,
} }
} }

View file

@ -398,6 +398,7 @@ export type MiniSettings = {
thinking: "show" | "hide" thinking: "show" | "hide"
shell_output: "show" | "hide" shell_output: "show" | "hide"
turn_summary: "show" | "hide" turn_summary: "show" | "hide"
footer: "show" | "hide"
mono: boolean mono: boolean
} }

View file

@ -55,7 +55,7 @@ test("down opens subagents from an empty prompt", async () => {
subagent={subagents} subagent={subagents}
theme={() => RUN_THEME_FALLBACK} theme={() => RUN_THEME_FALLBACK}
tuiConfig={config} 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} mono={false}
onSubmit={() => true} onSubmit={() => true}
onPermissionReply={() => {}} onPermissionReply={() => {}}

View file

@ -101,7 +101,7 @@ function footerState(input: Partial<FooterState> = {}) {
interrupt: 0, interrupt: 0,
exit: 0, exit: 0,
...input, ...input,
})[0] })
} }
async function renderFooter( async function renderFooter(
@ -129,10 +129,10 @@ async function renderFooter(
const [subagents] = createSignal<FooterSubagentState>( const [subagents] = createSignal<FooterSubagentState>(
input.subagents ?? { tabs: [], details: {}, permissions: [], forms: [] }, input.subagents ?? { tabs: [], details: {}, permissions: [], forms: [] },
) )
const state = footerState(input.state) const [state, setState] = footerState(input.state)
const config = input.tuiConfig ?? tuiConfig const config = input.tuiConfig ?? tuiConfig
const [miniSettings] = createSignal<MiniSettings>( const [miniSettings] = createSignal<MiniSettings>(
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() { function Harness() {
return ( return (
@ -186,6 +186,7 @@ async function renderFooter(
return { return {
...app, ...app,
setView, setView,
setState,
cleanup() { cleanup() {
app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedRenderable?.blur()
app.renderer.currentFocusedEditor?.blur() app.renderer.currentFocusedEditor?.blur()
@ -362,6 +363,7 @@ test("direct command panel renders grouped command palette", async () => {
]) ])
const [subagents] = createSignal([]) const [subagents] = createSignal([])
const [variants] = createSignal(["high", "minimal"]) const [variants] = createSignal(["high", "minimal"])
let status = 0
const app = await testRender( const app = await testRender(
() => ( () => (
@ -381,6 +383,7 @@ test("direct command panel renders grouped command palette", async () => {
onQueued={() => {}} onQueued={() => {}}
onVariant={() => {}} onVariant={() => {}}
onVariantCycle={() => {}} onVariantCycle={() => {}}
onStatus={() => status++}
onSettings={() => {}} onSettings={() => {}}
onCommand={() => {}} onCommand={() => {}}
onNew={() => {}} onNew={() => {}}
@ -406,6 +409,7 @@ test("direct command panel renders grouped command palette", async () => {
expect(frame).toContain("Prompt") expect(frame).toContain("Prompt")
expect(frame).toContain("Open editor") expect(frame).toContain("Open editor")
expect(frame).toContain("/editor") expect(frame).toContain("/editor")
expect(frame).toContain("Show status")
expect(frame).toContain("Switch model") expect(frame).toContain("Switch model")
expect(frame).toContain("Skills") expect(frame).toContain("Skills")
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("Cycle reasoning effort for future turns")
expect(frame).not.toContain("Review code") expect(frame).not.toContain("Review code")
expect(frame).not.toContain("Commands 8") 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 { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
@ -427,6 +437,7 @@ test("direct settings panel changes Mini transcript preferences", async () => {
thinking: "hide", thinking: "hide",
shell_output: "hide", shell_output: "hide",
turn_summary: "show", turn_summary: "show",
footer: "show",
mono: false, mono: false,
}) })
const app = await testRender( const app = await testRender(
@ -454,6 +465,7 @@ test("direct settings panel changes Mini transcript preferences", async () => {
expect(frame).toContain("Thinking") expect(frame).toContain("Thinking")
expect(frame).toContain("Shell") expect(frame).toContain("Shell")
expect(frame).toContain("Turn summary") expect(frame).toContain("Turn summary")
expect(frame).toContain("Footer details")
expect(frame).toContain("Monochrome UI") expect(frame).toContain("Monochrome UI")
expect(frame).toContain("left/right change") expect(frame).toContain("left/right change")
expect(frame).not.toMatch(/[^\x00-\x7F]/) expect(frame).not.toMatch(/[^\x00-\x7F]/)
@ -461,15 +473,16 @@ test("direct settings panel changes Mini transcript preferences", async () => {
app.mockInput.pressKey("ARROW_RIGHT") app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce() 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_DOWN") app.mockInput.pressKey("ARROW_DOWN")
app.mockInput.pressKey("ARROW_RIGHT") app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce() 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_DOWN")
app.mockInput.pressKey("ARROW_RIGHT") app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce() await app.renderOnce()
@ -589,6 +602,7 @@ test("direct command panel shows subagent entry when available", async () => {
onQueued={() => {}} onQueued={() => {}}
onVariant={() => {}} onVariant={() => {}}
onVariantCycle={() => {}} onVariantCycle={() => {}}
onStatus={() => {}}
onSettings={() => {}} onSettings={() => {}}
onCommand={() => {}} onCommand={() => {}}
onNew={() => {}} onNew={() => {}}
@ -638,6 +652,7 @@ test("direct command panel keeps completed subagents available", async () => {
onQueued={() => {}} onQueued={() => {}}
onVariant={() => {}} onVariant={() => {}}
onVariantCycle={() => {}} onVariantCycle={() => {}}
onStatus={() => {}}
onSettings={() => {}} onSettings={() => {}}
onCommand={() => {}} onCommand={() => {}}
onNew={() => {}} onNew={() => {}}
@ -1127,7 +1142,7 @@ test("direct footer shows authoritative pending work while running", async () =>
]} ]}
theme={() => RUN_THEME_FALLBACK} theme={() => RUN_THEME_FALLBACK}
tuiConfig={tuiConfig} 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} mono={false}
onSubmit={() => true} onSubmit={() => true}
onPermissionReply={() => {}} 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 () => { test("direct footer does not label normal mode as build", async () => {
const app = await renderFooter() const app = await renderFooter()

View file

@ -107,14 +107,18 @@ describe("run runtime boot", () => {
thinking: "hide", thinking: "hide",
shell_output: "hide", shell_output: "hide",
turn_summary: "show", turn_summary: "show",
footer: "show",
mono: false, mono: false,
}) })
expect( 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({ ).toEqual({
thinking: "show", thinking: "show",
shell_output: "show", shell_output: "show",
turn_summary: "hide", turn_summary: "hide",
footer: "hide",
mono: true, mono: true,
}) })
}) })