mini: pack statusline by content width (#38646)

This commit is contained in:
Simon Klee 2026-07-24 10:54:30 +02:00 committed by GitHub
parent ea010ab3a4
commit 00f063b381
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 230 additions and 115 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: {

View file

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