Przeglądaj źródła

mini: add hideable footer details (#38192)

Simon Klee 3 tygodni temu
rodzic
commit
2ed8fe5960

+ 3 - 0
packages/tui/src/config/index.tsx

@@ -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 - 0
packages/tui/src/mini/footer.command.tsx

@@ -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
packages/tui/src/mini/footer.view.tsx

@@ -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 - 0
packages/tui/src/mini/runtime.boot.ts

@@ -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 - 0
packages/tui/src/mini/types.ts

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

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

@@ -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
packages/tui/test/mini/footer.view.test.tsx

@@ -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
packages/tui/test/mini/runtime.boot.test.ts

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