fix(tui): quiet hidden mini footer (#38279)

This commit is contained in:
Simon Klee 2026-07-22 12:21:43 +02:00 committed by GitHub
commit 648183cecb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 31 additions and 61 deletions

View file

@ -194,8 +194,6 @@ export class RunFooter implements FooterApi {
private interruptTimeout: NodeJS.Timeout | undefined
private exitTimeout: NodeJS.Timeout | undefined
private noticeTimeout: NodeJS.Timeout | undefined
private noticeRestoreStatus = ""
private statusVersion = 0
private requestExitHandler: (() => boolean) | undefined
private scrollback: RunScrollbackStream
private themes: RunTheme[]
@ -225,6 +223,7 @@ export class RunFooter implements FooterApi {
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
notice: "",
model: options.modelLabel,
usage: "",
first: options.first,
@ -449,6 +448,7 @@ export class RunFooter implements FooterApi {
if (patch) {
if (typeof patch.status === "string") {
this.clearNoticeTimer()
patch.notice = ""
}
if (next.type === "turn.send") {
this.clearInterruptTimer()
@ -479,12 +479,10 @@ export class RunFooter implements FooterApi {
}
const prev = this.state()
if (typeof next.status === "string") {
this.statusVersion++
}
const state = {
phase: next.phase ?? prev.phase,
status: typeof next.status === "string" ? next.status : prev.status,
notice: typeof next.notice === "string" ? next.notice : prev.notice,
model: typeof next.model === "string" ? next.model : prev.model,
usage: typeof next.usage === "string" ? next.usage : prev.usage,
first: typeof next.first === "boolean" ? next.first : prev.first,
@ -635,26 +633,13 @@ export class RunFooter implements FooterApi {
}
private setNotice(status: string): void {
const restore = this.noticeTimeout ? this.noticeRestoreStatus : this.state().status
this.clearNoticeTimer(false)
this.patch({ status })
if (!status) {
this.noticeRestoreStatus = ""
return
}
this.clearNoticeTimer()
this.patch({ notice: status })
if (!status) return
this.noticeRestoreStatus = restore
const version = this.statusVersion
this.noticeTimeout = setTimeout(() => {
this.noticeTimeout = undefined
if (this.isGone || version !== this.statusVersion) {
this.noticeRestoreStatus = ""
return
}
const next = this.noticeRestoreStatus
this.noticeRestoreStatus = ""
this.patch({ status: next })
this.patch({ notice: "" })
}, NOTICE_DURATION)
}
@ -895,19 +880,9 @@ export class RunFooter implements FooterApi {
this.interruptTimeout = undefined
}
private clearNoticeTimer(reset = true): void {
if (!this.noticeTimeout) {
if (reset) {
this.noticeRestoreStatus = ""
}
return
}
clearTimeout(this.noticeTimeout)
private clearNoticeTimer(): void {
if (this.noticeTimeout) clearTimeout(this.noticeTimeout)
this.noticeTimeout = undefined
if (reset) {
this.noticeRestoreStatus = ""
}
}
private armInterruptTimer(): void {

View file

@ -55,8 +55,6 @@ import type { RunTheme } from "./theme"
registerOpencodeSpinner()
const FOOTER_DETAIL_DURATION = 3000
const EMPTY_BORDER = {
topLeft: "",
bottomLeft: "",
@ -228,23 +226,6 @@ export function RunFooterView(props: RunFooterViewProps) {
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
@ -379,6 +360,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const shell = createMemo(() => prompt() && composer.shell())
const menu = createMemo(() => prompt() && composer.visible())
const stateStatus = createMemo(() => props.state().status.trim())
const notice = createMemo(() => props.state().notice.trim())
const modeLabel = createMemo(() => {
if (exiting()) {
return "EXIT"
@ -404,7 +386,9 @@ export function RunFooterView(props: RunFooterViewProps) {
if (busy() && armed()) return "again to interrupt"
if (footerNotice()) return footerNotice()
if (notice()) return notice()
if (!footerDetails()) return shell() ? "Shell mode" : ""
if (busy()) return "interrupt"
@ -438,7 +422,7 @@ export function RunFooterView(props: RunFooterViewProps) {
return theme().highlight
}
if (busy() || footerNotice().length > 0 || stateStatus().length > 0) {
if (busy() || notice().length > 0 || stateStatus().length > 0) {
return theme().text
}
@ -488,7 +472,6 @@ export function RunFooterView(props: RunFooterViewProps) {
onCleanup(() => {
props.onRequestExit?.(undefined)
if (footerNoticeTimeout) clearTimeout(footerNoticeTimeout)
})
Keymap.createLayer(() => ({
@ -730,7 +713,7 @@ export function RunFooterView(props: RunFooterViewProps) {
closePanel()
}}
onStatus={() => {
showFooterStatus()
props.onStatus(footerStatus())
closePanel()
}}
onCommand={(name) => {
@ -898,7 +881,7 @@ export function RunFooterView(props: RunFooterViewProps) {
</Show>
<text fg={statusColor()} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
<Show when={busy() && !exiting()} fallback={statusText()}>
<Show when={busy() && !exiting() && (footerDetails() || armed())} fallback={statusText()}>
<Show when={interruptLabel()}>
{(label) => <span style={{ fg: armed() ? statusColor() : theme().muted }}>{label()} </span>}
</Show>

View file

@ -166,6 +166,7 @@ type FooterPhase = "idle" | "running"
export type FooterState = {
phase: FooterPhase
status: string
notice: string
model: string
usage: string
first: boolean

View file

@ -12,6 +12,7 @@ test("down opens subagents from an empty prompt", async () => {
const [state] = createSignal<FooterState>({
phase: "idle",
status: "",
notice: "",
model: "gpt-5",
usage: "",
first: false,

View file

@ -94,6 +94,7 @@ function footerState(input: Partial<FooterState> = {}) {
return createSignal<FooterState>({
phase: "idle",
status: "",
notice: "",
model: "gpt-5",
usage: "",
first: false,
@ -1109,6 +1110,7 @@ test("direct footer shows authoritative pending work while running", async () =>
const [state] = createSignal<FooterState>({
phase: "running",
status: "",
notice: "",
model: "gpt-5",
usage: "",
first: false,
@ -1324,7 +1326,7 @@ test("direct footer shows full usage metadata when room is available", async ()
}
})
test("direct footer can hide persistent details and briefly reveal changes", async () => {
test("direct footer hides routine activity and shows explicit notices", async () => {
const app = await renderFooter({
state: { usage: "159.6K (16%) · $4.23" },
miniSettings: {
@ -1345,13 +1347,21 @@ test("direct footer can hide persistent details and briefly reveal changes", asy
expect(initial).not.toContain("gpt-5")
expect(initial).not.toContain("159.6K")
app.setState((state) => ({ ...state, phase: "running" }))
app.setState((state) => ({ ...state, phase: "running", status: "assistant responding" }))
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(changed).not.toContain("running")
expect(changed).not.toContain("assistant responding")
expect(changed).not.toContain("interrupt")
expect(changed).not.toContain("gpt-5")
expect(changed).not.toContain("159.6K")
expect(boxPath(statusline, "SpinnerRenderable")).toBeUndefined()
app.setState((state) => ({ ...state, notice: "variant high" }))
await app.renderOnce()
expect(app.captureCharFrame()).toContain("variant high")
} finally {
app.cleanup()
}