feat(tui): add configurable status line#86
Merged
Merged
Conversation
Reframe the earlier usage-only design around the full status-line feature. The updated research keeps Claude Code, Codex, opencode, and the local setup as references while making the oxide-code choice explicit: a typed ordered roster of built-in segments first, with command hooks and account-limit telemetry deferred. The design now documents configurable ordering, implemented segment names, usage/cost data flow, default order, and the boundaries for later billing and task metadata work.
Replace the fixed status bar layout with an ordered built-in segment roster driven by [tui].status_line and OX_STATUS_LINE. The renderer keeps styling in the active theme, omits unavailable segments, and drops lower-utility segments before clipping core model/run-state information on narrow terminals. Thread Anthropic usage through the agent loop so the TUI can show cache-aware context pressure and an in-process estimated session cost. Pricing now lives on the model catalogue instead of a parallel matcher, with per-model rate rows for the known Claude catalogue. Update user docs, roadmap status, crate-tree notes, and snapshots to match the new status-line surface.
Anthropic's current lifecycle table still lists Opus 4.1 as active, so treating it as unknown would break explicit dated-id users and suppress available session-cost estimates. Keep Opus 4 deprecated and non-selectable, but restore the Opus 4.1 catalogue row with its higher first-party pricing. The picker remains curated to the latest Opus default.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Keep the latest provider usage for context pressure and auto-compaction, but accumulate every model request in a turn for billing estimates. Multi-round tool turns otherwise charged only the final assistant response and under-reported the status-line session cost.
A failed mid-session resume leaves the user on the current session, so clearing auto-compaction and status-line usage state made the still-active session lose context pressure and cost accounting. Return whether the swap succeeded and reset state only after an actual session change.
The configurable current-time segment was rendered from wall-clock time but idle status bars never marked the app dirty. Track the displayed minute for status lines that include current-time and request a repaint when it changes, without waking bars that do not render time.
An empty status-line roster hides the run state and interrupt affordances without making that tradeoff explicit. Validate both OX_STATUS_LINE and TOML status_line after parsing so launch fails with an actionable error instead of drawing blank chrome.
Remove stale wording that treated token and cost status as wholly deferred after the configurable status line shipped. Keep the remaining roadmap focused on extensions such as account-level billing, detailed cost commands, and persisted restore.
Add focused tests for the user-visible status-line branches Codecov highlighted: current-time rendering, final-segment truncation at very narrow widths, and priority-based omission before context, model, and run state are dropped.
The status-line cost estimate reads the active prompt-cache TTL through the client. Extend the existing config exposure test so the accessor stays covered without adding a getter-only test case.
Section order must mirror the production function order in the same file. Move `StatusLine::render` ahead of `context_label`, and tighten the omission test's model assertion so it fails when the segment dropped silently or rendered without separator framing.
Three fields on the usage path looked similar but had distinct lifetime semantics. `UsageSnapshot::context_tokens` is now stated as a cache-aware input total tied to context pressure; `estimated_cost_usd` is stated as a running session total. `TokenUsage::observe` documents the wire-event zero-skip rule, and `TurnReport` distinguishes the latest-round snapshot from the per-turn billing accumulator.
The bare "must contain at least one segment" message left users guessing whether disabling was supported, where to set it, and which segment names exist. The validation now points to the same recovery paths the unknown-segment error surfaces (remove the key or unset `OX_STATUS_LINE`) and lists every accepted segment name in the same format.
The parser silently dropped empty entries, so `model,,run-state` parsed as `[Model, RunState]` and a typo never surfaced. Empty entries now error with an actionable hint, while a wholly-empty value continues to fall through to the existing empty-roster message. The bail wording in both paths drops a semicolon between independent clauses for the same sentence-break treatment used elsewhere in the config layer.
Only the OX_STATUS_LINE path was checked. The TOML side relies on serde's `rename_all` rejection, which has a different format and a different field-context wrapper. Pin the surfacing wording so a future deserialize-attribute change cannot silently downgrade the message.
`render_drops_low_utility_segments_before_usage_model_and_state` only exercised one width, so a swap of two priorities in `segment_utility` would have gone undetected. Assert the surviving roster at three boundaries instead, covering five-, two-, and one-segment outcomes. `tick_idle_is_false` did not actually verify that the current-time refresh gate skips bars without `CurrentTime`. Add an explicit negative test that builds a roster without the segment and asserts `current_time_minute` stays `None` after a tick.
The previous shape returned `AbortResult<TurnReport>`, so the partial
report was lost on `TurnAbort::Cancelled`, `Quit`, and `Failed`. A turn
that streamed several rounds before the user pressed Esc charged real
API tokens, and the status-line cost segment quietly skipped them.
Replace the return type with `TurnOutcome { report, result }`. The
caller always sees the report, and the result indicates whether the
turn completed cleanly. The outer loop in `AgentLoopTask::handle_action`
now applies usage and cost on every exit path, and the REPL / headless
modes drop their abort-only Result handling for a direct destructure.
Drops the `Claude ` family prefix and replaces ` (1M context)` with ` [1M]` in the status bar so a 1M Opus pick fits as `Opus 4.7 [1M] (xhigh)`. Other surfaces (welcome screen, prompt env, error blocks) keep the full label.
Both the session header builder and the TUI startup path used to shell out to git through their own copies of `current_git_branch`. Move both through `util::git::current_branch`, which uses `branch --show-current` (detached HEAD comes back as empty stdout) and emits a `tracing::debug` record on every failure path so misbehavior is recoverable from the log.
A `git checkout` outside the TUI now becomes visible in the status bar within five seconds instead of staying frozen until the next session. The probe runs synchronously inside `tick()` but is throttled by `GIT_BRANCH_REFRESH_INTERVAL`, so the cost is one git invocation per five seconds, not per render.
Adds a `pull-request` segment that shells out to `gh pr view --json number --jq .number` and renders the open PR number for the current branch as `#86`. Probed once a minute (network-bound, slower than the git-branch probe) and only when the segment is configured. Drops before git-branch under width pressure since the branch implicitly carries the PR identity.
Mocha's `dim` (#585b70) is too low-contrast for the status-bar cwd when read across a full row of separators. Route `current-dir` to `muted` (#a6adc8) instead and document the per-segment slot mapping in the theming guide so users can re-skin one segment by patching its slot without per-segment overrides.
Adds a positive seed so the test asserts the running cost survives a model swap, catching a regression that would reset `total_estimated_cost_usd` alongside the auto-compaction breaker.
Removes one-line docstrings that just paraphrased the field name
(`model: Display label for the active model.`) on `StatusLineState`
and `Config{Snapshot,}::status_line`. Keeps the docstrings that name a
non-obvious invariant: cwd is pre-tildified, status_span is parent-rendered,
and `pull_request` is conditional on detection. `StatusLineSegment::DEFAULT`
now points at the design doc for order rationale.
The assertion already names the contract (`expect_err` + `is ambiguous`); the leading sentence narrated what the test does without adding a WHY.
Notes that `segment_utility` is a "drop first when narrow" rank (lower drops earlier, run state and model anchor the bar) and that `format_cost`'s 0.995 cutover collapses to two decimals exactly when two-decimal rounding would otherwise paint `$1.00`.
Adding a variant requires both an enum entry (serde derives the kebab-case form for TOML) and a line in `ALL` (string-only path used by `OX_STATUS_LINE`). Calls out the coupling so future additions don't ship through TOML but reject from the env var.
Splits `TokenCostRates`, the per-family rate constants, and the USD estimator out of `model.rs` into `model/pricing.rs`. The model catalogue file now stays focused on capability rows and lookups; price changes land in one place.
Adds a per-segment table covering what each segment renders and how often it refreshes, plus the priority order under width pressure and the OX_STATUS_LINE parsing edge cases (whitespace tolerance, stray commas rejected, unknown names rejected).
Folds the two-bullet "differs from Claude Code" list and the three-bullet "Order rationale" block into two prose sentences. The Design Decisions list keeps each entry — they each explain a different choice rather than repeating one another.
Lets readers click through from the feature list to the per-segment reference instead of having to grep for it.
…e it Notes that the `│` separator glyph is fixed and the `separator` slot only controls its color and modifiers, so users don't expect to swap it for a different character via theme overrides.
Round-1 usage reaching the caller through `TurnAbort::Cancelled` was already pinned, but the parallel `Failed` and safety-cap paths were not. A mutation that reset `billable_usage` on either branch would have passed every existing test, masking lost billable rounds in production.
The narrative restated the test name and used a semicolon between two independent clauses. The `Some(CAP)` test next door covers the opposite branch and the test name is descriptive enough on its own.
Both refresh_git_branch and refresh_pull_request stamp the throttle key before they read the probe result. A regression that only stamped on `Some(_)` would re-shell out every tick on a non-repo cwd. The existing tests skip the stamp (early-return on no cwd / disabled segment), so the invariant was load-bearing without test coverage.
Existing render tests covered current-time vs the high-utility segments and pull-request vs git-branch but never asserted the intermediate ordering between current-time (1) and pull-request (2). A swap of those two utility values would lose the more-informative PR badge before the noisier clock.
A user who configures both `model` and `model-with-effort` got the leftmost variant retained under width pressure regardless of which one carried more information. Splitting the utility ranks (Model = 7, ModelWithEffort = 8) keeps the effort tier visible by default and the docs-listed drop order now matches the implementation.
The probe debug log carried only the exit code, leaving a user debugging "why no PR badge" with no signal to distinguish auth failures from "no PR found" from rate limits. Capture the first non-blank stderr line (capped at 200 chars) so RUST_LOG=debug surfaces gh's own message without dumping a wall of hint text.
`UsageUpdated` is reset on `SessionCompacted` regardless of the `automatic` flag, but the doc only listed `/compact` and resume.
The previous note framed the table as `OX_STATUS_LINE`-only, but `validate_status_line` also enumerates valid names through it. A future contributor removing env support would otherwise be tempted to drop the table.
Several field docs and helper docs restated the type or the function
shape rather than carrying WHY content:
- Three status state fields (`pull_request`, `last_branch_probe`,
`last_pr_probe`) restated their `Option` shape; the field names
carry the meaning.
- `cwd` field doc used a semicolon between independent clauses for a
detail that was effectively "no further `~`".
- `TurnReport` had editorializing about "different temporal meanings"
that didn't help a future reader judge the contract.
- `TurnOutcome::{unwrap, expect, expect_err}` led with `Test helper:`
which `#[cfg(test)]` already conveys; the WHY is the
`Result`-mirror analogy.
Also pin the `fit_segments` invariant comment so a future contributor
doesn't read the iter_mut().max_by_key as a multi-segment truncation.
`StatusBar::tick()` propagates the dirty bit when `refresh_git_branch` surfaces a new branch. The path was uncovered: a regression that short-circuited the `if refresh_git_branch(...)` branch would leave the status bar painting a stale branch label until the next user input redraw. Also drop one semicolon-between-clauses in adjacent doc and test comments now that the file is open.
CLAUDE.md asks for a connector + comma (or two sentences) instead of a semicolon when joining two independent clauses. Three sites in files this PR already touches: TurnReport.usage doc, Config.theme_name doc, Config::load doc, and one in-test comment. The "X, not Y" antithesis on TurnReport.usage rephrases as "rather than" so the contrast doesn't read as ruling out a strawman.
hakula139
added a commit
that referenced
this pull request
May 18, 2026
ratatui's `Buffer::diff` reads `cell.symbol().width()` (UAX 11) to compute `to_skip` for trailing cells. Storing the OSC 8 envelope inside a cell's symbol made `unicode-width` count the URL bytes (~30 for a typical PR URL), so the diff skipped ~30 trailing cells: `#86` rendered as `#pus` once `Opus 4.7` shifted into the dropped columns. `StatusBar::render` now snapshots each link rect's visible cells + styles into a `pending_hyperlinks` queue. `App::render` drains that queue after `terminal.draw()` flushes via `emit_status_hyperlinks`, which writes CUP + OSC 8 opener + replayed styled cells + OSC 8 closer + SGR reset directly to the crossterm backend. The buffer cells stay plain (width 1), the diff math behaves, and modern terminals still make the segment Ctrl-clickable.
hakula139
added a commit
that referenced
this pull request
May 19, 2026
Three review passes (correctness / prose / conventions) surfaced cleanup that lands in a single sweep: - Lift inline-qualified type paths into top-level `use`: `StatusHyperlink` and `HyperlinkCell` in `tui/app.rs`, `PullRequest` in `tui/components/ status/line.rs`, `Style` in `tui/components/status.rs`. Brings the new types in line with the project's three-block import convention. - Reorder the test sections in `tui/app.rs` to mirror production order (`emit_status_hyperlinks` → `write_status_hyperlinks` → `build_status_hyperlink_envelope` → `tmux_passthrough` → `ratatui_color_to_crossterm`). The misfiled `emit_status_hyperlinks_is_a_noop_when_no_links_pending` gets its own divider. - Lift `plain_hyperlink` to a top-level test fixture so both `build_status_hyperlink_envelope` and `write_status_hyperlinks` tests reuse it. Add `pr_bar` next to `test_bar` in `tui/components/status.rs` to drop the triplicate `StatusBar::new` literal. - Trim docstrings that stated mechanics rather than contract on `emit_status_hyperlinks`, `write_status_hyperlinks`, `build_status_hyperlink_envelope`, `tmux_passthrough`, `StatusHyperlink`, `take_pending_hyperlinks`, `RenderedStatusLine`, `RenderedHyperlink`, `stderr_summary`. Drop comments that duplicated the surrounding doc. - Drop the dead `let _backend = render_status(...)` binds in the new status tests. - Tighten the `mouse-interactions` design doc: drop the parenthetical `(the original #86 → #pus bug)` task-narration aside, document the empty-`$TMUX` and tmux 3.3+ `allow-passthrough` requirements, replace "Three non-obvious mechanics:" with "Three mechanics worth surfacing:".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a configurable TUI status line backed by an ordered roster of built-in segments. The default line shows project location, branch, open pull request, compact model label / effort, cache-aware context use, estimated in-process session cost, run state, and thread title while letting users reorder or omit segments through config.
[tui].status_lineandOX_STATUS_LINE.gh pr viewon tick so external state changes (e.g.,git checkout,gh pr create) become visible without restarting the session.Design decisions
muted,accent,dim, ...), so re-skinning a slot re-skins everywhere it appears. The│separator glyph is fixed; theseparatorslot only controls its color and modifiers.agent_turnreturns aTurnOutcome { report, result }, threading partial usage through every abort path so cancelled / failed / capped turns still attribute the rounds that actually billed.git-branchandpull-requestre-probe on tick at fixed cadences (5 s and 60 s respectively) so the bar stays current without paying per-frame cost. The throttle is armed even when the probe returnsNoneso a non-repo cwd doesn't re-shell every tick.Claudefamily prefix and abbreviates(1M context)to[1M](Opus 4.7 [1M] (xhigh)). Welcome screen, prompt env, and error blocks keep the full label.model-with-effortranks above plainmodelunder width pressure since it carries strictly more information./modelaliases and picker rows stay focused on the current defaults.Changes
config.rs,config/file.rsStatusLineSegment(incl.pull-request), default segment order, TOML parsing for[tui].status_line, strictOX_STATUS_LINEenv support (rejects empty, stray-comma, and unknown values), and non-empty roster validation.agent.rs,agent/event.rs,client/anthropic/*,model.rs,model/pricing.rsUsageUpdated, accumulate estimated session cost across every model request in a turn (including cancelled / failed / capped turns for completed rounds), and split first-party Claude rates intomodel/pricing.rsbeside the catalogue.util/git.rsgit rev-parse/git branch --show-currentpaths into one best-effort probe and addgh pr viewfor the new PR segment, withtracing::debug!on every failure path including the first non-blank stderr line so users can distinguish auth failures from "no PR" results.main.rs,tui/app.rs,tui/components/status.rs,tui/components/status/line.rs,tui/theme.rscurrent-dirto the higher-contrastmutedslot, and use a width-tight short model name in the bar.slash.rs,slash/context.rs,slash/model.rs,prompt/environment.rsLiveSessionInfo::short_display_namefor status-bar consumers.tui/components/snapshots/*,tui/snapshots/*docs/research/tui/status-line.md,docs/design/tui/status-line.md,docs/guide/configuration.md,docs/guide/theming.md,docs/roadmap.mdOX_STATUS_LINEparsing edge cases.README.md,CLAUDE.md,docs/design/README.md,docs/research/README.md,docs/design/agent/auto-compaction.md,docs/design/slash/compact.mdmodel/pricing.rsandutil/git.rs), feature status, and compaction wording with the new usage surface.Test plan
cargo fmt --all --checkcargo buildcargo clippy --all-targets -- -D warnings: zero warningscargo test: 2060 tests passcargo llvm-cov --ignore-filename-regex 'main\.rs': 98.56% line coveragepnpm lint: 0 errorspnpm spellcheck: 0 issues