From 00f063b3811dae66a512b67246b899389485a554 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 24 Jul 2026 10:54:30 +0200 Subject: [PATCH] mini: pack statusline by content width (#38646) --- packages/tui/src/mini/footer.ts | 4 +- packages/tui/src/mini/footer.view.tsx | 163 ++++++++++++------ packages/tui/src/mini/footer.width.ts | 71 +++++--- packages/tui/test/mini/footer-keymap.test.tsx | 1 + packages/tui/test/mini/footer.test.ts | 4 +- packages/tui/test/mini/footer.view.test.tsx | 75 +++++++- packages/tui/test/mini/footer.width.test.ts | 27 +-- 7 files changed, 230 insertions(+), 115 deletions(-) diff --git a/packages/tui/src/mini/footer.ts b/packages/tui/src/mini/footer.ts index 002cc626168..99c86dd769e 100644 --- a/packages/tui/src/mini/footer.ts +++ b/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, diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index 956168565cf..0656a83eacc 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/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(() => props.view?.() ?? { type: "prompt" }) const subagent = createMemo(() => { 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 = () => {props.mono ? "- " : "· "} createEffect(() => { @@ -876,7 +914,7 @@ export function RunFooterView(props: RunFooterViewProps) { flexShrink={0} backgroundColor={statuslineBackground()} > - + {(label) => ( = 2 && !props.mono ? 1 : 0} + paddingRight={statuslineMainAvailable() >= (props.mono ? 1 : 2) ? 1 : 0} backgroundColor="transparent" + overflow="hidden" > - + = + (props.mono ? 1 : 2) + stringWidth(spin().frames[0] ?? "") + 1 + stringWidth(statuslineText()) + } + > @@ -917,29 +964,36 @@ export function RunFooterView(props: RunFooterViewProps) { - 0}> - - - {activityMeta()} - - + + {(usage) => ( + + + {usage()} + + + )} - + + {(agent) => ( + + + {sectionSeparator()} + {agent()} + + + )} + + + {(info) => ( - - - - {info().agent} - {props.mono ? " - " : " · "} + + + + {sectionSeparator()} {info().model} - + {(variant) => {variant()}} @@ -949,25 +1003,20 @@ export function RunFooterView(props: RunFooterViewProps) { {(hint, index) => ( - - - 0 || ((hasActivityMeta() || hasModelStatus()) && index() === 0)}> - {sectionSeparator()} - + + + 0 || (hasStatuslineInfo() && index() === 0)}>{sectionSeparator()} {hint.key}{" "} {hint.label} )} - {(hint) => ( - - - - {sectionSeparator()} - + + + 0}>{sectionSeparator()} {hint().key}{" "} {hint().label} diff --git a/packages/tui/src/mini/footer.width.ts b/packages/tui/src/mini/footer.width.ts index e7fd07b5bf9..c48f7eeb15a 100644 --- a/packages/tui/src/mini/footer.width.ts +++ b/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, + } +} diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index cca3526576e..40d7e09813d 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/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} diff --git a/packages/tui/test/mini/footer.test.ts b/packages/tui/test/mini/footer.test.ts index 8fbf7fc686b..caa84ee3574 100644 --- a/packages/tui/test/mini/footer.test.ts +++ b/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() }) diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index f86287124e0..881cf0e6257 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/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: { diff --git a/packages/tui/test/mini/footer.width.test.ts b/packages/tui/test/mini/footer.width.test.ts index 244ec83cb32..8e7fb229d92 100644 --- a/packages/tui/test/mini/footer.width.test.ts +++ b/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) }) })