From b602bbadee360ab4c73d044b659f3b7337094565 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 13:46:30 +0800 Subject: [PATCH 01/43] docs(tui): design status line surface 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. --- docs/design/README.md | 11 +-- docs/design/tui/status-line.md | 118 +++++++++++++++++++++++++++++++ docs/research/README.md | 1 + docs/research/tui/status-line.md | 63 +++++++++++++++++ 4 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 docs/design/tui/status-line.md create mode 100644 docs/research/tui/status-line.md diff --git a/docs/design/README.md b/docs/design/README.md index 90e9db0e..99e7cd51 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -34,8 +34,9 @@ Organized by topic. Each subdirectory mirrors the corresponding directory in [`d ## Terminal UI -| Document | Description | -| ---------------------------------------------------- | ------------------------------------------------------ | -| [Overview](tui/overview.md) | Core stack, rendering strategy, streaming architecture | -| [Cancellation and Queued Input](tui/cancellation.md) | Cancel, exit, and mid-turn queued prompts | -| [Welcome Screen](tui/welcome.md) | Empty-state renderer, `WelcomeSnapshot`, width ladder | +| Document | Description | +| ---------------------------------------------------- | -------------------------------------------------------- | +| [Overview](tui/overview.md) | Core stack, rendering strategy, streaming architecture | +| [Cancellation and Queued Input](tui/cancellation.md) | Cancel, exit, and mid-turn queued prompts | +| [Status Line](tui/status-line.md) | Configurable status-line segments and usage display | +| [Welcome Screen](tui/welcome.md) | Empty-state renderer, `WelcomeSnapshot`, width ladder | diff --git a/docs/design/tui/status-line.md b/docs/design/tui/status-line.md new file mode 100644 index 00000000..ee219e88 --- /dev/null +++ b/docs/design/tui/status-line.md @@ -0,0 +1,118 @@ +# Status Line + +Design for a configurable TUI status line. + +## Scope + +The TUI status line should be an ordered roster of built-in segments. This ships: + +- User-controlled segment order. +- Segments backed by local state today. +- Cache-aware context and session-cost display. + +Implemented segments: + +- current directory +- git branch +- model +- model with effort +- context used +- estimated session cost +- run state +- thread title +- current time + +Out of scope: + +- Command-based custom renderers. +- ccusage block / daily totals. +- Five-hour or weekly account-limit display. +- Pull request and task-progress segments. +- Persisting cost totals across resume. +- Non-Anthropic provider pricing. + +## Implementation + +Add `StatusLineSegment` in `config.rs` with TOML values: + +- `current-dir` +- `git-branch` +- `model` +- `model-with-effort` +- `context-used` +- `session-cost` +- `run-state` +- `thread-title` +- `current-time` + +`[tui] status_line = [...]` controls the order. `OX_STATUS_LINE` accepts the same comma-separated names. Segment colors always come from the active theme. + +`StatusBar` keeps component state and delegates segment formatting to `tui/components/status/line.rs`. + +Segment render rules: + +- Return `None` when data is unavailable. +- Do not render placeholders for absent branch, title, usage, or pricing. +- Use active theme styles; color customization belongs in theme overrides. +- Join only rendered segments, so omitted segments do not leave extra separators. +- When the row is too narrow, omit lower-utility segments before truncating the last remaining segment. Run state and model have the highest utility. + +## Usage Data + +Extend Anthropic `Usage` with `cache_creation_input_tokens` and `cache_read_input_tokens`. `TokenUsage::context_tokens()` returns input + cache creation + cache read. `TokenUsage::total_tokens()` returns context + output and remains the auto-compaction trigger input. + +A successful `agent_turn` returns the latest provider usage. `AgentLoopTask` keeps that usage for the next auto-compaction check, updates the displayed usage snapshot, adds the turn's estimated cost when rates are known, then emits `AgentEvent::UsageUpdated(UsageSnapshot)` before `TurnComplete`. + +Known first-party Claude API rates live in `model.rs` beside the model catalogue. Unknown models render context without session cost. The estimate excludes account discounts, marketplace billing, data-residency multipliers, fast mode, and server-side tool surcharges. + +On model swap, recompute the snapshot with the new model's context window if display usage exists. The session cost remains the accumulated estimate from turns that actually reported usage. On `/clear`, `/resume`, manual compaction, and automatic compaction, clear displayed usage because the visible transcript basis changed. + +## Default Order + +The default order is: + +```toml +status_line = [ + "current-dir", + "git-branch", + "model-with-effort", + "context-used", + "session-cost", + "run-state", + "thread-title", +] +``` + +This differs from the user's Claude Code script in two ways: + +- oxide-code stays one row because Ratatui already owns the app chrome. +- External billing data is omitted until there is a first-class provider boundary. + +Order rationale: + +- Location and branch lead because they orient the user. +- Model and usage follow because they describe request cost and context pressure. +- Run state stays near the end because it changes most often. + +## Design Decisions + +1. **Roster, not DSL.** A typed segment list is enough for first-party data and keeps rendering inside Ratatui. +2. **Implemented segments only.** Unsupported names fail config parsing instead of silently reserving future vocabulary. +3. **Local usage first.** Context and session cost use provider usage already observed by the current process. +4. **Segment omission over placeholders.** Missing usage, unknown pricing, absent branch, and blank titles disappear cleanly. + +## Deferred + +- ccusage or another account-usage provider boundary. +- Five-hour and weekly account-limit telemetry. +- Pull request and task-progress segments. +- Persisted cost restore after resume. +- Command-based custom status-line renderer. + +## Sources + +- [Status line research](../../research/tui/status-line.md) +- [Auto-compaction design](../agent/auto-compaction.md) +- `crates/oxide-code/src/tui/components/status.rs` +- `crates/oxide-code/src/tui/components/status/line.rs` +- `crates/oxide-code/src/main.rs` diff --git a/docs/research/README.md b/docs/research/README.md index 2187786e..228b95e8 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -46,4 +46,5 @@ Organized by topic. Each subdirectory mirrors the corresponding directory in [`d | ---------------------------------------------------- | ---------------------------------------------------------------- | | [Overview](tui/overview.md) | Reference TUI patterns, flickering prevention, ecosystem | | [Cancellation and Queued Input](tui/cancellation.md) | Cancel, exit, and input queueing patterns | +| [Status Line](tui/status-line.md) | Segment ordering, usage, and billing patterns across coding CLIs | | [Welcome Screen](tui/welcome.md) | Empty-state surfaces and layout primitives across the three CLIs | diff --git a/docs/research/tui/status-line.md b/docs/research/tui/status-line.md new file mode 100644 index 00000000..1ad8be4f --- /dev/null +++ b/docs/research/tui/status-line.md @@ -0,0 +1,63 @@ +# Status Line (Reference) + +Research on status-line composition in terminal coding assistants. Sources are Claude Code, OpenAI Codex, opencode, and the user's managed Claude / Codex setup. + +## Claude Code + +Claude Code delegates the whole status line to a command. The user's configured script renders: + +- Row 1: tty, current directory, git branch, and git dirtiness. +- Row 2: model, context use, session cost, ccusage block / daily totals, and current time. + +The useful data split is provider-local versus external: + +- **Provider-local**: context and session cost come from Claude Code's JSON input. Current usage includes input, cache creation, and cache read tokens. +- **External billing**: block and daily totals come from `ccusage blocks --json`, cached for 30 seconds by the script. +- **Rendering boundary**: the command owns formatting, while Claude Code owns structured data export. + +## OpenAI Codex + +Codex exposes a roster-style `tui.status_line` array. The user's setup orders: + +- current directory +- git branch +- model with reasoning +- context used +- five-hour and weekly limits +- PR number +- run state +- thread title +- task progress + +The important pattern is not the exact segment list. The useful part for oxide-code is the ordered roster, where users can place trustworthy built-in segments without adopting a command DSL. + +## opencode + +opencode keeps usage in the prompt footer rather than a fully configurable status line. It derives context from the latest assistant message usage, includes cache read and cache write tokens, and sums assistant-message cost across the session. Context percentage disappears when the provider model has no advertised context limit. + +## Pricing Reference + +Anthropic's pricing page lists first-party Claude API prices in USD per million tokens and separate prompt-cache rates for 5-minute writes, 1-hour writes, cache reads, and output tokens. Checked on 2026-05-14, the relevant rows for oxide-code's model table are: + +| Family | Input | 5m cache write | 1h cache write | Cache read | Output | +| -------------------- | ----- | -------------- | -------------- | ---------- | ------ | +| Opus 4.7 / 4.6 / 4.5 | $5 | $6.25 | $10 | $0.50 | $25 | +| Sonnet 4.x | $3 | $3.75 | $6 | $0.30 | $15 | +| Haiku 4.5 | $1 | $1.25 | $2 | $0.10 | $5 | + +Cost display should stay best-effort because account discounts, marketplace billing, data residency, fast mode, and server-side tool pricing can change the final bill. + +## Patterns Worth Borrowing for oxide-code + +1. **Typed ordered roster.** A list of built-in segment names is simpler and safer than a command DSL for the first version. +2. **Local usage first.** Context use and in-process session cost should come from provider usage already observed by oxide-code. +3. **Cache-aware accounting.** Context and cost should include cache creation and cache read tokens because prompt caching is part of the real request shape. +4. **Graceful omission.** Segments with no data should disappear instead of rendering placeholders. +5. **Theme-owned colors.** Status-line colors should come from the active theme rather than a separate status-line flag. + +## Patterns to Defer + +1. **ccusage integration.** Block and daily billing are useful, but shelling out to an external npm package adds installation, trust, cache, and schema concerns. A future version should either define a first-class external provider boundary or persist enough local usage to avoid it. +2. **Account-limit telemetry.** Five-hour and weekly limits need provider-specific state that is separate from per-turn token usage. +3. **PR and task-progress segments.** Those need reliable project metadata and task state that oxide-code does not own yet. +4. **Command-based status lines.** Claude Code's command hook is powerful but moves rendering, errors, and latency outside the Rust UI loop. From 01aec4f79064a20daf7f1f9adbd6d7de6b76b696 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 15:06:20 +0800 Subject: [PATCH 02/43] feat(tui): add configurable status line 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. --- CLAUDE.md | 4 +- README.md | 2 +- crates/oxide-code/src/agent.rs | 113 +++++-- crates/oxide-code/src/agent/event.rs | 14 + crates/oxide-code/src/client/anthropic.rs | 4 + crates/oxide-code/src/client/anthropic/sse.rs | 2 + .../src/client/anthropic/testing.rs | 3 +- .../oxide-code/src/client/anthropic/wire.rs | 15 +- crates/oxide-code/src/config.rs | 125 ++++++++ crates/oxide-code/src/config/file.rs | 19 ++ crates/oxide-code/src/main.rs | 90 +++++- crates/oxide-code/src/model.rs | 119 ++++++-- crates/oxide-code/src/prompt/environment.rs | 1 - crates/oxide-code/src/slash.rs | 2 + crates/oxide-code/src/slash/context.rs | 1 + crates/oxide-code/src/slash/model.rs | 12 +- crates/oxide-code/src/tui/app.rs | 47 +++ ...er_cancelling_shows_spinner_and_label.snap | 2 +- ...acting_shows_spinner_and_status_label.snap | 2 +- ...med_shows_static_hint_without_spinner.snap | 2 +- ..._with_title_shows_model_title_and_cwd.snap | 2 +- ...idle_without_title_leaves_slot_unused.snap | 2 +- ..._width_preserves_model_and_run_state.snap} | 2 +- ...eaming_shows_spinner_and_status_label.snap | 2 +- ...us__tests__render_tool_running_status.snap | 2 +- .../oxide-code/src/tui/components/status.rs | 264 +++++++++-------- .../src/tui/components/status/line.rs | 276 ++++++++++++++++++ .../oxide-code/src/tui/components/welcome.rs | 2 + ...ys_out_status_chat_and_input_in_order.snap | 2 +- ..._width_still_renders_all_three_panels.snap | 3 +- ...nders_queued_prompts_and_overflow_tag.snap | 3 +- ...streaming_shows_spinner_in_status_bar.snap | 3 +- ...frame_with_conversation_and_tool_call.snap | 3 +- crates/oxide-code/src/tui/theme.rs | 2 +- docs/design/agent/auto-compaction.md | 6 +- docs/design/tui/status-line.md | 2 +- docs/guide/configuration.md | 41 ++- docs/guide/theming.md | 2 +- docs/roadmap.md | 8 +- 39 files changed, 991 insertions(+), 215 deletions(-) rename crates/oxide-code/src/tui/components/snapshots/{ox__tui__components__status__tests__render_narrow_width_drops_cwd_and_title_slots.snap => ox__tui__components__status__tests__render_narrow_width_preserves_model_and_run_state.snap} (83%) create mode 100644 crates/oxide-code/src/tui/components/status/line.rs diff --git a/CLAUDE.md b/CLAUDE.md index a9a4e765..3e1d6662 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,9 @@ ox # Start an interactive session │ │ │ ├── popup.rs # Slash-command autocomplete overlay: dim non-selected, bold selected, alias parens │ │ │ └── snapshots/ # `cargo insta` baselines for popup render tests │ │ ├── snapshots/ # `cargo insta` baselines for chat, input, status, and welcome render tests -│ │ ├── status.rs # Status bar (model, spinner, status, working directory) +│ │ ├── status.rs # Configurable status-line component state + run-state spinner +│ │ ├── status/ +│ │ │ └── line.rs # Ordered segment rendering for the status line │ │ └── welcome.rs # Empty-state welcome screen: identity ribbon + body column, themed via `accent`/`text`/`dim` │ ├── cursor.rs # `place_clamped`: shared right-edge-clamp cursor placement for input surfaces │ ├── event.rs # ChannelSink (mpsc transport for the TUI) diff --git a/README.md b/README.md index cba7c31f..9c588b7b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ oxide-code is a Rust reimplementation of Claude Code, an interactive CLI agent t Early development. What works today: -- Terminal UI: streaming output, markdown rendering, syntax-highlighted code blocks, and 5 built-in themes with custom-TOML overrides +- Terminal UI: streaming output, markdown rendering, syntax-highlighted code blocks, configurable status line, and 5 built-in themes with custom-TOML overrides - Agent loop with extended thinking and tool-use round-trip - File and search tools: `read`, `write`, `edit`, `glob`, `grep`, `bash` - Turn interruption (Esc / Ctrl+C) plus mid-turn queued follow-up prompts that splice into the same turn between tool calls, with double-press Ctrl+C exit confirmation diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index fbc9666a..01ded50d 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -70,29 +70,61 @@ impl AgentClient for Client { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct TokenUsage { - input_tokens: u32, - output_tokens: u32, + input: u32, + cache_creation_input: u32, + cache_read_input: u32, + output: u32, } impl TokenUsage { #[cfg(test)] pub(crate) const fn new(input_tokens: u32, output_tokens: u32) -> Self { Self { - input_tokens, - output_tokens, + input: input_tokens, + cache_creation_input: 0, + cache_read_input: 0, + output: output_tokens, } } + pub(crate) const fn context_tokens(self) -> u32 { + self.input + .saturating_add(self.cache_creation_input) + .saturating_add(self.cache_read_input) + } + pub(crate) const fn total_tokens(self) -> u32 { - self.input_tokens.saturating_add(self.output_tokens) + self.context_tokens().saturating_add(self.output) + } + + pub(crate) const fn input_tokens(self) -> u32 { + self.input + } + + pub(crate) const fn cache_creation_input_tokens(self) -> u32 { + self.cache_creation_input + } + + pub(crate) const fn cache_read_input_tokens(self) -> u32 { + self.cache_read_input + } + + pub(crate) const fn output_tokens(self) -> u32 { + self.output } fn observe(&mut self, usage: &Usage) { if usage.input_tokens > 0 { - self.input_tokens = usage.input_tokens; + self.input = usage.input_tokens; + } + if usage.cache_creation_input_tokens > 0 { + self.cache_creation_input = usage.cache_creation_input_tokens; + } + if usage.cache_read_input_tokens > 0 { + self.cache_read_input = usage.cache_read_input_tokens; } if usage.output_tokens > 0 { - self.output_tokens = usage.output_tokens; + self.output = usage.output_tokens; } } } @@ -806,6 +838,8 @@ mod tests { model: "claude-sonnet-4-6".into(), usage: Some(Usage { input_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, output_tokens: 0, }), }, @@ -833,6 +867,8 @@ mod tests { model: "claude-sonnet-4-6".into(), usage: Some(Usage { input_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, output_tokens: 0, }), }, @@ -854,6 +890,8 @@ mod tests { model: "claude-sonnet-4-6".into(), usage: Some(Usage { input_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, output_tokens: 0, }), }, @@ -874,6 +912,8 @@ mod tests { }, usage: Some(Usage { input_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, output_tokens, }), }, @@ -917,6 +957,8 @@ mod tests { model: "claude-sonnet-4-6".into(), usage: Some(Usage { input_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, output_tokens, }), }, @@ -1014,6 +1056,21 @@ mod tests { crate::session::handle::testing::dead("dead-test-session") } + // ── TokenUsage ── + + #[test] + fn token_usage_context_and_total_include_cache_tokens() { + let usage = TokenUsage { + input: 10, + cache_creation_input: 20, + cache_read_input: 30, + output: 5, + }; + + assert_eq!(usage.context_tokens(), 60); + assert_eq!(usage.total_tokens(), 65); + } + // ── auto_compact_if_needed ── #[tokio::test] @@ -1041,8 +1098,10 @@ mod tests { &mut pending, None, Some(TokenUsage { - input_tokens: 20, - output_tokens: 1, + input: 20, + cache_creation_input: 0, + cache_read_input: 0, + output: 1, }), ) .await @@ -1086,8 +1145,10 @@ mod tests { file_tracker: &tracker, }), Some(TokenUsage { - input_tokens: 20, - output_tokens: 1, + input: 20, + cache_creation_input: 0, + cache_read_input: 0, + output: 1, }), ) .await @@ -1128,8 +1189,10 @@ mod tests { file_tracker: &tracker, }), Some(TokenUsage { - input_tokens: 20, - output_tokens: 1, + input: 20, + cache_creation_input: 0, + cache_read_input: 0, + output: 1, }), ) .await @@ -1177,8 +1240,10 @@ mod tests { file_tracker: &tracker, }), Some(TokenUsage { - input_tokens: 20, - output_tokens: 1, + input: 20, + cache_creation_input: 0, + cache_read_input: 0, + output: 1, }), ) .await @@ -1225,8 +1290,10 @@ mod tests { file_tracker: &tracker, }), Some(TokenUsage { - input_tokens: 20, - output_tokens: 1, + input: 20, + cache_creation_input: 0, + cache_read_input: 0, + output: 1, }), ) .await @@ -1258,8 +1325,10 @@ mod tests { let mut pending = Vec::new(); let mut failures = MAX_AUTO_COMPACT_FAILURES - 1; let usage = Some(TokenUsage { - input_tokens: 50_000, - output_tokens: 1, + input: 50_000, + cache_creation_input: 0, + cache_read_input: 0, + output: 1, }); let first = auto_compact_if_needed( @@ -1346,8 +1415,10 @@ mod tests { &mut pending, Some(&mut auto), Some(TokenUsage { - input_tokens: 20, - output_tokens: 1, + input: 20, + cache_creation_input: 0, + cache_read_input: 0, + output: 1, }), ); let queue_prompt = async { diff --git a/crates/oxide-code/src/agent/event.rs b/crates/oxide-code/src/agent/event.rs index 03d6d098..deee2347 100644 --- a/crates/oxide-code/src/agent/event.rs +++ b/crates/oxide-code/src/agent/event.rs @@ -17,6 +17,17 @@ pub(crate) const INTERRUPTED_MARKER: &str = "(interrupted)"; // ── Agent Events ── +/// Token and cost snapshot for status-line rendering. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct UsageSnapshot { + /// Current prompt-side context pressure. + pub(crate) context_tokens: u32, + /// Active model context window. `None` hides the percentage. + pub(crate) context_window: Option, + /// In-process session cost estimate. `None` hides the cost segment. + pub(crate) estimated_cost_usd: Option, +} + /// One-way notifications from the agent loop to whichever UI is rendering it /// (TUI [`ChannelSink`](crate::tui::event::ChannelSink) or stdio [`StdioSink`]). #[derive(Debug, Clone)] @@ -44,6 +55,8 @@ pub(crate) enum AgentEvent { PromptDrained(String), /// Turn ended cleanly with a final assistant reply (no further tool rounds). TurnComplete, + /// Live usage and estimated cost snapshot for the current process. + UsageUpdated(UsageSnapshot), /// User cancelled mid-turn ([`UserAction::Cancel`]). The in-flight reply is truncated and the /// inline [`INTERRUPTED_MARKER`] is rendered. Cancelled, @@ -234,6 +247,7 @@ impl StdioSink { } // TUI-only — no stdio surface to update. AgentEvent::PromptDrained(_) + | AgentEvent::UsageUpdated(_) | AgentEvent::AutoCompactionStarted | AgentEvent::SessionTitleUpdated { .. } | AgentEvent::SessionRolled { .. } diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 8189e82a..5fd5893c 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -152,6 +152,10 @@ impl Client { self.config.compaction } + pub(crate) fn prompt_cache_ttl(&self) -> crate::config::PromptCacheTtl { + self.config.prompt_cache_ttl + } + /// `None` means the agent loop runs without a per-turn round cap. pub(crate) fn max_tool_rounds(&self) -> Option { self.config.max_tool_rounds diff --git a/crates/oxide-code/src/client/anthropic/sse.rs b/crates/oxide-code/src/client/anthropic/sse.rs index 59125cdf..7ad94226 100644 --- a/crates/oxide-code/src/client/anthropic/sse.rs +++ b/crates/oxide-code/src/client/anthropic/sse.rs @@ -434,6 +434,8 @@ mod tests { ref model, usage: Some(super::super::wire::Usage { input_tokens: 25, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, output_tokens: 1, }), }, diff --git a/crates/oxide-code/src/client/anthropic/testing.rs b/crates/oxide-code/src/client/anthropic/testing.rs index 6b778ced..bd2e1b95 100644 --- a/crates/oxide-code/src/client/anthropic/testing.rs +++ b/crates/oxide-code/src/client/anthropic/testing.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex}; use super::Client; -use crate::config::{Auth, CompactionConfig, Config, PromptCacheTtl}; +use crate::config::{Auth, CompactionConfig, Config, PromptCacheTtl, StatusLineSegment}; use crate::tui::theme::Theme; /// Minimal [`Config`] for unit / wiremock tests. @@ -21,6 +21,7 @@ pub(crate) fn test_config(base_url: impl Into, auth: Auth, model: &str) thinking: None, show_thinking: false, show_welcome: true, + status_line: StatusLineSegment::DEFAULT.to_vec(), theme: Theme::default(), theme_name: "mocha".to_owned(), } diff --git a/crates/oxide-code/src/client/anthropic/wire.rs b/crates/oxide-code/src/client/anthropic/wire.rs index 66b1c97f..35db8b2f 100644 --- a/crates/oxide-code/src/client/anthropic/wire.rs +++ b/crates/oxide-code/src/client/anthropic/wire.rs @@ -232,11 +232,19 @@ pub(crate) struct MessageDeltaBody { pub(crate) stop_reason: Option, } +#[expect( + clippy::struct_field_names, + reason = "field names mirror Anthropic's usage payload" +)] #[derive(Debug, Clone, Deserialize)] pub(crate) struct Usage { #[serde(default)] pub(crate) input_tokens: u32, #[serde(default)] + pub(crate) cache_creation_input_tokens: u32, + #[serde(default)] + pub(crate) cache_read_input_tokens: u32, + #[serde(default)] pub(crate) output_tokens: u32, } @@ -310,7 +318,12 @@ mod tests { event, StreamEvent::MessageDelta { delta: MessageDeltaBody { stop_reason: Some(ref reason) }, - usage: Some(Usage { input_tokens: 0, output_tokens: 42 }), + usage: Some(Usage { + input_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + output_tokens: 42, + }), } if reason == "end_turn", )); } diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 85007a48..12d5cff6 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -62,6 +62,8 @@ pub(crate) struct ConfigSnapshot { pub(crate) compaction: CompactionConfig, pub(crate) show_thinking: bool, pub(crate) show_welcome: bool, + /// Ordered TUI status-line segments. + pub(crate) status_line: Vec, /// Resolved theme base name — built-in catalogue key or filesystem path. `/theme` reads this /// to mark the active row in the picker. pub(crate) theme_name: String, @@ -195,6 +197,66 @@ impl FromStr for PromptCacheTtl { } } +// ── Status Line ── + +/// User-configurable status-line segment identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum StatusLineSegment { + CurrentDir, + GitBranch, + Model, + ModelWithEffort, + ContextUsed, + SessionCost, + RunState, + ThreadTitle, + CurrentTime, +} + +impl StatusLineSegment { + const ALL: &'static [(Self, &'static str)] = &[ + (Self::CurrentDir, "current-dir"), + (Self::GitBranch, "git-branch"), + (Self::Model, "model"), + (Self::ModelWithEffort, "model-with-effort"), + (Self::ContextUsed, "context-used"), + (Self::SessionCost, "session-cost"), + (Self::RunState, "run-state"), + (Self::ThreadTitle, "thread-title"), + (Self::CurrentTime, "current-time"), + ]; + + /// Default roster: location first, then model, usage, run state, and thread title. + pub(crate) const DEFAULT: &'static [Self] = &[ + Self::CurrentDir, + Self::GitBranch, + Self::ModelWithEffort, + Self::ContextUsed, + Self::SessionCost, + Self::RunState, + Self::ThreadTitle, + ]; +} + +impl FromStr for StatusLineSegment { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + if let Some((segment, _)) = Self::ALL.iter().find(|(_, name)| *name == s) { + return Ok(*segment); + } + bail!( + "invalid status line segment {s:?}; expected one of: {}", + Self::ALL + .iter() + .map(|(_, name)| *name) + .collect::>() + .join(", "), + ) + } +} + // ── CompactionConfig ── #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -286,6 +348,8 @@ pub(crate) struct Config { pub(crate) thinking: Option, pub(crate) show_thinking: bool, pub(crate) show_welcome: bool, + /// Ordered TUI status-line segments. + pub(crate) status_line: Vec, pub(crate) theme: Theme, /// Built-in catalogue key (e.g. `"mocha"`) or filesystem path; mirrors `[tui.theme] base`, /// falling back to [`DEFAULT_THEME`] when unset. @@ -364,6 +428,13 @@ impl Config { .or(tui.show_welcome) .unwrap_or(true); + let status_line = match env::string("OX_STATUS_LINE") { + Some(raw) => parse_status_line_segments(&raw).context("OX_STATUS_LINE")?, + None => tui + .status_line + .unwrap_or_else(|| StatusLineSegment::DEFAULT.to_vec()), + }; + // 4.7 silently defaulted to `omitted`; `display` opts back into summarized. 4.6 and older // ignore the field. let thinking = Some(ThinkingConfig::Adaptive { @@ -401,6 +472,7 @@ impl Config { thinking, show_thinking, show_welcome, + status_line, theme, theme_name, }) @@ -420,6 +492,7 @@ impl Config { compaction: self.compaction, show_thinking: self.show_thinking, show_welcome: self.show_welcome, + status_line: self.status_line.clone(), theme_name: self.theme_name.clone(), } } @@ -427,6 +500,14 @@ impl Config { // ── Helpers ── +fn parse_status_line_segments(raw: &str) -> Result> { + raw.split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(str::parse) + .collect() +} + pub(crate) fn display_effort(effort: Option) -> String { effort.map_or_else(|| "(no effort tier)".to_owned(), |e| e.to_string()) } @@ -716,6 +797,7 @@ mod tests { "OX_COMPACTION_AUTO_THRESHOLD_TOKENS", "OX_SHOW_THINKING", "OX_SHOW_WELCOME", + "OX_STATUS_LINE", "OX_PROMPT_CACHE_TTL", "XDG_CONFIG_HOME", ]; @@ -783,6 +865,7 @@ mod tests { config.show_welcome, "default-on so the empty chat surfaces the welcome" ); + assert_eq!(config.status_line, StatusLineSegment::DEFAULT); assert!(matches!(config.auth, Auth::ApiKey(k) if k == "sk-default")); assert_eq!(config.theme_name, DEFAULT_THEME); } @@ -814,6 +897,7 @@ mod tests { env("OX_MAX_TOOL_ROUNDS", "200"), env("OX_SHOW_THINKING", "1"), env("OX_SHOW_WELCOME", "0"), + env("OX_STATUS_LINE", "run-state,model,current-dir"), ]); let config = temp_env::async_with_vars(vars, Config::load()) .await @@ -828,6 +912,14 @@ mod tests { !config.show_welcome, "env `0` flips the default-on welcome off" ); + assert_eq!( + config.status_line, + vec![ + StatusLineSegment::RunState, + StatusLineSegment::Model, + StatusLineSegment::CurrentDir, + ], + ); } #[tokio::test] @@ -845,6 +937,7 @@ mod tests { [tui] show_thinking = true show_welcome = false + status_line = ["current-dir", "git-branch", "model-with-effort"] "#}, ); let config = temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load()) @@ -860,6 +953,27 @@ mod tests { !config.show_welcome, "file `false` opts out of the default-on welcome" ); + assert_eq!( + config.status_line, + vec![ + StatusLineSegment::CurrentDir, + StatusLineSegment::GitBranch, + StatusLineSegment::ModelWithEffort, + ], + ); + } + + #[tokio::test] + async fn load_status_line_invalid_segment_surfaces_parse_error() { + let dir = tempfile::tempdir().unwrap(); + let vars = env_vars(vec![xdg(&dir), env("OX_STATUS_LINE", "model,nope")]); + let err = temp_env::async_with_vars(vars, Config::load()) + .await + .expect_err("invalid segment must propagate"); + let msg = format!("{err:#}"); + assert!(msg.contains("OX_STATUS_LINE"), "{msg}"); + assert!(msg.contains("nope"), "{msg}"); + assert!(msg.contains("current-dir"), "{msg}"); } #[tokio::test] @@ -1475,6 +1589,10 @@ mod tests { thinking: None, show_thinking: true, show_welcome: false, + status_line: vec![ + StatusLineSegment::CurrentDir, + StatusLineSegment::ModelWithEffort, + ], theme: Theme::default(), theme_name: "macchiato".to_owned(), }; @@ -1493,6 +1611,13 @@ mod tests { assert_eq!(snap.compaction.auto.threshold_tokens, Some(42)); assert!(snap.show_thinking); assert!(!snap.show_welcome); + assert_eq!( + snap.status_line, + vec![ + StatusLineSegment::CurrentDir, + StatusLineSegment::ModelWithEffort, + ], + ); assert_eq!(snap.theme_name, "macchiato"); } diff --git a/crates/oxide-code/src/config/file.rs b/crates/oxide-code/src/config/file.rs index f54cf2d5..cf9f042a 100644 --- a/crates/oxide-code/src/config/file.rs +++ b/crates/oxide-code/src/config/file.rs @@ -56,6 +56,7 @@ pub(super) struct CompactionConfig { pub(super) struct TuiConfig { pub(super) show_thinking: Option, pub(super) show_welcome: Option, + pub(super) status_line: Option>, pub(super) theme: Option, } @@ -117,6 +118,7 @@ impl TuiConfig { Self { show_thinking: other.show_thinking.or(self.show_thinking), show_welcome: other.show_welcome.or(self.show_welcome), + status_line: other.status_line.or(self.status_line), theme: merge_section(self.theme, other.theme, ThemeFileConfig::merge), } } @@ -248,6 +250,8 @@ fn find_project_config_from(mut dir: PathBuf) -> Option { mod tests { use indoc::indoc; + use crate::config::StatusLineSegment; + use super::*; // ── FileConfig::merge ── @@ -273,6 +277,7 @@ mod tests { tui: Some(TuiConfig { show_thinking: Some(false), show_welcome: None, + status_line: Some(vec![StatusLineSegment::Model]), theme: None, }), }; @@ -295,6 +300,7 @@ mod tests { tui: Some(TuiConfig { show_thinking: Some(true), show_welcome: None, + status_line: Some(vec![StatusLineSegment::CurrentDir]), theme: None, }), }; @@ -322,6 +328,7 @@ mod tests { let tui = merged.tui.expect("tui section should be present"); assert_eq!(tui.show_thinking, Some(true)); + assert_eq!(tui.status_line, Some(vec![StatusLineSegment::CurrentDir])); } #[test] @@ -364,6 +371,7 @@ mod tests { tui: Some(TuiConfig { show_thinking: Some(true), show_welcome: None, + status_line: Some(vec![StatusLineSegment::Model]), theme: None, }), }; @@ -389,6 +397,7 @@ mod tests { let tui = merged.tui.expect("tui section should survive"); assert_eq!(tui.show_thinking, Some(true)); + assert_eq!(tui.status_line, Some(vec![StatusLineSegment::Model])); } #[test] @@ -405,6 +414,7 @@ mod tests { tui: Some(TuiConfig { show_thinking: Some(true), show_welcome: None, + status_line: Some(vec![StatusLineSegment::CurrentDir]), theme: None, }), }; @@ -566,6 +576,7 @@ mod tests { [tui] show_thinking = true + status_line = ["run-state", "model", "current-dir"] [tui.theme] base = "latte" @@ -590,6 +601,14 @@ mod tests { let tui = config.tui.expect("tui section should be present"); assert_eq!(tui.show_thinking, Some(true)); + assert_eq!( + tui.status_line, + Some(vec![ + StatusLineSegment::RunState, + StatusLineSegment::Model, + StatusLineSegment::CurrentDir, + ]), + ); let theme = tui.theme.expect("theme section should be present"); assert_eq!(theme.base.as_deref(), Some("latte")); diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 2e664948..9642fc7c 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -14,6 +14,7 @@ mod tui; mod util; use std::io::{IsTerminal, Write}; +use std::path::Path; use std::sync::Arc; use anyhow::{Result, anyhow}; @@ -22,7 +23,9 @@ use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::sync::mpsc; use tracing::{debug, warn}; -use agent::event::{AgentEvent, AgentSink, StdioSink, UserAction, inert_user_action_channel}; +use agent::event::{ + AgentEvent, AgentSink, StdioSink, UsageSnapshot, UserAction, inert_user_action_channel, +}; use agent::{AutoCompact, TokenUsage, TurnAbort, agent_turn}; use client::anthropic::Client; use config::{Config, Effort}; @@ -254,13 +257,16 @@ async fn run_tui( let (agent_sink, agent_rx) = tui::event::channel(); let (user_tx, user_rx) = mpsc::channel::(32); - let cwd = std::env::current_dir() + let cwd_path = std::env::current_dir().ok(); + let cwd = cwd_path.as_deref().map(tildify).unwrap_or_default(); + let git_branch = cwd_path .as_deref() - .map(tildify) - .unwrap_or_default(); + .and_then(current_git_branch) + .filter(|branch| !branch.is_empty()); let session_info = LiveSessionInfo { cwd, + git_branch, version: env!("CARGO_PKG_VERSION"), session_id: session.session_id().to_owned(), config, @@ -326,6 +332,28 @@ async fn run_tui( result } +fn current_git_branch(cwd: &Path) -> Option { + let output = std::process::Command::new("git") + .args([ + "-C", + cwd.to_str()?, + "--no-optional-locks", + "branch", + "--show-current", + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let branch = std::str::from_utf8(&output.stdout).ok()?.trim(); + if branch.is_empty() { + None + } else { + Some(branch.to_owned()) + } +} + /// Each `TurnAbort` arm emits exactly one terminal event (`Error` xor `TurnComplete`). #[expect( clippy::too_many_arguments, @@ -352,6 +380,8 @@ async fn agent_loop_task( file_tracker, auto_compaction_failures: 0, last_usage: None, + displayed_usage: None, + total_estimated_cost_usd: 0.0, } .run() .await @@ -368,6 +398,8 @@ struct AgentLoopTask { file_tracker: Arc, auto_compaction_failures: u8, last_usage: Option, + displayed_usage: Option, + total_estimated_cost_usd: f64, } enum LoopControl { @@ -408,6 +440,7 @@ impl AgentLoopTask { self.client.set_session_id(outcome.new_id.clone()); self.messages.clear(); self.reset_auto_compaction(); + self.reset_usage_display(); // /clear succeeded server-side, so dropping `SessionRolled` would strand the TUI // on a stuck "old session" header. self.sink.emit( @@ -428,6 +461,7 @@ impl AgentLoopTask { ) .await; self.reset_auto_compaction(); + self.reset_usage_display(); LoopControl::Continue } UserAction::Compact { instructions } => { @@ -444,6 +478,7 @@ impl AgentLoopTask { match outcome { Ok(true) => { self.reset_auto_compaction(); + self.reset_usage_display(); LoopControl::Continue } Ok(false) => LoopControl::Continue, @@ -466,6 +501,7 @@ impl AgentLoopTask { UserAction::SwapConfig { model, effort } => { if apply_swap_config(&mut self.client, &self.sink, model, effort) { self.auto_compaction_failures = 0; + self.emit_usage_update(); } LoopControl::Continue } @@ -488,7 +524,10 @@ impl AgentLoopTask { ) .await; match pre_prompt_compact { - Ok(true) => self.last_usage = None, + Ok(true) => { + self.last_usage = None; + self.reset_usage_display(); + } Ok(false) => {} Err(TurnAbort::Cancelled) => { self.sink.emit(AgentEvent::Cancelled, "cancelled"); @@ -538,6 +577,13 @@ impl AgentLoopTask { match outcome { Ok(report) => { self.last_usage = report.usage; + if let Some(usage) = report.usage { + self.displayed_usage = Some(usage); + if let Some(cost) = estimate_usage_cost_usd(&self.client, usage) { + self.total_estimated_cost_usd += cost; + } + self.emit_usage_update(); + } self.sink.emit(AgentEvent::TurnComplete, "turn-complete"); LoopControl::Continue } @@ -558,6 +604,38 @@ impl AgentLoopTask { self.auto_compaction_failures = 0; self.last_usage = None; } + + fn reset_usage_display(&mut self) { + self.displayed_usage = None; + self.total_estimated_cost_usd = 0.0; + } + + fn emit_usage_update(&self) { + let Some(usage) = self.displayed_usage else { + return; + }; + self.sink.emit( + AgentEvent::UsageUpdated(UsageSnapshot { + context_tokens: usage.context_tokens(), + context_window: crate::model::context_window_for(self.client.model()), + estimated_cost_usd: (self.total_estimated_cost_usd > 0.0) + .then_some(self.total_estimated_cost_usd), + }), + "usage-updated", + ); + } +} + +fn estimate_usage_cost_usd(client: &Client, usage: TokenUsage) -> Option { + crate::model::token_cost_rates_for(client.model()).map(|rates| { + rates.estimate_usd( + usage.input_tokens(), + usage.cache_creation_input_tokens(), + usage.cache_read_input_tokens(), + usage.output_tokens(), + client.prompt_cache_ttl(), + ) + }) } /// Drives the mid-session resume: swap the handle, repaint the chat, surface previous-session @@ -1031,6 +1109,8 @@ mod tests { file_tracker, auto_compaction_failures: 3, last_usage: Some(TokenUsage::new(100_000, 1)), + displayed_usage: None, + total_estimated_cost_usd: 0.0, }; let control = task diff --git a/crates/oxide-code/src/model.rs b/crates/oxide-code/src/model.rs index 51ee3d1b..cf94c767 100644 --- a/crates/oxide-code/src/model.rs +++ b/crates/oxide-code/src/model.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; -use crate::config::Effort; +use crate::config::{Effort, PromptCacheTtl}; // ── ModelInfo ── @@ -14,11 +14,68 @@ pub(crate) struct ModelInfo { pub(crate) display_name: &'static str, pub(crate) cutoff: Option<&'static str>, pub(crate) capabilities: Capabilities, + /// First-party Claude API token prices in USD per million tokens. + pub(crate) cost_rates: Option, } const STANDARD_CONTEXT_WINDOW: u32 = 200_000; const CONTEXT_1M_WINDOW: u32 = 1_000_000; +// ── TokenCostRates ── + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct TokenCostRates { + input: f64, + cache_write_5m: f64, + cache_write_1h: f64, + cache_read: f64, + output: f64, +} + +const OPUS_RATES: TokenCostRates = TokenCostRates { + input: 5.0, + cache_write_5m: 6.25, + cache_write_1h: 10.0, + cache_read: 0.50, + output: 25.0, +}; + +const SONNET_RATES: TokenCostRates = TokenCostRates { + input: 3.0, + cache_write_5m: 3.75, + cache_write_1h: 6.0, + cache_read: 0.30, + output: 15.0, +}; + +const HAIKU_RATES: TokenCostRates = TokenCostRates { + input: 1.0, + cache_write_5m: 1.25, + cache_write_1h: 2.0, + cache_read: 0.10, + output: 5.0, +}; + +impl TokenCostRates { + pub(crate) fn estimate_usd( + self, + input_tokens: u32, + cache_creation_input_tokens: u32, + cache_read_input_tokens: u32, + output_tokens: u32, + cache_ttl: PromptCacheTtl, + ) -> f64 { + let cache_write = match cache_ttl { + PromptCacheTtl::FiveMin => self.cache_write_5m, + PromptCacheTtl::OneHour => self.cache_write_1h, + }; + million_tokens(input_tokens) * self.input + + million_tokens(cache_creation_input_tokens) * cache_write + + million_tokens(cache_read_input_tokens) * self.cache_read + + million_tokens(output_tokens) * self.output + } +} + // ── Capabilities ── /// Per-model gate set consumed by the wire-builder (header + body fields), the slash commands @@ -61,6 +118,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ ], structured_outputs: true, }, + cost_rates: Some(OPUS_RATES), }, ModelInfo { id_substr: "claude-opus-4-6", @@ -73,6 +131,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[Effort::Low, Effort::Medium, Effort::High, Effort::Max], structured_outputs: true, }, + cost_rates: Some(OPUS_RATES), }, ModelInfo { id_substr: "claude-sonnet-4-6", @@ -85,6 +144,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[Effort::Low, Effort::Medium, Effort::High], structured_outputs: true, }, + cost_rates: Some(SONNET_RATES), }, ModelInfo { id_substr: "claude-opus-4-5", @@ -97,6 +157,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[], structured_outputs: true, }, + cost_rates: Some(OPUS_RATES), }, ModelInfo { id_substr: "claude-sonnet-4-5", @@ -109,6 +170,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[], structured_outputs: true, }, + cost_rates: Some(SONNET_RATES), }, ModelInfo { id_substr: "claude-haiku-4-5", @@ -122,18 +184,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[], structured_outputs: true, }, - }, - ModelInfo { - id_substr: "claude-opus-4-1", - display_name: "Claude Opus 4.1", - cutoff: Some("January 2025"), - capabilities: Capabilities { - interleaved_thinking: true, - context_management: true, - context_1m: false, - supported_efforts: &[], - structured_outputs: true, - }, + cost_rates: Some(HAIKU_RATES), }, ]; @@ -222,6 +273,14 @@ pub(crate) fn context_window_for(model: &str) -> Option { } } +pub(crate) fn token_cost_rates_for(model: &str) -> Option { + lookup(model).and_then(|info| info.cost_rates) +} + +fn million_tokens(tokens: u32) -> f64 { + f64::from(tokens) / 1_000_000.0 +} + /// Human-facing label: the row's [`ModelInfo::display_name`] plus a ` (1M context)` suffix on /// `[1m]` ids; the raw id when the model is unknown. pub(crate) fn display_name(model: &str) -> Cow<'_, str> { @@ -286,7 +345,6 @@ mod tests { "claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5", - "claude-opus-4-1", ] { assert!( !lookup(other) @@ -314,7 +372,6 @@ mod tests { "claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5", - "claude-opus-4-1", ] { assert!( !lookup(unsupported) @@ -346,7 +403,6 @@ mod tests { "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", - "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-5", @@ -531,6 +587,36 @@ mod tests { assert_eq!(context_window_for("claude-future-9"), None); } + // ── token_cost_rates_for ── + + #[test] + fn token_cost_rates_for_known_model_estimates_prompt_cache_ttl() { + let rates = token_cost_rates_for("claude-opus-4-7[1m]").unwrap(); + let five_min = rates.estimate_usd( + 1_000_000, + 1_000_000, + 1_000_000, + 1_000_000, + PromptCacheTtl::FiveMin, + ); + let one_hour = rates.estimate_usd( + 1_000_000, + 1_000_000, + 1_000_000, + 1_000_000, + PromptCacheTtl::OneHour, + ); + + assert!((five_min - 36.75).abs() < 1e-9); + assert!((one_hour - 40.5).abs() < 1e-9); + } + + #[test] + fn token_cost_rates_for_unknown_model_is_absent() { + assert!(token_cost_rates_for("claude-future-9").is_none()); + assert!(token_cost_rates_for("claude-opus-4-1").is_none()); + } + // ── display_name ── #[test] @@ -542,7 +628,6 @@ mod tests { ("claude-opus-4-5", "Claude Opus 4.5"), ("claude-sonnet-4-5", "Claude Sonnet 4.5"), ("claude-haiku-4-5", "Claude Haiku 4.5"), - ("claude-opus-4-1", "Claude Opus 4.1"), ] { assert_eq!(display_name(id), expected, "{id}"); } diff --git a/crates/oxide-code/src/prompt/environment.rs b/crates/oxide-code/src/prompt/environment.rs index ac559220..8ec67576 100644 --- a/crates/oxide-code/src/prompt/environment.rs +++ b/crates/oxide-code/src/prompt/environment.rs @@ -258,7 +258,6 @@ mod tests { ("claude-opus-4-6", "May 2025"), ("claude-opus-4-5", "May 2025"), ("claude-haiku-4-5", "February 2025"), - ("claude-opus-4-1", "January 2025"), ("claude-sonnet-4-5", "January 2025"), ] { assert_eq!(knowledge_cutoff(id), Some(expected), "{id}"); diff --git a/crates/oxide-code/src/slash.rs b/crates/oxide-code/src/slash.rs index 8daa7c16..ebd514df 100644 --- a/crates/oxide-code/src/slash.rs +++ b/crates/oxide-code/src/slash.rs @@ -127,6 +127,7 @@ pub(crate) fn test_session_info() -> LiveSessionInfo { // Real MODELS row so `display_name()` resolves to a known label. LiveSessionInfo { cwd: "~/test".to_owned(), + git_branch: Some("main".to_owned()), version: "0.0.0-test", session_id: "test-session".to_owned(), config: ConfigSnapshot { @@ -144,6 +145,7 @@ pub(crate) fn test_session_info() -> LiveSessionInfo { }), show_thinking: false, show_welcome: true, + status_line: crate::config::StatusLineSegment::DEFAULT.to_vec(), theme_name: "mocha".to_owned(), }, } diff --git a/crates/oxide-code/src/slash/context.rs b/crates/oxide-code/src/slash/context.rs index 0753582c..6f228994 100644 --- a/crates/oxide-code/src/slash/context.rs +++ b/crates/oxide-code/src/slash/context.rs @@ -14,6 +14,7 @@ use crate::tui::modal::Modal; /// persisted JSONL record consumed by `--list`. pub(crate) struct LiveSessionInfo { pub(crate) cwd: String, + pub(crate) git_branch: Option, pub(crate) version: &'static str, pub(crate) session_id: String, pub(crate) config: ConfigSnapshot, diff --git a/crates/oxide-code/src/slash/model.rs b/crates/oxide-code/src/slash/model.rs index 7665a888..7a8c7649 100644 --- a/crates/oxide-code/src/slash/model.rs +++ b/crates/oxide-code/src/slash/model.rs @@ -334,7 +334,6 @@ mod tests { fn execute_short_id_resolves_via_suffix_tier() { for (arg, expected) in [ ("haiku-4-5", "claude-haiku-4-5"), - ("opus-4-1", "claude-opus-4-1"), ("sonnet-4-5", "claude-sonnet-4-5"), ("sonnet-4-6", "claude-sonnet-4-6"), ("opus-4-6[1m]", "claude-opus-4-6[1m]"), @@ -434,12 +433,11 @@ mod tests { assert!(msg.contains(id), "{id} should be listed: {msg}"); } // Older non-listed Opus rows must not appear in the curated listing. - for id in ["claude-opus-4-5", "claude-opus-4-1"] { - assert!( - !msg.contains(id), - "non-curated `{id}` must not appear: {msg}", - ); - } + let id = "claude-opus-4-5"; + assert!( + !msg.contains(id), + "non-curated `{id}` must not appear: {msg}", + ); } // ── resolve_model_arg ── diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 0fb1f1cc..2af3f7d6 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -91,8 +91,11 @@ impl App { ); let mut status_bar = StatusBar::new( theme, + session_info.config.status_line.clone(), session_info.display_name().into_owned(), + session_info.config.effort, session_info.cwd.clone(), + session_info.git_branch.clone(), ); status_bar.set_title(history.title); Self { @@ -459,6 +462,9 @@ impl App { self.pending_prompts.pop_front(); self.chat.push_user_message(text); } + AgentEvent::UsageUpdated(usage) => { + self.status_bar.set_usage(Some(usage)); + } AgentEvent::TurnComplete => { self.finish_turn(); } @@ -479,6 +485,7 @@ impl App { AgentEvent::SessionRolled { id } => { self.session_info.session_id = id; self.status_bar.set_title(None); + self.status_bar.set_usage(None); self.chat.clear_history(); self.active_prompt = None; } @@ -531,6 +538,7 @@ impl App { ) { self.session_info.session_id = id; self.status_bar.set_title(title); + self.status_bar.set_usage(None); self.chat.clear_history(); self.chat .load_history(messages, compact, tool_metadata, self.tools.as_ref()); @@ -560,6 +568,7 @@ impl App { ) { self.chat.clear_history(); self.pending_calls.clear(); + self.status_bar.set_usage(None); self.chat .push_compacted_block(pre_count, instructions, summary.to_owned()); if automatic && let Some(prompt) = &self.active_prompt { @@ -595,6 +604,7 @@ impl App { self.status_bar .set_model(crate::model::display_name(&model_id).into_owned()); } + self.status_bar.set_effort(effort); self.session_info.config.model_id = model_id; self.session_info.config.effort = effort; self.session_info.config.compaction = compaction; @@ -870,6 +880,7 @@ mod tests { use tokio::sync::mpsc; use super::*; + use crate::agent::event::UsageSnapshot; use crate::config::test_thresholds; use crate::tool::ToolRegistry; use crate::tui::modal::testing::ScriptedModal; @@ -928,6 +939,7 @@ mod tests { LiveSessionInfo { cwd: "~/test".to_owned(), + git_branch: Some("main".to_owned()), version: "0.0.0-test", session_id: "test-session".to_owned(), config: ConfigSnapshot { @@ -945,6 +957,7 @@ mod tests { }), show_thinking: false, show_welcome: true, + status_line: crate::config::StatusLineSegment::DEFAULT.to_vec(), theme_name: "mocha".to_owned(), }, } @@ -957,6 +970,14 @@ mod tests { }) } + fn usage_snapshot() -> UsageSnapshot { + UsageSnapshot { + context_tokens: 124_000, + context_window: Some(1_000_000), + estimated_cost_usd: Some(0.4321), + } + } + /// Minimal modal for layout tests: paints `title` on its only row, ignores keys. struct FakeModal { title: String, @@ -2349,11 +2370,23 @@ mod tests { ); } + #[test] + fn handle_usage_updated_sets_status_bar_usage() { + let (mut app, _rx, _agent_tx) = test_app(None); + let usage = usage_snapshot(); + + app.handle_agent_event(AgentEvent::UsageUpdated(usage)); + + assert_eq!(app.status_bar.usage(), Some(usage)); + assert!(app.dirty); + } + #[test] fn handle_session_rolled_clears_chat_rebinds_id_and_drops_stale_title() { let (mut app, _rx, _agent_tx) = test_app(Some("Old session title")); app.chat.push_user_message("old prompt".to_owned()); let original_id = app.session_info.session_id.clone(); + app.status_bar.set_usage(Some(usage_snapshot())); app.handle_agent_event(AgentEvent::SessionRolled { id: "rolled-session".to_owned(), @@ -2368,6 +2401,10 @@ mod tests { app.status_bar.title().is_none(), "stale AI title must be cleared on roll", ); + assert!( + app.status_bar.usage().is_none(), + "stale usage must be cleared on roll", + ); assert!( app.chat.is_empty(), "clear must drain the chat so the welcome can repaint", @@ -2379,6 +2416,7 @@ mod tests { fn handle_session_resumed_replays_transcript_and_clears_pending() { let (mut app, _rx, _agent_tx) = test_app(Some("Old")); app.chat.push_user_message("live prompt".to_owned()); + app.status_bar.set_usage(Some(usage_snapshot())); app.pending_prompts.push_back("queued".to_owned()); app.pending_calls.insert( "pending-1".to_owned(), @@ -2405,6 +2443,10 @@ mod tests { assert_eq!(app.session_info.session_id, "resumed-session"); assert_ne!(app.session_info.session_id, original_id); assert_eq!(app.status_bar.title(), Some("Resumed title")); + assert!( + app.status_bar.usage().is_none(), + "stale usage must be cleared on resume", + ); assert_eq!( app.chat.entry_count(), 3, @@ -2488,6 +2530,7 @@ mod tests { fn handle_session_compacted_replays_summary_and_clears_pending_calls() { let (mut app, _rx, _agent_tx) = test_app(Some("Pre-compact")); app.chat.push_user_message("pre-compact prompt".to_owned()); + app.status_bar.set_usage(Some(usage_snapshot())); app.pending_calls.insert( "pending-1".to_owned(), PendingCall { @@ -2514,6 +2557,10 @@ mod tests { 0, "pending tool calls must drop on compact", ); + assert!( + app.status_bar.usage().is_none(), + "stale usage must be cleared on compact", + ); assert_eq!(app.status_bar.status(), &Status::Idle); assert!(app.input.is_enabled(), "compact returns to idle input"); assert!(app.dirty); diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_cancelling_shows_spinner_and_label.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_cancelling_shows_spinner_and_label.snap index e0f88224..f5ce880d 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_cancelling_shows_spinner_and_label.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_cancelling_shows_spinner_and_label.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" Claude Opus 4.7 │ ⣷ Cancelling ~/projects/demo " +" ~/projects/demo │ main │ Claude Opus 4.7 (xhigh) │ ⣷ Cancelling " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_compacting_shows_spinner_and_status_label.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_compacting_shows_spinner_and_status_label.snap index ad494e2d..7c373795 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_compacting_shows_spinner_and_status_label.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_compacting_shows_spinner_and_status_label.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" Claude Opus 4.7 │ ⣷ Compacting · Esc to interrupt ~/projects/demo " +" ~/projects/demo │ Claude Opus 4.7 (xhigh) │ ⣷ Compacting · Esc to interrupt " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_exit_armed_shows_static_hint_without_spinner.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_exit_armed_shows_static_hint_without_spinner.snap index 53617e48..f9f56feb 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_exit_armed_shows_static_hint_without_spinner.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_exit_armed_shows_static_hint_without_spinner.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" Claude Opus 4.7 │ Press Ctrl+C again to exit ~/projects/demo " +" ~/projects/demo │ main │ Claude Opus 4.7 (xhigh) │ Press Ctrl+C again to exit " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_with_title_shows_model_title_and_cwd.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_with_title_shows_model_title_and_cwd.snap index d19210f5..d35cb2c8 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_with_title_shows_model_title_and_cwd.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_with_title_shows_model_title_and_cwd.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" Claude Opus 4.7 │ Fix login flow │ Ready ~/projects/demo " +" ~/projects/demo │ main │ Claude Opus 4.7 (xhigh) │ Ready │ Fix login flow " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_without_title_leaves_slot_unused.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_without_title_leaves_slot_unused.snap index 8a851165..e276d1ee 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_without_title_leaves_slot_unused.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_without_title_leaves_slot_unused.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" Claude Opus 4.7 │ Ready ~/projects/demo " +" ~/projects/demo │ main │ Claude Opus 4.7 (xhigh) │ Ready " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_narrow_width_drops_cwd_and_title_slots.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_narrow_width_preserves_model_and_run_state.snap similarity index 83% rename from crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_narrow_width_drops_cwd_and_title_slots.snap rename to crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_narrow_width_preserves_model_and_run_state.snap index 42a71901..27a4f69f 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_narrow_width_drops_cwd_and_title_slots.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_narrow_width_preserves_model_and_run_state.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 40)" --- -" Claude Opus 4.7 │ Ready " +" Claude Opus 4.7 (xhigh) │ Ready " "────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_streaming_shows_spinner_and_status_label.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_streaming_shows_spinner_and_status_label.snap index e5dfc90c..3e1cf811 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_streaming_shows_spinner_and_status_label.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_streaming_shows_spinner_and_status_label.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" Claude Opus 4.7 │ ⣷ Streaming · Esc to interrupt ~/projects/demo " +" ~/projects/demo │ Claude Opus 4.7 (xhigh) │ ⣷ Streaming · Esc to interrupt " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_tool_running_status.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_tool_running_status.snap index 299a5616..8e38a102 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_tool_running_status.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_tool_running_status.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" Claude Opus 4.7 │ ⣷ Running bash · Esc to interrupt ~/projects/demo " +" ~/projects/demo │ Claude Opus 4.7 (xhigh) │ ⣷ Running bash · Esc to interrupt " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 7c02526a..0cb876f3 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -1,26 +1,33 @@ -//! Status bar component (model, spinner, working directory). +//! Configurable status line component. + +mod line; use std::time::Instant; use ratatui::Frame; use ratatui::layout::Rect; -use ratatui::text::{Line, Span}; +use ratatui::text::Span; use ratatui::widgets::{Block, Borders, Paragraph}; -use unicode_width::UnicodeWidthStr; +use crate::agent::event::UsageSnapshot; +use crate::config::{Effort, StatusLineSegment}; use crate::tui::glyphs::SPINNER_FRAMES; use crate::tui::theme::Theme; -use crate::util::text::truncate_to_width; + +use self::line::{StatusLine, StatusLineState}; const TICKS_PER_FRAME: usize = 5; -const MAX_TITLE_WIDTH: usize = 40; /// Status bar at the top of the TUI. pub(crate) struct StatusBar { theme: Theme, + line: StatusLine, model: String, + effort: Option, title: Option, + usage: Option, cwd: String, + git_branch: Option, status: Status, spinner_frame: usize, tick_counter: usize, @@ -37,12 +44,23 @@ pub(crate) enum Status { } impl StatusBar { - pub(crate) fn new(theme: &Theme, model: String, cwd: String) -> Self { + pub(crate) fn new( + theme: &Theme, + segments: Vec, + model: String, + effort: Option, + cwd: String, + git_branch: Option, + ) -> Self { Self { theme: theme.clone(), + line: StatusLine::new(segments), model, + effort, title: None, + usage: None, cwd, + git_branch, status: Status::Idle, spinner_frame: 0, tick_counter: 0, @@ -61,6 +79,14 @@ impl StatusBar { self.model = model; } + pub(crate) fn set_effort(&mut self, effort: Option) { + self.effort = effort; + } + + pub(crate) fn set_usage(&mut self, usage: Option) { + self.usage = usage; + } + /// Re-skin subsequent renders; the spinner / status state is unaffected. pub(crate) fn set_theme(&mut self, theme: &Theme) { self.theme = theme.clone(); @@ -87,6 +113,11 @@ impl StatusBar { &self.model } + #[cfg(test)] + pub(crate) fn usage(&self) -> Option { + self.usage + } + /// Returns `true` if the spinner frame advanced (caller should repaint). pub(crate) fn tick(&mut self) -> bool { if !is_animated(&self.status) { @@ -104,51 +135,14 @@ impl StatusBar { impl StatusBar { pub(crate) fn render(&self, frame: &mut Frame, area: Rect) { - let sep = self.theme.separator_span(); - let area_width = usize::from(area.width); - - let core = vec![ - Span::raw(" "), - Span::styled(self.model.as_str(), self.theme.text()), - sep.clone(), - self.status_span(), - ]; - let core_width: usize = core.iter().map(Span::width).sum(); - - let title_slot = self - .title - .as_deref() - .map(|t| title_slot_spans(t, &sep, self.theme.muted())); - let title_slot_width = title_slot.as_deref().map_or(0, slot_width); - - let cwd_slot_content_width = self.cwd.width() + 2; - let cwd_display_width = if self.cwd.is_empty() { - 0 - } else { - cwd_slot_content_width + 1 - }; - - let mut spans = core; - let (include_title, include_cwd) = - fit_layout(area_width, core_width, title_slot_width, cwd_display_width); - if include_title && let Some(slot) = title_slot { - let status = spans.pop().expect("core always has the status span"); - spans.extend(slot); - spans.push(status); - } - if include_cwd { - let used: usize = spans.iter().map(Span::width).sum(); - let gap = area_width - used - cwd_slot_content_width; - spans.push(Span::raw(" ".repeat(gap))); - spans.push(Span::styled(&self.cwd, self.theme.dim())); - spans.push(Span::raw(" ")); - } - let block = Block::default() .borders(Borders::BOTTOM) .border_style(self.theme.border_unfocused()) .style(self.theme.surface()); - frame.render_widget(Paragraph::new(Line::from(spans)).block(block), area); + frame.render_widget( + Paragraph::new(self.render_line(area.width)).block(block), + area, + ); } } @@ -174,6 +168,22 @@ impl StatusBar { let spinner = SPINNER_FRAMES[self.spinner_frame]; Span::styled(format!("{spinner} {label}"), self.theme.info()) } + + fn render_line(&self, width: u16) -> ratatui::text::Line<'static> { + self.line.render( + &self.theme, + &StatusLineState { + model: &self.model, + effort: self.effort, + title: self.title.as_deref(), + usage: self.usage, + cwd: &self.cwd, + git_branch: self.git_branch.as_deref(), + status_span: self.status_span(), + }, + width, + ) + } } fn is_animated(status: &Status) -> bool { @@ -183,37 +193,6 @@ fn is_animated(status: &Status) -> bool { ) } -fn title_slot_spans<'a>( - title: &'a str, - sep: &Span<'a>, - style: ratatui::style::Style, -) -> Vec> { - vec![ - Span::styled(truncate_to_width(title, MAX_TITLE_WIDTH), style), - sep.clone(), - ] -} - -fn slot_width(slot: &[Span<'_>]) -> usize { - slot.iter().map(Span::width).sum() -} - -/// Returns `(include_title, include_cwd)`. Cwd wins over title when both -/// can't fit — it carries location context the title does not. -fn fit_layout(area_width: usize, core: usize, title: usize, cwd: usize) -> (bool, bool) { - let fits = |extra: usize| core + extra < area_width; - match ( - title > 0 && fits(title + cwd), - cwd > 0 && fits(cwd), - title > 0 && fits(title), - ) { - (true, _, _) => (true, cwd > 0), - (false, true, _) => (false, true), - (false, false, true) => (true, false), - _ => (false, false), - } -} - #[cfg(test)] mod tests { use ratatui::Terminal; @@ -224,8 +203,11 @@ mod tests { fn test_bar() -> StatusBar { StatusBar::new( &Theme::default(), + StatusLineSegment::DEFAULT.to_vec(), "test-model".to_owned(), + Some(Effort::High), "~/test".to_owned(), + Some("main".to_owned()), ) } @@ -393,7 +375,14 @@ mod tests { } fn bar_idle(title: Option<&str>, cwd: &str) -> StatusBar { - let mut bar = StatusBar::new(&Theme::default(), "Claude Opus 4.7".into(), cwd.into()); + let mut bar = StatusBar::new( + &Theme::default(), + StatusLineSegment::DEFAULT.to_vec(), + "Claude Opus 4.7".into(), + Some(Effort::Xhigh), + cwd.into(), + Some("main".to_owned()), + ); bar.set_title(title.map(ToOwned::to_owned)); bar } @@ -417,6 +406,23 @@ mod tests { insta::assert_snapshot!(render_status(&mut bar, 80)); } + #[test] + fn render_usage_shows_context_and_session_cost() { + let mut bar = bar_idle(None, "~/projects/demo"); + bar.set_usage(Some(UsageSnapshot { + context_tokens: 124_000, + context_window: Some(1_000_000), + estimated_cost_usd: Some(0.4321), + })); + let output = render_top_row(&mut bar, 120); + assert!( + output.contains("Ctx: 12% (124k/1M)"), + "usage slot should render before status: {output:?}", + ); + assert!(output.contains("Sess: $0.4321")); + assert!(output.contains("Ready")); + } + #[test] fn render_tool_running_status() { let mut bar = bar_idle(None, "~/projects/demo"); @@ -450,23 +456,70 @@ mod tests { } #[test] - fn render_narrow_width_drops_cwd_and_title_slots() { + fn render_configured_segments_control_order() { + let mut bar = StatusBar::new( + &Theme::default(), + vec![ + StatusLineSegment::RunState, + StatusLineSegment::Model, + StatusLineSegment::CurrentDir, + ], + "Claude Opus 4.7".into(), + Some(Effort::Xhigh), + "~/projects/demo".into(), + Some("main".to_owned()), + ); + let output = render_top_row(&mut bar, 120); + let state_at = output.find("Ready").unwrap(); + let model_at = output.find("Claude Opus 4.7").unwrap(); + let cwd_at = output.find("~/projects/demo").unwrap(); + assert!(state_at < model_at, "run state should lead: {output:?}"); + assert!(model_at < cwd_at, "cwd should follow model: {output:?}"); + assert!( + !output.contains("main"), + "git branch was not requested: {output:?}" + ); + } + + #[test] + fn render_uses_theme_separator_and_segment_labels() { + let mut bar = StatusBar::new( + &Theme::default(), + vec![ + StatusLineSegment::CurrentDir, + StatusLineSegment::GitBranch, + StatusLineSegment::ModelWithEffort, + StatusLineSegment::RunState, + ], + "Claude Opus 4.7".into(), + Some(Effort::Xhigh), + "~/projects/demo".into(), + Some("feat/status-line".to_owned()), + ); + let output = render_top_row(&mut bar, 120); + assert!( + output.contains("~/projects/demo │ feat/status-line │ Claude Opus 4.7 (xhigh) │ Ready") + ); + } + + #[test] + fn render_narrow_width_preserves_model_and_run_state() { let mut bar = bar_idle(Some("A rather long session title"), "~/projects/demo/long"); insta::assert_snapshot!(render_status(&mut bar, 40)); } #[test] - fn render_wide_shows_title_between_model_and_status() { + fn render_wide_shows_title_after_status() { let mut bar = test_bar(); bar.set_title(Some("Fix auth bug".to_owned())); let output = render_top_row(&mut bar, 120); let model_at = output.find("test-model").unwrap(); - let title_at = output.find("Fix auth bug").unwrap(); let status_at = output.find("Ready").unwrap(); + let title_at = output.find("Fix auth bug").unwrap(); assert!(model_at < title_at, "title should follow model: {output:?}"); assert!( - title_at < status_at, - "title should precede status: {output:?}" + status_at < title_at, + "title should follow status: {output:?}" ); } @@ -487,18 +540,6 @@ mod tests { ); } - #[test] - fn render_drops_title_first_when_tight() { - let mut bar = test_bar(); - bar.set_title(Some("Some long title".to_owned())); - let output = render_top_row(&mut bar, 40); - assert!(output.contains("~/test"), "cwd should survive: {output:?}"); - assert!( - !output.contains("Some long title"), - "title should drop before cwd: {output:?}", - ); - } - #[test] fn render_no_title_still_shows_cwd_wide() { let mut bar = test_bar(); @@ -512,7 +553,14 @@ mod tests { #[test] fn render_empty_cwd_drops_cwd_slot_entirely() { - let mut bar = StatusBar::new(&Theme::default(), "test-model".to_owned(), String::new()); + let mut bar = StatusBar::new( + &Theme::default(), + StatusLineSegment::DEFAULT.to_vec(), + "test-model".to_owned(), + None, + String::new(), + None, + ); let output = render_top_row(&mut bar, 120); assert!(output.contains("test-model")); assert!(output.contains("Ready")); @@ -521,26 +569,4 @@ mod tests { "no tildified path should appear: {output:?}", ); } - - // ── fit_layout ── - - #[test] - fn fit_layout_keeps_both_slots_when_everything_fits() { - assert_eq!(fit_layout(80, 25, 10, 10), (true, true)); - } - - #[test] - fn fit_layout_drops_title_before_cwd_when_combined_too_wide() { - assert_eq!(fit_layout(40, 25, 10, 10), (false, true)); - } - - #[test] - fn fit_layout_keeps_title_when_cwd_is_too_wide_to_fit_alone() { - assert_eq!(fit_layout(40, 25, 5, 20), (true, false)); - } - - #[test] - fn fit_layout_drops_both_when_nothing_extra_fits() { - assert_eq!(fit_layout(26, 25, 5, 5), (false, false)); - } } diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs new file mode 100644 index 00000000..759d1fa0 --- /dev/null +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -0,0 +1,276 @@ +use ratatui::text::{Line, Span}; +use time::OffsetDateTime; +use unicode_width::UnicodeWidthStr; + +use crate::agent::event::UsageSnapshot; +use crate::config::{Effort, StatusLineSegment}; +use crate::tui::theme::Theme; +use crate::util::text::truncate_to_width; +use crate::util::time::local_offset; + +const MAX_CURRENT_DIR_WIDTH: usize = 40; +const MAX_GIT_BRANCH_WIDTH: usize = 32; +const MAX_TITLE_WIDTH: usize = 40; + +/// Ordered segment roster for one status-line render. +#[derive(Debug, Clone)] +pub(super) struct StatusLine { + segments: Vec, +} + +impl StatusLine { + pub(super) fn new(segments: Vec) -> Self { + Self { segments } + } + + pub(super) fn render( + &self, + theme: &Theme, + state: &StatusLineState<'_>, + width: u16, + ) -> Line<'static> { + let sep = theme.separator_span(); + let sep_width = UnicodeWidthStr::width(sep.content.as_ref()); + let mut rendered = self + .segments + .iter() + .filter_map(|segment| Self::render_segment(*segment, theme, state)) + .collect::>(); + fit_segments(&mut rendered, usize::from(width), sep_width); + + let mut spans = vec![Span::raw(" ")]; + let mut first = true; + for segment in rendered { + if !first { + spans.push(sep.clone()); + } + spans.push(segment.span); + first = false; + } + Line::from(spans) + } + + fn render_segment( + segment: StatusLineSegment, + theme: &Theme, + state: &StatusLineState<'_>, + ) -> Option { + let span = match segment { + StatusLineSegment::CurrentDir => non_empty_span( + truncate_to_width(state.cwd, MAX_CURRENT_DIR_WIDTH), + Self::segment_style(theme, SegmentStyle::Dim), + ), + StatusLineSegment::GitBranch => state.git_branch.map(|branch| { + Span::styled( + truncate_to_width(branch, MAX_GIT_BRANCH_WIDTH), + Self::segment_style(theme, SegmentStyle::Accent), + ) + }), + StatusLineSegment::Model => Some(Span::styled( + state.model.to_owned(), + Self::segment_style(theme, SegmentStyle::Text), + )), + StatusLineSegment::ModelWithEffort => Some(Span::styled( + model_with_effort(state.model, state.effort), + Self::segment_style(theme, SegmentStyle::Text), + )), + StatusLineSegment::ContextUsed => state + .usage + .map(context_label) + .map(|label| Span::styled(label, Self::segment_style(theme, SegmentStyle::Dim))), + StatusLineSegment::SessionCost => state + .usage + .and_then(session_cost_label) + .map(|label| Span::styled(label, Self::segment_style(theme, SegmentStyle::Dim))), + StatusLineSegment::RunState => Some(state.status_span.clone()), + StatusLineSegment::ThreadTitle => state.title.map(|title| { + Span::styled( + truncate_to_width(title, MAX_TITLE_WIDTH), + Self::segment_style(theme, SegmentStyle::Muted), + ) + }), + StatusLineSegment::CurrentTime => Some(Span::styled( + current_time_label(), + Self::segment_style(theme, SegmentStyle::Dim), + )), + }?; + Some(RenderedSegment::new(segment, span)) + } + + fn segment_style(theme: &Theme, style: SegmentStyle) -> ratatui::style::Style { + match style { + SegmentStyle::Text => theme.text(), + SegmentStyle::Muted => theme.muted(), + SegmentStyle::Dim => theme.dim(), + SegmentStyle::Accent => theme.accent(), + } + } +} + +pub(super) struct StatusLineState<'a> { + /// Display label for the active model. + pub(super) model: &'a str, + /// Resolved effort tier for model-with-effort. + pub(super) effort: Option, + /// Optional session title. + pub(super) title: Option<&'a str>, + /// Latest usage snapshot from the agent loop. + pub(super) usage: Option, + /// Tildified working directory. + pub(super) cwd: &'a str, + /// Branch captured at TUI startup. + pub(super) git_branch: Option<&'a str>, + /// Pre-rendered run-state segment from the parent component. + pub(super) status_span: Span<'static>, +} + +#[derive(Debug, Clone, Copy)] +enum SegmentStyle { + Text, + Muted, + Dim, + Accent, +} + +#[derive(Debug, Clone)] +struct RenderedSegment { + segment: StatusLineSegment, + span: Span<'static>, +} + +impl RenderedSegment { + fn new(segment: StatusLineSegment, span: Span<'static>) -> Self { + Self { segment, span } + } + + fn width(&self) -> usize { + UnicodeWidthStr::width(self.span.content.as_ref()) + } +} + +fn fit_segments(segments: &mut Vec, max_width: usize, sep_width: usize) { + while segments.len() > 1 && total_width(segments, sep_width) > max_width { + let Some(index) = lowest_priority_index(segments) else { + break; + }; + segments.remove(index); + } + + if total_width(segments, sep_width) <= max_width { + return; + } + let content_width = max_width + .saturating_sub(2) + .saturating_sub(sep_width.saturating_mul(segments.len().saturating_sub(1))); + if let Some(segment) = segments.iter_mut().max_by_key(|segment| segment.width()) { + let label = truncate_to_width(segment.span.content.as_ref(), content_width); + segment.span = Span::styled(label, segment.span.style); + } +} + +fn total_width(segments: &[RenderedSegment], sep_width: usize) -> usize { + 2 + segments.iter().map(RenderedSegment::width).sum::() + + sep_width.saturating_mul(segments.len().saturating_sub(1)) +} + +fn lowest_priority_index(segments: &[RenderedSegment]) -> Option { + segments + .iter() + .enumerate() + .min_by_key(|(_, segment)| segment_utility(segment.segment)) + .map(|(index, _)| index) +} + +fn segment_utility(segment: StatusLineSegment) -> u8 { + match segment { + StatusLineSegment::ThreadTitle => 0, + StatusLineSegment::CurrentTime => 1, + StatusLineSegment::GitBranch => 2, + StatusLineSegment::CurrentDir => 3, + StatusLineSegment::SessionCost => 4, + StatusLineSegment::ContextUsed => 5, + StatusLineSegment::Model | StatusLineSegment::ModelWithEffort => 6, + StatusLineSegment::RunState => 7, + } +} + +fn non_empty_span(label: String, style: ratatui::style::Style) -> Option> { + (!label.is_empty()).then(|| Span::styled(label, style)) +} + +fn model_with_effort(model: &str, effort: Option) -> String { + match effort { + Some(effort) => format!("{model} ({effort})"), + None => model.to_owned(), + } +} + +fn context_label(usage: UsageSnapshot) -> String { + match usage.context_window { + Some(window) if window > 0 => { + let percent = usage.context_tokens.saturating_mul(100) / window; + format!( + "Ctx: {percent}% ({}/{})", + compact_tokens(usage.context_tokens), + compact_tokens(window), + ) + } + _ => format!("Ctx: {}", compact_tokens(usage.context_tokens)), + } +} + +fn session_cost_label(usage: UsageSnapshot) -> Option { + usage + .estimated_cost_usd + .map(|cost| format!("Sess: {}", format_cost(cost))) +} + +fn current_time_label() -> String { + let now = OffsetDateTime::now_utc().to_offset(local_offset()); + format!("{:02}:{:02}", now.hour(), now.minute()) +} + +fn compact_tokens(tokens: u32) -> String { + if tokens >= 1_000_000 { + format!("{}M", tokens / 1_000_000) + } else if tokens >= 1_000 { + format!("{}k", tokens / 1_000) + } else { + tokens.to_string() + } +} + +fn format_cost(cost: f64) -> String { + if cost >= 0.995 { + format!("${cost:.2}") + } else { + format!("${cost:.4}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── context_label ── + + #[test] + fn context_label_omits_unknown_context_window() { + assert_eq!( + context_label(UsageSnapshot { + context_tokens: 987, + context_window: None, + estimated_cost_usd: None, + }), + "Ctx: 987", + ); + } + + // ── format_cost ── + + #[test] + fn format_cost_uses_cents_for_larger_totals() { + assert_eq!(format_cost(1.234), "$1.23"); + assert_eq!(format_cost(0.12345), "$0.1235"); + } +} diff --git a/crates/oxide-code/src/tui/components/welcome.rs b/crates/oxide-code/src/tui/components/welcome.rs index d310719d..fa2d8d91 100644 --- a/crates/oxide-code/src/tui/components/welcome.rs +++ b/crates/oxide-code/src/tui/components/welcome.rs @@ -337,6 +337,7 @@ mod tests { fn fixture() -> LiveSessionInfo { LiveSessionInfo { cwd: "~/github/oxide-code".to_owned(), + git_branch: Some("main".to_owned()), version: "0.1.0", session_id: "test-session".to_owned(), config: ConfigSnapshot { @@ -354,6 +355,7 @@ mod tests { }), show_thinking: false, show_welcome: true, + status_line: crate::config::StatusLineSegment::DEFAULT.to_vec(), theme_name: "mocha".to_owned(), }, } diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_lays_out_status_chat_and_input_in_order.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_lays_out_status_chat_and_input_in_order.snap index 6f797c1a..eba2fb58 100644 --- a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_lays_out_status_chat_and_input_in_order.snap +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_lays_out_status_chat_and_input_in_order.snap @@ -2,7 +2,7 @@ source: crates/oxide-code/src/tui/app.rs expression: "render_app(&mut app, 80, 10)" --- -" test-model │ Session title │ Ready ~/test " +" ~/test │ main │ test-model (high) │ Ready │ Session title " "────────────────────────────────────────────────────────────────────────────────" " " " ━━━━ oxide-code v0.0.0-test ━━━━ " diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_narrow_width_still_renders_all_three_panels.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_narrow_width_still_renders_all_three_panels.snap index 806f1446..7b01d4b6 100644 --- a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_narrow_width_still_renders_all_three_panels.snap +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_narrow_width_still_renders_all_three_panels.snap @@ -1,9 +1,8 @@ --- source: crates/oxide-code/src/tui/app.rs -assertion_line: 2627 expression: "render_app(&mut app, 40, 8)" --- -" test-model │ narrow │ Ready ~/test " +" ~/test │ test-model (high) │ Ready " "────────────────────────────────────────" "❯ hi " " " diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_preview_panel_renders_queued_prompts_and_overflow_tag.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_preview_panel_renders_queued_prompts_and_overflow_tag.snap index 934f7289..99b54631 100644 --- a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_preview_panel_renders_queued_prompts_and_overflow_tag.snap +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_preview_panel_renders_queued_prompts_and_overflow_tag.snap @@ -1,9 +1,8 @@ --- source: crates/oxide-code/src/tui/app.rs -assertion_line: 2652 expression: "render_app(&mut app, 60, 14)" --- -" test-model │ ⣷ Streaming · Esc to interrupt ~/test " +" test-model (high) │ ⣷ Streaming · Esc to interrupt " "────────────────────────────────────────────────────────────" "❯ active " " " diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_streaming_shows_spinner_in_status_bar.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_streaming_shows_spinner_in_status_bar.snap index 38e18053..e3744080 100644 --- a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_streaming_shows_spinner_in_status_bar.snap +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_streaming_shows_spinner_in_status_bar.snap @@ -1,9 +1,8 @@ --- source: crates/oxide-code/src/tui/app.rs -assertion_line: 2620 expression: "render_app(&mut app, 60, 8)" --- -" test-model │ ⣷ Streaming · Esc to interrupt ~/test " +" test-model (high) │ ⣷ Streaming · Esc to interrupt " "────────────────────────────────────────────────────────────" "❯ working... " " " diff --git a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_with_conversation_and_tool_call.snap b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_with_conversation_and_tool_call.snap index 90859244..07d4390d 100644 --- a/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_with_conversation_and_tool_call.snap +++ b/crates/oxide-code/src/tui/snapshots/ox__tui__app__tests__draw_frame_with_conversation_and_tool_call.snap @@ -1,9 +1,8 @@ --- source: crates/oxide-code/src/tui/app.rs -assertion_line: 2609 expression: "render_app(&mut app, 60, 12)" --- -" test-model │ Ready ~/test " +" ~/test │ main │ test-model (high) │ Ready " "────────────────────────────────────────────────────────────" "▎ $ ls " "▎ ✓ ran ls " diff --git a/crates/oxide-code/src/tui/theme.rs b/crates/oxide-code/src/tui/theme.rs index a38a06e9..65c048f1 100644 --- a/crates/oxide-code/src/tui/theme.rs +++ b/crates/oxide-code/src/tui/theme.rs @@ -99,7 +99,7 @@ macro_rules! for_each_slot { (tool_icon, "Tool icon accent"), (border_focused, "Focused component border"), (border_unfocused, "Unfocused component border (default-aligned with `dim`)"), - (separator, "Status bar separator (dimmed pipe)"), + (separator, "Status-line segment separator"), } }; } diff --git a/docs/design/agent/auto-compaction.md b/docs/design/agent/auto-compaction.md index 1fdba10d..912a2baf 100644 --- a/docs/design/agent/auto-compaction.md +++ b/docs/design/agent/auto-compaction.md @@ -14,10 +14,10 @@ It does not interrupt an in-flight stream or tool call. If another prompt arrive The agent loop records the maximum observed token usage from each stream: -- `message_start.message.usage.input_tokens + output_tokens`; -- `message_delta.usage.input_tokens + output_tokens`. +- `message_start.message.usage.input_tokens + cache_creation_input_tokens + cache_read_input_tokens + output_tokens`; +- `message_delta.usage.input_tokens + cache_creation_input_tokens + cache_read_input_tokens + output_tokens`. -Anthropic's delta usage often carries only output tokens, so stream processing keeps the latest non-zero input and output values separately and computes `total = input + output`. Treat this value only as the auto-compaction trigger signal; it is unsuitable for billing telemetry. Missing usage means "do not auto-compact". +Anthropic's delta usage often carries only output tokens, so stream processing keeps the latest non-zero input, cache-creation input, cache-read input, and output values separately. The status-line context segment reuses the same cache-aware signal. Missing usage means "do not auto-compact". ## Threshold diff --git a/docs/design/tui/status-line.md b/docs/design/tui/status-line.md index ee219e88..7aa97604 100644 --- a/docs/design/tui/status-line.md +++ b/docs/design/tui/status-line.md @@ -45,7 +45,7 @@ Add `StatusLineSegment` in `config.rs` with TOML values: - `thread-title` - `current-time` -`[tui] status_line = [...]` controls the order. `OX_STATUS_LINE` accepts the same comma-separated names. Segment colors always come from the active theme. +`[tui] status_line = [...]` controls the order. `OX_STATUS_LINE` accepts the same comma-separated names. Segment colors and the separator glyph come from the active theme. `StatusBar` keeps component state and delegates segment formatting to `tui/components/status/line.rs`. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c4f0f8df..670c3e23 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -26,20 +26,29 @@ auto_threshold_tokens = 400000 [tui] show_thinking = true +status_line = [ + "current-dir", + "git-branch", + "model-with-effort", + "context-used", + "session-cost", + "run-state", + "thread-title", +] ``` ### `[client]`: API connection -| Key | Type | Default | Description | -| ------------------ | ------- | --------------------------- | ---------------------------------------------------------- | -| `api_key` | string | - | Anthropic API key (user config only) | -| `base_url` | string | `https://api.anthropic.com` | API base URL (user config only) | -| `extra_ca_certs` | string | - | PEM bundle appended to the trust store (user config only) | -| `model` | string | `claude-opus-4-7[1m]` | Model to use | -| `effort` | string | per-model (see below) | Intelligence-vs-latency tier | -| `max_tokens` | integer | effort-derived (see below) | Max tokens per response | -| `max_tool_rounds` | integer | unset (unbounded) | Per-turn safety cap on tool rounds | -| `prompt_cache_ttl` | string | `"1h"` | Prompt-cache TTL (`"5m"` or `"1h"`) | +| Key | Type | Default | Description | +| ------------------ | ------- | --------------------------- | --------------------------------------------------------- | +| `api_key` | string | - | Anthropic API key (user config only) | +| `base_url` | string | `https://api.anthropic.com` | API base URL (user config only) | +| `extra_ca_certs` | string | - | PEM bundle appended to the trust store (user config only) | +| `model` | string | `claude-opus-4-7[1m]` | Model to use | +| `effort` | string | per-model (see below) | Intelligence-vs-latency tier | +| `max_tokens` | integer | effort-derived (see below) | Max tokens per response | +| `max_tool_rounds` | integer | unset (unbounded) | Per-turn safety cap on tool rounds | +| `prompt_cache_ttl` | string | `"1h"` | Prompt-cache TTL (`"5m"` or `"1h"`) | #### `effort`: intelligence tier @@ -117,15 +126,18 @@ model = "claude-opus-4-7[1m]" ### `[tui]`: Terminal UI -| Key | Type | Default | Description | -| --------------- | ------- | ------- | ----------------------------------------- | -| `show_thinking` | boolean | `false` | Show extended thinking | -| `show_welcome` | boolean | `true` | Paint the welcome splash on an empty chat | +| Key | Type | Default | Description | +| --------------- | ------- | --------- | ----------------------------------------- | +| `show_thinking` | boolean | `false` | Show extended thinking | +| `show_welcome` | boolean | `true` | Paint the welcome splash on an empty chat | +| `status_line` | array | see below | Ordered status-line segments | On Opus 4.7, `show_thinking = true` opts the request into `thinking.display = "summarized"` so the API streams reasoning text. Otherwise the 4.7 default of `"omitted"` applies and the UI sees nothing until the final answer arrives. `show_welcome = false` blanks the chat region until you submit a prompt, which is useful when piping or screen-recording. +`status_line` accepts these segment names: `current-dir`, `git-branch`, `model`, `model-with-effort`, `context-used`, `session-cost`, `run-state`, `thread-title`, `current-time`. Segments with no data, such as a missing git branch or usage before the first completed turn, are omitted. The separator is part of the active theme's `separator` slot; there is no separate status-line separator setting. + ### `[tui.theme]`: Terminal theme | Key | Type | Default | Description | @@ -177,6 +189,7 @@ Environment variables override all config file values. | `OX_COMPACTION_AUTO_THRESHOLD_PERCENT` | `client.compaction.auto_threshold_percent` | model-derived | Percent compaction trigger | | `OX_SHOW_THINKING` | `tui.show_thinking` | `false` | Show extended thinking | | `OX_SHOW_WELCOME` | `tui.show_welcome` | `true` | Paint the welcome splash | +| `OX_STATUS_LINE` | `tui.status_line` | see `[tui]` | Comma-separated segments | Set `OX_SHOW_THINKING=1` to display the model's thinking process (dimmed text) when extended thinking is enabled for the model. diff --git a/docs/guide/theming.md b/docs/guide/theming.md index cdc5d46d..db255a60 100644 --- a/docs/guide/theming.md +++ b/docs/guide/theming.md @@ -175,7 +175,7 @@ Each slot maps to one role in the TUI. Override a slot by name to restyle that r | `tool_icon` | Per-tool icon | | `border_focused` | Focused component border (e.g., input) | | `border_unfocused` | Unfocused component border | -| `separator` | Status bar separator (dimmed pipe) | +| `separator` | Status-line segment separator | ## Overrides diff --git a/docs/roadmap.md b/docs/roadmap.md index 65e78cf2..12a034fc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -13,7 +13,7 @@ The direction is simple: ### Terminal UI - Streaming chat with markdown, syntax-highlighted code, and clear tool output. -- Multi-line input, a live status bar, and a focused welcome screen for new sessions. +- Multi-line input, a configurable status line with context / estimated-cost usage, and a focused welcome screen for new sessions. - Theme support with built-in palettes and user-defined TOML themes. - Full TUI, bare REPL (`--no-tui`), and headless (`-p`) modes. @@ -84,7 +84,7 @@ The direction is simple: Remaining surface beyond Working Today: -- Cost visibility, login/logout, custom commands, and a guided `/init` flow. +- Login/logout, custom commands, and a guided `/init` flow. Persistence stance: session commands should feel reversible. Cross-session writes will require an explicit user action. @@ -115,9 +115,9 @@ Persistence stance: session commands should feel reversible. Cross-session write - Auth slash commands. - Configurable instruction directories. -### Status Bar Redesign +### Status Line Redesign -- A clearer status surface for model, cost, queue state, session identity, and theme. +- Additional segments for queue state, session identity, theme, account-limit usage, pull requests, and task progress. ## Not the Goal Right Now From 1a864b883208c913628a271fdcc0f33b32213e38 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 15:15:24 +0800 Subject: [PATCH 03/43] fix(model): retain active opus 4.1 metadata 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. --- crates/oxide-code/src/model.rs | 46 +++++++++++++++++++++++++--- crates/oxide-code/src/slash/model.rs | 5 +-- docs/research/tui/status-line.md | 1 + 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/oxide-code/src/model.rs b/crates/oxide-code/src/model.rs index cf94c767..74e6cad9 100644 --- a/crates/oxide-code/src/model.rs +++ b/crates/oxide-code/src/model.rs @@ -32,7 +32,7 @@ pub(crate) struct TokenCostRates { output: f64, } -const OPUS_RATES: TokenCostRates = TokenCostRates { +const OPUS_4_5_PLUS_RATES: TokenCostRates = TokenCostRates { input: 5.0, cache_write_5m: 6.25, cache_write_1h: 10.0, @@ -40,6 +40,14 @@ const OPUS_RATES: TokenCostRates = TokenCostRates { output: 25.0, }; +const OPUS_4_1_RATES: TokenCostRates = TokenCostRates { + input: 15.0, + cache_write_5m: 18.75, + cache_write_1h: 30.0, + cache_read: 1.50, + output: 75.0, +}; + const SONNET_RATES: TokenCostRates = TokenCostRates { input: 3.0, cache_write_5m: 3.75, @@ -118,7 +126,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ ], structured_outputs: true, }, - cost_rates: Some(OPUS_RATES), + cost_rates: Some(OPUS_4_5_PLUS_RATES), }, ModelInfo { id_substr: "claude-opus-4-6", @@ -131,7 +139,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[Effort::Low, Effort::Medium, Effort::High, Effort::Max], structured_outputs: true, }, - cost_rates: Some(OPUS_RATES), + cost_rates: Some(OPUS_4_5_PLUS_RATES), }, ModelInfo { id_substr: "claude-sonnet-4-6", @@ -157,7 +165,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[], structured_outputs: true, }, - cost_rates: Some(OPUS_RATES), + cost_rates: Some(OPUS_4_5_PLUS_RATES), }, ModelInfo { id_substr: "claude-sonnet-4-5", @@ -186,6 +194,19 @@ pub(crate) const MODELS: &[ModelInfo] = &[ }, cost_rates: Some(HAIKU_RATES), }, + ModelInfo { + id_substr: "claude-opus-4-1", + display_name: "Claude Opus 4.1", + cutoff: Some("January 2025"), + capabilities: Capabilities { + interleaved_thinking: true, + context_management: true, + context_1m: false, + supported_efforts: &[], + structured_outputs: true, + }, + cost_rates: Some(OPUS_4_1_RATES), + }, ]; impl Capabilities { @@ -345,6 +366,7 @@ mod tests { "claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5", + "claude-opus-4-1", ] { assert!( !lookup(other) @@ -611,10 +633,23 @@ mod tests { assert!((one_hour - 40.5).abs() < 1e-9); } + #[test] + fn token_cost_rates_for_opus_4_1_uses_older_pricing() { + let rates = token_cost_rates_for("claude-opus-4-1").unwrap(); + let cost = rates.estimate_usd( + 1_000_000, + 1_000_000, + 1_000_000, + 1_000_000, + PromptCacheTtl::FiveMin, + ); + + assert!((cost - 110.25).abs() < 1e-9); + } + #[test] fn token_cost_rates_for_unknown_model_is_absent() { assert!(token_cost_rates_for("claude-future-9").is_none()); - assert!(token_cost_rates_for("claude-opus-4-1").is_none()); } // ── display_name ── @@ -628,6 +663,7 @@ mod tests { ("claude-opus-4-5", "Claude Opus 4.5"), ("claude-sonnet-4-5", "Claude Sonnet 4.5"), ("claude-haiku-4-5", "Claude Haiku 4.5"), + ("claude-opus-4-1", "Claude Opus 4.1"), ] { assert_eq!(display_name(id), expected, "{id}"); } diff --git a/crates/oxide-code/src/slash/model.rs b/crates/oxide-code/src/slash/model.rs index 7a8c7649..f18f86ea 100644 --- a/crates/oxide-code/src/slash/model.rs +++ b/crates/oxide-code/src/slash/model.rs @@ -398,8 +398,8 @@ mod tests { } #[test] - fn execute_retired_or_legacy_id_falls_through_to_ambiguity() { - // Retired ids substring-match multiple current rows; ambiguity must surface. + fn execute_family_prefix_falls_through_to_ambiguity() { + // Family prefixes substring-match multiple rows; ambiguity must surface. for arg in ["opus-4", "claude-opus-4", "claude-sonnet-4"] { let (_, outcome) = run_execute(arg); let msg = outcome.expect_err(&format!("`{arg}` must error")); @@ -471,6 +471,7 @@ mod tests { for dated in [ "claude-opus-4-7-20260101", "claude-opus-4-6-20250805", + "claude-opus-4-1-20250805", "claude-sonnet-4-5-20250929", ] { assert_eq!( diff --git a/docs/research/tui/status-line.md b/docs/research/tui/status-line.md index 1ad8be4f..912b904e 100644 --- a/docs/research/tui/status-line.md +++ b/docs/research/tui/status-line.md @@ -42,6 +42,7 @@ Anthropic's pricing page lists first-party Claude API prices in USD per million | Family | Input | 5m cache write | 1h cache write | Cache read | Output | | -------------------- | ----- | -------------- | -------------- | ---------- | ------ | | Opus 4.7 / 4.6 / 4.5 | $5 | $6.25 | $10 | $0.50 | $25 | +| Opus 4.1 | $15 | $18.75 | $30 | $1.50 | $75 | | Sonnet 4.x | $3 | $3.75 | $6 | $0.30 | $15 | | Haiku 4.5 | $1 | $1.25 | $2 | $0.10 | $5 | From 1160aa51b928ad5b7aa398ea4ecb1677e871f5ec Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 16:34:49 +0800 Subject: [PATCH 04/43] fix(agent): count all turn usage for session cost 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. --- crates/oxide-code/src/agent.rs | 105 ++++++++++++++++++++++++++++++++- crates/oxide-code/src/main.rs | 5 +- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index 01ded50d..92a94db6 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -113,6 +113,17 @@ impl TokenUsage { self.output } + fn add(self, other: Self) -> Self { + Self { + input: self.input.saturating_add(other.input), + cache_creation_input: self + .cache_creation_input + .saturating_add(other.cache_creation_input), + cache_read_input: self.cache_read_input.saturating_add(other.cache_read_input), + output: self.output.saturating_add(other.output), + } + } + fn observe(&mut self, usage: &Usage) { if usage.input_tokens > 0 { self.input = usage.input_tokens; @@ -132,6 +143,7 @@ impl TokenUsage { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct TurnReport { pub(crate) usage: Option, + pub(crate) billable_usage: Option, } pub(crate) struct AutoCompact<'a> { @@ -162,6 +174,7 @@ pub(crate) async fn agent_turn( let tool_defs = tools.definitions(); let mut pending_prompts: Vec = Vec::new(); let mut latest_usage = None; + let mut billable_usage = None; for _ in 0..max_tool_rounds.unwrap_or(u32::MAX) { strip_trailing_thinking(messages); @@ -175,7 +188,11 @@ pub(crate) async fn agent_turn( &mut pending_prompts, ) .await??; - latest_usage = usage.or(latest_usage); + if let Some(usage) = usage { + latest_usage = Some(usage); + billable_usage = + Some(billable_usage.map_or(usage, |total: TokenUsage| total.add(usage))); + } let tool_uses = collect_tool_uses(&blocks); let assistant_msg = Message { @@ -189,6 +206,7 @@ pub(crate) async fn agent_turn( messages.push(assistant_msg); return Ok(TurnReport { usage: latest_usage, + billable_usage, }); } @@ -921,6 +939,51 @@ mod tests { ] } + fn text_turn_with_cache_usage( + text: &str, + input_tokens: u32, + cache_creation_input_tokens: u32, + cache_read_input_tokens: u32, + output_tokens: u32, + ) -> Vec { + vec![ + StreamEvent::MessageStart { + message: MessageResponse { + id: "msg_1".into(), + model: "claude-sonnet-4-6".into(), + usage: Some(Usage { + input_tokens, + cache_creation_input_tokens, + cache_read_input_tokens, + output_tokens: 0, + }), + }, + }, + StreamEvent::ContentBlockStart { + index: 0, + content_block: ContentBlockInfo::Text { + text: String::new(), + }, + }, + StreamEvent::ContentBlockDelta { + index: 0, + delta: Delta::TextDelta { text: text.into() }, + }, + StreamEvent::MessageDelta { + delta: MessageDeltaBody { + stop_reason: Some("end_turn".into()), + }, + usage: Some(Usage { + input_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + output_tokens, + }), + }, + StreamEvent::MessageStop, + ] + } + fn tool_use_turn(id: &str, name: &str, input_json: &str) -> Vec { vec![ StreamEvent::ContentBlockStart { @@ -1628,6 +1691,42 @@ mod tests { .unwrap(); assert_eq!(report.usage.map(TokenUsage::total_tokens), Some(107)); + assert_eq!( + report.billable_usage.map(TokenUsage::total_tokens), + Some(107) + ); + } + + #[tokio::test] + async fn agent_turn_reports_cache_usage_for_context_and_cost() { + let dir = tempfile::tempdir().unwrap(); + let session = test_session(dir.path()); + let client = FakeClient::new(vec![text_turn_with_cache_usage("Hello!", 10, 20, 30, 5)]); + let tools = ToolRegistry::new(Vec::new()); + let sink = CapturingSink::new(); + let mut messages = vec![Message::user("hi")]; + + let report = agent_turn( + &client, + &tools, + &mut messages, + &empty_prompt(), + &sink, + &session, + &mut inert_user_rx(), + None, + ) + .await + .unwrap(); + + let usage = report.usage.expect("stream reports usage"); + assert_eq!(usage.input_tokens(), 10); + assert_eq!(usage.cache_creation_input_tokens(), 20); + assert_eq!(usage.cache_read_input_tokens(), 30); + assert_eq!(usage.output_tokens(), 5); + assert_eq!(usage.context_tokens(), 60); + assert_eq!(usage.total_tokens(), 65); + assert_eq!(report.billable_usage, Some(usage)); } #[tokio::test] @@ -1719,6 +1818,10 @@ mod tests { .unwrap(); assert_eq!(report.usage.map(TokenUsage::total_tokens), Some(3)); + assert_eq!( + report.billable_usage.map(TokenUsage::total_tokens), + Some(14) + ); assert_eq!( sink.events() .iter() diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 9642fc7c..4c79c253 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -579,7 +579,10 @@ impl AgentLoopTask { self.last_usage = report.usage; if let Some(usage) = report.usage { self.displayed_usage = Some(usage); - if let Some(cost) = estimate_usage_cost_usd(&self.client, usage) { + if let Some(cost) = report + .billable_usage + .and_then(|usage| estimate_usage_cost_usd(&self.client, usage)) + { self.total_estimated_cost_usd += cost; } self.emit_usage_update(); From 47d1d40702c96f8d4b6c35264c1948a7d77eddf5 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 16:36:12 +0800 Subject: [PATCH 05/43] fix(session): preserve usage state on failed resume 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. --- crates/oxide-code/src/main.rs | 60 ++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index 4c79c253..fb2cb567 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -450,7 +450,7 @@ impl AgentLoopTask { LoopControl::Continue } UserAction::Resume { session_id } => { - apply_resume( + let resumed = apply_resume( &mut self.session, &mut self.client, &mut self.messages, @@ -460,8 +460,10 @@ impl AgentLoopTask { &session_id, ) .await; - self.reset_auto_compaction(); - self.reset_usage_display(); + if resumed { + self.reset_auto_compaction(); + self.reset_usage_display(); + } LoopControl::Continue } UserAction::Compact { instructions } => { @@ -651,7 +653,7 @@ async fn apply_resume( file_tracker: &FileTracker, sink: &dyn AgentSink, target_id: &str, -) { +) -> bool { let outcome = match session::handle::roll_into(session, store, file_tracker, target_id).await { Ok(o) => o, Err(e) => { @@ -662,7 +664,7 @@ async fn apply_resume( )), "resume-failed", ); - return; + return false; } }; let new_id = session.session_id().to_owned(); @@ -695,6 +697,7 @@ async fn apply_resume( "resume-drift-warning", ); } + true } fn format_drift_warning(drifted: &[std::path::PathBuf]) -> String { @@ -1131,4 +1134,51 @@ mod tests { Some(AgentEvent::ConfigChanged { .. }) )); } + + #[tokio::test] + async fn handle_action_failed_resume_preserves_current_session_usage_state() { + let server = MockServer::start().await; + let config = test_config(server.uri(), api_key(), "claude-opus-4-7[1m]"); + let client = Client::new(config, Some("sid".to_owned())).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let store = test_store(dir.path()); + let session = session::handle::start(&store, "claude-opus-4-7[1m]"); + let original_id = session.session_id().to_owned(); + let file_tracker = Arc::new(FileTracker::default()); + let (sink, mut event_rx) = tui::event::channel(); + let (_user_tx, user_rx) = agent::event::inert_user_action_channel(); + let last_usage = TokenUsage::new(100_000, 10); + let displayed_usage = TokenUsage::new(90_000, 9); + let mut task = AgentLoopTask { + client, + tools: Arc::new(ToolRegistry::new(Vec::new())), + sink, + user_rx, + session, + messages: vec![Message::user("still here")], + store, + file_tracker, + auto_compaction_failures: 2, + last_usage: Some(last_usage), + displayed_usage: Some(displayed_usage), + total_estimated_cost_usd: 1.23, + }; + + let control = task + .handle_action(UserAction::Resume { + session_id: "missing-target-id".to_owned(), + }) + .await; + + assert!(matches!(control, LoopControl::Continue)); + assert_eq!(task.session.session_id(), original_id); + assert_eq!(task.auto_compaction_failures, 2); + assert_eq!(task.last_usage, Some(last_usage)); + assert_eq!(task.displayed_usage, Some(displayed_usage)); + assert!((task.total_estimated_cost_usd - 1.23).abs() < f64::EPSILON); + assert!(matches!( + event_rx.recv().await, + Some(AgentEvent::Error(msg)) if msg.contains("still on session") + )); + } } From 93019d879cfe2bd1c79c16ab626776c52ab63af5 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 16:37:36 +0800 Subject: [PATCH 06/43] fix(tui): refresh status time while idle 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. --- .../oxide-code/src/tui/components/status.rs | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 0cb876f3..2e916db5 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -22,6 +22,7 @@ const TICKS_PER_FRAME: usize = 5; pub(crate) struct StatusBar { theme: Theme, line: StatusLine, + current_time_minute: Option, model: String, effort: Option, title: Option, @@ -52,9 +53,13 @@ impl StatusBar { cwd: String, git_branch: Option, ) -> Self { + let current_time_minute = segments + .contains(&StatusLineSegment::CurrentTime) + .then(current_time_minute); Self { theme: theme.clone(), line: StatusLine::new(segments), + current_time_minute, model, effort, title: None, @@ -118,18 +123,30 @@ impl StatusBar { self.usage } - /// Returns `true` if the spinner frame advanced (caller should repaint). + /// Returns `true` when time or animation state changed and the caller should repaint. pub(crate) fn tick(&mut self) -> bool { - if !is_animated(&self.status) { - return false; + let mut dirty = self.refresh_current_time(); + if is_animated(&self.status) { + self.tick_counter += 1; + if self.tick_counter >= TICKS_PER_FRAME { + self.tick_counter = 0; + self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len(); + dirty = true; + } } - self.tick_counter += 1; - if self.tick_counter >= TICKS_PER_FRAME { - self.tick_counter = 0; - self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len(); - return true; + dirty + } + + fn refresh_current_time(&mut self) -> bool { + let Some(previous) = self.current_time_minute else { + return false; + }; + let current = current_time_minute(); + if current == previous { + return false; } - false + self.current_time_minute = Some(current); + true } } @@ -193,6 +210,11 @@ fn is_animated(status: &Status) -> bool { ) } +fn current_time_minute() -> u16 { + let now = time::OffsetDateTime::now_utc().to_offset(crate::util::time::local_offset()); + u16::from(now.hour()) * 60 + u16::from(now.minute()) +} + #[cfg(test)] mod tests { use ratatui::Terminal; @@ -311,6 +333,25 @@ mod tests { assert_eq!(bar.tick_counter, 0); } + #[test] + fn tick_idle_current_time_marks_dirty_on_minute_change() { + let mut bar = StatusBar::new( + &Theme::default(), + vec![StatusLineSegment::CurrentTime], + "test-model".to_owned(), + None, + "~/test".to_owned(), + None, + ); + let current = current_time_minute(); + bar.current_time_minute = Some((current + 1) % 1440); + + assert!(bar.tick()); + assert_eq!(bar.current_time_minute, Some(current)); + assert!(!bar.tick()); + assert_eq!(bar.spinner_frame, 0); + } + #[test] fn tick_streaming_before_threshold_does_not_advance_spinner_frame() { let mut bar = test_bar(); From 2ca8998c6631a142324f5e409b2a06159a50234e Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 16:39:02 +0800 Subject: [PATCH 07/43] fix(config): reject empty status lines 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. --- crates/oxide-code/src/config.rs | 51 +++++++++++++++++++++++++++++---- docs/guide/configuration.md | 2 +- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 12d5cff6..9cf58f18 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -430,9 +430,11 @@ impl Config { let status_line = match env::string("OX_STATUS_LINE") { Some(raw) => parse_status_line_segments(&raw).context("OX_STATUS_LINE")?, - None => tui - .status_line - .unwrap_or_else(|| StatusLineSegment::DEFAULT.to_vec()), + None => validate_status_line( + tui.status_line + .unwrap_or_else(|| StatusLineSegment::DEFAULT.to_vec()), + ) + .context("tui.status_line")?, }; // 4.7 silently defaulted to `omitted`; `display` opts back into summarized. 4.6 and older @@ -501,11 +503,20 @@ impl Config { // ── Helpers ── fn parse_status_line_segments(raw: &str) -> Result> { - raw.split(',') + let segments = raw + .split(',') .map(str::trim) .filter(|part| !part.is_empty()) .map(str::parse) - .collect() + .collect::>>()?; + validate_status_line(segments) +} + +fn validate_status_line(segments: Vec) -> Result> { + if segments.is_empty() { + bail!("status_line must contain at least one segment"); + } + Ok(segments) } pub(crate) fn display_effort(effort: Option) -> String { @@ -976,6 +987,36 @@ mod tests { assert!(msg.contains("current-dir"), "{msg}"); } + #[tokio::test] + async fn load_status_line_empty_env_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let vars = env_vars(vec![xdg(&dir), env("OX_STATUS_LINE", ",")]); + let err = temp_env::async_with_vars(vars, Config::load()) + .await + .expect_err("empty status line must not launch without run-state"); + let msg = format!("{err:#}"); + assert!(msg.contains("OX_STATUS_LINE"), "{msg}"); + assert!(msg.contains("at least one segment"), "{msg}"); + } + + #[tokio::test] + async fn load_status_line_empty_file_roster_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + write_user_config( + dir.path(), + indoc::indoc! {r" + [tui] + status_line = [] + "}, + ); + let err = temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load()) + .await + .expect_err("empty status line must not launch without run-state"); + let msg = format!("{err:#}"); + assert!(msg.contains("tui.status_line"), "{msg}"); + assert!(msg.contains("at least one segment"), "{msg}"); + } + #[tokio::test] async fn load_env_beats_config_file_field_by_field() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 670c3e23..618e146a 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -136,7 +136,7 @@ On Opus 4.7, `show_thinking = true` opts the request into `thinking.display = "s `show_welcome = false` blanks the chat region until you submit a prompt, which is useful when piping or screen-recording. -`status_line` accepts these segment names: `current-dir`, `git-branch`, `model`, `model-with-effort`, `context-used`, `session-cost`, `run-state`, `thread-title`, `current-time`. Segments with no data, such as a missing git branch or usage before the first completed turn, are omitted. The separator is part of the active theme's `separator` slot; there is no separate status-line separator setting. +`status_line` accepts these segment names: `current-dir`, `git-branch`, `model`, `model-with-effort`, `context-used`, `session-cost`, `run-state`, `thread-title`, `current-time`. The list must contain at least one segment. Segments with no data, such as a missing git branch or usage before the first completed turn, are omitted. The separator is part of the active theme's `separator` slot; there is no separate status-line separator setting. ### `[tui.theme]`: Terminal theme From 7488934715020d6809830b0e87a244d3cf987c3f Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 16:40:45 +0800 Subject: [PATCH 08/43] docs(tui): align status line follow-up scope 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. --- docs/design/agent/auto-compaction.md | 2 +- docs/design/slash/compact.md | 2 +- docs/design/tui/status-line.md | 36 ++++++++++------------------ docs/research/tui/status-line.md | 6 ++--- docs/roadmap.md | 2 +- 5 files changed, 19 insertions(+), 29 deletions(-) diff --git a/docs/design/agent/auto-compaction.md b/docs/design/agent/auto-compaction.md index 912a2baf..5ba8bed1 100644 --- a/docs/design/agent/auto-compaction.md +++ b/docs/design/agent/auto-compaction.md @@ -102,5 +102,5 @@ During TUI auto-compaction, the status bar uses the existing `Compacting` state. - Microcompact / prune for old tool-result bodies. - Anchored re-compaction that updates a previous summary in place. - Separate compaction model. -- Token / cost status-bar redesign. +- Persisted cost restore after resume. - Hook integration. diff --git a/docs/design/slash/compact.md b/docs/design/slash/compact.md index 2afe21a1..7de6a724 100644 --- a/docs/design/slash/compact.md +++ b/docs/design/slash/compact.md @@ -106,7 +106,7 @@ The TUI's `App::apply_session_compacted` clears the chat, pushes a single `Compa - **Pre-flight `count_tokens` API call.** All three reference CLIs use the API's `usage.total_tokens` from the previous response and accept the drift. When auto-compact ships, follow that convention. -- **`/cost` companion.** Token / cost telemetry surface. Status-bar redesign plus `/cost` listed under "Later" in the roadmap. +- **`/cost` companion.** The status line exposes local context and session-cost estimates. A command can later show detailed per-turn or account-level billing. ## Sources diff --git a/docs/design/tui/status-line.md b/docs/design/tui/status-line.md index 7aa97604..c7ac2841 100644 --- a/docs/design/tui/status-line.md +++ b/docs/design/tui/status-line.md @@ -12,15 +12,17 @@ The TUI status line should be an ordered roster of built-in segments. This ships Implemented segments: -- current directory -- git branch -- model -- model with effort -- context used -- estimated session cost -- run state -- thread title -- current time +| Segment | Config value | +| ---------------------- | ------------------- | +| current directory | `current-dir` | +| git branch | `git-branch` | +| model | `model` | +| model with effort | `model-with-effort` | +| context used | `context-used` | +| estimated session cost | `session-cost` | +| run state | `run-state` | +| thread title | `thread-title` | +| current time | `current-time` | Out of scope: @@ -33,19 +35,7 @@ Out of scope: ## Implementation -Add `StatusLineSegment` in `config.rs` with TOML values: - -- `current-dir` -- `git-branch` -- `model` -- `model-with-effort` -- `context-used` -- `session-cost` -- `run-state` -- `thread-title` -- `current-time` - -`[tui] status_line = [...]` controls the order. `OX_STATUS_LINE` accepts the same comma-separated names. Segment colors and the separator glyph come from the active theme. +Add `StatusLineSegment` in `config.rs` for the roster above. `[tui] status_line = [...]` controls the order. `OX_STATUS_LINE` accepts the same comma-separated names. Segment colors and the separator glyph come from the active theme. `StatusBar` keeps component state and delegates segment formatting to `tui/components/status/line.rs`. @@ -83,7 +73,7 @@ status_line = [ ] ``` -This differs from the user's Claude Code script in two ways: +This differs from the reference Claude Code script in two ways: - oxide-code stays one row because Ratatui already owns the app chrome. - External billing data is omitted until there is a first-class provider boundary. diff --git a/docs/research/tui/status-line.md b/docs/research/tui/status-line.md index 912b904e..066f6358 100644 --- a/docs/research/tui/status-line.md +++ b/docs/research/tui/status-line.md @@ -1,10 +1,10 @@ # Status Line (Reference) -Research on status-line composition in terminal coding assistants. Sources are Claude Code, OpenAI Codex, opencode, and the user's managed Claude / Codex setup. +Research on status-line composition in terminal coding assistants. Sources are Claude Code, OpenAI Codex, opencode, and local reference Claude / Codex configurations. ## Claude Code -Claude Code delegates the whole status line to a command. The user's configured script renders: +Claude Code delegates the whole status line to a command. The reference script renders: - Row 1: tty, current directory, git branch, and git dirtiness. - Row 2: model, context use, session cost, ccusage block / daily totals, and current time. @@ -17,7 +17,7 @@ The useful data split is provider-local versus external: ## OpenAI Codex -Codex exposes a roster-style `tui.status_line` array. The user's setup orders: +Codex exposes a roster-style `tui.status_line` array. The reference config orders: - current directory - git branch diff --git a/docs/roadmap.md b/docs/roadmap.md index 12a034fc..220c6908 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -115,7 +115,7 @@ Persistence stance: session commands should feel reversible. Cross-session write - Auth slash commands. - Configurable instruction directories. -### Status Line Redesign +### Status Line Extensions - Additional segments for queue state, session identity, theme, account-limit usage, pull requests, and task progress. From 374f2a71533e7ede10709fbffae1a036861c8fca Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 16:42:15 +0800 Subject: [PATCH 09/43] test(tui): cover status line fitting branches 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. --- .../src/tui/components/status/line.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 759d1fa0..37a9fb55 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -252,6 +252,40 @@ fn format_cost(cost: f64) -> String { mod tests { use super::*; + fn render_text(segments: Vec, width: u16) -> String { + let line = StatusLine::new(segments).render( + &Theme::default(), + &StatusLineState { + model: "m", + effort: Some(Effort::High), + title: Some("title"), + usage: Some(UsageSnapshot { + context_tokens: 100_000, + context_window: Some(200_000), + estimated_cost_usd: Some(0.1234), + }), + cwd: "~/repo", + git_branch: Some("main"), + status_span: Span::raw("Ready"), + }, + width, + ); + line.spans + .into_iter() + .map(|span| span.content) + .collect::() + } + + fn is_hh_mm(label: &str) -> bool { + let bytes = label.as_bytes(); + bytes.len() == 5 + && bytes[0].is_ascii_digit() + && bytes[1].is_ascii_digit() + && bytes[2] == b':' + && bytes[3].is_ascii_digit() + && bytes[4].is_ascii_digit() + } + // ── context_label ── #[test] @@ -266,6 +300,59 @@ mod tests { ); } + // ── StatusLine::render ── + + #[test] + fn render_current_time_uses_clock_label() { + let text = render_text(vec![StatusLineSegment::CurrentTime], 20); + assert!(is_hh_mm(text.trim()), "expected HH:MM label: {text:?}"); + } + + #[test] + fn render_truncates_single_oversized_segment_to_width() { + let line = StatusLine::new(vec![StatusLineSegment::RunState]).render( + &Theme::default(), + &StatusLineState { + model: "m", + effort: None, + title: None, + usage: None, + cwd: "", + git_branch: None, + status_span: Span::raw("Running a very long command name"), + }, + 12, + ); + let text = line + .spans + .into_iter() + .map(|span| span.content) + .collect::(); + + assert!(UnicodeWidthStr::width(text.as_str()) <= 12, "{text:?}"); + assert!(!text.contains("very long command")); + } + + #[test] + fn render_drops_low_utility_segments_before_usage_model_and_state() { + let text = render_text( + vec![ + StatusLineSegment::CurrentTime, + StatusLineSegment::SessionCost, + StatusLineSegment::ContextUsed, + StatusLineSegment::Model, + StatusLineSegment::RunState, + ], + 36, + ); + + assert!(text.contains("Ctx: 50% (100k/200k)"), "{text:?}"); + assert!(text.contains('m'), "{text:?}"); + assert!(text.contains("Ready"), "{text:?}"); + assert!(!text.contains("Sess:"), "{text:?}"); + assert!(UnicodeWidthStr::width(text.as_str()) <= 36, "{text:?}"); + } + // ── format_cost ── #[test] From 264bbe064d9a177d98ae19824081c5b09820dee9 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 16:43:05 +0800 Subject: [PATCH 10/43] test(client): cover prompt cache ttl accessor 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. --- crates/oxide-code/src/client/anthropic.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 5fd5893c..9c040460 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -409,7 +409,8 @@ mod tests { use super::wire::{ContentBlockInfo, Delta}; use super::*; use crate::config::{ - AutoCompactionConfig, CompactionConfig, Effort, ThinkingConfig, test_thresholds, + AutoCompactionConfig, CompactionConfig, Effort, PromptCacheTtl, ThinkingConfig, + test_thresholds, }; // ── Fixtures ── @@ -494,12 +495,14 @@ mod tests { #[test] fn new_exposes_compaction_config() { let mut config = test_config(OFFLINE_URL, Auth::ApiKey("sk-test".to_owned()), TEST_MODEL); + config.prompt_cache_ttl = PromptCacheTtl::FiveMin; config.compaction = CompactionConfig::resolved_for_test(AutoCompactionConfig { enabled: true, threshold_tokens: Some(123_456), }); let client = Client::new(config, None).unwrap(); + assert_eq!(client.prompt_cache_ttl(), PromptCacheTtl::FiveMin); assert_eq!(client.compaction().auto.threshold_tokens, Some(123_456)); } From 03c4fc93ba89da0b69536e60d0579a177e48dc8e Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 17:32:24 +0800 Subject: [PATCH 11/43] test(tui): align status line test order with production 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. --- .../src/tui/components/status/line.rs | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 37a9fb55..98aa2a0c 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -286,20 +286,6 @@ mod tests { && bytes[4].is_ascii_digit() } - // ── context_label ── - - #[test] - fn context_label_omits_unknown_context_window() { - assert_eq!( - context_label(UsageSnapshot { - context_tokens: 987, - context_window: None, - estimated_cost_usd: None, - }), - "Ctx: 987", - ); - } - // ── StatusLine::render ── #[test] @@ -329,8 +315,7 @@ mod tests { .map(|span| span.content) .collect::(); - assert!(UnicodeWidthStr::width(text.as_str()) <= 12, "{text:?}"); - assert!(!text.contains("very long command")); + assert_eq!(text, " Running..."); } #[test] @@ -346,11 +331,21 @@ mod tests { 36, ); - assert!(text.contains("Ctx: 50% (100k/200k)"), "{text:?}"); - assert!(text.contains('m'), "{text:?}"); - assert!(text.contains("Ready"), "{text:?}"); - assert!(!text.contains("Sess:"), "{text:?}"); - assert!(UnicodeWidthStr::width(text.as_str()) <= 36, "{text:?}"); + assert_eq!(text, " Ctx: 50% (100k/200k) │ m │ Ready"); + } + + // ── context_label ── + + #[test] + fn context_label_omits_unknown_context_window() { + assert_eq!( + context_label(UsageSnapshot { + context_tokens: 987, + context_window: None, + estimated_cost_usd: None, + }), + "Ctx: 987", + ); } // ── format_cost ── From 9c93e174c8bd9fb5bc24c405d7463c0a6c192022 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 17:39:18 +0800 Subject: [PATCH 12/43] docs(agent): clarify usage observation and per-turn report contracts 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. --- crates/oxide-code/src/agent.rs | 10 ++++++++++ crates/oxide-code/src/agent/event.rs | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index 92a94db6..22d87358 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -125,6 +125,10 @@ impl TokenUsage { } fn observe(&mut self, usage: &Usage) { + // Anthropic's wire usage carries fresh totals only on the events that own them: + // `MessageStart` reports input + cache fields with `output_tokens = 0`, and `MessageDelta` + // reports `output_tokens` with the input fields zeroed. Treat zero as "not reported here" + // so successive observations layer correctly into one snapshot. if usage.input_tokens > 0 { self.input = usage.input_tokens; } @@ -140,9 +144,15 @@ impl TokenUsage { } } +/// Per-turn usage report emitted at the end of [`agent_turn`]. The two fields carry different +/// temporal meanings and resist being collapsed into one. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct TurnReport { + /// Latest single round's usage. Drives auto-compaction threshold checks, where the trigger + /// depends on the most recent prompt size rather than the historical sum. pub(crate) usage: Option, + /// Sum of every round's usage in this turn. Drives session cost accumulation, since each + /// round was billed independently. pub(crate) billable_usage: Option, } diff --git a/crates/oxide-code/src/agent/event.rs b/crates/oxide-code/src/agent/event.rs index deee2347..4617b7ac 100644 --- a/crates/oxide-code/src/agent/event.rs +++ b/crates/oxide-code/src/agent/event.rs @@ -20,11 +20,13 @@ pub(crate) const INTERRUPTED_MARKER: &str = "(interrupted)"; /// Token and cost snapshot for status-line rendering. #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct UsageSnapshot { - /// Current prompt-side context pressure. + /// Cache-aware input total: `input + cache_creation + cache_read`. Drives the context-pressure + /// percentage and auto-compaction threshold. Resets on `/clear`, `/compact`, and resume. pub(crate) context_tokens: u32, /// Active model context window. `None` hides the percentage. pub(crate) context_window: Option, - /// In-process session cost estimate. `None` hides the cost segment. + /// Running session cost in USD, accumulated across every turn. Resets on the same boundaries + /// as `context_tokens`. `None` hides the cost segment. pub(crate) estimated_cost_usd: Option, } From dc97336f5dda13cb8a07982e4a64220240441795 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 17:42:51 +0800 Subject: [PATCH 13/43] fix(config): make empty status_line error actionable 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. --- crates/oxide-code/src/config.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 9cf58f18..1836937a 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -514,7 +514,15 @@ fn parse_status_line_segments(raw: &str) -> Result> { fn validate_status_line(segments: Vec) -> Result> { if segments.is_empty() { - bail!("status_line must contain at least one segment"); + bail!( + "status_line must list at least one segment; remove the key (or unset \ + OX_STATUS_LINE) to use the default. Valid segments: {}", + StatusLineSegment::ALL + .iter() + .map(|(_, name)| *name) + .collect::>() + .join(", "), + ); } Ok(segments) } @@ -997,6 +1005,8 @@ mod tests { let msg = format!("{err:#}"); assert!(msg.contains("OX_STATUS_LINE"), "{msg}"); assert!(msg.contains("at least one segment"), "{msg}"); + assert!(msg.contains("unset OX_STATUS_LINE"), "{msg}"); + assert!(msg.contains("run-state"), "{msg}"); } #[tokio::test] @@ -1015,6 +1025,8 @@ mod tests { let msg = format!("{err:#}"); assert!(msg.contains("tui.status_line"), "{msg}"); assert!(msg.contains("at least one segment"), "{msg}"); + assert!(msg.contains("remove the key"), "{msg}"); + assert!(msg.contains("run-state"), "{msg}"); } #[tokio::test] From f5e1a5cec5a85eff476c9f6861f0c07a74d28a8d Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 18:04:29 +0800 Subject: [PATCH 14/43] fix(config): reject stray commas in OX_STATUS_LINE 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. --- crates/oxide-code/src/config.rs | 42 ++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 1836937a..c52c99c6 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -503,19 +503,30 @@ impl Config { // ── Helpers ── fn parse_status_line_segments(raw: &str) -> Result> { - let segments = raw - .split(',') - .map(str::trim) - .filter(|part| !part.is_empty()) - .map(str::parse) - .collect::>>()?; - validate_status_line(segments) + let parts: Vec<&str> = raw.split(',').map(str::trim).collect(); + let all_empty = parts.iter().all(|part| part.is_empty()); + if all_empty { + return validate_status_line(Vec::new()); + } + parts + .into_iter() + .map(|part| { + if part.is_empty() { + bail!( + "OX_STATUS_LINE has an empty segment. Remove the stray comma or unset the \ + variable to use the default" + ); + } + part.parse::() + }) + .collect::>>() + .and_then(validate_status_line) } fn validate_status_line(segments: Vec) -> Result> { if segments.is_empty() { bail!( - "status_line must list at least one segment; remove the key (or unset \ + "status_line must list at least one segment. Remove the key (or unset \ OX_STATUS_LINE) to use the default. Valid segments: {}", StatusLineSegment::ALL .iter() @@ -995,6 +1006,19 @@ mod tests { assert!(msg.contains("current-dir"), "{msg}"); } + #[tokio::test] + async fn load_status_line_stray_comma_in_env_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let vars = env_vars(vec![xdg(&dir), env("OX_STATUS_LINE", "model,,run-state")]); + let err = temp_env::async_with_vars(vars, Config::load()) + .await + .expect_err("stray comma must surface as a typo"); + let msg = format!("{err:#}"); + assert!(msg.contains("OX_STATUS_LINE"), "{msg}"); + assert!(msg.contains("empty segment"), "{msg}"); + assert!(msg.contains("stray comma"), "{msg}"); + } + #[tokio::test] async fn load_status_line_empty_env_is_rejected() { let dir = tempfile::tempdir().unwrap(); @@ -1025,7 +1049,7 @@ mod tests { let msg = format!("{err:#}"); assert!(msg.contains("tui.status_line"), "{msg}"); assert!(msg.contains("at least one segment"), "{msg}"); - assert!(msg.contains("remove the key"), "{msg}"); + assert!(msg.contains("Remove the key"), "{msg}"); assert!(msg.contains("run-state"), "{msg}"); } From eeb9a21629fa4ee46c973ecf32ef2b5c16e631cb Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 18:08:58 +0800 Subject: [PATCH 15/43] test(config): cover TOML status_line unknown segment 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. --- crates/oxide-code/src/config.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index c52c99c6..43c1ebb2 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -1019,6 +1019,26 @@ mod tests { assert!(msg.contains("stray comma"), "{msg}"); } + #[tokio::test] + async fn load_status_line_invalid_segment_in_file_surfaces_parse_error() { + let dir = tempfile::tempdir().unwrap(); + write_user_config( + dir.path(), + indoc::indoc! {r#" + [tui] + status_line = ["model", "nope"] + "#}, + ); + let err = temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load()) + .await + .expect_err("invalid TOML segment must propagate"); + let msg = format!("{err:#}"); + assert!(msg.contains("status_line"), "{msg}"); + assert!(msg.contains("unknown variant `nope`"), "{msg}"); + assert!(msg.contains("current-dir"), "{msg}"); + assert!(msg.contains("run-state"), "{msg}"); + } + #[tokio::test] async fn load_status_line_empty_env_is_rejected() { let dir = tempfile::tempdir().unwrap(); From 65fd2405486f42126f5d842acf727c97df6295ee Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 18:13:10 +0800 Subject: [PATCH 16/43] test(tui): pin status-line priority drops and current-time gate `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. --- .../oxide-code/src/tui/components/status.rs | 16 +++++++++++++ .../src/tui/components/status/line.rs | 24 ++++++++++--------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 2e916db5..133a1167 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -333,6 +333,22 @@ mod tests { assert_eq!(bar.tick_counter, 0); } + #[test] + fn tick_without_current_time_segment_skips_minute_refresh() { + let mut bar = StatusBar::new( + &Theme::default(), + vec![StatusLineSegment::Model, StatusLineSegment::RunState], + "test-model".to_owned(), + None, + "~/test".to_owned(), + None, + ); + + assert_eq!(bar.current_time_minute, None); + assert!(!bar.tick()); + assert_eq!(bar.current_time_minute, None); + } + #[test] fn tick_idle_current_time_marks_dirty_on_minute_change() { let mut bar = StatusBar::new( diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 98aa2a0c..6dba8c2e 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -320,18 +320,20 @@ mod tests { #[test] fn render_drops_low_utility_segments_before_usage_model_and_state() { - let text = render_text( - vec![ - StatusLineSegment::CurrentTime, - StatusLineSegment::SessionCost, - StatusLineSegment::ContextUsed, - StatusLineSegment::Model, - StatusLineSegment::RunState, - ], - 36, - ); + let segments = vec![ + StatusLineSegment::CurrentTime, + StatusLineSegment::SessionCost, + StatusLineSegment::ContextUsed, + StatusLineSegment::Model, + StatusLineSegment::RunState, + ]; - assert_eq!(text, " Ctx: 50% (100k/200k) │ m │ Ready"); + assert_eq!( + render_text(segments.clone(), 34), + " Ctx: 50% (100k/200k) │ m │ Ready", + ); + assert_eq!(render_text(segments.clone(), 11), " m │ Ready"); + assert_eq!(render_text(segments, 10), " Ready"); } // ── context_label ── From eaa7b2f10a5e887fba9d54a468176c34ab662389 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 18:39:27 +0800 Subject: [PATCH 17/43] fix(agent): bill cancelled and failed turns for completed rounds The previous shape returned `AbortResult`, 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. --- crates/oxide-code/src/agent.rs | 163 +++++++++++++++++++++++++++++---- crates/oxide-code/src/main.rs | 45 ++++----- 2 files changed, 167 insertions(+), 41 deletions(-) diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index 22d87358..59350a91 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -156,6 +156,59 @@ pub(crate) struct TurnReport { pub(crate) billable_usage: Option, } +/// Result of one [`agent_turn`] invocation. The report carries usage observed before any abort +/// so the outer loop can still bill rounds the API already charged for, even when the user +/// cancels mid-stream or a later round fails. +#[derive(Debug)] +pub(crate) struct TurnOutcome { + pub(crate) report: TurnReport, + pub(crate) result: AbortResult<()>, +} + +impl TurnOutcome { + fn completed(report: TurnReport) -> Self { + Self { + report, + result: Ok(()), + } + } + + fn aborted(report: TurnReport, abort: TurnAbort) -> Self { + Self { + report, + result: Err(abort), + } + } + + /// Test helper: returns the report on success or panics with the abort. Mirrors + /// `Result::unwrap`. + #[cfg(test)] + pub(crate) fn unwrap(self) -> TurnReport { + match self.result { + Ok(()) => self.report, + Err(abort) => panic!("turn aborted: {abort:?}"), + } + } + + /// Test helper: returns the report on success or panics with the abort and `msg`. + #[cfg(test)] + pub(crate) fn expect(self, msg: &str) -> TurnReport { + match self.result { + Ok(()) => self.report, + Err(abort) => panic!("{msg}: {abort:?}"), + } + } + + /// Test helper: returns the abort on failure or panics. Mirrors `Result::expect_err`. + #[cfg(test)] + pub(crate) fn expect_err(self, msg: &str) -> TurnAbort { + match self.result { + Ok(()) => panic!("{msg}: turn unexpectedly completed"), + Err(abort) => abort, + } + } +} + pub(crate) struct AutoCompact<'a> { pub(crate) config: AutoCompactionConfig, pub(crate) failures: &'a mut u8, @@ -180,24 +233,38 @@ pub(crate) async fn agent_turn( session: &SessionHandle, user_rx: &mut mpsc::Receiver, max_tool_rounds: Option, -) -> AbortResult { +) -> TurnOutcome { let tool_defs = tools.definitions(); let mut pending_prompts: Vec = Vec::new(); let mut latest_usage = None; let mut billable_usage = None; + let report = |latest, billable| TurnReport { + usage: latest, + billable_usage: billable, + }; for _ in 0..max_tool_rounds.unwrap_or(u32::MAX) { strip_trailing_thinking(messages); - let StreamOutcome { - blocks, - parse_errors, - usage, - } = await_unless_aborted( + let stream = await_unless_aborted( stream_response(client, messages, &tool_defs, prompt, sink), user_rx, &mut pending_prompts, ) - .await??; + .await; + let StreamOutcome { + blocks, + parse_errors, + usage, + } = match stream { + Ok(Ok(outcome)) => outcome, + Ok(Err(error)) => { + return TurnOutcome::aborted( + report(latest_usage, billable_usage), + TurnAbort::Failed(error), + ); + } + Err(abort) => return TurnOutcome::aborted(report(latest_usage, billable_usage), abort), + }; if let Some(usage) = usage { latest_usage = Some(usage); billable_usage = @@ -214,13 +281,10 @@ pub(crate) async fn agent_turn( // Queued prompts drain on the TUI side at idle. record_message(session, assistant_msg.clone(), sink).await; messages.push(assistant_msg); - return Ok(TurnReport { - usage: latest_usage, - billable_usage, - }); + return TurnOutcome::completed(report(latest_usage, billable_usage)); } - let (results, sidecars) = run_tool_round( + let round = run_tool_round( tools, tool_uses, &parse_errors, @@ -228,7 +292,11 @@ pub(crate) async fn agent_turn( user_rx, &mut pending_prompts, ) - .await?; + .await; + let (results, sidecars) = match round { + Ok(pair) => pair, + Err(abort) => return TurnOutcome::aborted(report(latest_usage, billable_usage), abort), + }; let tool_result_msg = Message { role: Role::User, content: results, @@ -243,10 +311,13 @@ pub(crate) async fn agent_turn( // `None` resolves to `u32::MAX`, so this branch is reachable only when the caller set // `Some(n)` and the model ran `n` rounds without a final reply. let cap = max_tool_rounds.unwrap_or(u32::MAX); - Err(TurnAbort::Failed(anyhow!( - "agent stopped after {cap} tool rounds without a final response \ - — this is a safety cap against runaway loops. Ask again with a narrower request." - ))) + TurnOutcome::aborted( + report(latest_usage, billable_usage), + TurnAbort::Failed(anyhow!( + "agent stopped after {cap} tool rounds without a final response \ + — this is a safety cap against runaway loops. Ask again with a narrower request." + )), + ) } /// Decides whether to compact and drives it when the threshold and breaker allow. Returns @@ -2073,7 +2144,7 @@ mod tests { let (tx, mut rx) = mpsc::channel::(1); tx.try_send(action.clone()).unwrap(); - agent_turn( + let outcome = agent_turn( &client, &tools, &mut messages, @@ -2083,8 +2154,11 @@ mod tests { &mut rx, None, ) - .await - .unwrap_or_else(|_| panic!("turn must complete despite {action:?}")); + .await; + assert!( + outcome.result.is_ok(), + "turn must complete despite {action:?}" + ); assert_eq!( messages.len(), @@ -2151,6 +2225,55 @@ mod tests { drop(tx); } + #[tokio::test] + async fn agent_turn_cancel_during_tool_round_preserves_completed_round_usage() { + // Round 1's stream observes usage before the tool gates. Cancel arrives during the tool + // execution, so the abort must still carry billable_usage from the completed round. + let dir = tempfile::tempdir().unwrap(); + let session = test_session(dir.path()); + let client = FakeClient::new(vec![tool_use_turn_with_usage( + "tool_1", "gate", r"{}", 7, 3, + )]); + let started = Arc::new(Notify::new()); + let tools = ToolRegistry::new(vec![Box::new(GateTool { + started: started.clone(), + })]); + let sink = CapturingSink::new(); + let mut messages = vec![Message::user("kick off")]; + let (tx, mut rx) = mpsc::channel::(1); + let prompt = empty_prompt(); + + let (outcome, ()) = tokio::join!( + agent_turn( + &client, + &tools, + &mut messages, + &prompt, + &sink, + &session, + &mut rx, + None, + ), + async { + started.notified().await; + tx.send(UserAction::Cancel).await.unwrap(); + }, + ); + + assert!( + matches!(outcome.result, Err(TurnAbort::Cancelled)), + "got {:?}", + outcome.result, + ); + assert_eq!( + outcome.report.billable_usage.map(TokenUsage::total_tokens), + Some(10), + "round 1 usage must reach the caller despite the abort: {:?}", + outcome.report, + ); + drop(tx); + } + #[tokio::test] async fn agent_turn_unknown_tool_name_emits_error_result_and_retries() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index fb2cb567..cb171830 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -26,7 +26,7 @@ use tracing::{debug, warn}; use agent::event::{ AgentEvent, AgentSink, StdioSink, UsageSnapshot, UserAction, inert_user_action_channel, }; -use agent::{AutoCompact, TokenUsage, TurnAbort, agent_turn}; +use agent::{AutoCompact, TokenUsage, TurnAbort, TurnOutcome, agent_turn}; use client::anthropic::Client; use config::{Config, Effort}; use file_tracker::FileTracker; @@ -576,19 +576,22 @@ impl AgentLoopTask { self.client.max_tool_rounds(), ) .await; - match outcome { - Ok(report) => { - self.last_usage = report.usage; - if let Some(usage) = report.usage { - self.displayed_usage = Some(usage); - if let Some(cost) = report - .billable_usage - .and_then(|usage| estimate_usage_cost_usd(&self.client, usage)) - { - self.total_estimated_cost_usd += cost; - } - self.emit_usage_update(); - } + let TurnOutcome { report, result } = outcome; + self.last_usage = report.usage; + if let Some(usage) = report.usage { + self.displayed_usage = Some(usage); + } + if let Some(cost) = report + .billable_usage + .and_then(|usage| estimate_usage_cost_usd(&self.client, usage)) + { + self.total_estimated_cost_usd += cost; + } + if report.usage.is_some() || report.billable_usage.is_some() { + self.emit_usage_update(); + } + match result { + Ok(()) => { self.sink.emit(AgentEvent::TurnComplete, "turn-complete"); LoopControl::Continue } @@ -916,17 +919,17 @@ async fn bare_repl( &mut user_rx, client.max_tool_rounds(), ); - let turn_result = tokio::select! { - r = turn => r, + let TurnOutcome { report, result } = tokio::select! { + outcome = turn => outcome, () = shutdown_signal() => { eprintln!(); shutdown_fired = true; break; } }; - match turn_result { - Ok(report) => last_usage = report.usage, - Err(TurnAbort::Cancelled | TurnAbort::Quit) => {} + last_usage = report.usage; + match result { + Ok(()) | Err(TurnAbort::Cancelled | TurnAbort::Quit) => {} Err(TurnAbort::Failed(e)) => return Err(e), } sink.emit(AgentEvent::TurnComplete, "turn-complete"); @@ -975,8 +978,8 @@ async fn headless( client.max_tool_rounds(), ); let result: Result<()> = tokio::select! { - r = turn => match r { - Ok(_) | Err(TurnAbort::Cancelled | TurnAbort::Quit) => Ok(()), + outcome = turn => match outcome.result { + Ok(()) | Err(TurnAbort::Cancelled | TurnAbort::Quit) => Ok(()), Err(TurnAbort::Failed(e)) => Err(e), }, () = shutdown_signal() => { From 56fbdf561aba4c007d4cdb84e1bf7bac9c113310 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Thu, 14 May 2026 18:56:00 +0800 Subject: [PATCH 18/43] feat(tui): tighten status-bar model label width 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. --- crates/oxide-code/src/model.rs | 43 ++++++++++++++++++- crates/oxide-code/src/slash/context.rs | 6 ++- crates/oxide-code/src/tui/app.rs | 6 +-- ...er_cancelling_shows_spinner_and_label.snap | 2 +- ...acting_shows_spinner_and_status_label.snap | 2 +- ...med_shows_static_hint_without_spinner.snap | 2 +- ..._with_title_shows_model_title_and_cwd.snap | 2 +- ...idle_without_title_leaves_slot_unused.snap | 2 +- ...w_width_preserves_model_and_run_state.snap | 2 +- ...eaming_shows_spinner_and_status_label.snap | 2 +- ...us__tests__render_tool_running_status.snap | 2 +- .../oxide-code/src/tui/components/status.rs | 18 ++++---- docs/design/tui/status-line.md | 1 + 13 files changed, 67 insertions(+), 23 deletions(-) diff --git a/crates/oxide-code/src/model.rs b/crates/oxide-code/src/model.rs index 74e6cad9..505ea251 100644 --- a/crates/oxide-code/src/model.rs +++ b/crates/oxide-code/src/model.rs @@ -303,7 +303,7 @@ fn million_tokens(tokens: u32) -> f64 { } /// Human-facing label: the row's [`ModelInfo::display_name`] plus a ` (1M context)` suffix on -/// `[1m]` ids; the raw id when the model is unknown. +/// `[1m]` ids. Falls back to the raw id when the model is unknown. pub(crate) fn display_name(model: &str) -> Cow<'_, str> { let base = lookup(model).map_or(Cow::Borrowed(model), |info| { Cow::Borrowed(info.display_name) @@ -315,6 +315,23 @@ pub(crate) fn display_name(model: &str) -> Cow<'_, str> { } } +/// Width-constrained variant for the status bar. Drops the `Claude ` family prefix and +/// abbreviates the 1M opt-in to ` [1M]`. Falls back to the raw id when the model is unknown. +pub(crate) fn short_display_name(model: &str) -> Cow<'_, str> { + let Some(info) = lookup(model) else { + return Cow::Borrowed(model); + }; + let base = info + .display_name + .strip_prefix("Claude ") + .unwrap_or(info.display_name); + if model.ends_with("[1m]") { + Cow::Owned(format!("{base} [1M]")) + } else { + Cow::Borrowed(base) + } +} + #[cfg(test)] mod tests { use super::*; @@ -687,4 +704,28 @@ mod tests { assert_eq!(display_name("gpt-4"), "gpt-4"); assert_eq!(display_name("custom-model"), "custom-model"); } + + // ── short_display_name ── + + #[test] + fn short_display_name_strips_claude_family_prefix() { + for (id, expected) in [ + ("claude-opus-4-7", "Opus 4.7"), + ("claude-sonnet-4-6", "Sonnet 4.6"), + ("claude-haiku-4-5", "Haiku 4.5"), + ("claude-opus-4-1", "Opus 4.1"), + ] { + assert_eq!(short_display_name(id), expected, "{id}"); + } + } + + #[test] + fn short_display_name_replaces_1m_context_with_compact_tag() { + assert_eq!(short_display_name("claude-opus-4-7[1m]"), "Opus 4.7 [1M]"); + } + + #[test] + fn short_display_name_unknown_id_falls_through_to_raw() { + assert_eq!(short_display_name("gpt-4"), "gpt-4"); + } } diff --git a/crates/oxide-code/src/slash/context.rs b/crates/oxide-code/src/slash/context.rs index 6f228994..6e444e4d 100644 --- a/crates/oxide-code/src/slash/context.rs +++ b/crates/oxide-code/src/slash/context.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use crate::config::ConfigSnapshot; -use crate::model::display_name; +use crate::model::{display_name, short_display_name}; use crate::tui::components::chat::ChatView; use crate::tui::modal::Modal; @@ -24,6 +24,10 @@ impl LiveSessionInfo { pub(crate) fn display_name(&self) -> Cow<'_, str> { display_name(&self.config.model_id) } + + pub(crate) fn short_display_name(&self) -> Cow<'_, str> { + short_display_name(&self.config.model_id) + } } /// Borrowed App-owned state for one [`super::registry::SlashCommand::execute`] call. Open modals diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 2af3f7d6..a29ba9f7 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -92,7 +92,7 @@ impl App { let mut status_bar = StatusBar::new( theme, session_info.config.status_line.clone(), - session_info.display_name().into_owned(), + session_info.short_display_name().into_owned(), session_info.config.effort, session_info.cwd.clone(), session_info.git_branch.clone(), @@ -602,7 +602,7 @@ impl App { ); if model_changed { self.status_bar - .set_model(crate::model::display_name(&model_id).into_owned()); + .set_model(crate::model::short_display_name(&model_id).into_owned()); } self.status_bar.set_effort(effort); self.session_info.config.model_id = model_id; @@ -2143,7 +2143,7 @@ mod tests { app.session_info.config.effort, Some(crate::config::Effort::High), ); - assert_eq!(app.status_bar.model(), "Claude Sonnet 4.6"); + assert_eq!(app.status_bar.model(), "Sonnet 4.6"); assert_eq!( app.session_info.config.compaction.auto.threshold_tokens, Some(test_thresholds::WINDOW_200K), diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_cancelling_shows_spinner_and_label.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_cancelling_shows_spinner_and_label.snap index f5ce880d..e2dfaa7c 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_cancelling_shows_spinner_and_label.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_cancelling_shows_spinner_and_label.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" ~/projects/demo │ main │ Claude Opus 4.7 (xhigh) │ ⣷ Cancelling " +" ~/projects/demo │ main │ Opus 4.7 (xhigh) │ ⣷ Cancelling " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_compacting_shows_spinner_and_status_label.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_compacting_shows_spinner_and_status_label.snap index 7c373795..5df03297 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_compacting_shows_spinner_and_status_label.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_compacting_shows_spinner_and_status_label.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" ~/projects/demo │ Claude Opus 4.7 (xhigh) │ ⣷ Compacting · Esc to interrupt " +" ~/projects/demo │ main │ Opus 4.7 (xhigh) │ ⣷ Compacting · Esc to interrupt " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_exit_armed_shows_static_hint_without_spinner.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_exit_armed_shows_static_hint_without_spinner.snap index f9f56feb..9585ff7d 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_exit_armed_shows_static_hint_without_spinner.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_exit_armed_shows_static_hint_without_spinner.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" ~/projects/demo │ main │ Claude Opus 4.7 (xhigh) │ Press Ctrl+C again to exit " +" ~/projects/demo │ main │ Opus 4.7 (xhigh) │ Press Ctrl+C again to exit " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_with_title_shows_model_title_and_cwd.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_with_title_shows_model_title_and_cwd.snap index d35cb2c8..a29427c9 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_with_title_shows_model_title_and_cwd.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_with_title_shows_model_title_and_cwd.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" ~/projects/demo │ main │ Claude Opus 4.7 (xhigh) │ Ready │ Fix login flow " +" ~/projects/demo │ main │ Opus 4.7 (xhigh) │ Ready │ Fix login flow " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_without_title_leaves_slot_unused.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_without_title_leaves_slot_unused.snap index e276d1ee..9f86bb35 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_without_title_leaves_slot_unused.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_idle_without_title_leaves_slot_unused.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" ~/projects/demo │ main │ Claude Opus 4.7 (xhigh) │ Ready " +" ~/projects/demo │ main │ Opus 4.7 (xhigh) │ Ready " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_narrow_width_preserves_model_and_run_state.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_narrow_width_preserves_model_and_run_state.snap index 27a4f69f..1af8661e 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_narrow_width_preserves_model_and_run_state.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_narrow_width_preserves_model_and_run_state.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 40)" --- -" Claude Opus 4.7 (xhigh) │ Ready " +" Opus 4.7 (xhigh) │ Ready " "────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_streaming_shows_spinner_and_status_label.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_streaming_shows_spinner_and_status_label.snap index 3e1cf811..a522af7b 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_streaming_shows_spinner_and_status_label.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_streaming_shows_spinner_and_status_label.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" ~/projects/demo │ Claude Opus 4.7 (xhigh) │ ⣷ Streaming · Esc to interrupt " +" ~/projects/demo │ main │ Opus 4.7 (xhigh) │ ⣷ Streaming · Esc to interrupt " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_tool_running_status.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_tool_running_status.snap index 8e38a102..5e260f71 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_tool_running_status.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__status__tests__render_tool_running_status.snap @@ -2,5 +2,5 @@ source: crates/oxide-code/src/tui/components/status.rs expression: "render_status(&mut bar, 80)" --- -" ~/projects/demo │ Claude Opus 4.7 (xhigh) │ ⣷ Running bash · Esc to interrupt " +" ~/projects/demo │ main │ Opus 4.7 (xhigh) │ ⣷ Running bash · Esc to interrupt " "────────────────────────────────────────────────────────────────────────────────" diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 133a1167..6d588013 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -262,11 +262,11 @@ mod tests { #[test] fn set_model_replaces_displayed_model_label() { let mut bar = test_bar(); - bar.set_model("Claude Opus 4.7".to_owned()); - assert_eq!(bar.model(), "Claude Opus 4.7"); + bar.set_model("Opus 4.7".to_owned()); + assert_eq!(bar.model(), "Opus 4.7"); let output = render_top_row(&mut bar, 80); assert!( - output.contains("Claude Opus 4.7"), + output.contains("Opus 4.7"), "new label must reach the rendered bar: {output:?}", ); assert!( @@ -435,7 +435,7 @@ mod tests { let mut bar = StatusBar::new( &Theme::default(), StatusLineSegment::DEFAULT.to_vec(), - "Claude Opus 4.7".into(), + "Opus 4.7".into(), Some(Effort::Xhigh), cwd.into(), Some("main".to_owned()), @@ -521,14 +521,14 @@ mod tests { StatusLineSegment::Model, StatusLineSegment::CurrentDir, ], - "Claude Opus 4.7".into(), + "Opus 4.7".into(), Some(Effort::Xhigh), "~/projects/demo".into(), Some("main".to_owned()), ); let output = render_top_row(&mut bar, 120); let state_at = output.find("Ready").unwrap(); - let model_at = output.find("Claude Opus 4.7").unwrap(); + let model_at = output.find("Opus 4.7").unwrap(); let cwd_at = output.find("~/projects/demo").unwrap(); assert!(state_at < model_at, "run state should lead: {output:?}"); assert!(model_at < cwd_at, "cwd should follow model: {output:?}"); @@ -548,15 +548,13 @@ mod tests { StatusLineSegment::ModelWithEffort, StatusLineSegment::RunState, ], - "Claude Opus 4.7".into(), + "Opus 4.7".into(), Some(Effort::Xhigh), "~/projects/demo".into(), Some("feat/status-line".to_owned()), ); let output = render_top_row(&mut bar, 120); - assert!( - output.contains("~/projects/demo │ feat/status-line │ Claude Opus 4.7 (xhigh) │ Ready") - ); + assert!(output.contains("~/projects/demo │ feat/status-line │ Opus 4.7 (xhigh) │ Ready")); } #[test] diff --git a/docs/design/tui/status-line.md b/docs/design/tui/status-line.md index c7ac2841..7f763d59 100644 --- a/docs/design/tui/status-line.md +++ b/docs/design/tui/status-line.md @@ -46,6 +46,7 @@ Segment render rules: - Use active theme styles; color customization belongs in theme overrides. - Join only rendered segments, so omitted segments do not leave extra separators. - When the row is too narrow, omit lower-utility segments before truncating the last remaining segment. Run state and model have the highest utility. +- The `model` and `model-with-effort` segments use a width-tightened label: the `Claude` family prefix is dropped and `(1M context)` becomes `[1M]` (e.g., `Opus 4.7 [1M] (xhigh)`). Other surfaces (welcome screen, prompt environment, error blocks) keep the full `Claude Opus 4.7 (1M context)` label. ## Usage Data From a8f95d3e1abf41188ad43f9143ef3e6961173da8 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 09:59:02 +0800 Subject: [PATCH 19/43] refactor(util): consolidate git branch probe and log failures 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. --- crates/oxide-code/src/main.rs | 28 +------ crates/oxide-code/src/session/state.rs | 87 +------------------ crates/oxide-code/src/util.rs | 1 + crates/oxide-code/src/util/git.rs | 110 +++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 113 deletions(-) create mode 100644 crates/oxide-code/src/util/git.rs diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index cb171830..d2860990 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -14,7 +14,6 @@ mod tui; mod util; use std::io::{IsTerminal, Write}; -use std::path::Path; use std::sync::Arc; use anyhow::{Result, anyhow}; @@ -259,10 +258,7 @@ async fn run_tui( let cwd_path = std::env::current_dir().ok(); let cwd = cwd_path.as_deref().map(tildify).unwrap_or_default(); - let git_branch = cwd_path - .as_deref() - .and_then(current_git_branch) - .filter(|branch| !branch.is_empty()); + let git_branch = cwd_path.as_deref().and_then(util::git::current_branch); let session_info = LiveSessionInfo { cwd, @@ -332,28 +328,6 @@ async fn run_tui( result } -fn current_git_branch(cwd: &Path) -> Option { - let output = std::process::Command::new("git") - .args([ - "-C", - cwd.to_str()?, - "--no-optional-locks", - "branch", - "--show-current", - ]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let branch = std::str::from_utf8(&output.stdout).ok()?.trim(); - if branch.is_empty() { - None - } else { - Some(branch.to_owned()) - } -} - /// Each `TurnAbort` arm emits exactly one terminal event (`Error` xor `TurnComplete`). #[expect( clippy::too_many_arguments, diff --git a/crates/oxide-code/src/session/state.rs b/crates/oxide-code/src/session/state.rs index 8788c642..4986985c 100644 --- a/crates/oxide-code/src/session/state.rs +++ b/crates/oxide-code/src/session/state.rs @@ -266,7 +266,7 @@ fn new_header(model: &str) -> (String, Entry) { let git_branch = if cfg!(test) { None } else { - current_git_branch(&cwd) + crate::util::git::current_branch_str(&cwd) }; let header = Entry::Header { session_id: session_id.clone(), @@ -279,31 +279,6 @@ fn new_header(model: &str) -> (String, Entry) { (session_id, header) } -/// Best-effort branch name via `git rev-parse --abbrev-ref HEAD`. Returns `None` when not in a -/// repo, when git is missing, or when HEAD is detached. -fn current_git_branch(cwd: &str) -> Option { - let output = std::process::Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .current_dir(cwd) - .stderr(std::process::Stdio::null()) - .output() - .ok()?; - parse_git_branch(output.status.success(), &output.stdout) -} - -/// Pure parser for `git rev-parse --abbrev-ref HEAD` output. Split out from the shell-out so the -/// success / detached-HEAD / invalid-UTF-8 branches can be exercised without a fixture repo. -fn parse_git_branch(success: bool, stdout: &[u8]) -> Option { - if !success { - return None; - } - let branch = std::str::from_utf8(stdout).ok()?.trim(); - if branch.is_empty() || branch == "HEAD" { - return None; - } - Some(branch.to_owned()) -} - fn current_dir_string() -> String { format_current_dir(std::env::current_dir()) } @@ -814,66 +789,6 @@ mod tests { ); } - // ── current_git_branch ── - - #[test] - fn current_git_branch_in_a_real_repo_yields_the_branch_name() { - // Skipped silently if `git` isn't on PATH so CI without git doesn't fail. - let dir = tempfile::tempdir().unwrap(); - let cwd = dir.path().to_str().unwrap(); - let Ok(status) = std::process::Command::new("git") - .args(["init", "-q", "-b", "fixture-branch"]) - .current_dir(cwd) - .status() - else { - return; - }; - if !status.success() { - return; - } - for args in [ - ["config", "user.email", "test@example.com"].as_slice(), - ["config", "user.name", "Test"].as_slice(), - ["config", "commit.gpgsign", "false"].as_slice(), - ["commit", "-q", "--allow-empty", "-m", "init"].as_slice(), - ] { - std::process::Command::new("git") - .args(args) - .current_dir(cwd) - .status() - .unwrap(); - } - assert_eq!( - current_git_branch(cwd), - Some("fixture-branch".to_owned()), - "branch should round-trip after the initial commit on the requested branch" - ); - } - - #[test] - fn current_git_branch_outside_a_repo_is_absent() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(current_git_branch(dir.path().to_str().unwrap()), None); - } - - // ── parse_git_branch ── - - #[test] - fn parse_git_branch_keeps_branch_names_and_drops_everything_else() { - // Trailing newline trimmed so the metadata column doesn't render `\n`. - assert_eq!( - parse_git_branch(true, b"feat/login\n"), - Some("feat/login".to_owned()) - ); - // Non-zero exit (not-a-repo, missing git, ...) collapses to None. - assert_eq!(parse_git_branch(false, b"main\n"), None); - assert_eq!(parse_git_branch(true, &[0xff, 0xfe, b'\n']), None); - assert_eq!(parse_git_branch(true, b""), None); - assert_eq!(parse_git_branch(true, b" \n"), None); - // `HEAD` is rev-parse's detached-HEAD output, which is useless in the picker. - assert_eq!(parse_git_branch(true, b"HEAD\n"), None); - } - // ── format_current_dir ── #[test] diff --git a/crates/oxide-code/src/util.rs b/crates/oxide-code/src/util.rs index 3ae98bdd..bd87535f 100644 --- a/crates/oxide-code/src/util.rs +++ b/crates/oxide-code/src/util.rs @@ -2,6 +2,7 @@ pub(crate) mod env; pub(crate) mod fs; +pub(crate) mod git; pub(crate) mod lock; pub(crate) mod log; pub(crate) mod path; diff --git a/crates/oxide-code/src/util/git.rs b/crates/oxide-code/src/util/git.rs new file mode 100644 index 00000000..9349a164 --- /dev/null +++ b/crates/oxide-code/src/util/git.rs @@ -0,0 +1,110 @@ +//! Git probes used by the session header and the status bar. Best-effort: every probe collapses +//! to `None` on missing git, non-repo cwd, detached HEAD, or non-UTF-8 output. Failures log at +//! `debug` so they don't pollute normal use but are recoverable when the status bar misbehaves. + +use std::path::Path; +use std::process::{Command, Stdio}; + +use tracing::debug; + +/// Probe the current branch via `git branch --show-current`. Detached HEAD comes back as empty +/// stdout, which we collapse to `None`. +pub(crate) fn current_branch(cwd: &Path) -> Option { + let Some(cwd_str) = cwd.to_str() else { + debug!(cwd = ?cwd, "git branch probe: cwd is not valid UTF-8"); + return None; + }; + let output = match Command::new("git") + .args([ + "-C", + cwd_str, + "--no-optional-locks", + "branch", + "--show-current", + ]) + .stderr(Stdio::null()) + .output() + { + Ok(output) => output, + Err(e) => { + debug!(cwd = cwd_str, error = %e, "git branch probe: spawn failed"); + return None; + } + }; + if !output.status.success() { + debug!( + cwd = cwd_str, + code = output.status.code().unwrap_or(-1), + "git branch probe: non-zero exit", + ); + return None; + } + parse_branch(&output.stdout) +} + +/// `&str` overload for callers that already hold a string-shaped cwd. +pub(crate) fn current_branch_str(cwd: &str) -> Option { + current_branch(Path::new(cwd)) +} + +fn parse_branch(stdout: &[u8]) -> Option { + let branch = std::str::from_utf8(stdout).ok()?.trim(); + if branch.is_empty() { + None + } else { + Some(branch.to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── current_branch ── + + #[test] + fn current_branch_in_a_real_repo_yields_the_branch_name() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path(); + let Ok(status) = Command::new("git") + .args(["init", "-q", "-b", "fixture-branch"]) + .current_dir(cwd) + .status() + else { + return; + }; + if !status.success() { + return; + } + for args in [ + ["config", "user.email", "test@example.com"].as_slice(), + ["config", "user.name", "Test"].as_slice(), + ["config", "commit.gpgsign", "false"].as_slice(), + ["commit", "-q", "--allow-empty", "-m", "init"].as_slice(), + ] { + Command::new("git") + .args(args) + .current_dir(cwd) + .status() + .unwrap(); + } + assert_eq!(current_branch(cwd), Some("fixture-branch".to_owned())); + } + + #[test] + fn current_branch_outside_a_repo_is_absent() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(current_branch(dir.path()), None); + } + + // ── parse_branch ── + + #[test] + fn parse_branch_keeps_branch_names_and_drops_everything_else() { + assert_eq!(parse_branch(b"feat/login\n"), Some("feat/login".to_owned())); + // `branch --show-current` prints empty on detached HEAD. + assert_eq!(parse_branch(b""), None); + assert_eq!(parse_branch(b" \n"), None); + assert_eq!(parse_branch(&[0xff, 0xfe, b'\n']), None); + } +} From b77097484a26fdabd3312756565f4b4df5cf8971 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:00:01 +0800 Subject: [PATCH 20/43] feat(tui): refresh git branch on tick 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. --- crates/oxide-code/src/main.rs | 1 + crates/oxide-code/src/slash.rs | 1 + crates/oxide-code/src/slash/context.rs | 4 + crates/oxide-code/src/tui/app.rs | 2 + .../oxide-code/src/tui/components/status.rs | 81 ++++++++++++++++++- .../oxide-code/src/tui/components/welcome.rs | 1 + 6 files changed, 88 insertions(+), 2 deletions(-) diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index d2860990..b44966bf 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -262,6 +262,7 @@ async fn run_tui( let session_info = LiveSessionInfo { cwd, + git_cwd: cwd_path, git_branch, version: env!("CARGO_PKG_VERSION"), session_id: session.session_id().to_owned(), diff --git a/crates/oxide-code/src/slash.rs b/crates/oxide-code/src/slash.rs index ebd514df..aaf0f075 100644 --- a/crates/oxide-code/src/slash.rs +++ b/crates/oxide-code/src/slash.rs @@ -127,6 +127,7 @@ pub(crate) fn test_session_info() -> LiveSessionInfo { // Real MODELS row so `display_name()` resolves to a known label. LiveSessionInfo { cwd: "~/test".to_owned(), + git_cwd: None, git_branch: Some("main".to_owned()), version: "0.0.0-test", session_id: "test-session".to_owned(), diff --git a/crates/oxide-code/src/slash/context.rs b/crates/oxide-code/src/slash/context.rs index 6e444e4d..cb4eab05 100644 --- a/crates/oxide-code/src/slash/context.rs +++ b/crates/oxide-code/src/slash/context.rs @@ -2,6 +2,7 @@ //! [`LiveSessionInfo`] is the session-level snapshot. use std::borrow::Cow; +use std::path::PathBuf; use crate::config::ConfigSnapshot; use crate::model::{display_name, short_display_name}; @@ -14,6 +15,9 @@ use crate::tui::modal::Modal; /// persisted JSONL record consumed by `--list`. pub(crate) struct LiveSessionInfo { pub(crate) cwd: String, + /// Original cwd as a path. Held alongside `cwd` so the status bar can re-probe git without + /// re-resolving the tilde expansion. + pub(crate) git_cwd: Option, pub(crate) git_branch: Option, pub(crate) version: &'static str, pub(crate) session_id: String, diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index a29ba9f7..b31627af 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -95,6 +95,7 @@ impl App { session_info.short_display_name().into_owned(), session_info.config.effort, session_info.cwd.clone(), + session_info.git_cwd.clone(), session_info.git_branch.clone(), ); status_bar.set_title(history.title); @@ -939,6 +940,7 @@ mod tests { LiveSessionInfo { cwd: "~/test".to_owned(), + git_cwd: None, git_branch: Some("main".to_owned()), version: "0.0.0-test", session_id: "test-session".to_owned(), diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 6d588013..2eb6f1f3 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -2,7 +2,8 @@ mod line; -use std::time::Instant; +use std::path::PathBuf; +use std::time::{Duration, Instant}; use ratatui::Frame; use ratatui::layout::Rect; @@ -13,11 +14,16 @@ use crate::agent::event::UsageSnapshot; use crate::config::{Effort, StatusLineSegment}; use crate::tui::glyphs::SPINNER_FRAMES; use crate::tui::theme::Theme; +use crate::util::git; use self::line::{StatusLine, StatusLineState}; const TICKS_PER_FRAME: usize = 5; +/// How often the status bar re-probes git for the current branch. Branch changes outside the +/// session (manual `git checkout`) only become visible after one interval. +const GIT_BRANCH_REFRESH_INTERVAL: Duration = Duration::from_secs(5); + /// Status bar at the top of the TUI. pub(crate) struct StatusBar { theme: Theme, @@ -28,7 +34,11 @@ pub(crate) struct StatusBar { title: Option, usage: Option, cwd: String, + /// Working directory for git probes. `None` collapses every probe to a no-op. + git_cwd: Option, git_branch: Option, + /// Last time the git branch was probed. `None` until the first tick. + last_branch_probe: Option, status: Status, spinner_frame: usize, tick_counter: usize, @@ -51,6 +61,7 @@ impl StatusBar { model: String, effort: Option, cwd: String, + git_cwd: Option, git_branch: Option, ) -> Self { let current_time_minute = segments @@ -65,7 +76,9 @@ impl StatusBar { title: None, usage: None, cwd, + git_cwd, git_branch, + last_branch_probe: None, status: Status::Idle, spinner_frame: 0, tick_counter: 0, @@ -123,9 +136,13 @@ impl StatusBar { self.usage } - /// Returns `true` when time or animation state changed and the caller should repaint. + /// Returns `true` when time, animation, or git-branch state changed and the caller should + /// repaint. pub(crate) fn tick(&mut self) -> bool { let mut dirty = self.refresh_current_time(); + if self.refresh_git_branch(Instant::now()) { + dirty = true; + } if is_animated(&self.status) { self.tick_counter += 1; if self.tick_counter >= TICKS_PER_FRAME { @@ -148,6 +165,29 @@ impl StatusBar { self.current_time_minute = Some(current); true } + + /// Re-probes the git branch when [`GIT_BRANCH_REFRESH_INTERVAL`] has elapsed. Returns `true` + /// when the resolved branch changed. + fn refresh_git_branch(&mut self, now: Instant) -> bool { + let Some(cwd) = self.git_cwd.as_deref() else { + return false; + }; + if !should_probe_branch(self.last_branch_probe, now) { + return false; + } + self.last_branch_probe = Some(now); + let probed = git::current_branch(cwd); + if probed == self.git_branch { + return false; + } + self.git_branch = probed; + true + } +} + +/// Time-only predicate split out so the throttle can be exercised without shelling out to git. +fn should_probe_branch(last: Option, now: Instant) -> bool { + last.is_none_or(|prev| now.duration_since(prev) >= GIT_BRANCH_REFRESH_INTERVAL) } impl StatusBar { @@ -229,6 +269,7 @@ mod tests { "test-model".to_owned(), Some(Effort::High), "~/test".to_owned(), + None, Some("main".to_owned()), ) } @@ -342,6 +383,7 @@ mod tests { None, "~/test".to_owned(), None, + None, ); assert_eq!(bar.current_time_minute, None); @@ -358,6 +400,7 @@ mod tests { None, "~/test".to_owned(), None, + None, ); let current = current_time_minute(); bar.current_time_minute = Some((current + 1) % 1440); @@ -406,6 +449,36 @@ mod tests { assert_eq!(bar.spinner_frame, 0); } + // ── refresh_git_branch ── + + #[test] + fn refresh_git_branch_without_cwd_is_a_noop() { + let mut bar = test_bar(); + let probed_at = bar.last_branch_probe; + assert!(!bar.refresh_git_branch(Instant::now())); + assert_eq!(bar.last_branch_probe, probed_at); + } + + // ── should_probe_branch ── + + #[test] + fn should_probe_branch_runs_immediately_when_never_probed() { + assert!(should_probe_branch(None, Instant::now())); + } + + #[test] + fn should_probe_branch_skips_within_interval_and_runs_after() { + let earlier = Instant::now(); + assert!(!should_probe_branch( + Some(earlier), + earlier + Duration::from_millis(100), + )); + assert!(should_probe_branch( + Some(earlier), + earlier + GIT_BRANCH_REFRESH_INTERVAL, + )); + } + // ── render ── fn render_status(bar: &mut StatusBar, width: u16) -> TestBackend { @@ -438,6 +511,7 @@ mod tests { "Opus 4.7".into(), Some(Effort::Xhigh), cwd.into(), + None, Some("main".to_owned()), ); bar.set_title(title.map(ToOwned::to_owned)); @@ -524,6 +598,7 @@ mod tests { "Opus 4.7".into(), Some(Effort::Xhigh), "~/projects/demo".into(), + None, Some("main".to_owned()), ); let output = render_top_row(&mut bar, 120); @@ -551,6 +626,7 @@ mod tests { "Opus 4.7".into(), Some(Effort::Xhigh), "~/projects/demo".into(), + None, Some("feat/status-line".to_owned()), ); let output = render_top_row(&mut bar, 120); @@ -615,6 +691,7 @@ mod tests { None, String::new(), None, + None, ); let output = render_top_row(&mut bar, 120); assert!(output.contains("test-model")); diff --git a/crates/oxide-code/src/tui/components/welcome.rs b/crates/oxide-code/src/tui/components/welcome.rs index fa2d8d91..0f8cc4d0 100644 --- a/crates/oxide-code/src/tui/components/welcome.rs +++ b/crates/oxide-code/src/tui/components/welcome.rs @@ -337,6 +337,7 @@ mod tests { fn fixture() -> LiveSessionInfo { LiveSessionInfo { cwd: "~/github/oxide-code".to_owned(), + git_cwd: None, git_branch: Some("main".to_owned()), version: "0.1.0", session_id: "test-session".to_owned(), From 488c89e51f6fcc8384a5aff1ed18753baea03eaf Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:10:21 +0800 Subject: [PATCH 21/43] feat(tui): add pull-request status-line segment 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. --- crates/oxide-code/src/config.rs | 3 + .../oxide-code/src/tui/components/status.rs | 92 ++++++++++++++++--- .../src/tui/components/status/line.rs | 35 +++++-- crates/oxide-code/src/util/git.rs | 54 +++++++++++ docs/design/tui/status-line.md | 7 +- docs/guide/configuration.md | 3 +- 6 files changed, 172 insertions(+), 22 deletions(-) diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 43c1ebb2..f9b6b358 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -205,6 +205,7 @@ impl FromStr for PromptCacheTtl { pub(crate) enum StatusLineSegment { CurrentDir, GitBranch, + PullRequest, Model, ModelWithEffort, ContextUsed, @@ -218,6 +219,7 @@ impl StatusLineSegment { const ALL: &'static [(Self, &'static str)] = &[ (Self::CurrentDir, "current-dir"), (Self::GitBranch, "git-branch"), + (Self::PullRequest, "pull-request"), (Self::Model, "model"), (Self::ModelWithEffort, "model-with-effort"), (Self::ContextUsed, "context-used"), @@ -231,6 +233,7 @@ impl StatusLineSegment { pub(crate) const DEFAULT: &'static [Self] = &[ Self::CurrentDir, Self::GitBranch, + Self::PullRequest, Self::ModelWithEffort, Self::ContextUsed, Self::SessionCost, diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 2eb6f1f3..52dd9324 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -24,6 +24,10 @@ const TICKS_PER_FRAME: usize = 5; /// session (manual `git checkout`) only become visible after one interval. const GIT_BRANCH_REFRESH_INTERVAL: Duration = Duration::from_secs(5); +/// How often the status bar re-probes `gh` for the open pull request. Slower than the branch +/// probe because `gh pr view` hits the network. +const PR_REFRESH_INTERVAL: Duration = Duration::from_mins(1); + /// Status bar at the top of the TUI. pub(crate) struct StatusBar { theme: Theme, @@ -37,8 +41,15 @@ pub(crate) struct StatusBar { /// Working directory for git probes. `None` collapses every probe to a no-op. git_cwd: Option, git_branch: Option, + /// Open pull request number for `git_branch`. `None` until the first probe completes. + pull_request: Option, + /// `true` while the `pull-request` segment is configured. Skips the `gh` probe entirely when + /// the user hasn't opted in. + track_pull_request: bool, /// Last time the git branch was probed. `None` until the first tick. last_branch_probe: Option, + /// Last time the pull request was probed. `None` until the first tick. + last_pr_probe: Option, status: Status, spinner_frame: usize, tick_counter: usize, @@ -67,6 +78,7 @@ impl StatusBar { let current_time_minute = segments .contains(&StatusLineSegment::CurrentTime) .then(current_time_minute); + let track_pull_request = segments.contains(&StatusLineSegment::PullRequest); Self { theme: theme.clone(), line: StatusLine::new(segments), @@ -78,7 +90,10 @@ impl StatusBar { cwd, git_cwd, git_branch, + pull_request: None, + track_pull_request, last_branch_probe: None, + last_pr_probe: None, status: Status::Idle, spinner_frame: 0, tick_counter: 0, @@ -136,11 +151,15 @@ impl StatusBar { self.usage } - /// Returns `true` when time, animation, or git-branch state changed and the caller should - /// repaint. + /// Returns `true` when time, animation, git-branch, or pull-request state changed and the + /// caller should repaint. pub(crate) fn tick(&mut self) -> bool { let mut dirty = self.refresh_current_time(); - if self.refresh_git_branch(Instant::now()) { + let now = Instant::now(); + if self.refresh_git_branch(now) { + dirty = true; + } + if self.refresh_pull_request(now) { dirty = true; } if is_animated(&self.status) { @@ -172,7 +191,7 @@ impl StatusBar { let Some(cwd) = self.git_cwd.as_deref() else { return false; }; - if !should_probe_branch(self.last_branch_probe, now) { + if !should_probe(self.last_branch_probe, now, GIT_BRANCH_REFRESH_INTERVAL) { return false; } self.last_branch_probe = Some(now); @@ -183,11 +202,32 @@ impl StatusBar { self.git_branch = probed; true } + + /// Re-probes the open pull request via `gh` when [`PR_REFRESH_INTERVAL`] has elapsed. The + /// probe is skipped entirely when the user hasn't configured the `pull-request` segment. + fn refresh_pull_request(&mut self, now: Instant) -> bool { + if !self.track_pull_request { + return false; + } + let Some(cwd) = self.git_cwd.as_deref() else { + return false; + }; + if !should_probe(self.last_pr_probe, now, PR_REFRESH_INTERVAL) { + return false; + } + self.last_pr_probe = Some(now); + let probed = git::current_pull_request(cwd); + if probed == self.pull_request { + return false; + } + self.pull_request = probed; + true + } } -/// Time-only predicate split out so the throttle can be exercised without shelling out to git. -fn should_probe_branch(last: Option, now: Instant) -> bool { - last.is_none_or(|prev| now.duration_since(prev) >= GIT_BRANCH_REFRESH_INTERVAL) +/// Time-only predicate split out so the throttle can be exercised without shelling out. +fn should_probe(last: Option, now: Instant, interval: Duration) -> bool { + last.is_none_or(|prev| now.duration_since(prev) >= interval) } impl StatusBar { @@ -236,6 +276,7 @@ impl StatusBar { usage: self.usage, cwd: &self.cwd, git_branch: self.git_branch.as_deref(), + pull_request: self.pull_request, status_span: self.status_span(), }, width, @@ -459,23 +500,48 @@ mod tests { assert_eq!(bar.last_branch_probe, probed_at); } - // ── should_probe_branch ── + // ── refresh_pull_request ── #[test] - fn should_probe_branch_runs_immediately_when_never_probed() { - assert!(should_probe_branch(None, Instant::now())); + fn refresh_pull_request_when_segment_disabled_is_a_noop() { + let mut bar = test_bar(); + bar.track_pull_request = false; + bar.git_cwd = Some(std::path::PathBuf::from("/tmp")); + assert!(!bar.refresh_pull_request(Instant::now())); + assert!(bar.last_pr_probe.is_none(), "must skip when not tracked"); + } + + #[test] + fn refresh_pull_request_without_cwd_is_a_noop() { + let mut bar = test_bar(); + bar.track_pull_request = true; + assert!(!bar.refresh_pull_request(Instant::now())); + assert!(bar.last_pr_probe.is_none(), "must skip without cwd"); + } + + // ── should_probe ── + + #[test] + fn should_probe_runs_immediately_when_never_probed() { + assert!(should_probe( + None, + Instant::now(), + GIT_BRANCH_REFRESH_INTERVAL, + )); } #[test] - fn should_probe_branch_skips_within_interval_and_runs_after() { + fn should_probe_skips_within_interval_and_runs_after() { let earlier = Instant::now(); - assert!(!should_probe_branch( + assert!(!should_probe( Some(earlier), earlier + Duration::from_millis(100), + GIT_BRANCH_REFRESH_INTERVAL, )); - assert!(should_probe_branch( + assert!(should_probe( Some(earlier), earlier + GIT_BRANCH_REFRESH_INTERVAL, + GIT_BRANCH_REFRESH_INTERVAL, )); } diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 6dba8c2e..5582cbc1 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -66,6 +66,12 @@ impl StatusLine { Self::segment_style(theme, SegmentStyle::Accent), ) }), + StatusLineSegment::PullRequest => state.pull_request.map(|number| { + Span::styled( + format!("#{number}"), + Self::segment_style(theme, SegmentStyle::Accent), + ) + }), StatusLineSegment::Model => Some(Span::styled( state.model.to_owned(), Self::segment_style(theme, SegmentStyle::Text), @@ -120,6 +126,8 @@ pub(super) struct StatusLineState<'a> { pub(super) cwd: &'a str, /// Branch captured at TUI startup. pub(super) git_branch: Option<&'a str>, + /// Open pull request number for the current branch, when one is detected. + pub(super) pull_request: Option, /// Pre-rendered run-state segment from the parent component. pub(super) status_span: Span<'static>, } @@ -185,12 +193,13 @@ fn segment_utility(segment: StatusLineSegment) -> u8 { match segment { StatusLineSegment::ThreadTitle => 0, StatusLineSegment::CurrentTime => 1, - StatusLineSegment::GitBranch => 2, - StatusLineSegment::CurrentDir => 3, - StatusLineSegment::SessionCost => 4, - StatusLineSegment::ContextUsed => 5, - StatusLineSegment::Model | StatusLineSegment::ModelWithEffort => 6, - StatusLineSegment::RunState => 7, + StatusLineSegment::PullRequest => 2, + StatusLineSegment::GitBranch => 3, + StatusLineSegment::CurrentDir => 4, + StatusLineSegment::SessionCost => 5, + StatusLineSegment::ContextUsed => 6, + StatusLineSegment::Model | StatusLineSegment::ModelWithEffort => 7, + StatusLineSegment::RunState => 8, } } @@ -266,6 +275,7 @@ mod tests { }), cwd: "~/repo", git_branch: Some("main"), + pull_request: Some(86), status_span: Span::raw("Ready"), }, width, @@ -305,6 +315,7 @@ mod tests { usage: None, cwd: "", git_branch: None, + pull_request: None, status_span: Span::raw("Running a very long command name"), }, 12, @@ -336,6 +347,18 @@ mod tests { assert_eq!(render_text(segments, 10), " Ready"); } + #[test] + fn render_pull_request_renders_hash_prefix_and_drops_before_git_branch() { + let segments = vec![ + StatusLineSegment::GitBranch, + StatusLineSegment::PullRequest, + StatusLineSegment::RunState, + ]; + + assert_eq!(render_text(segments.clone(), 80), " main │ #86 │ Ready"); + assert_eq!(render_text(segments, 14), " main │ Ready"); + } + // ── context_label ── #[test] diff --git a/crates/oxide-code/src/util/git.rs b/crates/oxide-code/src/util/git.rs index 9349a164..c2b3bd3d 100644 --- a/crates/oxide-code/src/util/git.rs +++ b/crates/oxide-code/src/util/git.rs @@ -56,6 +56,40 @@ fn parse_branch(stdout: &[u8]) -> Option { } } +/// Probe the open pull request for `cwd`'s current branch via `gh pr view --json number --jq +/// .number`. Returns `None` when `gh` is missing, the user is unauthenticated, or no PR is open. +pub(crate) fn current_pull_request(cwd: &Path) -> Option { + let Some(cwd_str) = cwd.to_str() else { + debug!(cwd = ?cwd, "gh pr probe: cwd is not valid UTF-8"); + return None; + }; + let output = match Command::new("gh") + .args(["pr", "view", "--json", "number", "--jq", ".number"]) + .current_dir(cwd_str) + .stderr(Stdio::null()) + .output() + { + Ok(output) => output, + Err(e) => { + debug!(cwd = cwd_str, error = %e, "gh pr probe: spawn failed"); + return None; + } + }; + if !output.status.success() { + debug!( + cwd = cwd_str, + code = output.status.code().unwrap_or(-1), + "gh pr probe: non-zero exit", + ); + return None; + } + parse_pr_number(&output.stdout) +} + +fn parse_pr_number(stdout: &[u8]) -> Option { + std::str::from_utf8(stdout).ok()?.trim().parse().ok() +} + #[cfg(test)] mod tests { use super::*; @@ -107,4 +141,24 @@ mod tests { assert_eq!(parse_branch(b" \n"), None); assert_eq!(parse_branch(&[0xff, 0xfe, b'\n']), None); } + + // ── current_pull_request ── + + #[test] + fn current_pull_request_outside_a_repo_is_absent() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(current_pull_request(dir.path()), None); + } + + // ── parse_pr_number ── + + #[test] + fn parse_pr_number_keeps_positive_integers_and_drops_everything_else() { + assert_eq!(parse_pr_number(b"86\n"), Some(86)); + assert_eq!(parse_pr_number(b" 12\n"), Some(12)); + assert_eq!(parse_pr_number(b""), None); + assert_eq!(parse_pr_number(b"not-a-number\n"), None); + assert_eq!(parse_pr_number(b"-1\n"), None); + assert_eq!(parse_pr_number(&[0xff, b'\n']), None); + } } diff --git a/docs/design/tui/status-line.md b/docs/design/tui/status-line.md index 7f763d59..d2d1ca8d 100644 --- a/docs/design/tui/status-line.md +++ b/docs/design/tui/status-line.md @@ -16,6 +16,7 @@ Implemented segments: | ---------------------- | ------------------- | | current directory | `current-dir` | | git branch | `git-branch` | +| pull request | `pull-request` | | model | `model` | | model with effort | `model-with-effort` | | context used | `context-used` | @@ -29,7 +30,7 @@ Out of scope: - Command-based custom renderers. - ccusage block / daily totals. - Five-hour or weekly account-limit display. -- Pull request and task-progress segments. +- Task-progress segments. - Persisting cost totals across resume. - Non-Anthropic provider pricing. @@ -66,6 +67,7 @@ The default order is: status_line = [ "current-dir", "git-branch", + "pull-request", "model-with-effort", "context-used", "session-cost", @@ -91,12 +93,13 @@ Order rationale: 2. **Implemented segments only.** Unsupported names fail config parsing instead of silently reserving future vocabulary. 3. **Local usage first.** Context and session cost use provider usage already observed by the current process. 4. **Segment omission over placeholders.** Missing usage, unknown pricing, absent branch, and blank titles disappear cleanly. +5. **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 after `git checkout` or `gh pr create` without paying per-frame cost. ## Deferred - ccusage or another account-usage provider boundary. - Five-hour and weekly account-limit telemetry. -- Pull request and task-progress segments. +- Task-progress segments. - Persisted cost restore after resume. - Command-based custom status-line renderer. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 618e146a..90d05093 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -29,6 +29,7 @@ show_thinking = true status_line = [ "current-dir", "git-branch", + "pull-request", "model-with-effort", "context-used", "session-cost", @@ -136,7 +137,7 @@ On Opus 4.7, `show_thinking = true` opts the request into `thinking.display = "s `show_welcome = false` blanks the chat region until you submit a prompt, which is useful when piping or screen-recording. -`status_line` accepts these segment names: `current-dir`, `git-branch`, `model`, `model-with-effort`, `context-used`, `session-cost`, `run-state`, `thread-title`, `current-time`. The list must contain at least one segment. Segments with no data, such as a missing git branch or usage before the first completed turn, are omitted. The separator is part of the active theme's `separator` slot; there is no separate status-line separator setting. +`status_line` accepts these segment names: `current-dir`, `git-branch`, `pull-request`, `model`, `model-with-effort`, `context-used`, `session-cost`, `run-state`, `thread-title`, `current-time`. The list must contain at least one segment. Segments with no data, such as a missing git branch, an unopened pull request, or usage before the first completed turn, are omitted. The `pull-request` segment shells out to `gh pr view` so it requires the GitHub CLI on PATH. The separator is part of the active theme's `separator` slot; there is no separate status-line separator setting. ### `[tui.theme]`: Terminal theme From 7a9f5b888a069173af56acf7f8e4b1093a9d2565 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:13:33 +0800 Subject: [PATCH 22/43] feat(tui): bump current-dir to the muted slot for better contrast 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. --- .../src/tui/components/status/line.rs | 2 +- docs/guide/theming.md | 21 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 5582cbc1..0e5cdd9f 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -58,7 +58,7 @@ impl StatusLine { let span = match segment { StatusLineSegment::CurrentDir => non_empty_span( truncate_to_width(state.cwd, MAX_CURRENT_DIR_WIDTH), - Self::segment_style(theme, SegmentStyle::Dim), + Self::segment_style(theme, SegmentStyle::Muted), ), StatusLineSegment::GitBranch => state.git_branch.map(|branch| { Span::styled( diff --git a/docs/guide/theming.md b/docs/guide/theming.md index db255a60..6169a20b 100644 --- a/docs/guide/theming.md +++ b/docs/guide/theming.md @@ -175,7 +175,26 @@ Each slot maps to one role in the TUI. Override a slot by name to restyle that r | `tool_icon` | Per-tool icon | | `border_focused` | Focused component border (e.g., input) | | `border_unfocused` | Unfocused component border | -| `separator` | Status-line segment separator | +| `separator` | Status-line segment separator (`│`) | + +### Status-line segments + +Each status-line segment routes to one of the slots above so re-skinning the slot re-skins everywhere it appears. To restyle one segment, override the slot it uses. + +| Segment | Slot | +| -------------------- | -------- | +| `current-dir` | `muted` | +| `git-branch` | `accent` | +| `pull-request` | `accent` | +| `model` | `text` | +| `model-with-effort` | `text` | +| `context-used` | `dim` | +| `session-cost` | `dim` | +| `run-state` (idle) | `success`| +| `run-state` (busy) | `info` | +| `run-state` (warn) | `warning`| +| `thread-title` | `muted` | +| `current-time` | `dim` | ## Overrides From 2faaa239f78eb7d12fb4e9cd19c45222f76ee0b0 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:15:36 +0800 Subject: [PATCH 23/43] test(agent): pin swap-config preserves running session cost 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. --- crates/oxide-code/src/main.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/oxide-code/src/main.rs b/crates/oxide-code/src/main.rs index b44966bf..8f788603 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -1094,7 +1094,7 @@ mod tests { auto_compaction_failures: 3, last_usage: Some(TokenUsage::new(100_000, 1)), displayed_usage: None, - total_estimated_cost_usd: 0.0, + total_estimated_cost_usd: 1.23, }; let control = task @@ -1107,6 +1107,11 @@ mod tests { assert!(matches!(control, LoopControl::Continue)); assert_eq!(task.auto_compaction_failures, 0); assert_eq!(task.last_usage, Some(TokenUsage::new(100_000, 1))); + assert!( + (task.total_estimated_cost_usd - 1.23).abs() < f64::EPSILON, + "session cost must accumulate across swaps: {}", + task.total_estimated_cost_usd, + ); assert!(matches!( event_rx.recv().await, Some(AgentEvent::ConfigChanged { .. }) From 3a789a4c7d20be837df3e763e83cbb09ebfd3b05 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:18:11 +0800 Subject: [PATCH 24/43] docs(tui): trim restating docstrings on status-line state 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. --- crates/oxide-code/src/config.rs | 4 +--- crates/oxide-code/src/tui/components/status/line.rs | 7 +------ 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index f9b6b358..87162ea9 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -62,7 +62,6 @@ pub(crate) struct ConfigSnapshot { pub(crate) compaction: CompactionConfig, pub(crate) show_thinking: bool, pub(crate) show_welcome: bool, - /// Ordered TUI status-line segments. pub(crate) status_line: Vec, /// Resolved theme base name — built-in catalogue key or filesystem path. `/theme` reads this /// to mark the active row in the picker. @@ -229,7 +228,7 @@ impl StatusLineSegment { (Self::CurrentTime, "current-time"), ]; - /// Default roster: location first, then model, usage, run state, and thread title. + /// Order rationale documented in `docs/design/tui/status-line.md`. pub(crate) const DEFAULT: &'static [Self] = &[ Self::CurrentDir, Self::GitBranch, @@ -351,7 +350,6 @@ pub(crate) struct Config { pub(crate) thinking: Option, pub(crate) show_thinking: bool, pub(crate) show_welcome: bool, - /// Ordered TUI status-line segments. pub(crate) status_line: Vec, pub(crate) theme: Theme, /// Built-in catalogue key (e.g. `"mocha"`) or filesystem path; mirrors `[tui.theme] base`, diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 0e5cdd9f..b9e77f81 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -114,17 +114,12 @@ impl StatusLine { } pub(super) struct StatusLineState<'a> { - /// Display label for the active model. pub(super) model: &'a str, - /// Resolved effort tier for model-with-effort. pub(super) effort: Option, - /// Optional session title. pub(super) title: Option<&'a str>, - /// Latest usage snapshot from the agent loop. pub(super) usage: Option, - /// Tildified working directory. + /// Tildified working directory; rendered as-is, no further `~` substitution. pub(super) cwd: &'a str, - /// Branch captured at TUI startup. pub(super) git_branch: Option<&'a str>, /// Open pull request number for the current branch, when one is detected. pub(super) pull_request: Option, From b4d0b9bab9d4856041d05efeaaedfd1680ad9194 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:19:20 +0800 Subject: [PATCH 25/43] docs(slash): drop narrating comment from model command test The assertion already names the contract (`expect_err` + `is ambiguous`); the leading sentence narrated what the test does without adding a WHY. --- crates/oxide-code/src/slash/model.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/oxide-code/src/slash/model.rs b/crates/oxide-code/src/slash/model.rs index f18f86ea..2f65415f 100644 --- a/crates/oxide-code/src/slash/model.rs +++ b/crates/oxide-code/src/slash/model.rs @@ -399,7 +399,6 @@ mod tests { #[test] fn execute_family_prefix_falls_through_to_ambiguity() { - // Family prefixes substring-match multiple rows; ambiguity must surface. for arg in ["opus-4", "claude-opus-4", "claude-sonnet-4"] { let (_, outcome) = run_execute(arg); let msg = outcome.expect_err(&format!("`{arg}` must error")); From 9eaeb6f9e6cf885a57e9f1b946d5426de5a573ff Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:21:13 +0800 Subject: [PATCH 26/43] docs(tui): explain segment_utility ranking and format_cost cutover 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`. --- crates/oxide-code/src/tui/components/status/line.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index b9e77f81..c5de5790 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -184,6 +184,8 @@ fn lowest_priority_index(segments: &[RenderedSegment]) -> Option { .map(|(index, _)| index) } +/// Per-segment "drop me first when narrow" rank. Lower numbers drop earlier, so run state and +/// model sit at the top because the bar is useless without them. fn segment_utility(segment: StatusLineSegment) -> u8 { match segment { StatusLineSegment::ThreadTitle => 0, @@ -245,6 +247,8 @@ fn compact_tokens(tokens: u32) -> String { } fn format_cost(cost: f64) -> String { + // Switch to two-decimal display once `{cost:.2}` would round up to `$1.00` so the bar reads + // `$1.00` instead of `$0.9999` at the boundary. if cost >= 0.995 { format!("${cost:.2}") } else { From 1db3696570f03ab30b3bd88f839b74414243918c Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:23:30 +0800 Subject: [PATCH 27/43] docs(config): note ALL/serde coupling on StatusLineSegment 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. --- crates/oxide-code/src/config.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 87162ea9..d7d61dcc 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -215,6 +215,9 @@ pub(crate) enum StatusLineSegment { } impl StatusLineSegment { + /// Adding a variant: also add an entry here. The string must match the kebab-case form + /// `serde(rename_all = "kebab-case")` would produce, since `OX_STATUS_LINE` parsing reads + /// from this table while TOML config goes through serde. const ALL: &'static [(Self, &'static str)] = &[ (Self::CurrentDir, "current-dir"), (Self::GitBranch, "git-branch"), From 77e5728c2cc55d38daad9ace1759a9ea1ef6f4f1 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:28:54 +0800 Subject: [PATCH 28/43] refactor(model): extract pricing into its own submodule 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. --- CLAUDE.md | 3 ++ crates/oxide-code/src/model.rs | 75 +++----------------------- crates/oxide-code/src/model/pricing.rs | 73 +++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 68 deletions(-) create mode 100644 crates/oxide-code/src/model/pricing.rs diff --git a/CLAUDE.md b/CLAUDE.md index 3e1d6662..bf5ecd4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,8 @@ ox # Start an interactive session ├── main.rs # CLI entry point, mode dispatch (TUI / REPL / headless), signal handling ├── message.rs # Conversation message types ├── model.rs # Ground-truth table: display name, cutoff, capabilities, and unknown raw-id fallback +├── model/ +│ └── pricing.rs # Per-million-token cost rates + USD estimator (excludes account / marketplace adjustments) ├── prompt.rs # System prompt builder (section assembly) ├── prompt/ │ ├── environment.rs # Runtime environment detection (platform, git, date, knowledge cutoff) @@ -160,6 +162,7 @@ ox # Start an interactive session └── util/ ├── env.rs # Environment-variable helpers (`string`, `bool`: empty-is-absent semantics) ├── fs.rs # Filesystem helpers: `create_private_dir_all` (0o700) + `atomic_write_private` (0o600 temp+rename) + ├── git.rs # Git / `gh` probes for branch + open PR (best-effort, debug-logged failures) ├── lock.rs # Async retry helper for advisory locks (used by oauth) ├── log.rs # `tracing` subscriber init: file under $XDG_STATE_HOME in TUI mode, stderr otherwise ├── path.rs # Path display + expansion helpers (`tildify`: $HOME → ~/, `expand_user`: ~/ → $HOME) diff --git a/crates/oxide-code/src/model.rs b/crates/oxide-code/src/model.rs index 505ea251..f17ef0a1 100644 --- a/crates/oxide-code/src/model.rs +++ b/crates/oxide-code/src/model.rs @@ -1,8 +1,13 @@ //! Ground-truth table of known Claude models. Substring-matched, most-specific entry first. +mod pricing; + use std::borrow::Cow; -use crate::config::{Effort, PromptCacheTtl}; +use crate::config::Effort; + +pub(crate) use pricing::TokenCostRates; +use pricing::{HAIKU_RATES, OPUS_4_1_RATES, OPUS_4_5_PLUS_RATES, SONNET_RATES}; // ── ModelInfo ── @@ -21,69 +26,6 @@ pub(crate) struct ModelInfo { const STANDARD_CONTEXT_WINDOW: u32 = 200_000; const CONTEXT_1M_WINDOW: u32 = 1_000_000; -// ── TokenCostRates ── - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct TokenCostRates { - input: f64, - cache_write_5m: f64, - cache_write_1h: f64, - cache_read: f64, - output: f64, -} - -const OPUS_4_5_PLUS_RATES: TokenCostRates = TokenCostRates { - input: 5.0, - cache_write_5m: 6.25, - cache_write_1h: 10.0, - cache_read: 0.50, - output: 25.0, -}; - -const OPUS_4_1_RATES: TokenCostRates = TokenCostRates { - input: 15.0, - cache_write_5m: 18.75, - cache_write_1h: 30.0, - cache_read: 1.50, - output: 75.0, -}; - -const SONNET_RATES: TokenCostRates = TokenCostRates { - input: 3.0, - cache_write_5m: 3.75, - cache_write_1h: 6.0, - cache_read: 0.30, - output: 15.0, -}; - -const HAIKU_RATES: TokenCostRates = TokenCostRates { - input: 1.0, - cache_write_5m: 1.25, - cache_write_1h: 2.0, - cache_read: 0.10, - output: 5.0, -}; - -impl TokenCostRates { - pub(crate) fn estimate_usd( - self, - input_tokens: u32, - cache_creation_input_tokens: u32, - cache_read_input_tokens: u32, - output_tokens: u32, - cache_ttl: PromptCacheTtl, - ) -> f64 { - let cache_write = match cache_ttl { - PromptCacheTtl::FiveMin => self.cache_write_5m, - PromptCacheTtl::OneHour => self.cache_write_1h, - }; - million_tokens(input_tokens) * self.input - + million_tokens(cache_creation_input_tokens) * cache_write - + million_tokens(cache_read_input_tokens) * self.cache_read - + million_tokens(output_tokens) * self.output - } -} - // ── Capabilities ── /// Per-model gate set consumed by the wire-builder (header + body fields), the slash commands @@ -298,10 +240,6 @@ pub(crate) fn token_cost_rates_for(model: &str) -> Option { lookup(model).and_then(|info| info.cost_rates) } -fn million_tokens(tokens: u32) -> f64 { - f64::from(tokens) / 1_000_000.0 -} - /// Human-facing label: the row's [`ModelInfo::display_name`] plus a ` (1M context)` suffix on /// `[1m]` ids. Falls back to the raw id when the model is unknown. pub(crate) fn display_name(model: &str) -> Cow<'_, str> { @@ -335,6 +273,7 @@ pub(crate) fn short_display_name(model: &str) -> Cow<'_, str> { #[cfg(test)] mod tests { use super::*; + use crate::config::PromptCacheTtl; // ── capability rows ── diff --git a/crates/oxide-code/src/model/pricing.rs b/crates/oxide-code/src/model/pricing.rs new file mode 100644 index 00000000..50ae6252 --- /dev/null +++ b/crates/oxide-code/src/model/pricing.rs @@ -0,0 +1,73 @@ +//! Token cost rates and per-million USD pricing. +//! +//! Rates are quoted in USD per million tokens for first-party Anthropic API. Values exclude +//! account discounts, marketplace billing, data-residency multipliers, fast-mode adjustments, +//! and server-side tool surcharges. Update both this table and `MODELS` in lockstep when a row +//! changes pricing. + +use crate::config::PromptCacheTtl; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct TokenCostRates { + input: f64, + cache_write_5m: f64, + cache_write_1h: f64, + cache_read: f64, + output: f64, +} + +pub(super) const OPUS_4_5_PLUS_RATES: TokenCostRates = TokenCostRates { + input: 5.0, + cache_write_5m: 6.25, + cache_write_1h: 10.0, + cache_read: 0.50, + output: 25.0, +}; + +pub(super) const OPUS_4_1_RATES: TokenCostRates = TokenCostRates { + input: 15.0, + cache_write_5m: 18.75, + cache_write_1h: 30.0, + cache_read: 1.50, + output: 75.0, +}; + +pub(super) const SONNET_RATES: TokenCostRates = TokenCostRates { + input: 3.0, + cache_write_5m: 3.75, + cache_write_1h: 6.0, + cache_read: 0.30, + output: 15.0, +}; + +pub(super) const HAIKU_RATES: TokenCostRates = TokenCostRates { + input: 1.0, + cache_write_5m: 1.25, + cache_write_1h: 2.0, + cache_read: 0.10, + output: 5.0, +}; + +impl TokenCostRates { + pub(crate) fn estimate_usd( + self, + input_tokens: u32, + cache_creation_input_tokens: u32, + cache_read_input_tokens: u32, + output_tokens: u32, + cache_ttl: PromptCacheTtl, + ) -> f64 { + let cache_write = match cache_ttl { + PromptCacheTtl::FiveMin => self.cache_write_5m, + PromptCacheTtl::OneHour => self.cache_write_1h, + }; + million_tokens(input_tokens) * self.input + + million_tokens(cache_creation_input_tokens) * cache_write + + million_tokens(cache_read_input_tokens) * self.cache_read + + million_tokens(output_tokens) * self.output + } +} + +fn million_tokens(tokens: u32) -> f64 { + f64::from(tokens) / 1_000_000.0 +} From 3079df5dac007c453435ec1059e95fe7cd0dfea4 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:29:50 +0800 Subject: [PATCH 29/43] docs(guide): expand status-line segment reference 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). --- docs/guide/configuration.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 90d05093..8f933fa2 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -139,6 +139,23 @@ On Opus 4.7, `show_thinking = true` opts the request into `thinking.display = "s `status_line` accepts these segment names: `current-dir`, `git-branch`, `pull-request`, `model`, `model-with-effort`, `context-used`, `session-cost`, `run-state`, `thread-title`, `current-time`. The list must contain at least one segment. Segments with no data, such as a missing git branch, an unopened pull request, or usage before the first completed turn, are omitted. The `pull-request` segment shells out to `gh pr view` so it requires the GitHub CLI on PATH. The separator is part of the active theme's `separator` slot; there is no separate status-line separator setting. +| Segment | Renders | Refresh | +| ------------------- | ---------------------------------------------- | --------------- | +| `current-dir` | Tildified working directory | At startup | +| `git-branch` | Current branch (omitted on detached HEAD) | Every 5 s | +| `pull-request` | Open PR for the branch as `#86` | Every 60 s | +| `model` | Compact model label (e.g., `Opus 4.7`) | On `/model` | +| `model-with-effort` | Model label plus the effort tier in parens | On `/model` | +| `context-used` | `Ctx: 50% (100k/200k)` after the first turn | Per turn | +| `session-cost` | `Sess: $0.4321` running USD estimate | Per turn | +| `run-state` | `Ready` / spinner + label / Ctrl+C exit hint | On state change | +| `thread-title` | AI-generated session title | After turn 1 | +| `current-time` | `HH:MM` in the local timezone | Per minute | + +When the row is too narrow to fit every configured segment, low-utility ones drop in this order before the remaining content gets truncated: `thread-title` → `current-time` → `pull-request` → `git-branch` → `current-dir` → `session-cost` → `context-used` → `model` → `run-state`. Run state and model are always preserved. + +`OX_STATUS_LINE` accepts the same segment names, comma-separated, with optional whitespace (`OX_STATUS_LINE="model, run-state"`). An empty value, an empty entry between commas (`"model,,run-state"`), or any unknown segment name fails startup with a parse error rather than silently dropping the offending part. + ### `[tui.theme]`: Terminal theme | Key | Type | Default | Description | From 8c7f88a5cb8c75fbe2bcbe19673acb553a9de119 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:30:55 +0800 Subject: [PATCH 30/43] docs(design): synthesize status-line rationale into one prose section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/design/tui/status-line.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/design/tui/status-line.md b/docs/design/tui/status-line.md index d2d1ca8d..819bb6d7 100644 --- a/docs/design/tui/status-line.md +++ b/docs/design/tui/status-line.md @@ -76,16 +76,9 @@ status_line = [ ] ``` -This differs from the reference Claude Code script in two ways: +This differs from the reference Claude Code script in two ways: oxide-code stays one row because Ratatui already owns the app chrome, and external billing data is omitted until there is a first-class provider boundary. -- oxide-code stays one row because Ratatui already owns the app chrome. -- External billing data is omitted until there is a first-class provider boundary. - -Order rationale: - -- Location and branch lead because they orient the user. -- Model and usage follow because they describe request cost and context pressure. -- Run state stays near the end because it changes most often. +The default order tracks reading flow: location and branch orient the user, model and usage describe cost and context pressure, run state sits near the end because it changes most often. ## Design Decisions From 2fa311038e560f25727e8645a6cd25bf4c227492 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:31:46 +0800 Subject: [PATCH 31/43] docs(README): link status-line bullet to configuration guide Lets readers click through from the feature list to the per-segment reference instead of having to grep for it. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9c588b7b..f903da26 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ oxide-code is a Rust reimplementation of Claude Code, an interactive CLI agent t Early development. What works today: -- Terminal UI: streaming output, markdown rendering, syntax-highlighted code blocks, configurable status line, and 5 built-in themes with custom-TOML overrides +- Terminal UI: streaming output, markdown rendering, syntax-highlighted code blocks, [configurable status line](docs/guide/configuration.md#tui-terminal-ui), and 5 built-in themes with custom-TOML overrides - Agent loop with extended thinking and tool-use round-trip - File and search tools: `read`, `write`, `edit`, `glob`, `grep`, `bash` - Turn interruption (Esc / Ctrl+C) plus mid-turn queued follow-up prompts that splice into the same turn between tool calls, with double-press Ctrl+C exit confirmation From 6f3ecc380cff9f5814c2a6312eef9342160900e6 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 10:32:38 +0800 Subject: [PATCH 32/43] docs(theming): clarify separator slot styles glyph but doesn't replace it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/guide/theming.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guide/theming.md b/docs/guide/theming.md index 6169a20b..a77045b3 100644 --- a/docs/guide/theming.md +++ b/docs/guide/theming.md @@ -196,6 +196,8 @@ Each status-line segment routes to one of the slots above so re-skinning the slo | `thread-title` | `muted` | | `current-time` | `dim` | +The separator glyph itself (`│`) is fixed; the `separator` slot only controls its color and modifiers. + ## Overrides `[tui.theme.overrides]` is a table of `slot_name = patch` pairs. A patch is _additive_, so only the fields it lists are applied to the base slot. From 40d1c143aae755906fcecccd06cc6590d55038d9 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 13:27:15 +0800 Subject: [PATCH 33/43] test(agent): cover billable_usage on failed and max-rounds turns 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. --- crates/oxide-code/src/agent.rs | 85 ++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index 59350a91..e3fcb3c8 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -2398,6 +2398,46 @@ mod tests { assert!(msg.contains("safety cap"), "explains intent: {msg}"); } + #[tokio::test] + async fn agent_turn_safety_cap_bail_preserves_completed_round_usage() { + // Each capped round reports usage. The cap-trip abort must still fold every round into + // billable_usage so the caller bills what the API charged for. + const CAP: u32 = 2; + let dir = tempfile::tempdir().unwrap(); + let session = test_session(dir.path()); + let turns: Vec> = (0..CAP) + .map(|i| tool_use_turn_with_usage(&format!("tool_{i}"), "echo", r"{}", 5, 2)) + .collect(); + let client = FakeClient::new(turns); + let tools = ToolRegistry::new(vec![Box::new(EchoTool)]); + let sink = CapturingSink::new(); + let mut messages = vec![Message::user("loop forever")]; + + let outcome = agent_turn( + &client, + &tools, + &mut messages, + &empty_prompt(), + &sink, + &session, + &mut inert_user_rx(), + Some(CAP), + ) + .await; + + assert!( + matches!(outcome.result, Err(TurnAbort::Failed(_))), + "got {:?}", + outcome.result, + ); + assert_eq!( + outcome.report.billable_usage.map(TokenUsage::total_tokens), + Some(CAP * 7), + "every capped round must reach billable_usage: {:?}", + outcome.report, + ); + } + #[tokio::test] async fn agent_turn_with_none_cap_runs_unbounded_until_text_only_reply() { // The cap is None, so the loop must not bail on its own; it terminates only when the @@ -2464,6 +2504,51 @@ mod tests { ); } + #[tokio::test] + async fn agent_turn_failed_after_completed_round_preserves_billable_usage() { + // Round 1 completes with reported usage. Round 2 emits a stream Error so the turn + // bails with TurnAbort::Failed. The completed round's usage must still ride out on + // billable_usage so the caller bills what the API charged for. + let dir = tempfile::tempdir().unwrap(); + let session = test_session(dir.path()); + let client = FakeClient::new(vec![ + tool_use_turn_with_usage("tool_1", "echo", r"{}", 9, 4), + vec![StreamEvent::Error { + error: ApiError { + error_type: "overloaded_error".into(), + message: "Servers overloaded".into(), + }, + }], + ]); + let tools = ToolRegistry::new(vec![Box::new(EchoTool)]); + let sink = CapturingSink::new(); + let mut messages = vec![Message::user("hi")]; + + let outcome = agent_turn( + &client, + &tools, + &mut messages, + &empty_prompt(), + &sink, + &session, + &mut inert_user_rx(), + None, + ) + .await; + + assert!( + matches!(outcome.result, Err(TurnAbort::Failed(_))), + "got {:?}", + outcome.result, + ); + assert_eq!( + outcome.report.billable_usage.map(TokenUsage::total_tokens), + Some(13), + "round 1 usage must reach the caller despite the bail: {:?}", + outcome.report, + ); + } + #[tokio::test] async fn agent_turn_strips_trailing_thinking_before_next_round() { // The API rejects a trailing thinking block on follow-up rounds. From fae553305aab21d741a4324648247744e0c701ea Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 13:30:32 +0800 Subject: [PATCH 34/43] docs(agent): drop restating comment on unbounded-cap test 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. --- crates/oxide-code/src/agent.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index e3fcb3c8..fbc0b710 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -2440,9 +2440,6 @@ mod tests { #[tokio::test] async fn agent_turn_with_none_cap_runs_unbounded_until_text_only_reply() { - // The cap is None, so the loop must not bail on its own; it terminates only when the - // model produces a text-only round. Pin that an arbitrary multi-round chain still - // completes without tripping the safety cap. let dir = tempfile::tempdir().unwrap(); let session = test_session(dir.path()); let mut turns: Vec> = (0..50) From c94d636f30c7f5d042eb9bf625d7735e37a037ab Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 13:30:39 +0800 Subject: [PATCH 35/43] test(tui): pin status probe throttles on null result 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. --- .../oxide-code/src/tui/components/status.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 52dd9324..2d4b4df6 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -500,6 +500,28 @@ mod tests { assert_eq!(bar.last_branch_probe, probed_at); } + #[test] + fn refresh_git_branch_arms_throttle_when_probe_returns_none() { + // A non-repo cwd makes `git branch --show-current` return None. The throttle key must + // still advance, since a regression that only stamps on `Some(branch)` would re-shell + // out every tick on a non-repo cwd. + let dir = tempfile::tempdir().unwrap(); + let mut bar = test_bar(); + bar.git_cwd = Some(dir.path().to_path_buf()); + let now = Instant::now(); + bar.refresh_git_branch(now); + assert_eq!(bar.last_branch_probe, Some(now)); + assert!( + !bar.refresh_git_branch(now + Duration::from_millis(100)), + "second call within the interval must short-circuit", + ); + assert_eq!( + bar.last_branch_probe, + Some(now), + "stamp must not move while the throttle window is open", + ); + } + // ── refresh_pull_request ── #[test] @@ -519,6 +541,25 @@ mod tests { assert!(bar.last_pr_probe.is_none(), "must skip without cwd"); } + #[test] + fn refresh_pull_request_arms_throttle_when_probe_returns_none() { + // Same throttle invariant as the git branch probe. A non-repo cwd (or a cwd where `gh` + // can't find a PR) returns None; the stamp must advance regardless so we don't re-shell + // every tick. + let dir = tempfile::tempdir().unwrap(); + let mut bar = test_bar(); + bar.track_pull_request = true; + bar.git_cwd = Some(dir.path().to_path_buf()); + let now = Instant::now(); + assert!(!bar.refresh_pull_request(now)); + assert_eq!(bar.last_pr_probe, Some(now)); + assert!( + !bar.refresh_pull_request(now + Duration::from_millis(100)), + "second call within the interval must short-circuit", + ); + assert_eq!(bar.last_pr_probe, Some(now)); + } + // ── should_probe ── #[test] From 9e6d1129768655abb2c51eff383a43dd05eb4ba9 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 13:33:26 +0800 Subject: [PATCH 36/43] test(tui): assert current-time drops before pull-request 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. --- crates/oxide-code/src/tui/components/status/line.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index c5de5790..bad2e182 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -349,12 +349,20 @@ mod tests { #[test] fn render_pull_request_renders_hash_prefix_and_drops_before_git_branch() { let segments = vec![ + StatusLineSegment::CurrentTime, StatusLineSegment::GitBranch, StatusLineSegment::PullRequest, StatusLineSegment::RunState, ]; - assert_eq!(render_text(segments.clone(), 80), " main │ #86 │ Ready"); + let full = render_text(segments.clone(), 80); + assert!( + full.contains("#86") && full.contains("main") && full.ends_with("Ready"), + "wide width keeps every segment: {full}", + ); + // Width 22 forces time (utility 1) to drop before PR (2) and branch (3). Width 14 + // narrows further until only branch and run state remain. + assert_eq!(render_text(segments.clone(), 22), " main │ #86 │ Ready"); assert_eq!(render_text(segments, 14), " main │ Ready"); } From 8f1b573ad76d7cf0d4c035f577d21d3fc0d14cf0 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 13:35:26 +0800 Subject: [PATCH 37/43] feat(tui): rank model-with-effort above plain model 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. --- .../src/tui/components/status/line.rs | 19 +++++++++++++++++-- docs/guide/configuration.md | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index bad2e182..58971d16 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -195,8 +195,9 @@ fn segment_utility(segment: StatusLineSegment) -> u8 { StatusLineSegment::CurrentDir => 4, StatusLineSegment::SessionCost => 5, StatusLineSegment::ContextUsed => 6, - StatusLineSegment::Model | StatusLineSegment::ModelWithEffort => 7, - StatusLineSegment::RunState => 8, + StatusLineSegment::Model => 7, + StatusLineSegment::ModelWithEffort => 8, + StatusLineSegment::RunState => 9, } } @@ -346,6 +347,20 @@ mod tests { assert_eq!(render_text(segments, 10), " Ready"); } + #[test] + fn render_drops_plain_model_before_model_with_effort() { + // The compact `model-with-effort` label carries strictly more information than `model`, + // so a user who configures both keeps the more useful variant under width pressure. + let segments = vec![ + StatusLineSegment::Model, + StatusLineSegment::ModelWithEffort, + StatusLineSegment::RunState, + ]; + + assert_eq!(render_text(segments.clone(), 80), " m │ m (high) │ Ready"); + assert_eq!(render_text(segments, 18), " m (high) │ Ready"); + } + #[test] fn render_pull_request_renders_hash_prefix_and_drops_before_git_branch() { let segments = vec![ diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 8f933fa2..2fd208fa 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -152,7 +152,7 @@ On Opus 4.7, `show_thinking = true` opts the request into `thinking.display = "s | `thread-title` | AI-generated session title | After turn 1 | | `current-time` | `HH:MM` in the local timezone | Per minute | -When the row is too narrow to fit every configured segment, low-utility ones drop in this order before the remaining content gets truncated: `thread-title` → `current-time` → `pull-request` → `git-branch` → `current-dir` → `session-cost` → `context-used` → `model` → `run-state`. Run state and model are always preserved. +When the row is too narrow to fit every configured segment, low-utility ones drop in this order before the remaining content gets truncated: `thread-title` → `current-time` → `pull-request` → `git-branch` → `current-dir` → `session-cost` → `context-used` → `model` → `model-with-effort` → `run-state`. Run state, the model labels, and the bar's last surviving segment are always preserved. `OX_STATUS_LINE` accepts the same segment names, comma-separated, with optional whitespace (`OX_STATUS_LINE="model, run-state"`). An empty value, an empty entry between commas (`"model,,run-state"`), or any unknown segment name fails startup with a parse error rather than silently dropping the offending part. From 661d5938ee172828c958b31816bd778dc9c91662 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 13:37:01 +0800 Subject: [PATCH 38/43] feat(util): include probe stderr in debug log 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. --- crates/oxide-code/src/util/git.rs | 36 ++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/oxide-code/src/util/git.rs b/crates/oxide-code/src/util/git.rs index c2b3bd3d..ab6edb10 100644 --- a/crates/oxide-code/src/util/git.rs +++ b/crates/oxide-code/src/util/git.rs @@ -3,7 +3,7 @@ //! `debug` so they don't pollute normal use but are recoverable when the status bar misbehaves. use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::Command; use tracing::debug; @@ -22,7 +22,6 @@ pub(crate) fn current_branch(cwd: &Path) -> Option { "branch", "--show-current", ]) - .stderr(Stdio::null()) .output() { Ok(output) => output, @@ -35,6 +34,7 @@ pub(crate) fn current_branch(cwd: &Path) -> Option { debug!( cwd = cwd_str, code = output.status.code().unwrap_or(-1), + stderr = stderr_summary(&output.stderr), "git branch probe: non-zero exit", ); return None; @@ -66,7 +66,6 @@ pub(crate) fn current_pull_request(cwd: &Path) -> Option { let output = match Command::new("gh") .args(["pr", "view", "--json", "number", "--jq", ".number"]) .current_dir(cwd_str) - .stderr(Stdio::null()) .output() { Ok(output) => output, @@ -79,6 +78,7 @@ pub(crate) fn current_pull_request(cwd: &Path) -> Option { debug!( cwd = cwd_str, code = output.status.code().unwrap_or(-1), + stderr = stderr_summary(&output.stderr), "gh pr probe: non-zero exit", ); return None; @@ -90,6 +90,21 @@ fn parse_pr_number(stdout: &[u8]) -> Option { std::str::from_utf8(stdout).ok()?.trim().parse().ok() } +/// First non-blank stderr line, capped to keep log records terse. Surfaces the actionable signal +/// (`auth required`, `no pull requests found`, `not a git repository`) without dumping a wall of +/// hint text. +fn stderr_summary(stderr: &[u8]) -> String { + const MAX_LEN: usize = 200; + let text = String::from_utf8_lossy(stderr); + let line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); + let trimmed = line.trim(); + if trimmed.len() <= MAX_LEN { + trimmed.to_owned() + } else { + format!("{}...", &trimmed[..MAX_LEN]) + } +} + #[cfg(test)] mod tests { use super::*; @@ -161,4 +176,19 @@ mod tests { assert_eq!(parse_pr_number(b"-1\n"), None); assert_eq!(parse_pr_number(&[0xff, b'\n']), None); } + + // ── stderr_summary ── + + #[test] + fn stderr_summary_picks_first_meaningful_line_and_caps_length() { + assert_eq!(stderr_summary(b""), ""); + assert_eq!( + stderr_summary(b"\n \nfatal: not a git repository\nmore detail\n"), + "fatal: not a git repository", + ); + let huge = vec![b'x'; 500]; + let summary = stderr_summary(&huge); + assert_eq!(summary.len(), 203, "200 chars + '...': {summary}"); + assert!(summary.ends_with("...")); + } } From b20306b0cb479a6f23fb3fab380ba33fe04934ca Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 13:38:09 +0800 Subject: [PATCH 39/43] docs(agent): note auto-compaction also clears usage `UsageUpdated` is reset on `SessionCompacted` regardless of the `automatic` flag, but the doc only listed `/compact` and resume. --- crates/oxide-code/src/agent/event.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/oxide-code/src/agent/event.rs b/crates/oxide-code/src/agent/event.rs index 4617b7ac..c701b390 100644 --- a/crates/oxide-code/src/agent/event.rs +++ b/crates/oxide-code/src/agent/event.rs @@ -21,11 +21,12 @@ pub(crate) const INTERRUPTED_MARKER: &str = "(interrupted)"; #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct UsageSnapshot { /// Cache-aware input total: `input + cache_creation + cache_read`. Drives the context-pressure - /// percentage and auto-compaction threshold. Resets on `/clear`, `/compact`, and resume. + /// percentage and auto-compaction threshold. Cleared on session roll (`/clear`), `/resume`, + /// and any compaction (manual `/compact` or automatic). pub(crate) context_tokens: u32, /// Active model context window. `None` hides the percentage. pub(crate) context_window: Option, - /// Running session cost in USD, accumulated across every turn. Resets on the same boundaries + /// Running session cost in USD, accumulated across every turn. Cleared on the same boundaries /// as `context_tokens`. `None` hides the cost segment. pub(crate) estimated_cost_usd: Option, } From 0eb4619d6ebded5339ce43c6cdc516048dfa4903 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 13:38:57 +0800 Subject: [PATCH 40/43] docs(config): note ALL feeds the empty-roster error formatter 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. --- crates/oxide-code/src/config.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index d7d61dcc..485010a9 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -215,9 +215,9 @@ pub(crate) enum StatusLineSegment { } impl StatusLineSegment { - /// Adding a variant: also add an entry here. The string must match the kebab-case form - /// `serde(rename_all = "kebab-case")` would produce, since `OX_STATUS_LINE` parsing reads - /// from this table while TOML config goes through serde. + /// Canonical name table consumed by `OX_STATUS_LINE` parsing and the empty-roster error + /// formatter. TOML config goes through serde, so the strings here must match the kebab-case + /// form `serde(rename_all = "kebab-case")` would produce. const ALL: &'static [(Self, &'static str)] = &[ (Self::CurrentDir, "current-dir"), (Self::GitBranch, "git-branch"), From ac364cd28787f0bc675b247be2bc693703f70d28 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 13:41:46 +0800 Subject: [PATCH 41/43] docs: trim restating comments on status and turn types 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. --- crates/oxide-code/src/agent.rs | 16 +++++++--------- crates/oxide-code/src/tui/components/status.rs | 5 +---- .../oxide-code/src/tui/components/status/line.rs | 5 +++-- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index fbc0b710..95a55f8e 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -144,14 +144,13 @@ impl TokenUsage { } } -/// Per-turn usage report emitted at the end of [`agent_turn`]. The two fields carry different -/// temporal meanings and resist being collapsed into one. +/// Per-turn usage report emitted at the end of [`agent_turn`]. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct TurnReport { - /// Latest single round's usage. Drives auto-compaction threshold checks, where the trigger - /// depends on the most recent prompt size rather than the historical sum. + /// Latest single round's usage, used by the auto-compaction threshold check (which compares + /// against the most recent prompt size, not the historical sum). pub(crate) usage: Option, - /// Sum of every round's usage in this turn. Drives session cost accumulation, since each + /// Sum of every round's usage in this turn, used by session cost accumulation since each /// round was billed independently. pub(crate) billable_usage: Option, } @@ -180,8 +179,7 @@ impl TurnOutcome { } } - /// Test helper: returns the report on success or panics with the abort. Mirrors - /// `Result::unwrap`. + /// Mirrors `Result::unwrap`: returns the report on success or panics with the abort. #[cfg(test)] pub(crate) fn unwrap(self) -> TurnReport { match self.result { @@ -190,7 +188,7 @@ impl TurnOutcome { } } - /// Test helper: returns the report on success or panics with the abort and `msg`. + /// Mirrors `Result::expect`: returns the report on success or panics with the abort and `msg`. #[cfg(test)] pub(crate) fn expect(self, msg: &str) -> TurnReport { match self.result { @@ -199,7 +197,7 @@ impl TurnOutcome { } } - /// Test helper: returns the abort on failure or panics. Mirrors `Result::expect_err`. + /// Mirrors `Result::expect_err`: returns the abort on failure or panics. #[cfg(test)] pub(crate) fn expect_err(self, msg: &str) -> TurnAbort { match self.result { diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index 2d4b4df6..d27ba409 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -38,17 +38,14 @@ pub(crate) struct StatusBar { title: Option, usage: Option, cwd: String, - /// Working directory for git probes. `None` collapses every probe to a no-op. + /// `None` collapses every git probe to a no-op. git_cwd: Option, git_branch: Option, - /// Open pull request number for `git_branch`. `None` until the first probe completes. pull_request: Option, /// `true` while the `pull-request` segment is configured. Skips the `gh` probe entirely when /// the user hasn't opted in. track_pull_request: bool, - /// Last time the git branch was probed. `None` until the first tick. last_branch_probe: Option, - /// Last time the pull request was probed. `None` until the first tick. last_pr_probe: Option, status: Status, spinner_frame: usize, diff --git a/crates/oxide-code/src/tui/components/status/line.rs b/crates/oxide-code/src/tui/components/status/line.rs index 58971d16..dd82c75f 100644 --- a/crates/oxide-code/src/tui/components/status/line.rs +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -118,10 +118,9 @@ pub(super) struct StatusLineState<'a> { pub(super) effort: Option, pub(super) title: Option<&'a str>, pub(super) usage: Option, - /// Tildified working directory; rendered as-is, no further `~` substitution. + /// Already tilde-expanded, so the renderer must not substitute `~` again. pub(super) cwd: &'a str, pub(super) git_branch: Option<&'a str>, - /// Open pull request number for the current branch, when one is detected. pub(super) pull_request: Option, /// Pre-rendered run-state segment from the parent component. pub(super) status_span: Span<'static>, @@ -162,6 +161,8 @@ fn fit_segments(segments: &mut Vec, max_width: usize, sep_width if total_width(segments, sep_width) <= max_width { return; } + // The drop loop only stops with `segments.len() <= 1`, so truncating the widest is the + // single-survivor truncation path even though the iterator wording suggests otherwise. let content_width = max_width .saturating_sub(2) .saturating_sub(sep_width.saturating_mul(segments.len().saturating_sub(1))); From 28701f04c2e434bfc5ea9acd1fe631c106264185 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 14:30:55 +0800 Subject: [PATCH 42/43] test(tui): pin git branch change marks tick dirty `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. --- crates/oxide-code/src/tui/components/status.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/oxide-code/src/tui/components/status.rs b/crates/oxide-code/src/tui/components/status.rs index d27ba409..f57bafb4 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -117,7 +117,7 @@ impl StatusBar { self.usage = usage; } - /// Re-skin subsequent renders; the spinner / status state is unaffected. + /// Re-skin subsequent renders. The spinner / status state is unaffected. pub(crate) fn set_theme(&mut self, theme: &Theme) { self.theme = theme.clone(); } @@ -487,6 +487,20 @@ mod tests { assert_eq!(bar.spinner_frame, 0); } + #[test] + fn tick_marks_dirty_when_git_branch_changes() { + // With no animated status and no minute change, the only path flipping dirty is the git + // probe surfacing a new branch. A future `refresh_git_branch` reordering could quietly + // drop the dirty bit and leave the rendered branch label stale until the next user input. + let dir = tempfile::tempdir().unwrap(); + let mut bar = test_bar(); + bar.git_cwd = Some(dir.path().to_path_buf()); + bar.git_branch = Some("stale".to_owned()); + bar.last_branch_probe = None; + assert!(bar.tick()); + assert_eq!(bar.git_branch, None); + } + // ── refresh_git_branch ── #[test] @@ -541,7 +555,7 @@ mod tests { #[test] fn refresh_pull_request_arms_throttle_when_probe_returns_none() { // Same throttle invariant as the git branch probe. A non-repo cwd (or a cwd where `gh` - // can't find a PR) returns None; the stamp must advance regardless so we don't re-shell + // can't find a PR) returns None, but the stamp must still advance so we don't re-shell // every tick. let dir = tempfile::tempdir().unwrap(); let mut bar = test_bar(); From 3c713e398e1bdc87c3c3e1a5248fd5b1c00c7623 Mon Sep 17 00:00:00 2001 From: Hakula Chen Date: Fri, 15 May 2026 14:31:06 +0800 Subject: [PATCH 43/43] docs: drop semicolon-between-clauses in three doc comments 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. --- crates/oxide-code/src/agent.rs | 4 ++-- crates/oxide-code/src/config.rs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index 95a55f8e..271d1b33 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -147,8 +147,8 @@ impl TokenUsage { /// Per-turn usage report emitted at the end of [`agent_turn`]. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct TurnReport { - /// Latest single round's usage, used by the auto-compaction threshold check (which compares - /// against the most recent prompt size, not the historical sum). + /// Latest single round's usage, used by the auto-compaction threshold check, which compares + /// against the most recent prompt size rather than the historical sum. pub(crate) usage: Option, /// Sum of every round's usage in this turn, used by session cost accumulation since each /// round was billed independently. diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 485010a9..627b10bb 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -355,14 +355,14 @@ pub(crate) struct Config { pub(crate) show_welcome: bool, pub(crate) status_line: Vec, pub(crate) theme: Theme, - /// Built-in catalogue key (e.g. `"mocha"`) or filesystem path; mirrors `[tui.theme] base`, + /// Built-in catalogue key (e.g. `"mocha"`) or filesystem path. Mirrors `[tui.theme] base`, /// falling back to [`DEFAULT_THEME`] when unset. pub(crate) theme_name: String, } impl Config { /// Resolves config from layered sources. Per-field precedence is env > project `ox.toml` > - /// user `~/.config/ox/config.toml` > built-in default; see the module docs for the auth + /// user `~/.config/ox/config.toml` > built-in default. See the module docs for the auth /// chain. Parse errors (TOML, env, theme) propagate so a typo doesn't degrade silently into /// "no credentials". pub(crate) async fn load() -> Result { @@ -1538,7 +1538,7 @@ mod tests { #[tokio::test] async fn load_effort_clamps_xhigh_down_to_high_on_sonnet_4_6() { - // Sonnet 4.6 has effort but caps below `xhigh`; clamping keeps the request from 400ing. + // Sonnet 4.6 has effort but caps below `xhigh`. Clamping keeps the request from 400ing. let dir = tempfile::tempdir().unwrap(); let vars = env_vars(vec![ xdg(&dir),