diff --git a/Cargo.lock b/Cargo.lock index b654ebe..2ef4d1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,6 +679,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "tracing", ] [[package]] diff --git a/crates/harness-app/Cargo.toml b/crates/harness-app/Cargo.toml index ceb4f67..2eb3ea2 100644 --- a/crates/harness-app/Cargo.toml +++ b/crates/harness-app/Cargo.toml @@ -14,6 +14,7 @@ tokio = { workspace = true } tokio-util = { workspace = true } dirs = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/harness-app/src/lib.rs b/crates/harness-app/src/lib.rs index d9fd86c..44073c2 100644 --- a/crates/harness-app/src/lib.rs +++ b/crates/harness-app/src/lib.rs @@ -19,7 +19,7 @@ use harness_core::tool::ToolRegistry; use harness_core::types::{ Message, MessageId, ModelRef, Part, PartBody, PartId, Session, SessionId, }; -use harness_providers::{AnthropicProvider, OpenAiProvider, ProviderRegistry}; +use harness_providers::{AnthropicProvider, ModelCatalog, OpenAiProvider, ProviderRegistry}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -83,6 +83,7 @@ struct EngineInner { permissions: Arc, tools: ToolRegistry, providers: ProviderRegistry, + catalog: ModelCatalog, cwd: PathBuf, data_dir: PathBuf, runs: Mutex>, @@ -97,7 +98,9 @@ impl EngineHandle { pub fn init(cwd: PathBuf) -> Result { let path = db_path(&cwd); let store = Store::open(&path)?; - Self::new(cwd, store) + let handle = Self::new(cwd, store)?; + handle.spawn_catalog_refresh(); + Ok(handle) } /// In-memory store — useful for tests and ephemeral sessions. @@ -143,6 +146,10 @@ impl EngineHandle { .join("ai-harness") .join("tool-output"); + // No network at construction: use the cached copy if fresh, else the baked snapshot. + // `init` warms the cache in the background for the next launch. + let catalog = ModelCatalog::load_cached_or_baked(&ModelCatalog::default_cache_path()); + Ok(Self { inner: Arc::new(EngineInner { config, @@ -151,6 +158,7 @@ impl EngineHandle { permissions, tools, providers, + catalog, cwd, data_dir, runs: Mutex::new(HashMap::new()), @@ -158,6 +166,15 @@ impl EngineHandle { }) } + /// Fire-and-forget refresh of the models.dev cache so the next launch has current pricing. + fn spawn_catalog_refresh(&self) { + tokio::spawn(async { + if let Err(e) = ModelCatalog::refresh_default_cache().await { + tracing::debug!(error = %e, "models.dev refresh failed; using cached/baked metadata"); + } + }); + } + pub fn bus(&self) -> EventBus { self.inner.bus.clone() } @@ -254,8 +271,7 @@ impl EngineHandle { temperature: None, max_steps: DEFAULT_MAX_STEPS, instructions: self.inner.config.instructions.clone(), - // Populated from models.dev metadata in the follow-up increment. - cost: None, + cost: Some(self.inner.catalog.cost(provider_id, model_id)), }; // Reserve the run slot *before* spawning. If we inserted after spawning, a run that diff --git a/crates/harness-providers/assets/models-snapshot.json b/crates/harness-providers/assets/models-snapshot.json new file mode 100644 index 0000000..932efa7 --- /dev/null +++ b/crates/harness-providers/assets/models-snapshot.json @@ -0,0 +1,67 @@ +{ + "anthropic": { + "id": "anthropic", + "name": "Anthropic", + "models": { + "claude-sonnet-4-5": { + "id": "claude-sonnet-4-5", + "reasoning": true, + "tool_call": true, + "attachment": true, + "cost": { "input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75 }, + "limit": { "context": 200000, "output": 64000 } + }, + "claude-opus-4-1": { + "id": "claude-opus-4-1", + "reasoning": true, + "tool_call": true, + "attachment": true, + "cost": { "input": 15, "output": 75, "cache_read": 1.5, "cache_write": 18.75 }, + "limit": { "context": 200000, "output": 32000 } + }, + "claude-haiku-4-5": { + "id": "claude-haiku-4-5", + "reasoning": true, + "tool_call": true, + "attachment": true, + "cost": { "input": 1, "output": 5, "cache_read": 0.1, "cache_write": 1.25 }, + "limit": { "context": 200000, "output": 64000 } + } + } + }, + "openai": { + "id": "openai", + "name": "OpenAI", + "models": { + "gpt-4o": { + "id": "gpt-4o", + "tool_call": true, + "attachment": true, + "cost": { "input": 2.5, "output": 10, "cache_read": 1.25 }, + "limit": { "context": 128000, "output": 16384 } + }, + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "tool_call": true, + "attachment": true, + "cost": { "input": 0.15, "output": 0.6, "cache_read": 0.075 }, + "limit": { "context": 128000, "output": 16384 } + }, + "gpt-5": { + "id": "gpt-5", + "reasoning": true, + "tool_call": true, + "attachment": true, + "cost": { "input": 1.25, "output": 10, "cache_read": 0.125 }, + "limit": { "context": 400000, "output": 128000 } + }, + "o3": { + "id": "o3", + "reasoning": true, + "tool_call": true, + "cost": { "input": 2, "output": 8, "cache_read": 0.5 }, + "limit": { "context": 200000, "output": 100000 } + } + } + } +} diff --git a/crates/harness-providers/src/lib.rs b/crates/harness-providers/src/lib.rs index a91209c..5f08c31 100644 --- a/crates/harness-providers/src/lib.rs +++ b/crates/harness-providers/src/lib.rs @@ -1,10 +1,12 @@ pub mod anthropic; pub mod auth; pub mod codec; +pub mod modelsdev; pub mod openai; pub mod registry; pub use anthropic::AnthropicProvider; pub use auth::{AuthRecord, AuthStorage}; +pub use modelsdev::ModelCatalog; pub use openai::OpenAiProvider; pub use registry::ProviderRegistry; diff --git a/crates/harness-providers/src/modelsdev.rs b/crates/harness-providers/src/modelsdev.rs new file mode 100644 index 0000000..6d11bc2 --- /dev/null +++ b/crates/harness-providers/src/modelsdev.rs @@ -0,0 +1,296 @@ +//! models.dev metadata: per-model context/output limits, pricing, and capability flags. +//! +//! At runtime we prefer a cached copy of `https://models.dev/api.json` (refreshed every 24h); +//! if the cache is missing/stale and the network is unavailable, we fall back to a baked +//! snapshot so cost display and limits still work offline. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use harness_core::types::{ModelCost, ModelInfo, ModelRef}; +use serde::Deserialize; + +const API_URL: &str = "https://models.dev/api.json"; +const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); +/// Baked fallback, refreshed manually. Keeps cost/limits working with no cache and no network. +const SNAPSHOT: &str = include_str!("../assets/models-snapshot.json"); + +/// models.dev top-level shape: `{ provider_id: { models: { model_id: {...} } } }`. Unknown +/// keys (provider metadata, per-model extras) are ignored. +#[derive(Deserialize)] +struct ApiProvider { + #[serde(default)] + models: HashMap, +} + +#[derive(Deserialize)] +struct ApiModel { + #[serde(default)] + reasoning: bool, + #[serde(default)] + tool_call: bool, + #[serde(default)] + attachment: bool, + #[serde(default)] + cost: ApiCost, + #[serde(default)] + limit: ApiLimit, +} + +#[derive(Deserialize, Default)] +struct ApiCost { + #[serde(default)] + input: f64, + #[serde(default)] + output: f64, + #[serde(default)] + cache_read: f64, + #[serde(default)] + cache_write: f64, +} + +#[derive(Deserialize, Default)] +struct ApiLimit { + #[serde(default)] + context: u64, + #[serde(default)] + output: u64, +} + +/// Parsed metadata keyed by `(provider_id, model_id)`. +#[derive(Debug, Clone, Default)] +pub struct ModelCatalog { + models: HashMap<(String, String), ModelInfo>, +} + +impl ModelCatalog { + /// Parses a models.dev `api.json` document. + pub fn from_api_json(bytes: &[u8]) -> Result { + let raw: HashMap = serde_json::from_slice(bytes)?; + let mut models = HashMap::new(); + for (provider_id, provider) in raw { + for (model_id, m) in provider.models { + let info = ModelInfo { + model: ModelRef::new(provider_id.clone(), model_id.clone()), + context_limit: m.limit.context, + output_limit: m.limit.output, + cost: ModelCost { + input: m.cost.input, + output: m.cost.output, + cache_read: m.cost.cache_read, + cache_write: m.cost.cache_write, + }, + reasoning: m.reasoning, + tool_call: m.tool_call, + attachment: m.attachment, + }; + models.insert((provider_id.clone(), model_id), info); + } + } + Ok(Self { models }) + } + + /// The baked fallback snapshot — always available, never fails. + pub fn baked() -> Self { + Self::from_api_json(SNAPSHOT.as_bytes()).expect("baked models snapshot must parse") + } + + pub fn get(&self, provider: &str, model: &str) -> Option<&ModelInfo> { + self.models.get(&(provider.to_string(), model.to_string())) + } + + /// Pricing for a model, or zero-cost if unknown. + pub fn cost(&self, provider: &str, model: &str) -> ModelCost { + self.get(provider, model) + .map(|m| m.cost) + .unwrap_or_default() + } + + pub fn len(&self) -> usize { + self.models.len() + } + + pub fn is_empty(&self) -> bool { + self.models.is_empty() + } + + /// Default cache location: `~/.cache/ai-harness/models.json`. + pub fn default_cache_path() -> PathBuf { + dirs::cache_dir() + .unwrap_or_else(std::env::temp_dir) + .join("ai-harness") + .join("models.json") + } + + /// Loads from the cache if present and younger than [`CACHE_TTL`]; otherwise returns the + /// baked snapshot. Never touches the network — call [`ModelCatalog::refresh`] for that. + pub fn load_cached_or_baked(cache_path: &Path) -> Self { + if cache_fresh(cache_path, CACHE_TTL, SystemTime::now()) { + if let Ok(bytes) = std::fs::read(cache_path) { + if let Ok(catalog) = Self::from_api_json(&bytes) { + if !catalog.is_empty() { + return catalog; + } + } + } + } + Self::baked() + } + + /// Refreshes the default cache location with a fresh HTTP client. Convenience wrapper for + /// callers (the app) that don't want to depend on `reqwest` directly. + pub async fn refresh_default_cache() -> Result { + let client = reqwest::Client::new(); + Self::refresh(&client, &Self::default_cache_path()).await + } + + /// Fetches the latest metadata and writes it to `cache_path` (best-effort). Returns the + /// freshly-parsed catalog. Intended to run in the background so the *next* launch is current. + pub async fn refresh( + client: &reqwest::Client, + cache_path: &Path, + ) -> Result { + let bytes = client + .get(API_URL) + .send() + .await + .map_err(|e| RefreshError::Network(e.to_string()))? + .error_for_status() + .map_err(|e| RefreshError::Network(e.to_string()))? + .bytes() + .await + .map_err(|e| RefreshError::Network(e.to_string()))?; + + let catalog = + Self::from_api_json(&bytes).map_err(|e| RefreshError::Parse(e.to_string()))?; + + if let Some(parent) = cache_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(cache_path, &bytes) { + tracing::warn!(error = %e, "failed to cache models.dev metadata"); + } + Ok(catalog) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RefreshError { + #[error("network: {0}")] + Network(String), + #[error("parse: {0}")] + Parse(String), +} + +/// True when `path` exists and was modified within `ttl` of `now`. +fn cache_fresh(path: &Path, ttl: Duration, now: SystemTime) -> bool { + let Ok(modified) = std::fs::metadata(path).and_then(|m| m.modified()) else { + return false; + }; + match now.duration_since(modified) { + Ok(age) => age < ttl, + Err(_) => true, // modified in the future (clock skew) — treat as fresh + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &str = r#"{ + "anthropic": { + "id": "anthropic", + "name": "Anthropic", + "models": { + "claude-sonnet-4-5": { + "id": "claude-sonnet-4-5", + "reasoning": true, + "tool_call": true, + "attachment": true, + "cost": {"input": 3, "output": 15, "cache_read": 0.3, "cache_write": 3.75}, + "limit": {"context": 200000, "output": 64000} + } + } + }, + "openai": { + "id": "openai", + "models": { + "gpt-4o": { + "id": "gpt-4o", + "tool_call": true, + "cost": {"input": 2.5, "output": 10}, + "limit": {"context": 128000, "output": 16384} + } + } + } + }"#; + + #[test] + fn parses_providers_and_models() { + let catalog = ModelCatalog::from_api_json(FIXTURE.as_bytes()).unwrap(); + assert_eq!(catalog.len(), 2); + + let sonnet = catalog.get("anthropic", "claude-sonnet-4-5").unwrap(); + assert_eq!(sonnet.context_limit, 200_000); + assert_eq!(sonnet.output_limit, 64_000); + assert_eq!(sonnet.cost.input, 3.0); + assert_eq!(sonnet.cost.cache_write, 3.75); + assert!(sonnet.reasoning && sonnet.tool_call && sonnet.attachment); + } + + #[test] + fn missing_cost_fields_default_to_zero() { + let catalog = ModelCatalog::from_api_json(FIXTURE.as_bytes()).unwrap(); + let gpt = catalog.get("openai", "gpt-4o").unwrap(); + assert_eq!(gpt.cost.cache_read, 0.0); + assert_eq!(gpt.cost.cache_write, 0.0); + assert!(!gpt.reasoning); // absent → false + assert!(gpt.tool_call); + } + + #[test] + fn cost_helper_is_zero_for_unknown_model() { + let catalog = ModelCatalog::from_api_json(FIXTURE.as_bytes()).unwrap(); + assert_eq!(catalog.cost("openai", "nonexistent"), ModelCost::default()); + assert_eq!(catalog.cost("anthropic", "claude-sonnet-4-5").input, 3.0); + } + + #[test] + fn baked_snapshot_parses_and_is_nonempty() { + let catalog = ModelCatalog::baked(); + assert!(!catalog.is_empty()); + } + + #[test] + fn cache_freshness_respects_ttl() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("models.json"); + assert!(!cache_fresh(&path, CACHE_TTL, SystemTime::now())); // missing + + std::fs::write(&path, "{}").unwrap(); + let now = SystemTime::now(); + assert!(cache_fresh(&path, CACHE_TTL, now)); + // A "now" far in the future makes the file look stale. + let future = now + Duration::from_secs(48 * 60 * 60); + assert!(!cache_fresh(&path, CACHE_TTL, future)); + } + + #[test] + fn load_cached_or_baked_falls_back_when_cache_absent() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("models.json"); + let catalog = ModelCatalog::load_cached_or_baked(&path); + assert!(!catalog.is_empty()); // baked fallback + } + + #[test] + fn load_cached_or_baked_reads_fresh_cache() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("models.json"); + std::fs::write(&path, FIXTURE).unwrap(); + let catalog = ModelCatalog::load_cached_or_baked(&path); + assert_eq!(catalog.len(), 2); + assert!(catalog.get("anthropic", "claude-sonnet-4-5").is_some()); + } +} diff --git a/crates/harness-tui/src/input.rs b/crates/harness-tui/src/input.rs index 26ac273..ee8097c 100644 --- a/crates/harness-tui/src/input.rs +++ b/crates/harness-tui/src/input.rs @@ -187,6 +187,8 @@ pub async fn apply_action(action: InputAction, state: &mut AppState, engine: &En state.session_id = Some(id); state.messages.clear(); state.scroll_offset = 0; + state.session_cost = 0.0; + state.session_tokens = 0; state.dirty = true; } Err(e) => tracing::error!(error = %e, "failed to create session"), diff --git a/crates/harness-tui/src/render.rs b/crates/harness-tui/src/render.rs index 8cd3ffd..43994da 100644 --- a/crates/harness-tui/src/render.rs +++ b/crates/harness-tui/src/render.rs @@ -171,11 +171,25 @@ fn render_status(frame: &mut Frame, state: &AppState, area: Rect) { } else { "Ctrl+S: sessions | Esc: abort | Ctrl+C: quit" }; - let text = format!("{spinner}{status} · {hints}"); + let usage = format!( + "{} · ${:.4}", + format_tokens(state.session_tokens), + state.session_cost + ); + let text = format!("{spinner}{status} · {usage} · {hints}"); let paragraph = Paragraph::new(text).style(Style::new().fg(Color::Gray)); frame.render_widget(paragraph, area); } +/// Compact token count: `1234` → `1.2k`, `2_000_000` → `2.0M`. +fn format_tokens(n: u64) -> String { + match n { + 0..=999 => format!("{n} tok"), + 1_000..=999_999 => format!("{:.1}k tok", n as f64 / 1_000.0), + _ => format!("{:.1}M tok", n as f64 / 1_000_000.0), + } +} + fn render_permission_modal( frame: &mut Frame, request: &harness_core::event::PermissionRequest, diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap index de758c7..bc0e440 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 277 +assertion_line: 300 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap index e83d418..348638e 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 397 +assertion_line: 430 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap index 82c4456..7411661 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 449 +assertion_line: 487 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap index c35c01c..f7c0a66 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 330 +assertion_line: 357 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap index 24d9403..c0ec904 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 376 +assertion_line: 407 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap index 0b90d93..f5f4ce9 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 353 +assertion_line: 382 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap index f4cb1ee..4cc4add 100644 --- a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap @@ -1,6 +1,6 @@ --- source: crates/harness-tui/src/render.rs -assertion_line: 306 +assertion_line: 331 expression: buffer_to_string(terminal.backend()) --- new session · orchestrator · anthropic/claude-sonnet-4-5 @@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend()) ┌ input ───────────────────────────────────────────────────────────────────────┐ │ │ └──────────────────────────────────────────────────────────────────────────────┘ -idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit +idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/state.rs b/crates/harness-tui/src/state.rs index 1b00b18..cb507e1 100644 --- a/crates/harness-tui/src/state.rs +++ b/crates/harness-tui/src/state.rs @@ -105,6 +105,10 @@ pub struct AppState { pub ctrl_c_pressed: bool, /// Width the currently cached part lines were wrapped to; a change invalidates them. pub render_width: u16, + /// Accumulated session cost in USD, from `SessionCreated`/`SessionUpdated` events. + pub session_cost: f64, + /// Accumulated session tokens (input + output), for the status bar. + pub session_tokens: u64, } impl AppState { @@ -125,6 +129,8 @@ impl AppState { dirty: true, ctrl_c_pressed: false, render_width: 0, + session_cost: 0.0, + session_tokens: 0, } } @@ -147,6 +153,8 @@ impl AppState { self.session_title = session.title; self.agent = session.agent; self.model_ref = format!("{}/{}", session.model.provider_id, session.model.model_id); + self.session_cost = session.cost; + self.session_tokens = session.usage.input + session.usage.output; self.messages.clear(); let mut messages = messages; @@ -171,6 +179,8 @@ impl AppState { self.agent = session.agent; self.model_ref = format!("{}/{}", session.model.provider_id, session.model.model_id); + self.session_cost = session.cost; + self.session_tokens = session.usage.input + session.usage.output; self.dirty = true; } }