diff --git a/CLAUDE.md b/CLAUDE.md index a9a4e765..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) @@ -131,7 +133,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) @@ -158,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/README.md b/README.md index cba7c31f..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, 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 diff --git a/crates/oxide-code/src/agent.rs b/crates/oxide-code/src/agent.rs index fbc9666a..271d1b33 100644 --- a/crates/oxide-code/src/agent.rs +++ b/crates/oxide-code/src/agent.rs @@ -70,36 +70,141 @@ 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 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) { + // 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_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; } } } +/// 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 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. + 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), + } + } + + /// Mirrors `Result::unwrap`: returns the report on success or panics with the abort. + #[cfg(test)] + pub(crate) fn unwrap(self) -> TurnReport { + match self.result { + Ok(()) => self.report, + Err(abort) => panic!("turn aborted: {abort:?}"), + } + } + + /// 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 { + Ok(()) => self.report, + Err(abort) => panic!("{msg}: {abort:?}"), + } + } + + /// 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 { + Ok(()) => panic!("{msg}: turn unexpectedly completed"), + Err(abort) => abort, + } + } } pub(crate) struct AutoCompact<'a> { @@ -126,24 +231,43 @@ 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??; - latest_usage = usage.or(latest_usage); + .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 = + Some(billable_usage.map_or(usage, |total: TokenUsage| total.add(usage))); + } let tool_uses = collect_tool_uses(&blocks); let assistant_msg = Message { @@ -155,12 +279,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, - }); + return TurnOutcome::completed(report(latest_usage, billable_usage)); } - let (results, sidecars) = run_tool_round( + let round = run_tool_round( tools, tool_uses, &parse_errors, @@ -168,7 +290,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, @@ -183,10 +309,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 @@ -806,6 +935,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 +964,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 +987,53 @@ 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, + }), + }, + }, + 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 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, }), }, @@ -874,6 +1054,8 @@ mod tests { }, usage: Some(Usage { input_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, output_tokens, }), }, @@ -917,6 +1099,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 +1198,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 +1240,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 +1287,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 +1331,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 +1382,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 +1432,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 +1467,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 +1557,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 { @@ -1557,6 +1770,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] @@ -1648,6 +1897,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() @@ -1889,7 +2142,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, @@ -1899,8 +2152,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(), @@ -1967,6 +2223,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(); @@ -2091,11 +2396,48 @@ 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 - // 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) @@ -2157,6 +2499,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. diff --git a/crates/oxide-code/src/agent/event.rs b/crates/oxide-code/src/agent/event.rs index 03d6d098..c701b390 100644 --- a/crates/oxide-code/src/agent/event.rs +++ b/crates/oxide-code/src/agent/event.rs @@ -17,6 +17,20 @@ 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 { + /// Cache-aware input total: `input + cache_creation + cache_read`. Drives the context-pressure + /// 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. Cleared on the same boundaries + /// as `context_tokens`. `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 +58,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 +250,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..9c040460 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 @@ -405,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 ── @@ -490,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)); } 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..627b10bb 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -62,6 +62,7 @@ pub(crate) struct ConfigSnapshot { pub(crate) compaction: CompactionConfig, pub(crate) show_thinking: bool, pub(crate) show_welcome: bool, + 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 +196,72 @@ 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, + PullRequest, + Model, + ModelWithEffort, + ContextUsed, + SessionCost, + RunState, + ThreadTitle, + CurrentTime, +} + +impl StatusLineSegment { + /// 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"), + (Self::PullRequest, "pull-request"), + (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"), + ]; + + /// Order rationale documented in `docs/design/tui/status-line.md`. + pub(crate) const DEFAULT: &'static [Self] = &[ + Self::CurrentDir, + Self::GitBranch, + Self::PullRequest, + 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,15 +353,16 @@ pub(crate) struct Config { pub(crate) thinking: Option, pub(crate) show_thinking: bool, 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 { @@ -364,6 +432,15 @@ 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 => 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 // ignore the field. let thinking = Some(ThinkingConfig::Adaptive { @@ -401,6 +478,7 @@ impl Config { thinking, show_thinking, show_welcome, + status_line, theme, theme_name, }) @@ -420,6 +498,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 +506,42 @@ impl Config { // ── Helpers ── +fn parse_status_line_segments(raw: &str) -> Result> { + 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 \ + OX_STATUS_LINE) to use the default. Valid segments: {}", + StatusLineSegment::ALL + .iter() + .map(|(_, name)| *name) + .collect::>() + .join(", "), + ); + } + Ok(segments) +} + pub(crate) fn display_effort(effort: Option) -> String { effort.map_or_else(|| "(no effort tier)".to_owned(), |e| e.to_string()) } @@ -716,6 +831,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 +899,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 +931,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 +946,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 +971,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 +987,94 @@ 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] + 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_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(); + 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}"); + assert!(msg.contains("unset OX_STATUS_LINE"), "{msg}"); + assert!(msg.contains("run-state"), "{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}"); + assert!(msg.contains("Remove the key"), "{msg}"); + assert!(msg.contains("run-state"), "{msg}"); } #[tokio::test] @@ -1323,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), @@ -1475,6 +1690,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 +1712,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..8f788603 100644 --- a/crates/oxide-code/src/main.rs +++ b/crates/oxide-code/src/main.rs @@ -22,8 +22,10 @@ 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::{AutoCompact, TokenUsage, TurnAbort, agent_turn}; +use agent::event::{ + AgentEvent, AgentSink, StdioSink, UsageSnapshot, UserAction, inert_user_action_channel, +}; +use agent::{AutoCompact, TokenUsage, TurnAbort, TurnOutcome, agent_turn}; use client::anthropic::Client; use config::{Config, Effort}; use file_tracker::FileTracker; @@ -254,13 +256,14 @@ 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() - .as_deref() - .map(tildify) - .unwrap_or_default(); + 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(util::git::current_branch); let session_info = LiveSessionInfo { cwd, + git_cwd: cwd_path, + git_branch, version: env!("CARGO_PKG_VERSION"), session_id: session.session_id().to_owned(), config, @@ -352,6 +355,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 +373,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 +415,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( @@ -417,7 +425,7 @@ impl AgentLoopTask { LoopControl::Continue } UserAction::Resume { session_id } => { - apply_resume( + let resumed = apply_resume( &mut self.session, &mut self.client, &mut self.messages, @@ -427,7 +435,10 @@ impl AgentLoopTask { &session_id, ) .await; - self.reset_auto_compaction(); + if resumed { + self.reset_auto_compaction(); + self.reset_usage_display(); + } LoopControl::Continue } UserAction::Compact { instructions } => { @@ -444,6 +455,7 @@ impl AgentLoopTask { match outcome { Ok(true) => { self.reset_auto_compaction(); + self.reset_usage_display(); LoopControl::Continue } Ok(false) => LoopControl::Continue, @@ -466,6 +478,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 +501,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"); @@ -535,9 +551,22 @@ impl AgentLoopTask { self.client.max_tool_rounds(), ) .await; - match outcome { - Ok(report) => { - self.last_usage = report.usage; + 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 } @@ -558,6 +587,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 @@ -570,7 +631,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) => { @@ -581,7 +642,7 @@ async fn apply_resume( )), "resume-failed", ); - return; + return false; } }; let new_id = session.session_id().to_owned(); @@ -614,6 +675,7 @@ async fn apply_resume( "resume-drift-warning", ); } + true } fn format_drift_warning(drifted: &[std::path::PathBuf]) -> String { @@ -832,17 +894,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"); @@ -891,8 +953,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() => { @@ -1031,6 +1093,8 @@ mod tests { file_tracker, auto_compaction_failures: 3, last_usage: Some(TokenUsage::new(100_000, 1)), + displayed_usage: None, + total_estimated_cost_usd: 1.23, }; let control = task @@ -1043,9 +1107,61 @@ 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 { .. }) )); } + + #[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") + )); + } } diff --git a/crates/oxide-code/src/model.rs b/crates/oxide-code/src/model.rs index 51ee3d1b..f17ef0a1 100644 --- a/crates/oxide-code/src/model.rs +++ b/crates/oxide-code/src/model.rs @@ -1,9 +1,14 @@ //! Ground-truth table of known Claude models. Substring-matched, most-specific entry first. +mod pricing; + use std::borrow::Cow; 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 ── /// One row in the [`MODELS`] catalogue. Pure data — no methods. Looked up by substring against a @@ -14,6 +19,8 @@ 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; @@ -61,6 +68,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ ], structured_outputs: true, }, + cost_rates: Some(OPUS_4_5_PLUS_RATES), }, ModelInfo { id_substr: "claude-opus-4-6", @@ -73,6 +81,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[Effort::Low, Effort::Medium, Effort::High, Effort::Max], structured_outputs: true, }, + cost_rates: Some(OPUS_4_5_PLUS_RATES), }, ModelInfo { id_substr: "claude-sonnet-4-6", @@ -85,6 +94,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 +107,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[], structured_outputs: true, }, + cost_rates: Some(OPUS_4_5_PLUS_RATES), }, ModelInfo { id_substr: "claude-sonnet-4-5", @@ -109,6 +120,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[], structured_outputs: true, }, + cost_rates: Some(SONNET_RATES), }, ModelInfo { id_substr: "claude-haiku-4-5", @@ -122,6 +134,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[], structured_outputs: true, }, + cost_rates: Some(HAIKU_RATES), }, ModelInfo { id_substr: "claude-opus-4-1", @@ -134,6 +147,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[ supported_efforts: &[], structured_outputs: true, }, + cost_rates: Some(OPUS_4_1_RATES), }, ]; @@ -222,8 +236,12 @@ 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) +} + /// 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) @@ -235,9 +253,27 @@ 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::*; + use crate::config::PromptCacheTtl; // ── capability rows ── @@ -314,7 +350,6 @@ mod tests { "claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5", - "claude-opus-4-1", ] { assert!( !lookup(unsupported) @@ -346,7 +381,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 +565,49 @@ 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_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()); + } + // ── display_name ── #[test] @@ -566,4 +643,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/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 +} 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/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/slash.rs b/crates/oxide-code/src/slash.rs index 8daa7c16..aaf0f075 100644 --- a/crates/oxide-code/src/slash.rs +++ b/crates/oxide-code/src/slash.rs @@ -127,6 +127,8 @@ 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(), config: ConfigSnapshot { @@ -144,6 +146,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..cb4eab05 100644 --- a/crates/oxide-code/src/slash/context.rs +++ b/crates/oxide-code/src/slash/context.rs @@ -2,9 +2,10 @@ //! [`LiveSessionInfo`] is the session-level snapshot. use std::borrow::Cow; +use std::path::PathBuf; 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; @@ -14,6 +15,10 @@ 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, pub(crate) config: ConfigSnapshot, @@ -23,6 +28,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/slash/model.rs b/crates/oxide-code/src/slash/model.rs index 7665a888..2f65415f 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]"), @@ -399,8 +398,7 @@ 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() { 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")); @@ -434,12 +432,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 ── @@ -473,6 +470,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/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index 0fb1f1cc..b31627af 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -91,8 +91,12 @@ impl App { ); let mut status_bar = StatusBar::new( theme, - session_info.display_name().into_owned(), + session_info.config.status_line.clone(), + 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); Self { @@ -459,6 +463,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 +486,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 +539,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 +569,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 { @@ -593,8 +603,9 @@ 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; self.session_info.config.effort = effort; self.session_info.config.compaction = compaction; @@ -870,6 +881,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 +940,8 @@ 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(), config: ConfigSnapshot { @@ -945,6 +959,7 @@ mod tests { }), show_thinking: false, show_welcome: true, + status_line: crate::config::StatusLineSegment::DEFAULT.to_vec(), theme_name: "mocha".to_owned(), }, } @@ -957,6 +972,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, @@ -2122,7 +2145,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), @@ -2349,11 +2372,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 +2403,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 +2418,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 +2445,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 +2532,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 +2559,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..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)" --- -" Claude Opus 4.7 │ ⣷ Cancelling ~/projects/demo " +" ~/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 ad494e2d..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)" --- -" Claude Opus 4.7 │ ⣷ Compacting · Esc to interrupt ~/projects/demo " +" ~/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 53617e48..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)" --- -" Claude Opus 4.7 │ Press Ctrl+C again to exit ~/projects/demo " +" ~/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 d19210f5..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)" --- -" Claude Opus 4.7 │ Fix login flow │ Ready ~/projects/demo " +" ~/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 8a851165..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)" --- -" Claude Opus 4.7 │ Ready ~/projects/demo " +" ~/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_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..1af8661e 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 " +" 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..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)" --- -" Claude Opus 4.7 │ ⣷ Streaming · Esc to interrupt ~/projects/demo " +" ~/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 299a5616..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)" --- -" Claude Opus 4.7 │ ⣷ Running bash · Esc to interrupt ~/projects/demo " +" ~/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 7c02526a..f57bafb4 100644 --- a/crates/oxide-code/src/tui/components/status.rs +++ b/crates/oxide-code/src/tui/components/status.rs @@ -1,26 +1,52 @@ -//! Status bar component (model, spinner, working directory). +//! Configurable status line component. -use std::time::Instant; +mod line; + +use std::path::PathBuf; +use std::time::{Duration, 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 crate::util::git; + +use self::line::{StatusLine, StatusLineState}; const TICKS_PER_FRAME: usize = 5; -const MAX_TITLE_WIDTH: usize = 40; + +/// 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); + +/// 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, + line: StatusLine, + current_time_minute: Option, model: String, + effort: Option, title: Option, + usage: Option, cwd: String, + /// `None` collapses every git probe to a no-op. + git_cwd: Option, + git_branch: Option, + 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_branch_probe: Option, + last_pr_probe: Option, status: Status, spinner_frame: usize, tick_counter: usize, @@ -37,12 +63,34 @@ 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_cwd: Option, + git_branch: Option, + ) -> Self { + 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), + current_time_minute, model, + effort, title: None, + usage: None, 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, @@ -61,7 +109,15 @@ impl StatusBar { self.model = model; } - /// Re-skin subsequent renders; the spinner / status state is unaffected. + 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,68 +143,100 @@ impl StatusBar { &self.model } - /// Returns `true` if the spinner frame advanced (caller should repaint). + #[cfg(test)] + pub(crate) fn usage(&self) -> Option { + self.usage + } + + /// Returns `true` when time, animation, git-branch, or pull-request 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(); + let now = Instant::now(); + if self.refresh_git_branch(now) { + 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; + if self.refresh_pull_request(now) { + dirty = true; + } + 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; + } } - false + dirty } -} -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 + 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; + } + 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(self.last_branch_probe, now, GIT_BRANCH_REFRESH_INTERVAL) { + 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 + } - 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); + /// 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; } - 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(" ")); + 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. +fn should_probe(last: Option, now: Instant, interval: Duration) -> bool { + last.is_none_or(|prev| now.duration_since(prev) >= interval) +} +impl StatusBar { + pub(crate) fn render(&self, frame: &mut Frame, area: Rect) { 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 +262,23 @@ 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(), + pull_request: self.pull_request, + status_span: self.status_span(), + }, + width, + ) + } } fn is_animated(status: &Status) -> bool { @@ -183,35 +288,9 @@ 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), - } +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)] @@ -224,8 +303,12 @@ mod tests { fn test_bar() -> StatusBar { StatusBar::new( &Theme::default(), + StatusLineSegment::DEFAULT.to_vec(), "test-model".to_owned(), + Some(Effort::High), "~/test".to_owned(), + None, + Some("main".to_owned()), ) } @@ -258,11 +341,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!( @@ -329,6 +412,43 @@ 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, + 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( + &Theme::default(), + vec![StatusLineSegment::CurrentTime], + "test-model".to_owned(), + None, + "~/test".to_owned(), + None, + 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(); @@ -367,6 +487,116 @@ 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] + 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); + } + + #[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] + 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"); + } + + #[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, but the stamp must still advance 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] + fn should_probe_runs_immediately_when_never_probed() { + assert!(should_probe( + None, + Instant::now(), + GIT_BRANCH_REFRESH_INTERVAL, + )); + } + + #[test] + fn should_probe_skips_within_interval_and_runs_after() { + let earlier = Instant::now(); + assert!(!should_probe( + Some(earlier), + earlier + Duration::from_millis(100), + GIT_BRANCH_REFRESH_INTERVAL, + )); + assert!(should_probe( + Some(earlier), + earlier + GIT_BRANCH_REFRESH_INTERVAL, + GIT_BRANCH_REFRESH_INTERVAL, + )); + } + // ── render ── fn render_status(bar: &mut StatusBar, width: u16) -> TestBackend { @@ -393,7 +623,15 @@ 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(), + "Opus 4.7".into(), + Some(Effort::Xhigh), + cwd.into(), + None, + Some("main".to_owned()), + ); bar.set_title(title.map(ToOwned::to_owned)); bar } @@ -417,6 +655,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 +705,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, + ], + "Opus 4.7".into(), + Some(Effort::Xhigh), + "~/projects/demo".into(), + None, + Some("main".to_owned()), + ); + let output = render_top_row(&mut bar, 120); + let state_at = output.find("Ready").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:?}"); + 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, + ], + "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); + assert!(output.contains("~/projects/demo │ feat/status-line │ 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 +789,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 +802,15 @@ 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, + None, + ); let output = render_top_row(&mut bar, 120); assert!(output.contains("test-model")); assert!(output.contains("Ready")); @@ -521,26 +819,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..dd82c75f --- /dev/null +++ b/crates/oxide-code/src/tui/components/status/line.rs @@ -0,0 +1,406 @@ +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::Muted), + ), + StatusLineSegment::GitBranch => state.git_branch.map(|branch| { + Span::styled( + truncate_to_width(branch, MAX_GIT_BRANCH_WIDTH), + 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), + )), + 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> { + pub(super) model: &'a str, + pub(super) effort: Option, + pub(super) title: Option<&'a str>, + pub(super) usage: Option, + /// Already tilde-expanded, so the renderer must not substitute `~` again. + pub(super) cwd: &'a str, + pub(super) git_branch: Option<&'a str>, + pub(super) pull_request: Option, + /// 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; + } + // 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))); + 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) +} + +/// 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, + StatusLineSegment::CurrentTime => 1, + StatusLineSegment::PullRequest => 2, + StatusLineSegment::GitBranch => 3, + StatusLineSegment::CurrentDir => 4, + StatusLineSegment::SessionCost => 5, + StatusLineSegment::ContextUsed => 6, + StatusLineSegment::Model => 7, + StatusLineSegment::ModelWithEffort => 8, + StatusLineSegment::RunState => 9, + } +} + +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 { + // 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 { + format!("${cost:.4}") + } +} + +#[cfg(test)] +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"), + pull_request: Some(86), + 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() + } + + // ── 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, + pull_request: None, + status_span: Span::raw("Running a very long command name"), + }, + 12, + ); + let text = line + .spans + .into_iter() + .map(|span| span.content) + .collect::(); + + assert_eq!(text, " Running..."); + } + + #[test] + fn render_drops_low_utility_segments_before_usage_model_and_state() { + let segments = vec![ + StatusLineSegment::CurrentTime, + StatusLineSegment::SessionCost, + StatusLineSegment::ContextUsed, + StatusLineSegment::Model, + StatusLineSegment::RunState, + ]; + + 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"); + } + + #[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![ + StatusLineSegment::CurrentTime, + StatusLineSegment::GitBranch, + StatusLineSegment::PullRequest, + StatusLineSegment::RunState, + ]; + + 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"); + } + + // ── 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..0f8cc4d0 100644 --- a/crates/oxide-code/src/tui/components/welcome.rs +++ b/crates/oxide-code/src/tui/components/welcome.rs @@ -337,6 +337,8 @@ 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(), config: ConfigSnapshot { @@ -354,6 +356,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/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..ab6edb10 --- /dev/null +++ b/crates/oxide-code/src/util/git.rs @@ -0,0 +1,194 @@ +//! 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; + +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", + ]) + .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), + stderr = stderr_summary(&output.stderr), + "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()) + } +} + +/// 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) + .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), + stderr = stderr_summary(&output.stderr), + "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() +} + +/// 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::*; + + // ── 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); + } + + // ── 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); + } + + // ── 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("...")); + } +} 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/agent/auto-compaction.md b/docs/design/agent/auto-compaction.md index 1fdba10d..5ba8bed1 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 @@ -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 new file mode 100644 index 00000000..819bb6d7 --- /dev/null +++ b/docs/design/tui/status-line.md @@ -0,0 +1,105 @@ +# 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: + +| Segment | Config value | +| ---------------------- | ------------------- | +| current directory | `current-dir` | +| git branch | `git-branch` | +| pull request | `pull-request` | +| 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: + +- Command-based custom renderers. +- ccusage block / daily totals. +- Five-hour or weekly account-limit display. +- Task-progress segments. +- Persisting cost totals across resume. +- Non-Anthropic provider pricing. + +## Implementation + +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`. + +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. +- 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 + +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", + "pull-request", + "model-with-effort", + "context-used", + "session-cost", + "run-state", + "thread-title", +] +``` + +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. + +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 + +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. +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. +- 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/guide/configuration.md b/docs/guide/configuration.md index c4f0f8df..2fd208fa 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -26,20 +26,30 @@ auto_threshold_tokens = 400000 [tui] show_thinking = true +status_line = [ + "current-dir", + "git-branch", + "pull-request", + "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 +127,35 @@ 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`, `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` → `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. + ### `[tui.theme]`: Terminal theme | Key | Type | Default | Description | @@ -177,6 +207,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..a77045b3 100644 --- a/docs/guide/theming.md +++ b/docs/guide/theming.md @@ -175,7 +175,28 @@ 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 (`│`) | + +### 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` | + +The separator glyph itself (`│`) is fixed; the `separator` slot only controls its color and modifiers. ## Overrides 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..066f6358 --- /dev/null +++ b/docs/research/tui/status-line.md @@ -0,0 +1,64 @@ +# Status Line (Reference) + +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 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. + +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 reference config 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 | +| 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 | + +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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 65e78cf2..220c6908 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 Extensions -- 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