From acffdcd2401e4d83d02231380c3f3221e23237b0 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 22 Jul 2026 11:42:59 +0200 Subject: [PATCH] fix(tui): quiet hidden mini footer --- packages/tui/src/mini/footer.ts | 43 ++++--------------- packages/tui/src/mini/footer.view.tsx | 31 +++---------- packages/tui/src/mini/types.ts | 1 + packages/tui/test/mini/footer-keymap.test.tsx | 1 + packages/tui/test/mini/footer.view.test.tsx | 16 +++++-- 5 files changed, 31 insertions(+), 61 deletions(-) diff --git a/packages/tui/src/mini/footer.ts b/packages/tui/src/mini/footer.ts index 2673cc5e6f45..cc5520b4dbe4 100644 --- a/packages/tui/src/mini/footer.ts +++ b/packages/tui/src/mini/footer.ts @@ -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({ 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 { diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index dfc326f4b456..3a6b622bfaa3 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -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 | 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 @@ -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) { - + {(label) => {label()} } diff --git a/packages/tui/src/mini/types.ts b/packages/tui/src/mini/types.ts index 3d7920f93e3a..0bafcbbe5dd6 100644 --- a/packages/tui/src/mini/types.ts +++ b/packages/tui/src/mini/types.ts @@ -166,6 +166,7 @@ type FooterPhase = "idle" | "running" export type FooterState = { phase: FooterPhase status: string + notice: string model: string usage: string first: boolean diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index da2513dba4b4..5bd264fa4208 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/packages/tui/test/mini/footer-keymap.test.tsx @@ -12,6 +12,7 @@ test("down opens subagents from an empty prompt", async () => { const [state] = createSignal({ phase: "idle", status: "", + notice: "", model: "gpt-5", usage: "", first: false, diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 38eabf85ae9c..418689f14c62 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -94,6 +94,7 @@ function footerState(input: Partial = {}) { return createSignal({ 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({ 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() }