浏览代码

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

Simon Klee 3 周之前
父节点
当前提交
648183cecb

+ 9 - 34
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<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 {

+ 7 - 24
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<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>

+ 1 - 0
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

+ 1 - 0
packages/tui/test/mini/footer-keymap.test.tsx

@@ -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,

+ 13 - 3
packages/tui/test/mini/footer.view.test.tsx

@@ -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()
   }