Status Line
The status line is the persistent metadata strip at the bottom (or top) of the TUI that tells the user what the agent is doing and how much of the context window it has consumed. It sounds trivial, but every element of it is hard to get right: token count requires knowing which tokens were counted, the git branch requires a background subprocess, the model name needs to fit in a variable-width terminal, and the entire thing must rebuild gracefully as the terminal is resized. Aider does not bother with a persistent status line at all — it prints inline after each turn. Codex builds the most complete implementation, with a configurable multi-item bar that the user can reorder. OpenCode splits the metadata across a header and a footer.
Feature Definition
Section titled “Feature Definition”A status line in a coding agent TUI must answer several questions simultaneously:
- What model am I talking to? Model name, optionally with reasoning effort level
- How much context have I consumed? Token count in absolute and relative (%) form
- Where am I? Current working directory, git branch
- What is the agent doing right now? Working / waiting / error, with elapsed time
- Are there pending items? Permission requests, pending tool calls, background agents
- What are the session costs? Token usage for this session, rate limit status
The display must degrade gracefully when the terminal is narrow: items should be dropped in priority order rather than truncating in the middle of a value.
Aider Implementation
Section titled “Aider Implementation”Source: aider/coders/base_coder.py, aider/utils.py
Aider has no persistent status line. There is no widget that updates in place at a fixed position on screen. Instead, status information is emitted inline as plain text messages after each LLM turn.
Token Usage Report
Section titled “Token Usage Report”After each API call completes, base_coder.py builds a usage_report string and calls show_usage_report():
# base_coder.py:2023-2029tokens_report = f"Tokens: {format_tokens(self.message_tokens_sent)} sent"
if cache_write_tokens: tokens_report += f", {format_tokens(cache_write_tokens)} cache write"if cache_hit_tokens: tokens_report += f", {format_tokens(cache_hit_tokens)} cache hit"tokens_report += f", {format_tokens(self.message_tokens_received)} received."The cost is then appended if available:
# base_coder.py:2046-2047self.total_cost += costself.message_cost += costThe display is printed via self.io.tool_output(self.usage_report) (base_coder.py:2109) — a plain tool_output call that goes directly to the terminal. It appears once per turn, scrolling away as more output is printed.
Token Formatting Helper
Section titled “Token Formatting Helper”# utils.py:279-285def format_tokens(count): if count < 1000: return f"{count}" elif count < 10000: return f"{count / 1000:.1f}k" else: return f"{round(count / 1000)}k"Example output: Tokens: 8.2k sent, 1.5k cache hit, 312 received. Cost: $0.0031 message, $0.0124 session.
What Aider Does Not Have
Section titled “What Aider Does Not Have”- No persistent status bar widget
- No model name display (only shown at startup)
- No git branch in the UI
- No session ID display
- No in-flight task indicator or spinner (Rich’s
Livecontext is used during streaming, but it disappears after the turn) - No rate limit display
The philosophy is minimalism: a terminal tool should not use up screen space for a status bar when that space could show conversation history. Users who want model or cost info can check startup output.
Codex Implementation
Section titled “Codex Implementation”Source: codex-rs/tui/src/bottom_pane/status_line_setup.rs, codex-rs/tui/src/bottom_pane/footer.rs, codex-rs/tui/src/status/, codex-rs/tui/src/status_indicator_widget.rs
Codex has the most complete status line implementation of the three reference tools: a configurable, user-reorderable bar in the footer with 15 distinct data items, plus a separate in-flight task indicator above the composer.
StatusLineItem Enum
Section titled “StatusLineItem Enum”The status line is composed of typed items chosen from this enum:
// status_line_setup.rs:37-57#[derive(EnumIter, EnumString, Display, Debug, Clone, Eq, PartialEq)]#[strum(serialize_all = "kebab_case")]pub(crate) enum StatusLineItem { ModelName, ModelWithReasoning, CurrentDir, ProjectRoot, GitBranch, ContextRemaining, // "18% left" ContextUsed, // "82% used" FiveHourLimit, // "5h 100%" WeeklyLimit, // "weekly 98%" CodexVersion, ContextWindowSize, // "258K window" UsedTokens, // "27.3K used" TotalInputTokens, // "17,588 in" TotalOutputTokens, // "265 out" SessionId, // UUID}The #[strum(serialize_all = "kebab_case")] derive means each item serializes to a config-friendly string like "model-name" or "context-remaining". The user’s chosen set is saved to config and restored across sessions.
StatusLineSetupView
Section titled “StatusLineSetupView”The configuration UI (status_line_setup.rs:155–278) is a MultiSelectPicker widget. The user navigates with arrow keys, reorders items with left/right, and toggles visibility with space. A live preview row below the picker renders exactly what the final status line will look like:
gpt-5.2-codex · 18% left · feat/awesome-featureItems are joined by · with muted color. Width-aware collapse logic ensures items are dropped from the right when the terminal is too narrow to show everything.
Footer Architecture
Section titled “Footer Architecture”// footer.rs:49-73pub(crate) struct FooterProps { pub(crate) mode: FooterMode, pub(crate) status_line_value: Option<Line<'static>>, pub(crate) status_line_enabled: bool, pub(crate) context_window_percent: Option<i64>, pub(crate) context_window_used_tokens: Option<i64>, // ... additional fields for hints and narrowness}The FooterMode enum distinguishes between Normal (showing the status bar), StatusLineSetup (showing the config picker), and similar overlay states.
Status Line Refresh
Section titled “Status Line Refresh”The ChatWidget refreshes the status line whenever relevant state changes:
// chatwidget.rs:892-962pub(crate) fn refresh_status_line(&mut self) { let enabled = self.status_line_enabled; let config = self.status_line_config.clone();
// Collect data from all sources let model_name = self.model_name.clone(); let cwd = self.cwd.clone(); let git_branch = self.git_branch.clone(); let token_info = self.token_info.clone(); let rate_limits = self.rate_limits.clone(); let session_id = self.session_id;
let line = format_status_line_from_items( &config, model_name, cwd, git_branch, token_info, rate_limits, session_id ); self.set_status_line(line);}
pub(crate) fn set_status_line_branch(&mut self, cwd: PathBuf, branch: Option<String>) { self.git_branch = branch; self.cwd = cwd; self.refresh_status_line(); // Triggers on every git branch change}Compact Formatters
Section titled “Compact Formatters”// status/helpers.rs:105-143pub(crate) fn format_tokens_compact(value: i64) -> String { // 0 → "0" // 999 → "999" // 1000 → "1.00K" // 27300 → "27.3K" // 2700000 → "2.70M"}
// status/helpers.rs:146-167pub(crate) fn format_directory_display(directory: &Path, max_width: Option<usize>) -> String { // ~/projects/myapp // ~/projects/my…pp (center-truncated if too wide)}Width-Adaptive Left Side
Section titled “Width-Adaptive Left Side”The footer’s left side has the most complex layout logic in the entire codebase (footer.rs). It goes through multiple passes to decide what to show given the available width:
- Try: default state (all hints visible) + right-side context indicator
- Fallback: drop cycle hint but keep queue message
- Fallback: shorter queue hint only
- Fallback: drop context indicator to make room for queue hint
- Fallback: mode label only
- Final fallback: nothing
This ensures that in even the narrowest terminal, the most important information (current mode) survives while less important items (keyboard shortcuts, context %) are dropped first.
StatusIndicatorWidget
Section titled “StatusIndicatorWidget”Separate from the footer, the StatusIndicatorWidget (status_indicator_widget.rs:36–89) renders above the composer to show the state of the in-flight agent task:
pub(crate) struct StatusIndicatorWidget { header: String, // "Working", "Running tool: bash", etc. details: Option<String>, // Tool arguments or sub-operation description inline_message: Option<String>, show_interrupt_hint: bool, // "esc to interrupt" elapsed_running: Duration, last_resume_at: Instant,}The elapsed timer pauses when the agent is waiting for user approval and resumes when it resumes work. The compact elapsed formatter produces human-readable output:
pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String { // 45 → "45s" // 90 → "1m 30s" // 3789 → "1h 03m 09s"}The show_interrupt_hint boolean is set to true once the task has been running long enough that the user might want to cancel — avoiding the “esc to interrupt” hint appearing on trivially fast operations.
OpenCode Implementation
Section titled “OpenCode Implementation”Source: opencode/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx, opencode/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
OpenCode splits its metadata display across two components: a footer showing connection status and tooling, and a header showing session identity and token costs.
Footer Component
Section titled “Footer Component”// footer.tsx:1-91export function Footer() { const { theme } = useTheme() const sync = useSync() const route = useRoute()
const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length ) const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed") ) const lsp = createMemo(() => Object.keys(sync.data.lsp)) const permissions = createMemo(() => { if (route.data.type !== "session") return [] return sync.data.permission[route.data.sessionID] ?? [] }) const directory = useDirectory() const connected = useConnected()
return ( <box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}> <text fg={theme.textMuted}>{directory()}</text> <box gap={2} flexDirection="row" flexShrink={0}> <Switch> <Match when={store.welcome}> <text fg={theme.text}> Get started <span style={{ fg: theme.textMuted }}>/connect</span> </text> </Match> <Match when={connected()}> <Show when={permissions().length > 0}> <text fg={theme.warning}> △ {permissions().length} Permission{permissions().length > 1 ? "s" : ""} </text> </Show> <text fg={theme.text}> <span style={{ fg: lsp().length > 0 ? theme.success : theme.textMuted }}>•</span>{" "} {lsp().length} LSP </text> <Show when={mcp()}> <text fg={theme.text}> <Switch> <Match when={mcpError()}><span style={{ fg: theme.error }}>⊙ </span></Match> <Match when={true}><span style={{ fg: theme.success }}>⊙ </span></Match> </Switch> {mcp()} MCP </text> </Show> <text fg={theme.textMuted}>/status</text> </Match> </Switch> </box> </box> )}Left side: Current working directory in muted color.
Right side when connected:
- Pending permission requests: warning triangle
△+ count (only shown when > 0) - LSP server count: green dot if any connected, gray otherwise; always shows the number
- MCP server count: green
⊙if all healthy, red⊙if any failed (hidden if 0) /statusslash command hint
Right side when disconnected: Welcome message with /connect hint.
The reactive Solid.js signals (createMemo, createSignal) mean this component updates automatically whenever sync.data changes — no manual refresh needed.
Header Component
Section titled “Header Component”The header (header.tsx) shows session-level metadata at the top of the chat view:
const cost = createMemo(() => { const total = pipe( messages(), sumBy((x) => (x.role === "assistant" ? x.cost : 0)), ) return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(total)})
const context = createMemo(() => { const last = messages().findLast( (x) => x.role === "assistant" && x.tokens.output > 0 ) as AssistantMessage if (!last) return const total = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write const model = sync.data.provider.find((x) => x.id === last.providerID) ?.models[last.modelID] let result = total.toLocaleString() if (model?.limit.context) { result += " " + Math.round((total / model.limit.context) * 100) + "%" } return result})Session title — shown with # prefix.
Total cost — cumulative cost of all assistant messages in the session, formatted as USD currency via Intl.NumberFormat.
Context usage — the last assistant message’s total token count (input + output + reasoning + cache.read + cache.write), plus the percentage of the model’s declared context window used if the model metadata is available.
What OpenCode Does Not Have
Section titled “What OpenCode Does Not Have”- No git branch in the TUI status (branch is not displayed anywhere in the chat view)
- No session-wide token breakdown by type (only total cost and last-message context %)
- No configurable status line item ordering
- No elapsed timer for in-flight tasks
- No rate limit display
- The footer does not show the model name (it is visible in the session settings view only)
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”Git Branch Requires a Background Process
Section titled “Git Branch Requires a Background Process”The git branch is the only status line item that requires an external process. Querying git symbolic-ref --short HEAD (or equivalent) is fast but not instant, and must not block the main UI loop. Codex triggers the branch query on workspace directory changes and receives the result via an async message. The status line shows a placeholder until the first result arrives.
Token Count Sources Can Disagree
Section titled “Token Count Sources Can Disagree”The status line shows “context used” but there are at least three token counts available: the count sent to the API (from the HTTP request), the count reported by the API response (from usage headers), and the count estimated locally before the request. These numbers do not always agree — cached tokens are often not included in what the API counts as “context”. Showing a stale or estimated count without labeling it as such is confusing.
Item Width Is Non-Deterministic at Layout Time
Section titled “Item Width Is Non-Deterministic at Layout Time”Status line items have variable widths depending on the data. ModelName can be 10 chars ("gpt-5.2-codex") or 30 chars ("claude-4-6-opus-20260115"). The footer layout must measure the rendered width of each item before deciding which ones to drop. This means the layout algorithm has to try rendering items in order and stop when they would overflow — it cannot pre-compute a plan.
Compact Number Formatting Is Locale-Sensitive
Section titled “Compact Number Formatting Is Locale-Sensitive”Intl.NumberFormat in OpenCode is locale-aware but potentially surprising: users in some locales see . as a thousands separator and , as a decimal point. For token counts in a TUI, a locale-independent compact format (27.3K, 2.7M) is more predictable. Codex’s format_tokens_compact() avoids the issue by implementing its own formatter.
The Elapsed Timer Must Account for Pauses
Section titled “The Elapsed Timer Must Account for Pauses”An in-flight timer that counts wall clock time will show inflated times when the user spends time in an approval dialog. Codex’s StatusIndicatorWidget tracks elapsed_running: Duration separately from last_resume_at: Instant, incrementing the duration only when the agent is actually running, not when it is blocked waiting for user input.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”Architecture
Section titled “Architecture”Split into two widgets following Codex’s pattern:
StatusBar— persistent footer widget, user-configurable items, refreshes when session state changesTaskIndicator— ephemeral widget above the composer, shows in-flight task state with elapsed timer
StatusBarItem Enum
Section titled “StatusBarItem Enum”#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, strum::EnumIter, strum::Display)]#[strum(serialize_all = "kebab_case")]pub enum StatusBarItem { ModelName, ContextUsed, // "27.3K / 200K (14%)" ContextPercent, // "14% used" GitBranch, WorkingDirectory, SessionId, ElapsedThisSession, TotalCost,}Refresh Strategy
Section titled “Refresh Strategy”The StatusBar widget holds a StatusBarState that is rebuilt any time its inputs change:
pub struct StatusBarState { pub items: Vec<StatusBarItem>, pub model_name: Option<String>, pub git_branch: Option<String>, pub cwd: PathBuf, pub token_used: Option<i64>, pub context_window: Option<i64>, pub session_id: Uuid, pub total_cost: f64,}The state is passed into the widget render() call. Width-adaptive collapse: try to render all items; if overflow, drop rightmost items first.
TaskIndicator
Section titled “TaskIndicator”pub struct TaskIndicator { pub header: String, pub details: Option<String>, pub elapsed_running: Duration, pub last_resume_at: Option<Instant>, pub show_interrupt_hint: bool,}
impl TaskIndicator { pub fn pause(&mut self) { if let Some(resume) = self.last_resume_at.take() { self.elapsed_running += resume.elapsed(); } }
pub fn resume(&mut self) { self.last_resume_at = Some(Instant::now()); }
pub fn total_elapsed(&self) -> Duration { let running = self.last_resume_at .map(|t| t.elapsed()) .unwrap_or_default(); self.elapsed_running + running }}Crates
Section titled “Crates”| Crate | Purpose |
|---|---|
ratatui | Widget rendering primitives |
strum | Enum iteration and string serialization for item types |
unicode-width | Correct column width for item overflow detection |
tokio | Background git branch query without blocking UI loop |