Skip to content

Commit 5449913

Browse files
Merge pull request #164 from jhult/fix/ui-updates
Clean up UI metadata, error modal, and free model cost display
2 parents e458628 + 6adbe2e commit 5449913

15 files changed

Lines changed: 909 additions & 656 deletions

File tree

src-rust/crates/api/src/model_registry.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,10 +449,21 @@ fn parse_snapshot_str(s: &str) -> Option<ParsedSnapshot> {
449449
parse_snapshot_bytes(s.as_bytes())
450450
}
451451

452+
// models.dev uses "opencode" as the top-level key, but our provider config and
453+
// free-model detection expect "opencode-zen". Remap at parse time so a snapshot
454+
// refresh never silently breaks free-model detection again.
455+
fn remap_provider_id(id: &str) -> &str {
456+
match id {
457+
"opencode" => "opencode-zen",
458+
other => other,
459+
}
460+
}
461+
452462
fn transform_api(api: md::ApiJson) -> ParsedSnapshot {
453463
let mut out = ParsedSnapshot::default();
454464

455-
for (provider_id, p) in api.into_iter() {
465+
for (raw_provider_id, p) in api.into_iter() {
466+
let provider_id = remap_provider_id(&raw_provider_id).to_string();
456467
let pid = ProviderId::new(provider_id.clone());
457468

458469
out.providers.insert(provider_id.clone(), ProviderEntry {
@@ -1173,4 +1184,24 @@ mod tests {
11731184
assert_eq!(anthropic.name, "Anthropic");
11741185
assert!(anthropic.env.iter().any(|e| e == "ANTHROPIC_API_KEY"));
11751186
}
1187+
1188+
#[test]
1189+
fn opencode_remapped_to_opencode_zen() {
1190+
// models.dev uses "opencode" as the top-level key; we must normalize it
1191+
// to "opencode-zen" so free-model detection works correctly.
1192+
let json = r#"{"opencode":{"id":"opencode","name":"OpenCode Zen","env":[],"models":{"qwen3.6-plus-free":{"id":"qwen3.6-plus-free","name":"Qwen 3.6 Plus Free","cost":{"input":0,"output":0},"limit":{"context":8192,"output":4096}}}}}"#;
1193+
let parsed = parse_snapshot_str(json).expect("parse must succeed");
1194+
assert!(
1195+
parsed.providers.contains_key("opencode-zen"),
1196+
"provider must be stored as opencode-zen"
1197+
);
1198+
assert!(
1199+
!parsed.providers.contains_key("opencode"),
1200+
"raw opencode key must not appear in registry"
1201+
);
1202+
assert!(
1203+
parsed.models.contains_key("opencode-zen/qwen3.6-plus-free"),
1204+
"model key must use opencode-zen prefix"
1205+
);
1206+
}
11761207
}

src-rust/crates/cli/src/main.rs

Lines changed: 17 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,10 @@ async fn main() -> anyhow::Result<()> {
445445
let base_filter = EnvFilter::try_from_default_env()
446446
.unwrap_or_else(|_| EnvFilter::new(log_level));
447447
let log_filter = base_filter
448-
.add_directive("rmcp::service::client=error".parse().expect("valid rmcp directive"));
448+
.add_directive("rmcp::service::client=error".parse().expect("valid rmcp directive"))
449+
// Suppress error/warn logs from providers and query — errors are already shown as error modals
450+
.add_directive("claurst_api::providers::free=off".parse().expect("valid directive"))
451+
.add_directive("claurst_query=off".parse().expect("valid directive"));
449452
tracing_subscriber::fmt()
450453
.with_env_filter(log_filter)
451454
.with_target(false)
@@ -2008,38 +2011,7 @@ async fn run_interactive(
20082011

20092012
// Enter => submit input (but NOT when ANY dialog/overlay is open —
20102013
// dialogs handle their own Enter in handle_key_event).
2011-
let any_dialog_open = app.connect_dialog.visible
2012-
|| app.import_config_picker.visible
2013-
|| app.import_config_dialog.visible
2014-
|| app.key_input_dialog.visible
2015-
|| app.custom_provider_dialog.visible
2016-
|| app.device_auth_dialog.visible
2017-
|| app.command_palette.visible
2018-
|| app.model_picker.visible
2019-
|| app.onboarding_dialog.visible
2020-
|| app.bypass_permissions_dialog.visible
2021-
|| app.file_injection_dialog.visible
2022-
|| app.ask_user_dialog.visible
2023-
|| app.settings_screen.visible
2024-
|| app.export_dialog.visible
2025-
|| app.theme_screen.visible
2026-
|| app.stats_dialog.open
2027-
|| app.invalid_config_dialog.visible
2028-
|| app.context_viz.visible
2029-
|| app.mcp_approval.visible
2030-
|| app.session_browser.visible
2031-
|| app.session_branching.visible
2032-
|| app.tasks_overlay.visible
2033-
|| app.mcp_view.open
2034-
|| app.agents_menu.open
2035-
|| app.diff_viewer.open
2036-
|| app.help_overlay.visible
2037-
|| app.history_search_overlay.visible
2038-
|| app.rewind_flow.visible
2039-
|| app.show_help
2040-
|| app.context_menu_state.is_some()
2041-
|| app.permission_request.is_some()
2042-
|| app.global_search.open;
2014+
let any_dialog_open = app.any_modal_open();
20432015
if key.code == KeyCode::Enter && app.is_streaming && !any_dialog_open {
20442016
// Queue the message: it will auto-submit once the
20452017
// current turn finishes (issue #149).
@@ -3523,8 +3495,17 @@ async fn run_interactive(
35233495

35243496
if task_finished {
35253497
if let Some((handle, msgs_arc)) = current_query.take() {
3526-
// Get the outcome (ignore errors for now)
3527-
let _ = handle.await;
3498+
// Get the outcome and handle errors
3499+
if let Ok(QueryOutcome::Error(err)) = handle.await {
3500+
while app.notifications.current_is_error() {
3501+
app.notifications.dismiss_current();
3502+
}
3503+
app.notifications.push(
3504+
claurst_tui::notifications::NotificationKind::Error,
3505+
err.to_string(),
3506+
None,
3507+
);
3508+
}
35283509
// Sync the updated conversation back to our local vector
35293510
messages = msgs_arc.lock().await.clone();
35303511
session.messages = messages.clone();
@@ -3738,7 +3719,7 @@ async fn run_interactive(
37383719
tool_ctx.mcp_manager = new_mcp_manager.clone();
37393720
app.mcp_manager = new_mcp_manager.clone();
37403721
tools_arc = build_tools_with_mcp(new_mcp_manager.clone());
3741-
if app.mcp_view.open {
3722+
if app.mcp_view.visible {
37423723
app.refresh_mcp_view();
37433724
}
37443725

src-rust/crates/core/src/lib.rs

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ pub mod format_utils;
3434
pub mod crypto_utils;
3535
pub mod status_notices;
3636
pub mod auto_mode;
37+
pub mod spinner;
38+
pub use spinner::{SPINNER_VERBS, TURN_COMPLETION_VERBS, sample_spinner_verb, sample_completion_verb};
3739

3840
// Remote session sync and cloud session API (T3-1, T3-2).
3941
pub mod remote_session;
@@ -1277,7 +1279,6 @@ pub mod config {
12771279
}
12781280

12791281

1280-
12811282
/// Resolve the effective max-tokens.
12821283
pub fn effective_max_tokens(&self) -> u32 {
12831284
self.max_tokens
@@ -3215,8 +3216,36 @@ pub mod cost {
32153216
use std::sync::atomic::{AtomicU64, Ordering};
32163217
use std::sync::Arc;
32173218

3219+
/// Free upstream provider IDs used in the free provider system.
3220+
///
3221+
/// These overlap with providers that appear in `api_key_env_vars_for_provider`.
3222+
/// When adding a provider to one, check whether it also belongs in the other.
3223+
const FREE_UPSTREAM_IDS: &[&str] = &[
3224+
"groq",
3225+
"cerebras",
3226+
"google",
3227+
"mistral",
3228+
"sambanova",
3229+
"nvidia",
3230+
"cohere",
3231+
"openrouter",
3232+
"opencode-zen",
3233+
"zai",
3234+
"zhipuai",
3235+
];
3236+
3237+
/// Check if a model name is an upstream-prefixed free model (e.g., "groq/llama-3.3-70b-versatile").
3238+
fn is_free_upstream_model(model: &str) -> bool {
3239+
for upstream_id in FREE_UPSTREAM_IDS {
3240+
if model.starts_with(&format!("{}/", upstream_id)) {
3241+
return true;
3242+
}
3243+
}
3244+
false
3245+
}
3246+
32183247
/// Per-model pricing tiers (USD per million tokens).
3219-
#[derive(Debug, Clone, Copy)]
3248+
#[derive(Debug, Clone, Copy, PartialEq)]
32203249
pub struct ModelPricing {
32213250
pub input_per_mtk: f64,
32223251
pub output_per_mtk: f64,
@@ -3249,14 +3278,27 @@ pub mod cost {
32493278
cache_read_per_mtk: 0.08,
32503279
};
32513280

3281+
/// Free model pricing (no cost).
3282+
pub const FREE: Self = Self {
3283+
input_per_mtk: 0.0,
3284+
output_per_mtk: 0.0,
3285+
cache_creation_per_mtk: 0.0,
3286+
cache_read_per_mtk: 0.0,
3287+
};
3288+
32523289
/// Default pricing is Opus (most capable, highest cost).
32533290
pub fn default_pricing() -> Self {
32543291
Self::OPUS
32553292
}
32563293

32573294
/// Pick pricing based on model name substring matching.
32583295
pub fn for_model(model: &str) -> Self {
3259-
if model.contains("opus") {
3296+
// Check for free models first (those with "-free" suffix, "free/" prefix, or upstream-prefixed free model)
3297+
if model.ends_with("-free") || model.starts_with("free/") {
3298+
Self::FREE
3299+
} else if is_free_upstream_model(model) {
3300+
Self::FREE
3301+
} else if model.contains("opus") {
32603302
Self::OPUS
32613303
} else if model.contains("haiku") {
32623304
Self::HAIKU
@@ -3359,7 +3401,9 @@ pub mod cost {
33593401
pub fn summary(&self) -> String {
33603402
let cost = self.total_cost_usd();
33613403
let total = self.total_tokens();
3362-
if cost < 0.01 {
3404+
if cost == 0.0 {
3405+
format!("{} tokens ($0.00)", total)
3406+
} else if cost < 0.01 {
33633407
format!("{} tokens (<$0.01)", total)
33643408
} else {
33653409
format!("{} tokens (${:.2})", total, cost)
@@ -4343,6 +4387,43 @@ mod tests {
43434387
assert_eq!(tracker.total_cost_usd(), 0.0);
43444388
}
43454389

4390+
#[test]
4391+
fn test_cost_tracker_free_model() {
4392+
let tracker = CostTracker::with_model("deepseek-v4-flash-free");
4393+
tracker.add_usage(1000, 500, 200, 100);
4394+
// Free models should have zero cost even with token usage
4395+
assert_eq!(tracker.total_cost_usd(), 0.0);
4396+
}
4397+
4398+
#[test]
4399+
fn test_model_pricing_free_variants() {
4400+
// Test that models ending with -free use FREE pricing
4401+
assert_eq!(cost::ModelPricing::for_model("deepseek-v4-flash-free"), cost::ModelPricing::FREE);
4402+
assert_eq!(cost::ModelPricing::for_model("zen/minimax-m2.5-free"), cost::ModelPricing::FREE);
4403+
4404+
// Test that models starting with free/ use FREE pricing
4405+
assert_eq!(cost::ModelPricing::for_model("free/auto"), cost::ModelPricing::FREE);
4406+
assert_eq!(cost::ModelPricing::for_model("free/some-model"), cost::ModelPricing::FREE);
4407+
4408+
// Test that upstream-prefixed free models use FREE pricing
4409+
assert_eq!(cost::ModelPricing::for_model("groq/llama-3.3-70b-versatile"), cost::ModelPricing::FREE);
4410+
assert_eq!(cost::ModelPricing::for_model("cerebras/qwen-3-235b-a22b-instruct-2507"), cost::ModelPricing::FREE);
4411+
assert_eq!(cost::ModelPricing::for_model("google/gemini-2.5-flash"), cost::ModelPricing::FREE);
4412+
assert_eq!(cost::ModelPricing::for_model("mistral/mistral-large-latest"), cost::ModelPricing::FREE);
4413+
assert_eq!(cost::ModelPricing::for_model("sambanova/Meta-Llama-3.3-70B-Instruct"), cost::ModelPricing::FREE);
4414+
assert_eq!(cost::ModelPricing::for_model("nvidia/meta/llama-3.3-70b-instruct"), cost::ModelPricing::FREE);
4415+
assert_eq!(cost::ModelPricing::for_model("cohere/command-r-plus"), cost::ModelPricing::FREE);
4416+
assert_eq!(cost::ModelPricing::for_model("openrouter/free"), cost::ModelPricing::FREE);
4417+
assert_eq!(cost::ModelPricing::for_model("opencode-zen/minimax-m2.5-free"), cost::ModelPricing::FREE);
4418+
assert_eq!(cost::ModelPricing::for_model("zai/glm-4.6"), cost::ModelPricing::FREE);
4419+
assert_eq!(cost::ModelPricing::for_model("zhipuai/glm-4.5"), cost::ModelPricing::FREE);
4420+
4421+
// Test that other models use their appropriate pricing
4422+
assert_eq!(cost::ModelPricing::for_model("claude-opus"), cost::ModelPricing::OPUS);
4423+
assert_eq!(cost::ModelPricing::for_model("claude-haiku"), cost::ModelPricing::HAIKU);
4424+
assert_eq!(cost::ModelPricing::for_model("claude-sonnet"), cost::ModelPricing::SONNET);
4425+
}
4426+
43464427
#[test]
43474428
fn managed_agent_config_serde_round_trip() {
43484429
let cfg = ManagedAgentConfig {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/// Spinner verbs displayed during processing.
2+
pub const SPINNER_VERBS: &[&str] = &[
3+
"Accomplishing", "Actioning", "Actualizing", "Architecting", "Baking", "Beaming",
4+
"Beboppin'", "Befuddling", "Billowing", "Blanching", "Bloviating", "Boogieing",
5+
"Boondoggling", "Booping", "Bootstrapping", "Brewing", "Bunning", "Burrowing",
6+
"Calculating", "Canoodling", "Caramelizing", "Cascading", "Catapulting", "Cerebrating",
7+
"Channeling", "Choreographing", "Churning", "Clauding", "Coalescing", "Cogitating",
8+
"Combobulating", "Composing", "Computing", "Concocting", "Considering", "Contemplating",
9+
"Cooking", "Crafting", "Creating", "Crunching", "Crystallizing", "Cultivating",
10+
"Deciphering", "Deliberating", "Determining", "Dilly-dallying", "Discombobulating",
11+
"Doing", "Doodling", "Drizzling", "Ebbing", "Effecting", "Elucidating", "Embellishing",
12+
"Enchanting", "Envisioning", "Evaporating", "Fermenting", "Fiddle-faddling", "Finagling",
13+
"Flambéing", "Flibbertigibbeting", "Flowing", "Flummoxing", "Fluttering", "Forging",
14+
"Forming", "Frolicking", "Frosting", "Gallivanting", "Galloping", "Garnishing",
15+
"Generating", "Gesticulating", "Germinating", "Gitifying", "Grooving", "Gusting",
16+
"Harmonizing", "Hashing", "Hatching", "Herding", "Honking", "Hullaballooing",
17+
"Hyperspacing", "Ideating", "Imagining", "Improvising", "Incubating", "Inferring",
18+
"Infusing", "Ionizing", "Jitterbugging", "Julienning", "Kneading", "Leavening",
19+
"Levitating", "Lollygagging", "Manifesting", "Marinating", "Meandering", "Metamorphosing",
20+
"Misting", "Moonwalking", "Moseying", "Mulling", "Mustering", "Musing", "Nebulizing",
21+
"Nesting", "Newspapering", "Noodling", "Nucleating", "Orbiting", "Orchestrating",
22+
"Osmosing", "Perambulating", "Percolating", "Perusing", "Philosophising",
23+
"Photosynthesizing", "Pollinating", "Pondering", "Pontificating", "Pouncing",
24+
"Precipitating", "Prestidigitating", "Processing", "Proofing", "Propagating", "Puttering",
25+
"Puzzling", "Quantumizing", "Razzle-dazzling", "Razzmatazzing", "Recombobulating",
26+
"Reticulating", "Roosting", "Ruminating", "Sautéing", "Scampering", "Schlepping",
27+
"Scurrying", "Seasoning", "Shenaniganing", "Shimmying", "Simmering", "Skedaddling",
28+
"Sketching", "Slithering", "Smooshing", "Sock-hopping", "Spelunking", "Spinning",
29+
"Sprouting", "Stewing", "Sublimating", "Swirling", "Swooping", "Symbioting",
30+
"Synthesizing", "Tempering", "Thinking", "Thundering", "Tinkering", "Tomfoolering",
31+
"Topsy-turvying", "Transfiguring", "Transmuting", "Twisting", "Undulating", "Unfurling",
32+
"Unravelling", "Vibing", "Waddling", "Wandering", "Warping", "Whatchamacalliting",
33+
"Whirlpooling", "Whirring", "Whisking", "Wibbling", "Working", "Wrangling", "Zesting",
34+
"Zigzagging",
35+
];
36+
37+
/// Past-tense verbs shown in the status row after a turn completes.
38+
pub const TURN_COMPLETION_VERBS: &[&str] = &[
39+
"Baked", "Brewed", "Churned", "Cogitated", "Cooked", "Crunched",
40+
"Pondered", "Processed", "Worked",
41+
];
42+
43+
/// Select a random spinner verb.
44+
pub fn sample_spinner_verb(seed: usize) -> &'static str {
45+
SPINNER_VERBS[seed % SPINNER_VERBS.len()]
46+
}
47+
48+
/// Select a random completion verb.
49+
pub fn sample_completion_verb(seed: usize) -> &'static str {
50+
TURN_COMPLETION_VERBS[seed % TURN_COMPLETION_VERBS.len()]
51+
}

src-rust/crates/query/src/lib.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,8 @@ const MAX_TOKENS_RECOVERY_MSG: &str =
689689
you were doing. Pick up mid-thought if that is where the cut happened. \
690690
Break remaining work into smaller pieces.";
691691

692+
// Spinner verbs are imported from claurst_core::spinner
693+
692694
/// Run the agentic query loop.
693695
///
694696
/// This sends the conversation to the API, handles tool calls in a loop, and
@@ -1055,9 +1057,12 @@ pub async fn run_query_loop(
10551057
if let Some(provider) = provider {
10561058
debug!(provider = %provider_id_str, model = %model_id_str, "Dispatching to non-Anthropic provider");
10571059

1058-
// Notify TUI that we're calling the provider
1060+
// Notify TUI that we're calling the provider using a random spinner verb
10591061
if let Some(ref tx) = event_tx {
1060-
let _ = tx.send(QueryEvent::Status(format!("Calling {} ({})…", provider.name(), model_id_str)));
1062+
use claurst_core::sample_spinner_verb;
1063+
let seed = (provider_id_str.len() ^ model_id_str.len()) as usize;
1064+
let verb = sample_spinner_verb(seed);
1065+
let _ = tx.send(QueryEvent::Status(format!("✳ {}…", verb)));
10611066
}
10621067

10631068
// Build ProviderRequest from the already-assembled request data.

src-rust/crates/tui/src/agents_view.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ pub enum AgentsRoute {
227227
/// Full state for the agents menu overlay.
228228
#[derive(Debug, Clone)]
229229
pub struct AgentsMenuState {
230-
pub open: bool,
230+
pub visible: bool,
231231
pub route: AgentsRoute,
232232
pub definitions: Vec<AgentDefinition>,
233233
pub active_agents: Vec<AgentInfo>,
@@ -240,7 +240,7 @@ pub struct AgentsMenuState {
240240
impl AgentsMenuState {
241241
pub fn new() -> Self {
242242
Self {
243-
open: false,
243+
visible: false,
244244
route: AgentsRoute::List,
245245
definitions: Vec::new(),
246246
active_agents: Vec::new(),
@@ -257,11 +257,11 @@ impl AgentsMenuState {
257257
self.list_scroll = 0;
258258
self.route = AgentsRoute::List;
259259
self.project_root = Some(project_root.to_path_buf());
260-
self.open = true;
260+
self.visible = true;
261261
}
262262

263263
pub fn close(&mut self) {
264-
self.open = false;
264+
self.visible = false;
265265
}
266266

267267
pub fn select_prev(&mut self) {
@@ -560,7 +560,7 @@ fn write_editor_to_disk(path: &Path, editor: &AgentEditorState) -> Result<(), St
560560

561561
/// Render the agents menu overlay.
562562
pub fn render_agents_menu(state: &AgentsMenuState, area: Rect, buf: &mut Buffer) {
563-
if !state.open {
563+
if !state.visible {
564564
return;
565565
}
566566

0 commit comments

Comments
 (0)