Skip to content

feat(tui): add configurable status line#86

Merged
hakula139 merged 43 commits into
mainfrom
feat/status-line
May 15, 2026
Merged

feat(tui): add configurable status line#86
hakula139 merged 43 commits into
mainfrom
feat/status-line

Conversation

@hakula139
Copy link
Copy Markdown
Owner

@hakula139 hakula139 commented May 14, 2026

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.

  • Replaces the fixed status bar composition with [tui].status_line and OX_STATUS_LINE.
  • Threads Anthropic usage through the agent loop so the TUI can show context pressure and session cost estimates that survive cancellations and model swaps.
  • Re-probes git branch and gh pr view on tick so external state changes (e.g., git checkout, gh pr create) become visible without restarting the session.

Design decisions

  • Use a typed ordered roster. Built-in segment names keep rendering inside Ratatui while giving users control over order without introducing a command-renderer boundary yet.
  • Keep colors and separators theme-owned. Segment styling routes through the existing theme slots (muted, accent, dim, ...), so re-skinning a slot re-skins everywhere it appears. The separator glyph is fixed; the separator slot only controls its color and modifiers.
  • Use local usage first. Context and cost use provider usage already observed by the current process, including cache creation and cache read tokens. External billing totals and provider account limits are deferred until there is a first-class telemetry boundary.
  • Bill every completed round. agent_turn returns a TurnOutcome { report, result }, threading partial usage through every abort path so cancelled / failed / capped turns still attribute the rounds that actually billed.
  • Probe-throttled segments. git-branch and pull-request re-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 returns None so a non-repo cwd doesn't re-shell every tick.
  • Width-tight model label in the status bar only. The bar drops the Claude family 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-effort ranks above plain model under width pressure since it carries strictly more information.
  • Preserve a curated picker. The model catalogue carries known rates, including the active Opus 4.1 row for explicit users, while /model aliases and picker rows stay focused on the current defaults.

Changes

File Description
config.rs, config/file.rs Add StatusLineSegment (incl. pull-request), default segment order, TOML parsing for [tui].status_line, strict OX_STATUS_LINE env support (rejects empty, stray-comma, and unknown values), and non-empty roster validation.
agent.rs, agent/event.rs, client/anthropic/*, model.rs, model/pricing.rs Capture cache-aware usage, emit UsageUpdated, 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 into model/pricing.rs beside the catalogue.
util/git.rs Consolidate the duplicated git rev-parse / git branch --show-current paths into one best-effort probe and add gh pr view for the new PR segment, with tracing::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.rs Feed cwd / git cwd / usage state into the TUI, preserve usage state on failed resume, refresh current-time / git branch / pull request while idle, render ordered status-line segments with omission and narrow-width fitting, route current-dir to the higher-contrast muted slot, and use a width-tight short model name in the bar.
slash.rs, slash/context.rs, slash/model.rs, prompt/environment.rs Keep live session metadata and model lookup behavior aligned with the expanded status-line model catalogue and add LiveSessionInfo::short_display_name for status-bar consumers.
tui/components/snapshots/*, tui/snapshots/* Update status and frame snapshots for the new status-line composition (compact model label).
docs/research/tui/status-line.md, docs/design/tui/status-line.md, docs/guide/configuration.md, docs/guide/theming.md, docs/roadmap.md Add the research / design docs and document per-segment data sources, refresh cadences, priority drop order, segment-to-slot mapping, and OX_STATUS_LINE parsing edge cases.
README.md, CLAUDE.md, docs/design/README.md, docs/research/README.md, docs/design/agent/auto-compaction.md, docs/design/slash/compact.md Sync docs index entries, crate tree (incl. model/pricing.rs and util/git.rs), feature status, and compaction wording with the new usage surface.

Test plan

  • cargo fmt --all --check
  • cargo build
  • cargo clippy --all-targets -- -D warnings: zero warnings
  • cargo test: 2060 tests pass
  • cargo llvm-cov --ignore-filename-regex 'main\.rs': 98.56% line coverage
  • pnpm lint: 0 errors
  • pnpm spellcheck: 0 issues

hakula139 added 3 commits May 14, 2026 15:10
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.
@hakula139 hakula139 added the enhancement New feature or request label May 14, 2026
@hakula139 hakula139 self-assigned this May 14, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 14, 2026

hakula139 added 24 commits May 14, 2026 16:34
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.
hakula139 added 16 commits May 15, 2026 10:28
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 hakula139 merged commit fea1fdc into main May 15, 2026
4 checks passed
@hakula139 hakula139 deleted the feat/status-line branch May 15, 2026 06:41
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:".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant