From dd50d457b0226989cff3e1ab3589abb9613e4372 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Tue, 21 Jul 2026 16:08:19 +0200 Subject: [PATCH] refactor(tui): simplify mini footer status line (#38112) --- packages/tui/src/component/prompt/index.tsx | 8 +- packages/tui/src/mini/catalog.shared.ts | 1 + packages/tui/src/mini/footer.view.tsx | 74 +++++-------------- packages/tui/src/mini/footer.width.ts | 7 +- packages/tui/src/mini/runtime.ts | 2 + packages/tui/src/mini/stream-v2.transport.ts | 10 ++- packages/tui/src/mini/theme.ts | 2 +- packages/tui/src/mini/types.ts | 3 + .../src/routes/session/subagent-footer.tsx | 8 +- packages/tui/src/util/session.ts | 6 ++ packages/tui/test/mini/catalog.shared.test.ts | 3 + packages/tui/test/mini/footer.view.test.tsx | 34 +++------ packages/tui/test/mini/footer.width.test.ts | 8 +- packages/tui/test/mini/runtime.boot.test.ts | 3 + .../tui/test/mini/stream-v2.transport.test.ts | 44 +++++++++++ 15 files changed, 111 insertions(+), 102 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 1d1a223e109..7f5e081cff9 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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, } }) diff --git a/packages/tui/src/mini/catalog.shared.ts b/packages/tui/src/mini/catalog.shared.ts index d9eb4cb874c..b152a4926f3 100644 --- a/packages/tui/src/mini/catalog.shared.ts +++ b/packages/tui/src/mini/catalog.shared.ts @@ -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, {}])), } diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index 811ddf838fe..2ddeef1ec0b 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -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 | 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()} > - - - {modeLabel()} - - + + {(label) => ( + + + {label()} + + + )} + - - {(info) => ( - - - {info().model} - - {(variant) => ( - <> - {variant()} - - )} - - - - )} - - {(hint, index) => ( - 0 || ((hasActivityMeta() || hasModelStatus()) && index() === 0)}> + 0 || (hasActivityMeta() && index() === 0)}> {sectionSeparator()} {hint.key}{" "} @@ -871,7 +837,7 @@ export function RunFooterView(props: RunFooterViewProps) { {(hint) => ( - + {sectionSeparator()} {hint().key}{" "} diff --git a/packages/tui/src/mini/footer.width.ts b/packages/tui/src/mini/footer.width.ts index db5ed87b69e..b0979858e46 100644 --- a/packages/tui/src/mini/footer.width.ts +++ b/packages/tui/src/mini/footer.width.ts @@ -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, }, } } diff --git a/packages/tui/src/mini/runtime.ts b/packages/tui/src/mini/runtime.ts index 444c5b24520..eb5ea1362da 100644 --- a/packages/tui/src/mini/runtime.ts +++ b/packages/tui/src/mini/runtime.ts @@ -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() diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 886c983cab7..1ec8cdfd310 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -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 + contextLimit?: (model: NonNullable) => number | undefined } export type SessionTurnInput = { @@ -141,6 +143,7 @@ type State = { errors: Set pending: Map admitted: Set + 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 void let readyReject!: (error: unknown) => void @@ -857,6 +861,7 @@ export async function createSessionTransport(input: StreamInput): Promise 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 diff --git a/packages/tui/src/mini/theme.ts b/packages/tui/src/mini/theme.ts index 8e2f0abd944..e8457ce4470 100644 --- a/packages/tui/src/mini/theme.ts +++ b/packages/tui/src/mini/theme.ts @@ -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 diff --git a/packages/tui/src/mini/types.ts b/packages/tui/src/mini/types.ts index 35831d8f999..f2519732256 100644 --- a/packages/tui/src/mini/types.ts +++ b/packages/tui/src/mini/types.ts @@ -58,6 +58,9 @@ export type RunProviderModel = { cost?: { input: number } + limit?: { + context: number + } status?: string variants?: Record } diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index dbaed08e036..f4654f200e8 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -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, } }) diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 7fe11fb7dbe..edec41e2d5b 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -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}%)` +} diff --git a/packages/tui/test/mini/catalog.shared.test.ts b/packages/tui/test/mini/catalog.shared.test.ts index 39163e62b09..334454c3aa4 100644 --- a/packages/tui/test/mini/catalog.shared.test.ts +++ b/packages/tui/test/mini/catalog.shared.test.ts @@ -81,6 +81,9 @@ describe("run catalog shared", () => { cost: { input: 0, }, + limit: { + context: 128_000, + }, status: "active", variants: { high: {}, diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 842653adf98..b8c191ceb00 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -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() } diff --git a/packages/tui/test/mini/footer.width.test.ts b/packages/tui/test/mini/footer.width.test.ts index 9db7f3c7e60..3f9809d3566 100644 --- a/packages/tui/test/mini/footer.width.test.ts +++ b/packages/tui/test/mini/footer.width.test.ts @@ -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) }) }) diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index 9587299ca1c..c7592014820 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -128,6 +128,9 @@ describe("run runtime boot", () => { cost: { input: 0, }, + limit: { + context: 128_000, + }, status: "active", variants: { high: {}, diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index 72dedd94d7d..371611972ec 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -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())