Sfoglia il codice sorgente

mini: pack statusline by content width (#38646)

Simon Klee 3 settimane fa
parent
commit
00f063b381

+ 3 - 1
packages/tui/src/mini/footer.ts

@@ -104,7 +104,8 @@ type RunFooterOptions = {
 
 export function resolveRunAgent(agents: RunAgent[], current: string | undefined) {
   const selectable = agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
-  return selectable.find((agent) => agent.id === current) ?? selectable.at(0)
+  if (current === undefined) return selectable.at(0)
+  return selectable.find((agent) => agent.id === current)
 }
 
 const PERMISSION_ROWS = 12
@@ -327,6 +328,7 @@ export class RunFooter implements FooterApi {
               providers: footer.providers,
               currentAgent: footer.currentAgent,
               currentAgentID: footer.currentAgentID,
+              currentAgentExplicit: () => selectedAgentID() !== undefined,
               currentModel: footer.currentModel,
               variants: footer.variants,
               currentVariant: footer.currentVariant,

+ 106 - 57
packages/tui/src/mini/footer.view.tsx

@@ -29,10 +29,11 @@ import { RunPromptBody, createPromptState } from "./footer.prompt"
 import { RunPermissionBody } from "./footer.permission"
 import { RunFormBody } from "./footer.form"
 import { createFormBodyState, type FormBodyState } from "./form.shared"
-import { footerWidthPolicy } from "./footer.width"
+import { footerStatuslinePolicy } from "./footer.width"
 import { Keymap } from "../context/keymap"
 import { modelInfo } from "./variant.shared"
 import { monoShortcut } from "./mono"
+import { stringWidth } from "../util/string-width"
 
 import type {
   FooterPromptRoute,
@@ -79,6 +80,7 @@ type RunFooterViewProps = {
   providers: () => RunProvider[] | undefined
   currentAgent: () => string
   currentAgentID: () => string | undefined
+  currentAgentExplicit: () => boolean
   currentModel: () => RunInput["model"]
   variants: () => string[]
   currentVariant: () => string | undefined
@@ -116,7 +118,6 @@ type RunFooterViewProps = {
 export function RunFooterView(props: RunFooterViewProps) {
   const term = useTerminalDimensions()
   const width = createMemo(() => term().width)
-  const responsive = createMemo(() => footerWidthPolicy(width()))
   const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
   const subagent = createMemo<FooterSubagentState>(() => {
     return (
@@ -410,19 +411,19 @@ export function RunFooterView(props: RunFooterViewProps) {
     return shell() ? "Shell mode" : ""
   })
   const activityMeta = createMemo(() => {
-    if (!footerDetails() || !responsive().statusline.showActivityMeta || usage().length === 0) {
-      return ""
-    }
-
+    if (!footerDetails()) return ""
     return props.mono ? usage().replaceAll(" · ", " - ") : usage()
   })
+  const agentStatus = createMemo(() => {
+    if (!footerDetails() || !prompt() || shell() || !props.currentAgentExplicit()) return undefined
+    return props.currentAgent()
+  })
   const modelStatus = createMemo(() => {
     const current = model() ?? props.state().model.trim()
-    if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showModel || !current) return
+    if (!footerDetails() || !prompt() || shell() || !current) return
     return {
-      agent: props.currentAgent(),
       model: current,
-      variant: responsive().statusline.showModelVariant ? props.currentVariant() : undefined,
+      variant: props.currentVariant(),
     }
   })
   const statusColor = createMemo(() => {
@@ -441,32 +442,26 @@ export function RunFooterView(props: RunFooterViewProps) {
     return theme().muted
   })
   const statuslineBackground = createMemo(() => theme().status)
-  const hasActivityMeta = createMemo(() => activityMeta().length > 0)
-  const hasModelStatus = createMemo(() => Boolean(modelStatus()))
-  const contextHints = createMemo(() => {
-    if (!footerDetails() || !prompt() || shell() || !responsive().statusline.showContextHints) {
+  const contextHintCandidates = createMemo(() => {
+    if (!footerDetails() || !prompt() || shell()) {
       return []
     }
 
-    const items: Array<{ kind: string; key: string; label: string }> = []
+    const items: Array<{ key: string; label: string }> = []
     if (foregroundSubagents() && backgroundShortcut()) {
-      items.push({ kind: "background", key: backgroundShortcut(), label: "background" })
+      items.push({ key: backgroundShortcut(), label: "background" })
     }
     if (queuedPrompts().length > 0 && queuedShortcut()) {
-      items.push({ kind: "queued", key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
+      items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
     }
     if (activeTabs().length > 0 && subagentShortcut()) {
-      items.push({ kind: "subagents", key: subagentShortcut(), label: "subagents" })
+      items.push({ key: subagentShortcut(), label: "subagents" })
     }
 
-    const limit = responsive().statusline.contextHintLimit
-    return limit === undefined ? items : items.slice(0, limit)
+    return items
   })
-  const hasContextHints = createMemo(() => contextHints().length > 0)
   const commandHint = createMemo(() => {
-    if (!prompt() || !responsive().statusline.showCommandHint) {
-      return
-    }
+    if (!prompt()) return
 
     if (shell()) {
       return { key: "esc", label: "normal" }
@@ -476,6 +471,49 @@ export function RunFooterView(props: RunFooterViewProps) {
       return { key: command(), label: "cmd" }
     }
   })
+  const commandHintWidth = createMemo(() => {
+    const hint = commandHint()
+    return hint ? stringWidth(`${hint.key} ${hint.label}`) : 0
+  })
+  const statuslineText = createMemo(() =>
+    busy() && !exiting() && (footerDetails() || armed())
+      ? `${interruptLabel() ? `${interruptLabel()} ` : ""}${statusText()}`
+      : statusText(),
+  )
+  const statuslineMainWidth = createMemo(() => {
+    const mode = modeLabel()
+    const modeWidth = mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0
+    const spinnerWidth = footerDetails() && busy() && !exiting() ? stringWidth(spin().frames[0] ?? "") + 1 : 0
+    return modeWidth + Math.max(12, (props.mono ? 1 : 2) + spinnerWidth + stringWidth(statuslineText()))
+  })
+  const visibleModeLabel = createMemo(() => {
+    const mode = modeLabel()
+    if (!mode || width() - commandHintWidth() < stringWidth(mode) + (props.mono ? 1 : 2)) return undefined
+    return mode
+  })
+  const statuslineMainAvailable = createMemo(() => {
+    const mode = visibleModeLabel()
+    return width() - commandHintWidth() - (mode ? stringWidth(mode) + (props.mono ? 1 : 2) : 0)
+  })
+  const statuslineLayout = createMemo(() => {
+    const agent = agentStatus()
+    const info = modelStatus()
+    return footerStatuslinePolicy({
+      width: width(),
+      mainWidth: statuslineMainWidth(),
+      commandWidth: commandHint() ? commandHintWidth() : undefined,
+      agentWidth: agent ? stringWidth(agent) : undefined,
+      contextWidths: contextHintCandidates().map((item) => stringWidth(`${item.key} ${item.label}`)),
+      modelWidth: info ? stringWidth(info.model) : undefined,
+      variantWidth: info?.variant ? stringWidth(` ${info.variant}`) : undefined,
+      usageWidth: activityMeta() ? stringWidth(activityMeta()) : undefined,
+    })
+  })
+  const contextHints = createMemo(() => contextHintCandidates().slice(0, statuslineLayout().contextCount))
+  const hasStatuslineInfo = createMemo(() => {
+    const layout = statuslineLayout()
+    return layout.showUsage || layout.showAgent || layout.showModel
+  })
   const sectionSeparator = () => <span style={{ fg: theme().muted }}>{props.mono ? "- " : "· "}</span>
 
   createEffect(() => {
@@ -876,7 +914,7 @@ export function RunFooterView(props: RunFooterViewProps) {
                 flexShrink={0}
                 backgroundColor={statuslineBackground()}
               >
-                <Show when={modeLabel()}>
+                <Show when={visibleModeLabel()}>
                   {(label) => (
                     <box
                       paddingLeft={props.mono ? 0 : 1}
@@ -896,12 +934,21 @@ export function RunFooterView(props: RunFooterViewProps) {
                   gap={1}
                   flexGrow={1}
                   flexShrink={1}
-                  minWidth={12}
-                  paddingLeft={props.mono ? 0 : 1}
-                  paddingRight={1}
+                  minWidth={0}
+                  paddingLeft={statuslineMainAvailable() >= 2 && !props.mono ? 1 : 0}
+                  paddingRight={statuslineMainAvailable() >= (props.mono ? 1 : 2) ? 1 : 0}
                   backgroundColor="transparent"
+                  overflow="hidden"
                 >
-                  <Show when={footerDetails() && busy() && !exiting()}>
+                  <Show
+                    when={
+                      footerDetails() &&
+                      busy() &&
+                      !exiting() &&
+                      statuslineMainAvailable() >=
+                        (props.mono ? 1 : 2) + stringWidth(spin().frames[0] ?? "") + 1 + stringWidth(statuslineText())
+                    }
+                  >
                     <box flexShrink={0}>
                       <spinner color={spin().color} frames={spin().frames} interval={40} />
                     </box>
@@ -917,29 +964,36 @@ export function RunFooterView(props: RunFooterViewProps) {
                   </text>
                 </box>
 
-                <Show when={activityMeta().length > 0}>
-                  <box paddingRight={1} backgroundColor="transparent" flexShrink={1}>
-                    <text fg={theme().muted} wrapMode="none" truncate>
-                      {activityMeta()}
-                    </text>
-                  </box>
+                <Show when={statuslineLayout().showUsage && activityMeta()}>
+                  {(usage) => (
+                    <box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
+                      <text fg={theme().muted} wrapMode="none">
+                        {usage()}
+                      </text>
+                    </box>
+                  )}
+                </Show>
+
+                <Show when={statuslineLayout().showAgent && agentStatus()}>
+                  {(agent) => (
+                    <box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
+                      <text fg={theme().text} wrapMode="none">
+                        <Show when={statuslineLayout().showUsage}>{sectionSeparator()}</Show>
+                        {agent()}
+                      </text>
+                    </box>
+                  )}
                 </Show>
 
-                <Show when={modelStatus()}>
+                <Show when={statuslineLayout().showModel && modelStatus()}>
                   {(info) => (
-                    <box
-                      minWidth={8}
-                      paddingRight={1}
-                      backgroundColor="transparent"
-                      flexShrink={1}
-                    >
-                      <text fg={theme().text} wrapMode="none" truncate>
-                        <Show when={responsive().statusline.showAgent}>
-                          {info().agent}
-                          <span style={{ fg: theme().muted }}>{props.mono ? " - " : " · "}</span>
+                    <box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
+                      <text fg={theme().text} wrapMode="none">
+                        <Show when={statuslineLayout().showUsage || statuslineLayout().showAgent}>
+                          {sectionSeparator()}
                         </Show>
                         {info().model}
-                        <Show when={info().variant}>
+                        <Show when={statuslineLayout().showVariant && info().variant}>
                           {(variant) => <span style={{ fg: theme().warning, bold: true }}> {variant()}</span>}
                         </Show>
                       </text>
@@ -949,25 +1003,20 @@ export function RunFooterView(props: RunFooterViewProps) {
 
                 <For each={contextHints()}>
                   {(hint, index) => (
-                    <box paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={24}>
-                      <text fg={theme().text} wrapMode="none" truncate>
-                        <Show when={index() > 0 || ((hasActivityMeta() || hasModelStatus()) && index() === 0)}>
-                          {sectionSeparator()}
-                        </Show>
+                    <box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
+                      <text fg={theme().text} wrapMode="none">
+                        <Show when={index() > 0 || (hasStatuslineInfo() && index() === 0)}>{sectionSeparator()}</Show>
                         <span style={{ fg: theme().text }}>{hint.key}</span>{" "}
                         <span style={{ fg: theme().muted }}>{hint.label}</span>
                       </text>
                     </box>
                   )}
                 </For>
-
                 <Show when={commandHint()}>
                   {(hint) => (
-                    <box paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={18}>
-                      <text fg={theme().text} wrapMode="none" truncate>
-                        <Show when={hasActivityMeta() || hasModelStatus() || hasContextHints()}>
-                          {sectionSeparator()}
-                        </Show>
+                    <box backgroundColor="transparent" flexShrink={0}>
+                      <text fg={theme().text} wrapMode="none">
+                        <Show when={hasStatuslineInfo() || contextHints().length > 0}>{sectionSeparator()}</Show>
                         <span style={{ fg: theme().text }}>{hint().key}</span>{" "}
                         <span style={{ fg: theme().muted }}>{hint().label}</span>
                       </text>

+ 46 - 25
packages/tui/src/mini/footer.width.ts

@@ -1,31 +1,52 @@
-// Shared responsive width policy
-
-const FOOTER_WIDTH_BREAKPOINTS = {
-  commandHint: 24,
-  model: 32,
-  modelVariant: 40,
-  compact: 80,
-  context: 120,
-  spacious: 150,
-} as const
-
 export function footerWidthPolicy(width: number) {
-  const compact = width >= FOOTER_WIDTH_BREAKPOINTS.compact
-  const context = width >= FOOTER_WIDTH_BREAKPOINTS.context
-  const spacious = width >= FOOTER_WIDTH_BREAKPOINTS.spacious
-
   return {
     dialog: {
-      narrow: !compact,
-    },
-    statusline: {
-      showActivityMeta: compact,
-      showAgent: compact,
-      showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint,
-      showModel: width >= FOOTER_WIDTH_BREAKPOINTS.model,
-      showModelVariant: width >= FOOTER_WIDTH_BREAKPOINTS.modelVariant,
-      showContextHints: compact,
-      contextHintLimit: !compact ? 0 : spacious ? undefined : context ? 2 : 1,
+      narrow: width < 80,
     },
   }
 }
+
+export function footerStatuslinePolicy(input: {
+  width: number
+  mainWidth: number
+  commandWidth?: number
+  agentWidth?: number
+  contextWidths: number[]
+  modelWidth?: number
+  variantWidth?: number
+  usageWidth?: number
+}) {
+  let remaining = input.width - input.mainWidth - (input.commandWidth ?? 0)
+  let hasSection = input.commandWidth !== undefined
+  const include = (width: number | undefined) => {
+    if (width === undefined) return false
+    const required = width + (hasSection ? 3 : 1)
+    if (remaining < required) return false
+    remaining -= required
+    hasSection = true
+    return true
+  }
+
+  const showModel = include(input.modelWidth)
+  const showAgent = include(input.agentWidth)
+  const hiddenContext = input.contextWidths.findIndex((width) => !include(width))
+  const contextCount = hiddenContext === -1 ? input.contextWidths.length : hiddenContext
+  const contextComplete = contextCount === input.contextWidths.length
+  const variantWidth = input.variantWidth
+  const showVariant = showModel && contextComplete && variantWidth !== undefined && remaining >= variantWidth
+  if (showVariant) remaining -= variantWidth
+  const showUsage =
+    (showModel || input.modelWidth === undefined) &&
+    (showAgent || input.agentWidth === undefined) &&
+    contextComplete &&
+    (showVariant || input.variantWidth === undefined) &&
+    include(input.usageWidth)
+
+  return {
+    showAgent,
+    contextCount,
+    showModel,
+    showVariant,
+    showUsage,
+  }
+}

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

@@ -49,6 +49,7 @@ test("down opens subagents from an empty prompt", async () => {
           providers={() => undefined}
           currentAgent={() => "Build"}
           currentAgentID={() => "build"}
+          currentAgentExplicit={() => false}
           currentModel={() => undefined}
           variants={() => []}
           currentVariant={() => undefined}

+ 2 - 2
packages/tui/test/mini/footer.test.ts

@@ -24,7 +24,7 @@ test("coalesces progress only within the same message and tool state", () => {
   )
 })
 
-test("resolves the first selectable agent when none is selected", () => {
+test("falls back only when no agent is selected", () => {
   const agents: RunAgent[] = [
     { id: "task", name: "Task", mode: "subagent", hidden: false },
     { id: "secret", name: "Secret", mode: "primary", hidden: true },
@@ -34,5 +34,5 @@ test("resolves the first selectable agent when none is selected", () => {
 
   expect(resolveRunAgent(agents, undefined)?.id).toBe("build")
   expect(resolveRunAgent(agents, "plan")?.id).toBe("plan")
-  expect(resolveRunAgent(agents, "missing")?.id).toBe("build")
+  expect(resolveRunAgent(agents, "missing")).toBeUndefined()
 })

+ 69 - 6
packages/tui/test/mini/footer.view.test.tsx

@@ -157,6 +157,7 @@ async function renderFooter(
           providers={() => input.providers}
           currentAgent={() => input.currentAgent ?? "Build"}
           currentAgentID={() => input.currentAgent?.toLowerCase() ?? "build"}
+          currentAgentExplicit={() => input.currentAgent !== undefined}
           currentModel={() => input.currentModel}
           variants={() => []}
           currentVariant={() => input.currentVariant}
@@ -208,11 +209,13 @@ async function renderFooter(
   }
 }
 
-test("direct footer shows the generic default model before resolution", async () => {
+test("direct footer shows the default model without the fallback agent", async () => {
   const app = await renderFooter({ state: { model: "Default model" } })
   try {
     await app.renderOnce()
-    expect(app.captureCharFrame()).toContain("Default model")
+    const frame = app.captureCharFrame()
+    expect(frame).toContain("Default model")
+    expect(frame).not.toContain("Build")
   } finally {
     app.cleanup()
   }
@@ -1179,6 +1182,7 @@ test("direct footer shows authoritative pending work while running", async () =>
           providers={() => undefined}
           currentAgent={() => "Build"}
           currentAgentID={() => "build"}
+          currentAgentExplicit={() => false}
           currentModel={() => ({
             providerID: "opencode",
             modelID: "a-model-name-long-enough-to-force-responsive-truncation",
@@ -1276,14 +1280,13 @@ test("direct footer progressively adds model details after the command hint", as
   for (const expected of [
     { width: 24, agent: false, model: false, variant: false },
     { width: 32, agent: false, model: true, variant: false },
-    { width: 40, agent: false, model: true, variant: true },
-    { width: 80, agent: true, model: true, variant: true },
+    { width: 40, agent: true, model: true, variant: false },
+    { width: 48, agent: true, model: true, variant: true },
   ]) {
     const app = await renderFooter({
-      providers: [provider()],
       currentAgent: "Plan",
-      currentModel: { providerID: "opencode", modelID: "gpt-5" },
       currentVariant: "xhigh",
+      state: { model: "GPT-5" },
       width: expected.width,
     })
 
@@ -1303,6 +1306,66 @@ test("direct footer progressively adds model details after the command hint", as
   }
 })
 
+test("direct footer keeps commands and active work ahead of usage under width pressure", async () => {
+  const app = await renderFooter({
+    currentAgent: "Plan",
+    subagents: {
+      tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow" })],
+      details: {},
+      permissions: [],
+      forms: [],
+    },
+    state: {
+      phase: "running",
+      model: "a-model-name-long-enough-to-force-responsive-truncation",
+      usage: "159.6K (16%) · $4.23",
+    },
+    width: 80,
+  })
+
+  try {
+    await app.renderOnce()
+    const frame = app.captureCharFrame()
+
+    expect(frame).toContain("Plan")
+    expect(frame).toContain("ctrl+b background")
+    expect(frame).toContain("↓ subagents")
+    expect(frame).toContain("ctrl+p cmd")
+    expect(frame).not.toContain("a-model-name")
+    expect(frame).not.toContain("159.6K")
+    expect(frame).not.toContain("$4.23")
+  } finally {
+    app.cleanup()
+  }
+})
+
+test("direct footer keeps the command hint at its minimum width", async () => {
+  const app = await renderFooter({ state: { phase: "running" }, width: 10 })
+
+  try {
+    await app.renderOnce()
+    expect(app.captureCharFrame()).toContain("ctrl+p cmd")
+  } finally {
+    app.cleanup()
+  }
+})
+
+test("direct footer keeps complete status text ahead of the spinner", async () => {
+  const app = await renderFooter({
+    tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none" } }),
+    state: { phase: "running" },
+    width: 22,
+  })
+
+  try {
+    await app.renderOnce()
+    expect(app.captureCharFrame()).toContain("interrupt")
+    expect(boxPath(footerStatusline(app.renderer.root), "SpinnerRenderable")).toBeUndefined()
+  } finally {
+    app.cleanup()
+  }
+})
+
 test("direct footer always offers backgrounding for a foreground subagent", async () => {
   const app = await renderFooter({
     subagents: {

+ 3 - 24
packages/tui/test/mini/footer.width.test.ts

@@ -2,29 +2,8 @@ import { describe, expect, test } from "bun:test"
 import { footerWidthPolicy } from "../../src/mini/footer.width"
 
 describe("run footer width", () => {
-  test("preserves shared dialog and statusline breakpoints", () => {
-    expect([23, 24].map((width) => footerWidthPolicy(width).statusline.showCommandHint)).toEqual([false, true])
-    expect([31, 32].map((width) => footerWidthPolicy(width).statusline.showModel)).toEqual([false, true])
-    expect([39, 40].map((width) => footerWidthPolicy(width).statusline.showModelVariant)).toEqual([false, true])
-
-    const narrow = footerWidthPolicy(79)
-    expect(narrow.dialog.narrow).toBe(true)
-    expect(narrow.statusline.showActivityMeta).toBe(false)
-    expect(narrow.statusline.showAgent).toBe(false)
-    expect(narrow.statusline.showContextHints).toBe(false)
-    expect(narrow.statusline.contextHintLimit).toBe(0)
-
-    const compact = footerWidthPolicy(80)
-    expect(compact.dialog.narrow).toBe(false)
-    expect(compact.statusline.showActivityMeta).toBe(true)
-    expect(compact.statusline.showAgent).toBe(true)
-    expect(compact.statusline.showContextHints).toBe(true)
-    expect(compact.statusline.contextHintLimit).toBe(1)
-
-    const context = footerWidthPolicy(120)
-    expect(context.statusline.contextHintLimit).toBe(2)
-
-    const spacious = footerWidthPolicy(150)
-    expect(spacious.statusline.contextHintLimit).toBeUndefined()
+  test("preserves the dialog breakpoint", () => {
+    expect(footerWidthPolicy(79).dialog.narrow).toBe(true)
+    expect(footerWidthPolicy(80).dialog.narrow).toBe(false)
   })
 })