refactor(tui): simplify mini footer status line (#38112)

This commit is contained in:
Simon Klee 2026-07-21 16:08:19 +02:00 committed by GitHub
parent 592ef7433a
commit dd50d457b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 111 additions and 102 deletions

View file

@ -52,7 +52,7 @@ import { readLocalAttachment } from "./local-attachment"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { contextUsage } from "../../util/session"
import { contextUsage, formatContextUsage } from "../../util/session"
import { abbreviateHome } from "../../runtime"
registerOpencodeSpinner()
@ -312,11 +312,7 @@ export function Prompt(props: PromptProps) {
session.revert?.messageID,
)
return {
context: context
? context.percent === undefined
? Locale.number(context.tokens)
: `${Locale.number(context.tokens)} (${context.percent}%)`
: undefined,
context: context ? formatContextUsage(context.tokens, context.percent) : undefined,
cost: formattedCost,
}
})

View file

@ -75,6 +75,7 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
provider.models[model.id] = {
name: model.name,
cost: cost === undefined ? undefined : { input: cost },
limit: { context: model.limit.context },
status: model.status,
variants: Object.fromEntries((model.variants ?? []).map((variant) => [variant.id, {}])),
}

View file

@ -48,7 +48,6 @@ import type {
RunTuiConfig,
} from "./types"
import type { RunTheme } from "./theme"
import { modelInfo } from "./variant.shared"
registerOpencodeSpinner()
@ -158,10 +157,6 @@ export function RunFooterView(props: RunFooterViewProps) {
return tabs().findIndex((item) => item.sessionID === sessionID) + 1
})
const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background))
const model = createMemo(() => {
const current = props.currentModel()
return current ? modelInfo(props.providers(), current).model : props.state().model
})
const detail = createMemo(() => {
const current = route()
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
@ -190,19 +185,15 @@ export function RunFooterView(props: RunFooterViewProps) {
const theme = createMemo(() => runTheme().footer)
const block = createMemo(() => runTheme().block)
const spin = createMemo(() => {
const options = {
color: theme().highlight,
style: "blocks" as const,
inactiveFactor: 0.6,
minAlpha: 0.3,
}
return {
frames: createFrames({
color: theme().highlight,
style: "blocks",
inactiveFactor: 0.6,
minAlpha: 0.3,
}),
color: createColors({
color: theme().highlight,
style: "blocks",
inactiveFactor: 0.6,
minAlpha: 0.3,
}),
frames: createFrames(options),
color: createColors(options),
}
})
const permission = createMemo<Extract<FooterView, { type: "permission" }> | undefined>(() => {
@ -338,7 +329,7 @@ export function RunFooterView(props: RunFooterViewProps) {
return "EXIT"
}
return shell() ? "SHELL" : "BUILD"
return shell() ? "SHELL" : undefined
})
const modeColor = createMemo(() => {
if (exiting()) {
@ -373,17 +364,6 @@ export function RunFooterView(props: RunFooterViewProps) {
return usage()
})
const modelStatus = createMemo(() => {
const current = props.currentModel()
if (!prompt() || shell() || !current) {
return
}
return {
model: model(),
variant: props.currentVariant(),
}
})
const statusColor = createMemo(() => {
if (exiting()) {
return theme().error
@ -401,7 +381,6 @@ export function RunFooterView(props: RunFooterViewProps) {
})
const statuslineBackground = createMemo(() => theme().status)
const hasActivityMeta = createMemo(() => activityMeta().length > 0)
const hasModelStatus = createMemo(() => responsive().statusline.showModel && Boolean(modelStatus()))
const contextHints = createMemo(() => {
if (!prompt() || shell() || !responsive().statusline.showContextHints) {
return []
@ -796,11 +775,15 @@ export function RunFooterView(props: RunFooterViewProps) {
flexShrink={0}
backgroundColor={statuslineBackground()}
>
<box paddingLeft={1} paddingRight={1} backgroundColor={theme().statusAccent} flexShrink={0}>
<text wrapMode="none" truncate>
<span style={{ fg: modeColor(), bold: true }}>{modeLabel()}</span>
</text>
</box>
<Show when={modeLabel()}>
{(label) => (
<box paddingLeft={1} paddingRight={1} backgroundColor={theme().statusAccent} flexShrink={0}>
<text wrapMode="none" truncate>
<span style={{ fg: modeColor(), bold: true }}>{label()}</span>
</text>
</box>
)}
</Show>
<box
flexDirection="row"
@ -836,28 +819,11 @@ export function RunFooterView(props: RunFooterViewProps) {
</box>
</Show>
<Show when={responsive().statusline.showModel && modelStatus()}>
{(info) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0}>
<text fg={theme().text} wrapMode="none">
{info().model}
<Show when={info().variant}>
{(variant) => (
<>
<span style={{ fg: theme().warning, bold: true }}> {variant()}</span>
</>
)}
</Show>
</text>
</box>
)}
</Show>
<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)}>
<Show when={index() > 0 || (hasActivityMeta() && index() === 0)}>
{sectionSeparator()}
</Show>
<span style={{ fg: theme().text }}>{hint.key}</span>{" "}
@ -871,7 +837,7 @@ export function RunFooterView(props: RunFooterViewProps) {
{(hint) => (
<box paddingRight={1} backgroundColor="transparent" flexShrink={0} maxWidth={18}>
<text fg={theme().text} wrapMode="none" truncate>
<Show when={hasActivityMeta() || hasModelStatus() || hasContextHints()}>
<Show when={hasActivityMeta() || hasContextHints()}>
{sectionSeparator()}
</Show>
<span style={{ fg: theme().text }}>{hint().key}</span>{" "}

View file

@ -3,13 +3,13 @@
const FOOTER_WIDTH_BREAKPOINTS = {
compact: 80,
commandHint: 66,
model: 120,
context: 120,
spacious: 150,
} as const
export function footerWidthPolicy(width: number) {
const compact = width >= FOOTER_WIDTH_BREAKPOINTS.compact
const model = width >= FOOTER_WIDTH_BREAKPOINTS.model
const context = width >= FOOTER_WIDTH_BREAKPOINTS.context
const spacious = width >= FOOTER_WIDTH_BREAKPOINTS.spacious
return {
@ -20,8 +20,7 @@ export function footerWidthPolicy(width: number) {
showActivityMeta: compact,
showCommandHint: width >= FOOTER_WIDTH_BREAKPOINTS.commandHint,
showContextHints: compact,
contextHintLimit: !compact ? 0 : spacious ? undefined : model ? 2 : 1,
showModel: model,
contextHintLimit: !compact ? 0 : spacious ? undefined : context ? 2 : 1,
},
}
}

View file

@ -729,6 +729,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
onCommit: rememberLocal,
trace: log,
onCatalogRefresh: requestCatalogRefresh,
contextLimit: (model) =>
state.providers.find((provider) => provider.id === model.providerID)?.models[model.modelID]?.limit?.context,
})
if (footer.isClosed) {
await handle.close()

View file

@ -10,6 +10,7 @@ import type {
} from "@opencode-ai/client/promise"
import { Event } from "@opencode-ai/schema/event"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { formatContextUsage } from "../util/session"
import { blockerStatus, pickBlockerView } from "./session-data"
import { writeSessionOutput } from "./stream"
import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment"
@ -48,6 +49,7 @@ type StreamInput = {
trace?: Trace
signal?: AbortSignal
onCatalogRefresh?: (signal?: AbortSignal) => unknown | Promise<unknown>
contextLimit?: (model: NonNullable<RunInput["model"]>) => number | undefined
}
export type SessionTurnInput = {
@ -141,6 +143,7 @@ type State = {
errors: Set<string>
pending: Map<string, FooterQueuedPrompt>
admitted: Set<string>
stepModel: RunInput["model"]
}
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
@ -409,6 +412,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
errors: new Set(),
pending: new Map(),
admitted: new Set(),
stepModel: undefined,
}
let readyResolve!: () => void
let readyReject!: (error: unknown) => void
@ -857,6 +861,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "session.step.started") {
state.stepModel = { providerID: event.data.model.providerID, modelID: event.data.model.id }
write([], { phase: "running", status: "assistant responding" })
return
}
@ -1106,13 +1111,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.data.tokens.reasoning +
event.data.tokens.cache.read +
event.data.tokens.cache.write
const usage = total > 0 ? total.toLocaleString() : ""
const limit = state.stepModel ? input.contextLimit?.(state.stepModel) : undefined
state.stepModel = undefined
const usage = total > 0 ? formatContextUsage(total, limit ? Math.round((total / limit) * 100) : undefined) : ""
write([], {
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
})
return
}
if (event.type === "session.step.failed") {
state.stepModel = undefined
const rendered = state.errors.has(event.data.assistantMessageID)
state.errors.add(event.data.assistantMessageID)
if (state.wait) state.wait.failureRendered = true

View file

@ -374,7 +374,7 @@ function map(
const shade = fade(footerTheme.backgroundMenu, footerTheme.background, 0.12, 0.56, 0.72)
const surface = fade(footerTheme.backgroundMenu, footerTheme.background, 0.18, 0.76, 0.9)
const line = fade(footerTheme.backgroundMenu, footerTheme.background, 0.24, 0.9, 0.98)
const statusBase = tint(footerBackground, rgba("#000000"), footerMode === "dark" ? 0.12 : 0.06)
const statusBase = tint(footerBackground, rgba("#000000"), footerMode === "dark" ? 0.13 : 0.06)
const statusAccentBase =
footerMode === "dark" ? tint(footerBackground, rgba("#ffffff"), 0.06) : tint(statusBase, rgba("#000000"), 0.04)
const collapsedStatus = footerMode === "dark" && luminance(statusBase) <= 0.04

View file

@ -58,6 +58,9 @@ export type RunProviderModel = {
cost?: {
input: number
}
limit?: {
context: number
}
status?: string
variants?: Record<string, unknown>
}

View file

@ -6,7 +6,7 @@ import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { Keymap } from "../../context/keymap"
import { contextUsage } from "../../util/session"
import { contextUsage, formatContextUsage } from "../../util/session"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
@ -37,11 +37,7 @@ export function SubagentFooter() {
)
return {
context: context
? context.percent === undefined
? Locale.number(context.tokens)
: `${Locale.number(context.tokens)} (${context.percent}%)`
: undefined,
context: context ? formatContextUsage(context.tokens, context.percent) : undefined,
cost: formattedCost,
}
})

View file

@ -1,4 +1,5 @@
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { Locale } from "./locale"
export function isDefaultTitle(title: string) {
return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
@ -33,3 +34,8 @@ export function contextUsage(
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : undefined,
}
}
export function formatContextUsage(tokens: number, percent?: number) {
const value = Locale.number(tokens)
return percent === undefined ? value : `${value} (${percent}%)`
}

View file

@ -81,6 +81,9 @@ describe("run catalog shared", () => {
cost: {
input: 0,
},
limit: {
context: 128_000,
},
status: "active",
variants: {
high: {},

View file

@ -259,15 +259,9 @@ function footerComposerFrame(root: BoxRenderable | RootRenderable) {
function footerStatusline(root: BoxRenderable | RootRenderable) {
const status = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
const boxes = root.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
for (const box of boxes) {
const first = box.getChildren().find((item): item is BoxRenderable => item instanceof BoxRenderable)
if (
box.backgroundColor?.toInts().every((value, index) => value === status[index]) &&
first?.backgroundColor?.toInts().every((value, index) => value === accent[index])
)
return box
if (box.backgroundColor?.toInts().every((value, index) => value === status[index])) return box
boxes.push(...box.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable))
}
throw new Error("Footer statusline not found")
@ -1076,30 +1070,25 @@ test("direct footer shows authoritative pending work while running", async () =>
const frame = app.captureCharFrame()
const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
const statusline = footerStatusline(app.renderer.root)
const statusItems = statusline.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
const mode = statusItems[0]
const main = statusItems[1]
const main = statusItems[0]
const spinner = main.getChildren()[0]
const model = statusItems[2]
const queued = statusItems[3]
const background = statusItems[1]
const queued = statusItems[2]
const hint = statusItems.at(-1)!
expect(spinner).toBeDefined()
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
expect(frame).toContain("1 pending")
expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+x q 1 pending")
expect(frame).toContain("↓ subagents")
expect(frame).toContain("ctrl+p cmd")
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
expect(frame).toContain("subagents · ctrl+p cmd")
expect(frame).not.toContain("1 agent")
expect(statusline.backgroundColor.toInts()).toEqual(tinted)
expect(mode.backgroundColor.toInts()).toEqual(accent)
expect(main.backgroundColor.toInts()).toEqual(transparent)
expect(model.backgroundColor.toInts()).toEqual(transparent)
expect(background.backgroundColor.toInts()).toEqual(transparent)
expect(queued.backgroundColor.toInts()).toEqual(transparent)
expect(hint.backgroundColor.toInts()).toEqual(transparent)
} finally {
@ -1127,9 +1116,7 @@ test("direct footer always offers backgrounding for a foreground subagent", asyn
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+b background · ↓ subagents · ctrl+p cmd")
expect(frame).toContain("ctrl+b background")
expect(frame).toContain("ctrl+b background · ↓ subagents · ctrl+p cmd")
expect(frame).not.toContain("queued")
} finally {
app.cleanup()
@ -1154,8 +1141,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
await app.renderOnce()
const frame = app.captureCharFrame()
expect(frame).toContain("GPT-5")
expect(frame).toContain("xhigh · ctrl+p cmd")
expect(frame).toContain("ctrl+p cmd")
expect(frame).not.toContain("↓ subagents")
} finally {
app.cleanup()
@ -1194,7 +1180,7 @@ test("direct footer shows full usage metadata when room is available", async ()
}
})
test("direct footer mode label keeps left padding without a status pill", async () => {
test("direct footer does not label normal mode as build", async () => {
const app = await renderFooter()
try {
@ -1202,10 +1188,10 @@ test("direct footer mode label keeps left padding without a status pill", async
const statusline = app
.captureCharFrame()
.split("\n")
.find((line) => line.includes("BUILD") && line.includes("cmd"))
.find((line) => line.includes("cmd"))
expect(statusline).toBeDefined()
expect(statusline?.startsWith(" BUILD ")).toBe(true)
expect(statusline).not.toContain("BUILD")
} finally {
app.cleanup()
}

View file

@ -9,7 +9,6 @@ describe("run footer width", () => {
expect(narrow.statusline.showCommandHint).toBe(true)
expect(narrow.statusline.showContextHints).toBe(false)
expect(narrow.statusline.contextHintLimit).toBe(0)
expect(narrow.statusline.showModel).toBe(false)
const command = footerWidthPolicy(65)
expect(command.statusline.showCommandHint).toBe(false)
@ -22,14 +21,11 @@ describe("run footer width", () => {
expect(compact.statusline.showActivityMeta).toBe(true)
expect(compact.statusline.showContextHints).toBe(true)
expect(compact.statusline.contextHintLimit).toBe(1)
expect(compact.statusline.showModel).toBe(false)
const model = footerWidthPolicy(120)
expect(model.statusline.contextHintLimit).toBe(2)
expect(model.statusline.showModel).toBe(true)
const context = footerWidthPolicy(120)
expect(context.statusline.contextHintLimit).toBe(2)
const spacious = footerWidthPolicy(150)
expect(spacious.statusline.contextHintLimit).toBeUndefined()
expect(spacious.statusline.showModel).toBe(true)
})
})

View file

@ -128,6 +128,9 @@ describe("run runtime boot", () => {
cost: {
input: 0,
},
limit: {
context: 128_000,
},
status: "active",
variants: {
high: {},

View file

@ -200,6 +200,50 @@ afterEach(() => {
})
describe("V2 mini transport", () => {
test("formats footer usage with compact tokens and context percentage", async () => {
const events = feed()
events.push(connected())
const ui = footer()
const transport = await createSessionTransport({
sdk: sdk({ streams: [events] }),
sessionID: "ses_1",
thinking: false,
footer: ui.api,
contextLimit: (model) =>
model.providerID === "test" && model.modelID === "model" ? 160_000 : undefined,
})
events.push({
id: "evt_step_started",
created: 1,
type: "session.step.started",
durable: durable("ses_1", 1),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
agent: "build",
model: { providerID: "test", id: "model" },
},
})
events.push({
id: "evt_step_ended",
created: 2,
type: "session.step.ended",
durable: durable("ses_1", 2),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
finish: "stop",
cost: 0,
tokens: { input: 7_000, output: 500, reasoning: 8, cache: { read: 0, write: 0 } },
},
})
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.usage)) await Bun.sleep(0)
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { usage: "7.5K (5%)" } })
await transport.close()
})
test("recursively hydrates blockers for direct and transitive descendants", async () => {
const events = feed()
events.push(connected())