mini: add hideable footer details (#38192)

This commit is contained in:
Simon Klee
2026-07-21 23:28:40 +02:00
committed by GitHub
parent 1ea9137c01
commit 2ed8fe5960
8 changed files with 130 additions and 16 deletions
+3
View File
@@ -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",
}),
+20
View File
@@ -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",
+45 -8
View File
@@ -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<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 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"
>
<Show when={busy() && !exiting()}>
<Show when={footerDetails() && busy() && !exiting()}>
<box flexShrink={0}>
<spinner color={spin().color} frames={spin().frames} interval={40} />
</box>
+1
View File
@@ -89,6 +89,7 @@ export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }):
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,
}
}
+1
View File
@@ -398,6 +398,7 @@ export type MiniSettings = {
thinking: "show" | "hide"
shell_output: "show" | "hide"
turn_summary: "show" | "hide"
footer: "show" | "hide"
mono: boolean
}
@@ -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={() => {}}
+54 -6
View File
@@ -101,7 +101,7 @@ function footerState(input: Partial<FooterState> = {}) {
interrupt: 0,
exit: 0,
...input,
})[0]
})
}
async function renderFooter(
@@ -129,10 +129,10 @@ async function renderFooter(
const [subagents] = createSignal<FooterSubagentState>(
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<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() {
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()
+5 -1
View File
@@ -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,
})
})