diff --git a/CHANGELOG.md b/CHANGELOG.md index 1325f7c2..277b3da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ title: Changelog description: Release notes for claude-code-proxy. --- +## Unreleased + +- Wide monitor tables show effective non-default provider execution modes. + Codex priority service displays as `FAST`, Codex flex as `FLEX`, and Cursor + fast requests as `FAST`, while the model column keeps the resolved model ID. + ## v0.1.30 (2026-07-31) - OpenAI-compatible clients can use Kimi, Grok, Cursor, or Codex through the diff --git a/docs/src/content/docs/using/monitor-tui.md b/docs/src/content/docs/using/monitor-tui.md index ad70c795..f07feab4 100644 --- a/docs/src/content/docs/using/monitor-tui.md +++ b/docs/src/content/docs/using/monitor-tui.md @@ -11,6 +11,8 @@ description: Use the claude-code-proxy monitor to inspect sessions, active and r - Sessions grouped by Claude Code session ID and project - Active request lifecycle and selected provider or model +- Effective non-default execution modes in wide tables, such as Codex + `service_tier: "priority"` or Cursor `fast: true`, displayed as `FAST` - Recent requests, HTTP status, elapsed time, and errors - Input and output token totals - Output throughput based on matched upstream timing and cumulative usage samples diff --git a/src/monitor.rs b/src/monitor.rs index 03aa642e..d7515d95 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -9,6 +9,8 @@ mod mock; pub use mock::{MockMonitor, mock_state}; +use crate::providers::cursor::model::strip_cursor_routing_prefix; + const DEFAULT_RECENT_LIMIT: usize = 200; pub const SESSION_TOKEN_BUCKET_SECS: u64 = 10; @@ -85,6 +87,7 @@ pub enum MonitorEvent { ModelResolved { request_id: String, model: String, + mode: Option, }, CompactionStarted { request_id: String, @@ -136,6 +139,7 @@ pub struct ActiveRequest { pub project: Option, pub provider: Option, pub model: Option, + pub mode: Option, pub effort: Option, pub endpoint: EndpointKind, pub started_at: SystemTime, @@ -178,6 +182,7 @@ pub struct CompletedRequest { pub project: Option, pub provider: Option, pub model: Option, + pub mode: Option, pub effort: Option, pub endpoint: EndpointKind, pub started_at: SystemTime, @@ -249,6 +254,7 @@ pub struct SessionSummary { pub failure_count: usize, pub provider: Option, pub model: Option, + pub mode: Option, pub effort: Option, pub last_seen: SystemTime, pub input_tokens: u64, @@ -372,9 +378,19 @@ impl MonitorHandle { } pub fn model_resolved(&self, request_id: impl Into, model: impl Into) { + self.execution_resolved(request_id, model, None); + } + + pub fn execution_resolved( + &self, + request_id: impl Into, + model: impl Into, + mode: Option, + ) { self.publish(MonitorEvent::ModelResolved { request_id: request_id.into(), model: model.into(), + mode, }); } @@ -487,6 +503,7 @@ impl MonitorStore { project: None, provider: None, model: None, + mode: None, effort: None, endpoint, started_at: SystemTime::now(), @@ -535,13 +552,25 @@ impl MonitorStore { active.status = RequestStatus::ProviderSelected; } } - MonitorEvent::ModelResolved { request_id, model } => { + MonitorEvent::ModelResolved { + request_id, + model, + mode, + } => { if let Some(active) = self.active.get_mut(&request_id) { active.model = Some(match active.model.take() { - Some(incoming) if incoming != model => format!("{incoming} → {model}"), - Some(incoming) => incoming, + Some(incoming) => { + let displayed = + displayed_incoming_model(active.provider.as_deref(), &incoming); + if displayed == model { + model + } else { + format!("{displayed} → {model}") + } + } None => model, }); + active.mode = mode; } } MonitorEvent::CompactionStarted { request_id } => { @@ -754,6 +783,7 @@ impl MonitorStore { project: None, provider: None, model: None, + mode: None, effort: None, endpoint: EndpointKind::Messages, started_at: SystemTime::now(), @@ -784,6 +814,7 @@ impl MonitorStore { project: active.project, provider: active.provider, model: active.model, + mode: active.mode, effort: active.effort, endpoint: active.endpoint, started_at: active.started_at, @@ -845,13 +876,28 @@ impl MonitorStore { } } +#[derive(Debug)] +struct LatestSessionMode { + session_seq: Option, + started_at: SystemTime, + mode: Option, +} + fn session_summaries( active: &[ActiveRequest], recent: &VecDeque, session_output_buckets: &HashMap, Vec<(u64, u64)>>, ) -> Vec { let mut sessions: HashMap, SessionSummary> = HashMap::new(); + let mut latest_modes: HashMap, LatestSessionMode> = HashMap::new(); for request in recent.iter().rev() { + update_latest_session_mode( + &mut latest_modes, + request.session_id.clone(), + request.session_seq, + request.started_at, + request.mode.clone(), + ); let entry = sessions .entry(request.session_id.clone()) .or_insert_with(|| SessionSummary { @@ -862,6 +908,7 @@ fn session_summaries( failure_count: 0, provider: None, model: None, + mode: None, effort: None, last_seen: request.finished_at, input_tokens: 0, @@ -902,6 +949,13 @@ fn session_summaries( } for request in active { + update_latest_session_mode( + &mut latest_modes, + request.session_id.clone(), + request.session_seq, + request.started_at, + request.mode.clone(), + ); let entry = sessions .entry(request.session_id.clone()) .or_insert_with(|| SessionSummary { @@ -912,6 +966,7 @@ fn session_summaries( failure_count: 0, provider: None, model: None, + mode: None, effort: None, last_seen: request.started_at, input_tokens: 0, @@ -949,6 +1004,12 @@ fn session_summaries( entry.last_status = request.status.label().to_string(); } + for (session_id, latest) in latest_modes { + if let Some(session) = sessions.get_mut(&session_id) { + session.mode = latest.mode; + } + } + for (session_id, session) in &mut sessions { if let Some(buckets) = session_output_buckets.get(session_id) { session.output_token_samples = buckets @@ -963,6 +1024,31 @@ fn session_summaries( out } +fn update_latest_session_mode( + latest_modes: &mut HashMap, LatestSessionMode>, + session_id: Option, + session_seq: Option, + started_at: SystemTime, + mode: Option, +) { + let should_replace = latest_modes.get(&session_id).is_none_or(|latest| { + match (session_seq, latest.session_seq) { + (Some(candidate), Some(current)) if candidate != current => candidate > current, + _ => started_at.duration_since(latest.started_at).is_ok(), + } + }); + if should_replace { + latest_modes.insert( + session_id, + LatestSessionMode { + session_seq, + started_at, + mode, + }, + ); + } +} + fn session_token_bucket(timestamp: SystemTime) -> u64 { timestamp .duration_since(SystemTime::UNIX_EPOCH) @@ -983,6 +1069,19 @@ fn max_system_time(left: SystemTime, right: SystemTime) -> SystemTime { } } +fn displayed_incoming_model<'a>(provider: Option<&str>, model: &'a str) -> &'a str { + let model = if provider == Some("cursor") { + strip_cursor_routing_prefix(model) + } else { + model + }; + if matches!(provider, Some("codex" | "cursor")) { + model.strip_suffix("-fast").unwrap_or(model) + } else { + model + } +} + pub fn throughput( output_tokens: Option, streamed_bytes: u64, @@ -1080,6 +1179,161 @@ mod tests { assert_eq!(state.active[0].model.as_deref(), Some("gpt-5.6-sol")); } + #[test] + fn fast_suffix_becomes_execution_mode_instead_of_model_mapping() { + let monitor = MonitorHandle::new(10); + monitor.request_started("r1", None, None, EndpointKind::Messages); + monitor.provider_selected("r1", "codex", "gpt-5.6-terra-fast", None); + monitor.execution_resolved("r1", "gpt-5.6-terra", Some("FAST".to_string())); + + let state = monitor.snapshot(); + assert_eq!(state.active[0].model.as_deref(), Some("gpt-5.6-terra")); + assert_eq!(state.active[0].mode.as_deref(), Some("FAST")); + } + + #[test] + fn fast_suffix_is_removed_when_model_override_changes_target() { + let monitor = MonitorHandle::new(10); + monitor.request_started("r1", None, None, EndpointKind::Messages); + monitor.provider_selected("r1", "codex", "gpt-5.4-fast", None); + monitor.execution_resolved("r1", "gpt-5.6-terra", Some("FAST".to_string())); + + let state = monitor.snapshot(); + assert_eq!( + state.active[0].model.as_deref(), + Some("gpt-5.4 → gpt-5.6-terra") + ); + } + + #[test] + fn latest_completed_default_mode_clears_session_mode() { + let monitor = MonitorHandle::new(10); + monitor.request_started( + "fast", + Some("session".to_string()), + Some(1), + EndpointKind::Messages, + ); + monitor.provider_selected("fast", "codex", "gpt-5.6-sol-fast", None); + monitor.execution_resolved("fast", "gpt-5.6-sol", Some("FAST".to_string())); + monitor.request_completed("fast", 200, None, None); + + monitor.request_started( + "default", + Some("session".to_string()), + Some(2), + EndpointKind::Messages, + ); + monitor.provider_selected("default", "codex", "gpt-5.6-sol", None); + monitor.execution_resolved("default", "gpt-5.6-sol", None); + monitor.request_completed("default", 200, None, None); + + let state = monitor.snapshot(); + assert_eq!(state.sessions[0].mode, None); + } + + #[test] + fn active_default_mode_clears_completed_session_mode() { + let monitor = MonitorHandle::new(10); + monitor.request_started( + "fast", + Some("session".to_string()), + Some(1), + EndpointKind::Messages, + ); + monitor.provider_selected("fast", "codex", "gpt-5.6-sol-fast", None); + monitor.execution_resolved("fast", "gpt-5.6-sol", Some("FAST".to_string())); + monitor.request_completed("fast", 200, None, None); + + monitor.request_started( + "default", + Some("session".to_string()), + Some(2), + EndpointKind::Messages, + ); + monitor.provider_selected("default", "codex", "gpt-5.6-sol", None); + monitor.execution_resolved("default", "gpt-5.6-sol", None); + + let state = monitor.snapshot(); + assert_eq!(state.sessions[0].mode, None); + } + + #[test] + fn newer_completed_default_mode_wins_over_older_active_fast_mode() { + let monitor = MonitorHandle::new(10); + monitor.request_started( + "older-fast", + Some("session".to_string()), + Some(1), + EndpointKind::Messages, + ); + monitor.provider_selected("older-fast", "codex", "gpt-5.6-sol-fast", None); + monitor.execution_resolved("older-fast", "gpt-5.6-sol", Some("FAST".to_string())); + + monitor.request_started( + "newer-default", + Some("session".to_string()), + Some(2), + EndpointKind::Messages, + ); + monitor.provider_selected("newer-default", "codex", "gpt-5.6-sol", None); + monitor.execution_resolved("newer-default", "gpt-5.6-sol", None); + monitor.request_completed("newer-default", 200, None, None); + + let state = monitor.snapshot(); + assert_eq!(state.sessions[0].mode, None); + } + + #[test] + fn later_session_sequence_wins_when_requests_complete_out_of_order() { + let monitor = MonitorHandle::new(10); + monitor.request_started( + "older-fast", + Some("session".to_string()), + Some(1), + EndpointKind::Messages, + ); + monitor.provider_selected("older-fast", "codex", "gpt-5.6-sol-fast", None); + monitor.execution_resolved("older-fast", "gpt-5.6-sol", Some("FAST".to_string())); + + monitor.request_started( + "newer-default", + Some("session".to_string()), + Some(2), + EndpointKind::Messages, + ); + monitor.provider_selected("newer-default", "codex", "gpt-5.6-sol", None); + monitor.execution_resolved("newer-default", "gpt-5.6-sol", None); + monitor.request_completed("newer-default", 200, None, None); + monitor.request_completed("older-fast", 200, None, None); + + let state = monitor.snapshot(); + assert_eq!(state.sessions[0].mode, None); + } + + #[test] + fn cursor_route_prefix_is_not_shown_as_model_mapping() { + let monitor = MonitorHandle::new(10); + monitor.request_started("r1", None, None, EndpointKind::Messages); + monitor.provider_selected("r1", "cursor", "cursor:composer-2.5", None); + monitor.execution_resolved("r1", "composer-2.5", None); + + let state = monitor.snapshot(); + assert_eq!(state.active[0].model.as_deref(), Some("composer-2.5")); + } + + #[test] + fn cursor_namespaced_model_keeps_the_resolved_wire_id() { + let monitor = MonitorHandle::new(10); + monitor.request_started("r1", None, None, EndpointKind::Messages); + monitor.provider_selected("r1", "cursor", "cursor:vendor:model-fast", None); + monitor.execution_resolved("r1", "vendor:model", Some("FAST".to_string())); + + let state = monitor.snapshot(); + assert_eq!(state.active[0].model.as_deref(), Some("vendor:model")); + assert_eq!(state.active[0].mode.as_deref(), Some("FAST")); + } + #[test] fn compaction_started_marks_request_compacting() { let monitor = MonitorHandle::new(10); @@ -1263,6 +1517,7 @@ data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input project: None, provider: Some("codex".to_string()), model: Some("gpt-5.6-sol".to_string()), + mode: None, effort: None, endpoint: EndpointKind::Messages, started_at: SystemTime::UNIX_EPOCH, diff --git a/src/monitor/mock.rs b/src/monitor/mock.rs index 45ea7222..0f908206 100644 --- a/src/monitor/mock.rs +++ b/src/monitor/mock.rs @@ -524,6 +524,7 @@ fn simulated_active_request( if phase >= 3 { request.provider = Some(profile.provider.to_string()); request.model = Some(profile.model.to_string()); + request.mode = profile.mode.map(str::to_string); request.effort = profile.effort.map(str::to_string); } if phase >= 10 { @@ -573,6 +574,7 @@ fn simulated_completed_request( request.project = Some(profile.project.to_string()); request.provider = Some(profile.provider.to_string()); request.model = Some(profile.model.to_string()); + request.mode = profile.mode.map(str::to_string); request.effort = profile.effort.map(str::to_string); request.input_tokens = Some(1_800 + cycle.saturating_mul(137)); if failed { @@ -591,6 +593,7 @@ struct SimulationProfile { project: &'static str, provider: &'static str, model: &'static str, + mode: Option<&'static str>, effort: Option<&'static str>, } @@ -600,24 +603,28 @@ fn simulation_profile(cycle: u64) -> SimulationProfile { project: "live-dashboard", provider: "codex", model: "gpt-5.6-sol", + mode: Some("FAST"), effort: Some("high"), }, SimulationProfile { project: "api-client", provider: "kimi", model: "kimi-k2.6", + mode: None, effort: Some("medium"), }, SimulationProfile { project: "editor-extension", provider: "cursor", - model: "cursor:composer-2.5-fast", + model: "composer-2.5", + mode: Some("FAST"), effort: None, }, SimulationProfile { project: "agent-workbench", provider: "grok", model: "grok-4.5", + mode: None, effort: Some("low"), }, ]; @@ -643,6 +650,7 @@ fn active_request( project: None, provider: None, model: None, + mode: None, effort: None, endpoint, started_at: now - elapsed, @@ -682,6 +690,7 @@ fn completed_request( project: None, provider: None, model: None, + mode: None, effort: None, endpoint, started_at: finished_at - latency, diff --git a/src/providers/codex/chat_completions/mod.rs b/src/providers/codex/chat_completions/mod.rs index eb816c39..c26a1991 100644 --- a/src/providers/codex/chat_completions/mod.rs +++ b/src/providers/codex/chat_completions/mod.rs @@ -43,7 +43,11 @@ impl ChatCompletionsBackend { pub async fn handle(&self, request: TranslatedRequest, ctx: RequestContext) -> Response { if let Some(monitor) = ctx.monitor.as_ref() { - monitor.model_resolved(&ctx.req_id, &request.model); + monitor.execution_resolved( + &ctx.req_id, + &request.model, + super::wire_service_tier_mode(request.upstream.get("service_tier")), + ); monitor.upstream_started(&ctx.req_id); } let upstream = match self diff --git a/src/providers/codex/mod.rs b/src/providers/codex/mod.rs index d2534250..cdeef0a2 100644 --- a/src/providers/codex/mod.rs +++ b/src/providers/codex/mod.rs @@ -53,7 +53,8 @@ use self::translate::model_allowlist::{ }; use self::translate::reducer::finish_metadata_from_upstream; use self::translate::request::{ - TranslateOptions, has_hosted_web_search, is_compact_messages_request, translate_request, + ServiceTier, TranslateOptions, has_hosted_web_search, is_compact_messages_request, + translate_request, }; const MAX_RETRYABLE_LIVE_STREAM_RETRIES: u32 = 10; @@ -69,6 +70,22 @@ pub(crate) fn clear_session_compaction(session_id: &str) { compaction::clear_compaction(session_id); } +pub(crate) fn service_tier_mode(tier: Option<&ServiceTier>) -> Option<&'static str> { + match tier { + Some(ServiceTier::Priority) => Some("FAST"), + Some(ServiceTier::Flex) => Some("FLEX"), + None => None, + } +} + +pub(crate) fn wire_service_tier_mode(tier: Option<&serde_json::Value>) -> Option { + match tier.and_then(serde_json::Value::as_str) { + Some("fast" | "priority") => Some("FAST".to_string()), + Some("flex") => Some("FLEX".to_string()), + _ => None, + } +} + pub struct CodexProvider { client: Arc, } @@ -207,9 +224,6 @@ impl Provider for CodexProvider { ); } let use_responses_lite = apply_model_lane_for_request(&mut resolved.model, &body); - if let Some(monitor) = ctx.monitor.as_ref() { - monitor.model_resolved(&ctx.req_id, &resolved.model); - } let mut translated = match translate_request( &body, @@ -229,6 +243,13 @@ impl Provider for CodexProvider { ); } }; + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.execution_resolved( + &ctx.req_id, + &translated.model, + service_tier_mode(translated.service_tier.as_ref()).map(str::to_string), + ); + } let compact_boundary = is_compact_messages_request(&body); let server_compaction_enabled = config::codex_server_compaction(); @@ -449,9 +470,6 @@ impl Provider for CodexProvider { ); } let use_responses_lite = apply_model_lane_for_request(&mut resolved.model, &body); - if let Some(monitor) = ctx.monitor.as_ref() { - monitor.model_resolved(&ctx.req_id, &resolved.model); - } let translated = match translate_request( &body, @@ -471,6 +489,9 @@ impl Provider for CodexProvider { ); } }; + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.execution_resolved(&ctx.req_id, &translated.model, None); + } let tokens = count_translated_tokens(&translated); if let Some(monitor) = ctx.monitor.as_ref() { @@ -1311,6 +1332,19 @@ fn format_auth_saved_output(auth_path: &str, account_id: Option<&str>) -> String mod tests { use super::*; + #[test] + fn service_tiers_have_monitor_modes() { + assert_eq!( + service_tier_mode(Some(&translate::request::ServiceTier::Priority)), + Some("FAST") + ); + assert_eq!( + service_tier_mode(Some(&translate::request::ServiceTier::Flex)), + Some("FLEX") + ); + assert_eq!(service_tier_mode(None), None); + } + fn upstream_sse(events: &[serde_json::Value]) -> Vec { let mut bytes = Vec::new(); for event in events { diff --git a/src/providers/codex/native.rs b/src/providers/codex/native.rs index d3924225..fb494c02 100644 --- a/src/providers/codex/native.rs +++ b/src/providers/codex/native.rs @@ -46,7 +46,11 @@ impl CodexNativeBackend { Err(response) => return response, }; if let Some(monitor) = ctx.monitor.as_ref() { - monitor.model_resolved(&ctx.req_id, &resolved.model); + monitor.execution_resolved( + &ctx.req_id, + &resolved.model, + super::wire_service_tier_mode(body.get("service_tier")), + ); monitor.upstream_started(&ctx.req_id); } diff --git a/src/providers/cursor/mod.rs b/src/providers/cursor/mod.rs index 3d7732dc..325ec699 100644 --- a/src/providers/cursor/mod.rs +++ b/src/providers/cursor/mod.rs @@ -75,14 +75,17 @@ impl Provider for CursorProvider { let want_stream = body.stream; let model = body.model.as_deref().unwrap_or("cursor"); - let resolved = resolve_cursor_model(model); - if let Err(e) = resolved { - return json_error( - StatusCode::BAD_REQUEST, - "invalid_request_error", - format!("Model \"{model}\" is not supported: {e}"), - ); - } + let resolved = match resolve_cursor_model(model) { + Ok(resolved) => resolved, + Err(e) => { + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request_error", + format!("Model \"{model}\" is not supported: {e}"), + ); + } + }; + report_cursor_resolution(&ctx, &resolved); if let Some(ref session_id) = ctx.session_id && let Some(pending) = BridgeRegistry::pending_tool(session_id) @@ -252,9 +255,7 @@ impl Provider for CursorProvider { format!("Model \"{requested}\" is not supported: {error}"), ) })?; - if let Some(monitor) = ctx.monitor.as_ref() { - monitor.model_resolved(&ctx.req_id, &resolved.model_id); - } + report_cursor_resolution(&ctx, &resolved); let message_id = format!("msg_{}", uuid::Uuid::new_v4().simple()); if let Some(session_id) = ctx.session_id.as_deref() && let Some(pending) = BridgeRegistry::pending_tool(session_id) @@ -347,6 +348,16 @@ impl Provider for CursorProvider { } } +fn report_cursor_resolution(ctx: &RequestContext, resolved: &model::CursorModelResolution) { + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.execution_resolved( + &ctx.req_id, + &resolved.model_id, + resolved.fast.then(|| "FAST".to_string()), + ); + } +} + fn count_sse_events(bytes: &[u8]) -> u64 { String::from_utf8_lossy(bytes).matches("event:").count() as u64 } @@ -491,6 +502,33 @@ pub(crate) static CURSOR_CLI: CursorCli = CursorCli; mod tests { use super::*; + #[test] + fn cursor_resolution_reports_fast_execution_mode() { + let monitor = crate::monitor::MonitorHandle::new(10); + monitor.request_started( + "request", + None, + None, + crate::monitor::EndpointKind::Responses, + ); + monitor.provider_selected("request", "cursor", "cursor:composer-2.5-fast", None); + let context = RequestContext { + req_id: "request".to_string(), + session_id: None, + session_seq: None, + provider: "cursor".to_string(), + traffic: None, + monitor: Some(monitor.clone()), + }; + let resolved = resolve_cursor_model("cursor:composer-2.5-fast").unwrap(); + + report_cursor_resolution(&context, &resolved); + + let state = monitor.snapshot(); + assert_eq!(state.active[0].model.as_deref(), Some("composer-2.5")); + assert_eq!(state.active[0].mode.as_deref(), Some("FAST")); + } + #[test] fn supported_models_includes_legacy_and_agent() { let provider = CursorProvider::new(); diff --git a/src/providers/cursor/model.rs b/src/providers/cursor/model.rs index 54a12240..2dc163c2 100644 --- a/src/providers/cursor/model.rs +++ b/src/providers/cursor/model.rs @@ -17,6 +17,13 @@ pub enum CursorAgentMode { Ask, } +const CURSOR_ROUTING_PREFIXES: [(&str, CursorAgentMode); 4] = [ + ("cursor-agent:", CursorAgentMode::Agent), + ("cursor-plan:", CursorAgentMode::Plan), + ("cursor-ask:", CursorAgentMode::Ask), + ("cursor:", CursorAgentMode::Agent), +]; + impl CursorAgentMode { pub fn as_str(&self) -> &'static str { match self { @@ -33,14 +40,16 @@ fn resolve_wire_model(raw: &str) -> (String, bool) { .unwrap_or_else(|| (raw.to_string(), false)) } +pub(crate) fn strip_cursor_routing_prefix(model: &str) -> &str { + CURSOR_ROUTING_PREFIXES + .iter() + .find_map(|(prefix, _)| model.strip_prefix(prefix)) + .unwrap_or(model) +} + pub fn resolve_cursor_model(model: &str) -> Result { let model = model.trim(); - for (prefix, mode) in [ - ("cursor-agent:", CursorAgentMode::Agent), - ("cursor-plan:", CursorAgentMode::Plan), - ("cursor-ask:", CursorAgentMode::Ask), - ("cursor:", CursorAgentMode::Agent), - ] { + for &(prefix, mode) in &CURSOR_ROUTING_PREFIXES { if let Some(raw) = model.strip_prefix(prefix) { let (model_id, fast) = resolve_wire_model(raw); return Ok(CursorModelResolution { diff --git a/src/tui.rs b/src/tui.rs index 25766c6c..1877c943 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -2,7 +2,7 @@ mod layout; use layout::{ CODE_WIDTH, COUNT_WIDTH, ColumnSpec, DURATION_WIDTH, EFFORT_WIDTH, ENDPOINT_WIDTH, ERROR_WIDTH, - ID_WIDTH, LayoutTier, MODEL_MEDIUM_WIDTH, MODEL_NARROW_WIDTH, MODEL_WIDE_WIDTH, + ID_WIDTH, LayoutTier, MODE_WIDTH, MODEL_MEDIUM_WIDTH, MODEL_NARROW_WIDTH, MODEL_WIDE_WIDTH, PROJECT_MEDIUM_WIDTH, PROJECT_WIDE_WIDTH, PROVIDER_WIDTH, RATE_WIDTH, STATUS_WIDTH, TIME_WIDTH, TOKEN_WIDTH, }; @@ -765,6 +765,7 @@ enum SessionColumn { Counts, Provider, Model, + Mode, Target, Effort, Input, @@ -786,6 +787,7 @@ fn session_columns(tier: LayoutTier, show_full_sparkline: bool) -> Vec provider_cell(session.provider.as_deref()), SessionColumn::Model => model_cell(session.model.as_deref(), width), + SessionColumn::Mode => text_cell(session.mode.as_deref().unwrap_or("-")), SessionColumn::Target => { target_cell(session.provider.as_deref(), session.model.as_deref(), width) } @@ -921,6 +924,7 @@ enum ActiveColumn { Session, Provider, Model, + Mode, Target, Effort, Endpoint, @@ -940,6 +944,7 @@ fn active_columns(tier: LayoutTier) -> Vec> { ColumnSpec::fixed(C::Session, "Session", Alignment::Left, ID_WIDTH), ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH), ColumnSpec::fixed(C::Model, "Model", Alignment::Left, MODEL_WIDE_WIDTH), + ColumnSpec::fixed(C::Mode, "Mode", Alignment::Left, MODE_WIDTH), ColumnSpec::fixed(C::Effort, "Effort", Alignment::Left, EFFORT_WIDTH), ColumnSpec::flex(C::Endpoint, "Endpoint", Alignment::Left, 1), ColumnSpec::fixed(C::Input, "In", Alignment::Right, TOKEN_WIDTH), @@ -1025,6 +1030,7 @@ fn render_active( } ActiveColumn::Provider => provider_cell(request.provider.as_deref()), ActiveColumn::Model => model_cell(request.model.as_deref(), width), + ActiveColumn::Mode => text_cell(request.mode.as_deref().unwrap_or("-")), ActiveColumn::Target => { target_cell(request.provider.as_deref(), request.model.as_deref(), width) } @@ -1053,6 +1059,7 @@ enum RecentColumn { Session, Provider, Model, + Mode, Target, Effort, Endpoint, @@ -1074,6 +1081,7 @@ fn recent_columns(tier: LayoutTier) -> Vec> { ColumnSpec::fixed(C::Session, "Session", Alignment::Left, ID_WIDTH), ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH), ColumnSpec::fixed(C::Model, "Model", Alignment::Left, MODEL_WIDE_WIDTH), + ColumnSpec::fixed(C::Mode, "Mode", Alignment::Left, MODE_WIDTH), ColumnSpec::fixed(C::Effort, "Effort", Alignment::Left, EFFORT_WIDTH), ColumnSpec::fixed(C::Endpoint, "Endpoint", Alignment::Left, ENDPOINT_WIDTH), ColumnSpec::fixed(C::Latency, "Latency", Alignment::Right, DURATION_WIDTH), @@ -1178,6 +1186,7 @@ fn render_recent( } RecentColumn::Provider => provider_cell(request.provider.as_deref()), RecentColumn::Model => model_cell(request.model.as_deref(), width), + RecentColumn::Mode => text_cell(request.mode.as_deref().unwrap_or("-")), RecentColumn::Target => { target_cell(request.provider.as_deref(), request.model.as_deref(), width) } @@ -1213,6 +1222,7 @@ enum EventColumn { Session, Provider, Model, + Mode, Message, } @@ -1226,6 +1236,7 @@ fn event_columns(tier: LayoutTier) -> Vec> { ColumnSpec::fixed(C::Session, "Session", Alignment::Left, ID_WIDTH), ColumnSpec::fixed(C::Provider, "Provider", Alignment::Left, PROVIDER_WIDTH), ColumnSpec::fixed(C::Model, "Model", Alignment::Left, MODEL_WIDE_WIDTH), + ColumnSpec::fixed(C::Mode, "Mode", Alignment::Left, MODE_WIDTH), ColumnSpec::flex(C::Message, "Message", Alignment::Left, 1), ], LayoutTier::Expanded => vec![ @@ -1297,6 +1308,7 @@ fn render_events(frame: &mut ratatui::Frame<'_>, area: Rect, recent: &[Completed } EventColumn::Provider => provider_cell(request.provider.as_deref()), EventColumn::Model => model_cell(request.model.as_deref(), width), + EventColumn::Mode => text_cell(request.mode.as_deref().unwrap_or("-")), EventColumn::Message => detail_cell(message), } }) @@ -1316,7 +1328,7 @@ fn render_session_detail( selected: usize, ) { let lines = if let Some(session) = state.sessions.get(selected) { - vec![ + let mut lines = vec![ detail_line("session", session.label(), WHITE), detail_line("project", session.project.as_deref().unwrap_or("-"), TEAL), detail_line("active requests", session.active_count.to_string(), YELLOW), @@ -1354,7 +1366,11 @@ fn render_session_detail( session.last_status.as_str(), status_color(&session.last_status), ), - ] + ]; + if let Some(mode) = session.mode.as_deref() { + lines.insert(7, detail_line("mode", mode, YELLOW)); + } + lines } else { vec![Line::from(Span::styled( "No session selected", @@ -1433,6 +1449,9 @@ fn render_request_detail( DIM_WHITE, ), ]; + if let Some(mode) = request.mode.as_deref() { + lines.insert(10, detail_line("mode", mode, YELLOW)); + } if let Some(error) = request.error.as_deref().filter(|error| !error.is_empty()) { lines.push(detail_line("detail", error, YELLOW)); } @@ -1864,6 +1883,28 @@ mod tests { ); } + #[test] + fn wide_request_tables_show_execution_mode() { + assert!(headers(&session_columns(LayoutTier::Wide, true)).contains(&"Mode")); + assert!(headers(&active_columns(LayoutTier::Wide)).contains(&"Mode")); + assert!(headers(&recent_columns(LayoutTier::Wide)).contains(&"Mode")); + assert!(headers(&event_columns(LayoutTier::Wide)).contains(&"Mode")); + assert!(!headers(&active_columns(LayoutTier::Narrow)).contains(&"Mode")); + } + + #[test] + fn wide_breakpoint_reserves_readable_recent_details() { + const MIN_DETAILS_WIDTH: u16 = 12; + let minimum_wide_outer_width = (2..=u16::MAX) + .find(|width| LayoutTier::for_outer_width(*width) == LayoutTier::Wide) + .unwrap(); + let minimum_wide_inner_width = minimum_wide_outer_width.saturating_sub(2); + let details_width = minimum_wide_inner_width + .saturating_sub(fixed_budget(&recent_columns(LayoutTier::Wide))); + + assert!(details_width >= MIN_DETAILS_WIDTH); + } + #[test] fn responsive_schemas_fit_their_minimum_terminal_widths() { assert!(fixed_budget(&session_columns(LayoutTier::Emergency, false)) <= 75); @@ -1924,7 +1965,7 @@ mod tests { assert!(expanded.contains("Endpoint"), "{expanded}"); assert!(!expanded.contains("In"), "{expanded}"); - let wide = render_at(154); + let wide = render_at(163); assert!(wide.contains("Project"), "{wide}"); assert!(wide.contains("Session"), "{wide}"); assert!(wide.contains("In"), "{wide}"); diff --git a/src/tui/layout.rs b/src/tui/layout.rs index 8056f475..36d4b6eb 100644 --- a/src/tui/layout.rs +++ b/src/tui/layout.rs @@ -11,6 +11,7 @@ pub const MODEL_WIDE_WIDTH: u16 = 36; pub const PROJECT_MEDIUM_WIDTH: u16 = 12; pub const PROJECT_WIDE_WIDTH: u16 = 16; pub const EFFORT_WIDTH: u16 = 6; +pub const MODE_WIDTH: u16 = 4; pub const ENDPOINT_WIDTH: u16 = 12; pub const STATUS_WIDTH: u16 = 11; pub const RATE_WIDTH: u16 = 12; @@ -33,7 +34,7 @@ impl LayoutTier { 0..=75 => Self::Emergency, 76..=87 => Self::Narrow, 88..=117 => Self::Medium, - 118..=151 => Self::Expanded, + 118..=160 => Self::Expanded, _ => Self::Wide, } } @@ -90,7 +91,7 @@ mod tests { assert_eq!(LayoutTier::for_outer_width(78), LayoutTier::Narrow); assert_eq!(LayoutTier::for_outer_width(90), LayoutTier::Medium); assert_eq!(LayoutTier::for_outer_width(120), LayoutTier::Expanded); - assert_eq!(LayoutTier::for_outer_width(153), LayoutTier::Expanded); - assert_eq!(LayoutTier::for_outer_width(154), LayoutTier::Wide); + assert_eq!(LayoutTier::for_outer_width(162), LayoutTier::Expanded); + assert_eq!(LayoutTier::for_outer_width(163), LayoutTier::Wide); } }