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

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