diff --git a/Cargo.lock b/Cargo.lock index 64661cbfd..c33ab2598 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3400,13 +3400,6 @@ dependencies = [ "arboard", "async-stream", "async-trait", - "aws-config", - "aws-credential-types", - "aws-sdk-bedrock", - "aws-sdk-bedrockruntime", - "aws-sdk-sts", - "aws-smithy-types", - "aws-types", "base64 0.22.1", "bytes", "chrono", @@ -3423,7 +3416,6 @@ dependencies = [ "jcode-agent-runtime", "jcode-ambient-types", "jcode-auth-types", - "jcode-azure-auth", "jcode-background-types", "jcode-base", "jcode-batch-types", @@ -3443,10 +3435,7 @@ dependencies = [ "jcode-plan", "jcode-protocol", "jcode-provider-core", - "jcode-provider-gemini", "jcode-provider-metadata", - "jcode-provider-openai", - "jcode-provider-openrouter", "jcode-selfdev-types", "jcode-session-types", "jcode-side-panel-types", @@ -3457,17 +3446,6 @@ dependencies = [ "jcode-terminal-launch", "jcode-tool-core", "jcode-tool-types", - "jcode-tui-account-picker", - "jcode-tui-core", - "jcode-tui-markdown", - "jcode-tui-mermaid", - "jcode-tui-messages", - "jcode-tui-render", - "jcode-tui-session-picker", - "jcode-tui-style", - "jcode-tui-tool-display", - "jcode-tui-usage-overlay", - "jcode-tui-workspace", "jcode-update-core", "jcode-usage-types", "libc", @@ -3528,19 +3506,10 @@ version = "0.1.0" dependencies = [ "agentgrep", "anyhow", - "arboard", "async-trait", - "aws-config", - "aws-credential-types", - "aws-sdk-bedrock", - "aws-sdk-bedrockruntime", - "aws-sdk-sts", - "aws-smithy-types", - "aws-types", "base64 0.22.1", "bytes", "chrono", - "clap", "crossterm", "dirs", "flate2", @@ -3551,14 +3520,12 @@ dependencies = [ "ignore", "image", "jcode-agent-runtime", - "jcode-ambient-types", "jcode-app-core", "jcode-auth-types", "jcode-azure-auth", "jcode-background-types", "jcode-batch-types", "jcode-build-meta", - "jcode-build-support", "jcode-compaction-core", "jcode-config-types", "jcode-core", @@ -3568,55 +3535,39 @@ dependencies = [ "jcode-logging", "jcode-memory-types", "jcode-message-types", - "jcode-notify-email", - "jcode-overnight-core", "jcode-plan", "jcode-protocol", + "jcode-provider-bedrock", "jcode-provider-core", + "jcode-provider-env", "jcode-provider-gemini", "jcode-provider-metadata", "jcode-provider-openai", "jcode-provider-openrouter", + "jcode-render-core", "jcode-selfdev-types", "jcode-session-types", "jcode-side-panel-types", "jcode-storage", - "jcode-swarm-core", "jcode-task-types", "jcode-terminal-image", "jcode-terminal-launch", "jcode-tool-core", "jcode-tool-types", - "jcode-tui-account-picker", - "jcode-tui-core", - "jcode-tui-markdown", - "jcode-tui-mermaid", - "jcode-tui-messages", - "jcode-tui-render", - "jcode-tui-session-picker", - "jcode-tui-style", - "jcode-tui-tool-display", - "jcode-tui-usage-overlay", - "jcode-tui-workspace", - "jcode-update-core", "jcode-usage-types", "libc", "open", "proctitle", "qrcode", "rand 0.9.3", - "ratatui", "regex", "reqwest 0.12.28", - "rustls 0.23.37", "serde", "serde_json", "serde_yaml", "sha2 0.10.9", "similar", - "tar", "tempfile", - "thiserror 1.0.69", "tikv-jemalloc-ctl", "tikv-jemalloc-sys", "tikv-jemallocator", @@ -3624,7 +3575,6 @@ dependencies = [ "tokio-stream", "tokio-tungstenite", "toml", - "unicode-width 0.2.0", "url", "urlencoding", "uuid", @@ -3750,7 +3700,7 @@ name = "jcode-memory-types" version = "0.1.0" dependencies = [ "chrono", - "jcode-core", + "rand 0.9.3", "serde", "serde_json", ] @@ -3861,6 +3811,35 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jcode-provider-bedrock" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "aws-config", + "aws-credential-types", + "aws-sdk-bedrock", + "aws-sdk-bedrockruntime", + "aws-sdk-sts", + "aws-smithy-types", + "aws-types", + "base64 0.22.1", + "chrono", + "futures", + "jcode-core", + "jcode-logging", + "jcode-message-types", + "jcode-provider-core", + "jcode-provider-env", + "jcode-storage", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-stream", +] + [[package]] name = "jcode-provider-core" version = "0.1.0" @@ -3868,13 +3847,27 @@ dependencies = [ "anyhow", "async-trait", "futures", + "jcode-logging", "jcode-message-types", "reqwest 0.12.28", "serde", "serde_json", + "sha2 0.10.9", "tokio", ] +[[package]] +name = "jcode-provider-env" +version = "0.1.0" +dependencies = [ + "anyhow", + "jcode-core", + "jcode-logging", + "jcode-provider-metadata", + "jcode-storage", + "tempfile", +] + [[package]] name = "jcode-provider-gemini" version = "0.1.0" @@ -3895,9 +3888,18 @@ dependencies = [ name = "jcode-provider-openai" version = "0.1.0" dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "futures", + "jcode-core", + "jcode-logging", "jcode-message-types", "jcode-provider-core", + "reqwest 0.12.28", + "serde", "serde_json", + "tokio", ] [[package]] @@ -4045,6 +4047,7 @@ dependencies = [ "jcode-tui-style", "jcode-tui-tool-display", "jcode-tui-usage-overlay", + "jcode-tui-visual-debug", "jcode-tui-workspace", "libc", "open", @@ -4065,7 +4068,11 @@ dependencies = [ name = "jcode-tui-account-picker" version = "0.1.0" dependencies = [ + "anyhow", + "crossterm", + "ratatui", "serde", + "serde_json", ] [[package]] @@ -4131,6 +4138,7 @@ dependencies = [ name = "jcode-tui-render" version = "0.1.0" dependencies = [ + "chrono", "ratatui", "unicode-width 0.2.0", ] @@ -4163,8 +4171,25 @@ dependencies = [ name = "jcode-tui-usage-overlay" version = "0.1.0" dependencies = [ + "anyhow", + "chrono", + "crossterm", + "jcode-usage-types", "ratatui", "serde", + "serde_json", +] + +[[package]] +name = "jcode-tui-visual-debug" +version = "0.1.0" +dependencies = [ + "dirs", + "jcode-logging", + "ratatui", + "regex", + "serde", + "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8635279b6..875ff6412 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,9 @@ members = [ "crates/jcode-azure-auth", "crates/jcode-notify-email", "crates/jcode-provider-metadata", + "crates/jcode-provider-env", "crates/jcode-provider-core", + "crates/jcode-provider-bedrock", "crates/jcode-provider-openrouter", "crates/jcode-provider-openai", "crates/jcode-provider-gemini", @@ -55,6 +57,7 @@ members = [ "crates/jcode-tui-account-picker", "crates/jcode-tui-anim", "crates/jcode-tui-render", + "crates/jcode-tui-visual-debug", "crates/jcode-tui-session-picker", "crates/jcode-tui-style", "crates/jcode-tui-tool-display", diff --git a/crates/jcode-app-core/Cargo.toml b/crates/jcode-app-core/Cargo.toml index 2046e9a91..6016d6e3e 100644 --- a/crates/jcode-app-core/Cargo.toml +++ b/crates/jcode-app-core/Cargo.toml @@ -69,29 +69,17 @@ hex = "0.4" url = "2" open = "5" # Open URLs in browser jcode-auth-types = { path = "../jcode-auth-types" } -jcode-azure-auth = { path = "../jcode-azure-auth" } jcode-agent-runtime = { path = "../jcode-agent-runtime" } jcode-ambient-types = { path = "../jcode-ambient-types" } jcode-notify-email = { path = "../jcode-notify-email" } jcode-provider-metadata = { path = "../jcode-provider-metadata" } jcode-provider-core = { path = "../jcode-provider-core" } -jcode-provider-openai = { path = "../jcode-provider-openai" } -jcode-provider-openrouter = { path = "../jcode-provider-openrouter" } -jcode-provider-gemini = { path = "../jcode-provider-gemini" } -jcode-tui-markdown = { path = "../jcode-tui-markdown" } -jcode-tui-messages = { path = "../jcode-tui-messages" } -jcode-tui-core = { path = "../jcode-tui-core" } -jcode-tui-mermaid = { path = "../jcode-tui-mermaid" } -jcode-tui-account-picker = { path = "../jcode-tui-account-picker" } -jcode-tui-render = { path = "../jcode-tui-render" } -jcode-tui-session-picker = { path = "../jcode-tui-session-picker", features = ["serde"] } -jcode-tui-style = { path = "../jcode-tui-style" } -jcode-tui-tool-display = { path = "../jcode-tui-tool-display" } -jcode-tui-usage-overlay = { path = "../jcode-tui-usage-overlay" } +# NOTE: jcode-app-core does NOT depend on any jcode-tui-* crate. They were +# unused dead dependency edges here (the TUI declares them itself). Removing +# them stops a jcode-tui-* edit from cascading a recompile through app-core. jcode-update-core = { path = "../jcode-update-core" } jcode-terminal-launch = { path = "../jcode-terminal-launch" } jcode-terminal-image = { path = "../jcode-terminal-image" } -jcode-tui-workspace = { path = "../jcode-tui-workspace" } jcode-usage-types = { path = "../jcode-usage-types" } # Streaming @@ -141,13 +129,6 @@ tar = "0.4" tempfile = "3" agentgrep = { git = "https://github.com/1jehuang/agentgrep.git", tag = "v0.1.2" } qrcode = { version = "0.14.1", default-features = false } -aws-config = "1.8.16" -aws-credential-types = "1.2.14" -aws-sdk-bedrockruntime = "1.130.0" -aws-types = "1.3.15" -aws-smithy-types = "1.4.7" -aws-sdk-bedrock = "1.141.0" -aws-sdk-sts = "1.103.0" [features] default = ["pdf", "embeddings"] diff --git a/crates/jcode-app-core/src/agent/provider.rs b/crates/jcode-app-core/src/agent/provider.rs index e1aeefa05..59d977c66 100644 --- a/crates/jcode-app-core/src/agent/provider.rs +++ b/crates/jcode-app-core/src/agent/provider.rs @@ -172,9 +172,7 @@ impl Agent { /// the provider distinguishes OAuth (subscription) from API key (cost). /// Resolved authoritatively here so remote clients can render billing/usage /// without re-deriving it from the provider name. - pub fn active_resolved_credential( - &self, - ) -> Option { + pub fn active_resolved_credential(&self) -> Option { self.provider.active_resolved_credential() } diff --git a/crates/jcode-app-core/src/agent/turn_execution.rs b/crates/jcode-app-core/src/agent/turn_execution.rs index d61077ddd..146296dea 100644 --- a/crates/jcode-app-core/src/agent/turn_execution.rs +++ b/crates/jcode-app-core/src/agent/turn_execution.rs @@ -325,8 +325,8 @@ impl Agent { fn apply_selfdev_tool_surface(tools: &mut [ToolDefinition], is_canary: bool) { for tool in tools.iter_mut() { if tool.name == "selfdev" { - tool.description = crate::tool::selfdev::SelfDevTool::description_for(is_canary) - .to_string(); + tool.description = + crate::tool::selfdev::SelfDevTool::description_for(is_canary).to_string(); tool.input_schema = crate::tool::selfdev::SelfDevTool::schema_for(is_canary); } } @@ -401,7 +401,9 @@ impl Agent { vec![ContentBlock::ToolUse { id: tool_call_id, name: tool_name, - input, thought_signature: None, }], + input, + thought_signature: None, + }], ); self.session.save()?; Ok(message_id) diff --git a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs index fa8246a81..13f65df1a 100644 --- a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs +++ b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs @@ -438,8 +438,9 @@ impl Agent { // answer renders as a normal paragraph rather than as reasoning. if reasoning_open && !text.trim().is_empty() { reasoning_open = false; - let _ = event_tx - .send(ServerEvent::ReasoningDone { duration_secs: None }); + let _ = event_tx.send(ServerEvent::ReasoningDone { + duration_secs: None, + }); } text_content.push_str(&text); if !text_wrapped_detected { @@ -474,8 +475,9 @@ impl Agent { StreamEvent::ToolUseStart { id, name } => { if reasoning_open { reasoning_open = false; - let _ = event_tx - .send(ServerEvent::ReasoningDone { duration_secs: None }); + let _ = event_tx.send(ServerEvent::ReasoningDone { + duration_secs: None, + }); } let _ = event_tx.send(ServerEvent::ToolStart { id: id.clone(), @@ -631,8 +633,9 @@ impl Agent { // step) so the client flushes its live partial line. if reasoning_open { reasoning_open = false; - let _ = event_tx - .send(ServerEvent::ReasoningDone { duration_secs: None }); + let _ = event_tx.send(ServerEvent::ReasoningDone { + duration_secs: None, + }); } if reason.is_some() { stop_reason = reason; @@ -891,7 +894,9 @@ impl Agent { content_blocks.push(ContentBlock::ToolUse { id: tc.id.clone(), name: tc.name.clone(), - input: tc.input.clone(), thought_signature: None, }); + input: tc.input.clone(), + thought_signature: None, + }); } let assistant_message_id = if !content_blocks.is_empty() { @@ -1448,7 +1453,10 @@ mod tests { // exists once both halves are appended; the overlap window must catch it. let mut acc = String::new(); acc.push_str("answer to=fun"); - assert_eq!(find_wrap_marker_incremental(&acc, "answer to=fun".len()), None); + assert_eq!( + find_wrap_marker_incremental(&acc, "answer to=fun".len()), + None + ); acc.push_str("ctions.tool"); let hit = find_wrap_marker_incremental(&acc, "ctions.tool".len()); assert_eq!(hit, find_wrap_marker_full(&acc)); diff --git a/crates/jcode-app-core/src/agent_tests.rs b/crates/jcode-app-core/src/agent_tests.rs index 9e472bce1..9f115a19a 100644 --- a/crates/jcode-app-core/src/agent_tests.rs +++ b/crates/jcode-app-core/src/agent_tests.rs @@ -703,7 +703,9 @@ async fn build_memory_prompt_nonblocking_defers_pending_memory_during_tool_loop( content: vec![ContentBlock::ToolUse { id: "call_1".to_string(), name: "bash".to_string(), - input: serde_json::json!({}), thought_signature: None, }], + input: serde_json::json!({}), + thought_signature: None, + }], timestamp: Some(chrono::Utc::now()), tool_duration_ms: None, }, diff --git a/crates/jcode-app-core/src/ambient/prompt.rs b/crates/jcode-app-core/src/ambient/prompt.rs index 2cca08b28..ed3f5927f 100644 --- a/crates/jcode-app-core/src/ambient/prompt.rs +++ b/crates/jcode-app-core/src/ambient/prompt.rs @@ -219,7 +219,9 @@ pub fn gather_recent_sessions(since: Option>) -> Vec= load_budget { diff --git a/crates/jcode-app-core/src/ambient_runner.rs b/crates/jcode-app-core/src/ambient_runner.rs index 26a04d3b5..b621b4902 100644 --- a/crates/jcode-app-core/src/ambient_runner.rs +++ b/crates/jcode-app-core/src/ambient_runner.rs @@ -1,3 +1,3 @@ #![cfg_attr(test, allow(clippy::await_holding_lock))] -pub use crate::ambient::runner::*; +pub use crate::ambient::runner::AmbientRunnerHandle; diff --git a/crates/jcode-app-core/src/ambient_scheduler.rs b/crates/jcode-app-core/src/ambient_scheduler.rs index 19fac30f9..e74d2a3fe 100644 --- a/crates/jcode-app-core/src/ambient_scheduler.rs +++ b/crates/jcode-app-core/src/ambient_scheduler.rs @@ -1 +1,3 @@ -pub use crate::ambient::scheduler::*; +pub use crate::ambient::scheduler::{ + AdaptiveScheduler, AmbientSchedulerConfig, RateLimitInfo, UsageLog, UsageRecord, UsageSource, +}; diff --git a/crates/jcode-app-core/src/build.rs b/crates/jcode-app-core/src/build.rs new file mode 100644 index 000000000..2ba13b860 --- /dev/null +++ b/crates/jcode-app-core/src/build.rs @@ -0,0 +1,27 @@ +pub use jcode_build_support::{ + BinaryChoice, BinaryVersionReport, BuildInfo, BuildManifest, CanaryStatus, CrashInfo, + DevBinarySourceMetadata, MigrationContext, PendingActivation, PublishedBuild, + SELFDEV_CARGO_PROFILE, SelfDevBuildCommand, SelfDevBuildTarget, SharedServerRepair, + SourceState, advance_shared_server_if_tracking_stable, binary_name, binary_stem, + build_log_path, build_progress_path, builds_dir, canary_binary_path, clear_build_progress, + clear_migration_context, client_update_candidate, complete_pending_activation_for_session, + current_binary_build_time_string, current_binary_built_at, current_binary_path, + current_build_info, current_git_diff, current_git_hash, current_git_hash_full, + current_source_state, current_version_file, ensure_source_state_matches, find_dev_binary, + find_repo_in_ancestors, get_commit_message, get_repo_dir, install_binary_at_version, + install_local_release, install_version, is_jcode_repo, is_working_tree_dirty, + launcher_binary_path, launcher_dir, load_migration_context, manifest_path, + migration_context_path, preferred_reload_candidate, promote_version_to_shared_server, + publish_local_current_build, publish_local_current_build_for_source, read_build_progress, + read_current_version, read_shared_server_version, read_stable_version, release_binary_path, + repair_stale_shared_server_channel, repo_build_version, repo_scope_key, + rollback_pending_activation_for_session, run_selfdev_build, save_migration_context, + selfdev_binary_path, selfdev_build_command, selfdev_build_command_for_target, + shared_server_binary_path, shared_server_tracks_stable, shared_server_update_candidate, + shared_server_version_file, smoke_test_binary, smoke_test_server_binary, stable_binary_path, + stable_version_file, update_canary_symlink, update_current_symlink, + update_launcher_symlink_to_current, update_launcher_symlink_to_stable, + update_shared_server_symlink, update_stable_symlink, version_binary_path, + version_matches_installed_channel, worktree_scope_key, write_build_progress, + write_current_dev_binary_source_metadata, write_dev_binary_source_metadata, +}; diff --git a/crates/jcode-app-core/src/catchup.rs b/crates/jcode-app-core/src/catchup.rs index 536bd312c..06a0d75f2 100644 --- a/crates/jcode-app-core/src/catchup.rs +++ b/crates/jcode-app-core/src/catchup.rs @@ -46,11 +46,7 @@ impl CatchupSeenSnapshot { if !is_attention_status(status) { return false; } - let seen = self - .state - .seen_at_ms_by_session - .get(session_id) - .copied(); + let seen = self.state.seen_at_ms_by_session.get(session_id).copied(); needs_catchup_with_seen(updated_at.timestamp_millis(), seen, status) } } @@ -632,7 +628,9 @@ mod tests { vec![ContentBlock::ToolUse { id: "tool_1".to_string(), name: "read".to_string(), - input: serde_json::json!({"file_path": "src/tui/session_picker.rs"}), thought_signature: None, }], + input: serde_json::json!({"file_path": "src/tui/session_picker.rs"}), + thought_signature: None, + }], ); session.add_message( Role::Assistant, diff --git a/crates/jcode-app-core/src/lib.rs b/crates/jcode-app-core/src/lib.rs index 49e7311d8..ddb625899 100644 --- a/crates/jcode-app-core/src/lib.rs +++ b/crates/jcode-app-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod agent; pub mod ambient; pub mod ambient_runner; pub mod ambient_scheduler; +pub mod build; pub mod catchup; pub mod channel; pub mod external_auth; diff --git a/crates/jcode-app-core/src/replay.rs b/crates/jcode-app-core/src/replay.rs index 152ba25c0..a429d692b 100644 --- a/crates/jcode-app-core/src/replay.rs +++ b/crates/jcode-app-core/src/replay.rs @@ -232,7 +232,10 @@ pub fn export_timeline(session: &Session) -> Vec { .content .iter() .filter_map(|b| { - if let ContentBlock::ToolUse { id, name, input, .. } = b { + if let ContentBlock::ToolUse { + id, name, input, .. + } = b + { Some((id.clone(), name.clone(), input.clone())) } else { None diff --git a/crates/jcode-app-core/src/server.rs b/crates/jcode-app-core/src/server.rs index 5d8ee1043..4f6963c79 100644 --- a/crates/jcode-app-core/src/server.rs +++ b/crates/jcode-app-core/src/server.rs @@ -58,9 +58,8 @@ use self::runtime::ServerRuntime; use self::swarm::{ broadcast_swarm_plan, broadcast_swarm_plan_with_previous, broadcast_swarm_status, record_swarm_event, record_swarm_event_for_session, refresh_swarm_task_staleness, - remove_plan_participant, remove_session_file_touches, remove_session_from_swarm, - rename_plan_participant, run_swarm_message, update_member_status, - update_member_status_with_report, + remove_plan_participant, remove_session_from_swarm, rename_plan_participant, run_swarm_message, + update_member_status, update_member_status_with_report, }; use self::swarm_channels::{ remove_session_channel_subscriptions, subscribe_session_to_channel, @@ -361,6 +360,9 @@ use self::util::{ mod file_activity; use self::file_activity::file_activity_scope_label; +mod file_touch_service; +pub(crate) use self::file_touch_service::FileTouchService; + #[cfg(test)] mod socket_tests; @@ -402,10 +404,8 @@ pub struct Server { client_count: Arc>, /// Connected client mapping (client_id -> session_id) client_connections: Arc>>, - /// Track file touches: path -> list of accesses - file_touches: Arc>>>, - /// Reverse index for file touches: session_id -> touched paths - files_touched_by_session: Arc>>>, + /// File-touch tracking service (forward path index + reverse session index) + file_touch: FileTouchService, /// Shared ownership of core swarm coordination state. swarm_state: SwarmState, /// Shared context by swarm (swarm_id -> key -> SharedContext) @@ -489,8 +489,7 @@ impl Server { session_id: Arc::new(RwLock::new(String::new())), client_count: Arc::new(RwLock::new(0)), client_connections: Arc::new(RwLock::new(HashMap::new())), - file_touches: Arc::new(RwLock::new(HashMap::new())), - files_touched_by_session: Arc::new(RwLock::new(HashMap::new())), + file_touch: FileTouchService::new(), swarm_state: SwarmState::new( restored_swarm_members, restored_swarms_by_id, @@ -1005,8 +1004,7 @@ impl Server { } // Spawn the bus monitor for swarm coordination - let monitor_file_touches = Arc::clone(&self.file_touches); - let monitor_files_touched_by_session = Arc::clone(&self.files_touched_by_session); + let monitor_file_touch = self.file_touch.clone(); let monitor_swarm_members = Arc::clone(&self.swarm_state.members); let monitor_swarms_by_id = Arc::clone(&self.swarm_state.swarms_by_id); let monitor_swarm_plans = Arc::clone(&self.swarm_state.plans); @@ -1019,8 +1017,7 @@ impl Server { let monitor_swarm_event_tx = self.swarm_event_tx.clone(); tokio::spawn(async move { Self::monitor_bus( - monitor_file_touches, - monitor_files_touched_by_session, + monitor_file_touch, monitor_swarm_members, monitor_swarms_by_id, monitor_swarm_plans, @@ -1474,8 +1471,7 @@ impl Server { reason = "bus monitor needs file state, swarm state, sessions, queues, and event history sinks" )] async fn monitor_bus( - file_touches: Arc>>>, - files_touched_by_session: Arc>>>, + file_touch: FileTouchService, swarm_members: Arc>>, swarms_by_id: Arc>>>, _swarm_plans: Arc>>, @@ -1495,23 +1491,7 @@ impl Server { loop { // Periodic cleanup of expired file touches if last_cleanup.elapsed() > CLEANUP_INTERVAL { - let mut touches = file_touches.write().await; - let now = Instant::now(); - touches.retain(|_, accesses| { - accesses.retain(|a| now.duration_since(a.timestamp) < TOUCH_EXPIRY); - !accesses.is_empty() - }); - let mut rebuilt_reverse_index: HashMap> = HashMap::new(); - for (path, accesses) in touches.iter() { - for access in accesses { - rebuilt_reverse_index - .entry(access.session_id.clone()) - .or_default() - .insert(path.clone()); - } - } - drop(touches); - *files_touched_by_session.write().await = rebuilt_reverse_index; + file_touch.expire_older_than(TOUCH_EXPIRY).await; last_cleanup = Instant::now(); } @@ -1521,26 +1501,20 @@ impl Server { let session_id = touch.session_id.clone(); // Record this touch - { - let mut touches = file_touches.write().await; - let accesses = touches.entry(path.clone()).or_insert_with(Vec::new); - accesses.push(FileAccess { - session_id: session_id.clone(), - op: touch.op.clone(), - timestamp: Instant::now(), - absolute_time: std::time::SystemTime::now(), - intent: touch.intent.clone(), - summary: touch.summary.clone(), - detail: touch.detail.clone(), - }); - } - { - let mut reverse_index = files_touched_by_session.write().await; - reverse_index - .entry(session_id.clone()) - .or_default() - .insert(path.clone()); - } + file_touch + .record_touch( + path.clone(), + FileAccess { + session_id: session_id.clone(), + op: touch.op.clone(), + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + intent: touch.intent.clone(), + summary: touch.summary.clone(), + detail: touch.detail.clone(), + }, + ) + .await; // Record event for subscription { @@ -1603,12 +1577,11 @@ impl Server { )); } let previous_touches: Vec = if is_modification { - let touches = file_touches.read().await; - if let Some(accesses) = touches.get(&path) { + if let Some(accesses) = file_touch.accesses_for_path(&path).await { let swarm_session_ids_set: HashSet = swarm_session_ids.iter().cloned().collect(); let result = - latest_peer_touches(accesses, &session_id, &swarm_session_ids_set); + latest_peer_touches(&accesses, &session_id, &swarm_session_ids_set); crate::logging::info(&format!( "[file-activity] {} prior peer touches ({} total accesses)", result.len(), diff --git a/crates/jcode-app-core/src/server/client_comm_context.rs b/crates/jcode-app-core/src/server/client_comm_context.rs index b99a1b56a..b51dae2cd 100644 --- a/crates/jcode-app-core/src/server/client_comm_context.rs +++ b/crates/jcode-app-core/src/server/client_comm_context.rs @@ -1,11 +1,10 @@ +use super::debug::ClientConnectionInfo; use super::{ - SharedContext, SwarmEvent, SwarmEventType, SwarmMember, fanout_session_event, + FileTouchService, SharedContext, SwarmEvent, SwarmEventType, SwarmMember, fanout_session_event, record_swarm_event, }; -use super::debug::ClientConnectionInfo; use crate::protocol::{AgentInfo, ContextEntry, NotificationType, ServerEvent}; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tokio::sync::{RwLock, broadcast, mpsc}; @@ -198,7 +197,7 @@ pub(super) async fn handle_comm_list( client_event_tx: &mpsc::UnboundedSender, swarm_members: &Arc>>, swarms_by_id: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, sessions: &super::SessionAgents, client_connections: &Arc>>, ) { @@ -232,7 +231,7 @@ pub(super) async fn handle_comm_list( let statics: Vec = { let members = swarm_members.read().await; - let touches = files_touched_by_session.read().await; + let touches = file_touch.reverse_snapshot().await; swarm_session_ids .iter() .filter_map(|sid| { diff --git a/crates/jcode-app-core/src/server/client_comm_tests.rs b/crates/jcode-app-core/src/server/client_comm_tests.rs index 35bf65c4a..04f19bdb5 100644 --- a/crates/jcode-app-core/src/server/client_comm_tests.rs +++ b/crates/jcode-app-core/src/server/client_comm_tests.rs @@ -402,7 +402,7 @@ async fn comm_list_includes_member_status_and_detail() { swarm_id, HashSet::from([requester_id.clone(), peer_id.clone()]), )]))); - let file_touches = Arc::new(RwLock::new(HashMap::new())); + let file_touch = crate::server::FileTouchService::new(); let sessions = Arc::new(RwLock::new(HashMap::from([ (requester_id.clone(), requester.clone()), (peer_id.clone(), peer.clone()), @@ -415,7 +415,7 @@ async fn comm_list_includes_member_status_and_detail() { &client_event_tx, &swarm_members, &swarms_by_id, - &file_touches, + &file_touch, &sessions, &client_connections, ) diff --git a/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs b/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs index f2d596775..24af18999 100644 --- a/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs +++ b/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs @@ -1,14 +1,13 @@ use super::{ - ClientConnectionInfo, ClientDebugState, FileAccess, SessionInterruptQueues, SwarmEvent, + ClientConnectionInfo, ClientDebugState, FileTouchService, SessionInterruptQueues, SwarmEvent, SwarmEventType, SwarmMember, VersionedPlan, record_swarm_event, - remove_session_channel_subscriptions, remove_session_file_touches, remove_session_from_swarm, + remove_session_channel_subscriptions, remove_session_from_swarm, remove_session_interrupt_queue, unregister_session_event_sender, update_member_status, }; use crate::agent::Agent; use anyhow::Result; use jcode_agent_runtime::InterruptSignal; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tokio::sync::{Mutex, RwLock, broadcast}; @@ -62,8 +61,7 @@ pub(super) async fn cleanup_client_connection( swarms_by_id: &Arc>>>, swarm_coordinators: &Arc>>, swarm_plans: &Arc>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, client_debug_state: &Arc>, @@ -244,8 +242,7 @@ pub(super) async fn cleanup_client_connection( channel_subscriptions_by_session, ) .await; - remove_session_file_touches(client_session_id, file_touches, files_touched_by_session) - .await; + file_touch.clear_session(client_session_id).await; } { diff --git a/crates/jcode-app-core/src/server/client_lifecycle.rs b/crates/jcode-app-core/src/server/client_lifecycle.rs index 0455b09bd..a6d4100e9 100644 --- a/crates/jcode-app-core/src/server/client_lifecycle.rs +++ b/crates/jcode-app-core/src/server/client_lifecycle.rs @@ -45,10 +45,11 @@ use super::provider_control::{ try_available_models_updated_event, }; use super::{ - AwaitMembersRuntime, ClientConnectionInfo, ClientDebugState, FileAccess, SessionControlHandle, - SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, SwarmMutationRuntime, - VersionedPlan, format_structured_completion_report, register_session_interrupt_queue, - truncate_detail, update_member_status, update_member_status_with_report, + AwaitMembersRuntime, ClientConnectionInfo, ClientDebugState, FileTouchService, + SessionControlHandle, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, + SwarmMutationRuntime, VersionedPlan, format_structured_completion_report, + register_session_interrupt_queue, truncate_detail, update_member_status, + update_member_status_with_report, }; use crate::agent::Agent; use crate::bus::{Bus, BusEvent}; @@ -61,7 +62,6 @@ use anyhow::Result; use futures::FutureExt; use jcode_agent_runtime::{InterruptSignal, SoftInterruptSource, StreamError}; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -315,8 +315,7 @@ pub(super) async fn handle_client( shared_context: Arc>>>, swarm_plans: Arc>>, swarm_coordinators: Arc>>, - file_touches: Arc>>>, - files_touched_by_session: Arc>>>, + file_touch: FileTouchService, channel_subscriptions: ChannelSubscriptions, channel_subscriptions_by_session: ChannelSubscriptions, client_debug_state: Arc>, @@ -371,7 +370,7 @@ pub(super) async fn handle_client( shared_context: &shared_context, swarm_plans: &swarm_plans, swarm_coordinators: &swarm_coordinators, - files_touched_by_session: &files_touched_by_session, + file_touch: &file_touch, channel_subscriptions: &channel_subscriptions, channel_subscriptions_by_session: &channel_subscriptions_by_session, client_connections: &client_connections, @@ -1102,8 +1101,7 @@ pub(super) async fn handle_client( &client_connections, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, @@ -1285,8 +1283,7 @@ pub(super) async fn handle_client( &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, @@ -1513,8 +1510,7 @@ pub(super) async fn handle_client( &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, @@ -1923,7 +1919,7 @@ pub(super) async fn handle_client( &client_event_tx, &swarm_members, &swarms_by_id, - &files_touched_by_session, + &file_touch, &sessions, &client_connections, ) @@ -2157,7 +2153,7 @@ pub(super) async fn handle_client( &sessions, &swarm_members, &client_connections, - &files_touched_by_session, + &file_touch, &client_event_tx, ) .await; @@ -2449,8 +2445,7 @@ pub(super) async fn handle_client( &swarms_by_id, &swarm_coordinators, &swarm_plans, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &client_debug_state, diff --git a/crates/jcode-app-core/src/server/client_lifecycle_tests.rs b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs index c02140f5e..3d2eef902 100644 --- a/crates/jcode-app-core/src/server/client_lifecycle_tests.rs +++ b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs @@ -645,8 +645,7 @@ async fn lightweight_comm_request_skips_full_session_initialization() { let shared_context = Arc::new(RwLock::new(HashMap::new())); let swarm_plans = Arc::new(RwLock::new(HashMap::new())); let swarm_coordinators = Arc::new(RwLock::new(HashMap::new())); - let file_touches = Arc::new(RwLock::new(HashMap::new())); - let files_touched_by_session = Arc::new(RwLock::new(HashMap::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::new())); let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::new())); let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); @@ -674,8 +673,7 @@ async fn lightweight_comm_request_skips_full_session_initialization() { shared_context, swarm_plans, swarm_coordinators, - file_touches, - files_touched_by_session, + file_touch, channel_subscriptions, channel_subscriptions_by_session, client_debug_state, diff --git a/crates/jcode-app-core/src/server/client_lightweight_control.rs b/crates/jcode-app-core/src/server/client_lightweight_control.rs index 8ab3ac0e8..4dad99b74 100644 --- a/crates/jcode-app-core/src/server/client_lightweight_control.rs +++ b/crates/jcode-app-core/src/server/client_lightweight_control.rs @@ -18,9 +18,9 @@ use super::comm_sync::{ handle_comm_resync_plan, handle_comm_status, handle_comm_summary, }; use super::{ - AwaitMembersRuntime, ChannelSubscriptions, ClientConnectionInfo, SessionAgents, - SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, SwarmMutationRuntime, - VersionedPlan, format_structured_completion_report, truncate_detail, + AwaitMembersRuntime, ChannelSubscriptions, ClientConnectionInfo, FileTouchService, + SessionAgents, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, + SwarmMutationRuntime, VersionedPlan, format_structured_completion_report, truncate_detail, update_member_status_with_report, }; use crate::config::SwarmSpawnMode; @@ -28,7 +28,6 @@ use crate::protocol::{Request, ServerEvent}; use crate::provider::Provider; use anyhow::Result; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; @@ -64,7 +63,7 @@ pub(super) struct LightweightControlContext<'a> { pub(super) shared_context: &'a Arc>>>, pub(super) swarm_plans: &'a Arc>>, pub(super) swarm_coordinators: &'a Arc>>, - pub(super) files_touched_by_session: &'a Arc>>>, + pub(super) file_touch: &'a FileTouchService, pub(super) channel_subscriptions: &'a ChannelSubscriptions, pub(super) channel_subscriptions_by_session: &'a ChannelSubscriptions, pub(super) client_connections: &'a Arc>>, @@ -91,7 +90,7 @@ pub(super) async fn handle_lightweight_control_request( shared_context, swarm_plans, swarm_coordinators, - files_touched_by_session, + file_touch, channel_subscriptions, channel_subscriptions_by_session, client_connections, @@ -203,7 +202,7 @@ pub(super) async fn handle_lightweight_control_request( &client_event_tx, swarm_members, swarms_by_id, - files_touched_by_session, + file_touch, sessions, client_connections, ) @@ -427,7 +426,7 @@ pub(super) async fn handle_lightweight_control_request( sessions, swarm_members, client_connections, - files_touched_by_session, + file_touch, &client_event_tx, ) .await; diff --git a/crates/jcode-app-core/src/server/client_session.rs b/crates/jcode-app-core/src/server/client_session.rs index 01b229fd1..59a0e8bfd 100644 --- a/crates/jcode-app-core/src/server/client_session.rs +++ b/crates/jcode-app-core/src/server/client_session.rs @@ -2,13 +2,12 @@ use super::client_state::{handle_get_history, spawn_model_prefetch_update}; use super::{ - ClientConnectionInfo, ClientDebugState, FileAccess, SessionInterruptQueues, SwarmEvent, + ClientConnectionInfo, ClientDebugState, FileTouchService, SessionInterruptQueues, SwarmEvent, SwarmMember, SwarmState, VersionedPlan, broadcast_swarm_status, fanout_live_client_event, persist_swarm_state_for, register_session_event_sender, register_session_interrupt_queue, - remove_plan_participant, remove_session_channel_subscriptions, remove_session_file_touches, - remove_session_from_swarm, remove_session_interrupt_queue, rename_plan_participant, - rename_session_interrupt_queue, swarm_id_for_dir, unregister_session_event_sender, - update_member_status, + remove_plan_participant, remove_session_channel_subscriptions, remove_session_from_swarm, + remove_session_interrupt_queue, rename_plan_participant, rename_session_interrupt_queue, + swarm_id_for_dir, unregister_session_event_sender, update_member_status, }; use crate::agent::Agent; use crate::message::ContentBlock; @@ -134,8 +133,7 @@ pub(super) async fn handle_clear_session( client_connections: &Arc>>, swarm_members: &Arc>>, swarms_by_id: &Arc>>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, swarm_plans: &Arc>>, @@ -228,7 +226,7 @@ pub(super) async fn handle_clear_session( swarm.insert(new_id.clone()); } } - remove_session_file_touches(client_session_id, file_touches, files_touched_by_session).await; + file_touch.clear_session(client_session_id).await; remove_session_channel_subscriptions( client_session_id, channel_subscriptions, @@ -762,8 +760,7 @@ async fn cleanup_detached_source_session_if_unused( client_connections: &Arc>>, swarm_members: &Arc>>, swarms_by_id: &Arc>>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, swarm_plans: &Arc>>, @@ -809,7 +806,7 @@ async fn cleanup_detached_source_session_if_unused( channel_subscriptions_by_session, ) .await; - remove_session_file_touches(old_session_id, file_touches, files_touched_by_session).await; + file_touch.clear_session(old_session_id).await; let removed_swarm_id = { let mut members = swarm_members.write().await; @@ -850,8 +847,7 @@ pub(super) async fn handle_resume_session( client_debug_state: &Arc>, swarm_members: &Arc>>, swarms_by_id: &Arc>>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, swarm_plans: &Arc>>, @@ -939,8 +935,7 @@ pub(super) async fn handle_resume_session( client_connections, swarm_members, swarms_by_id, - file_touches, - files_touched_by_session, + file_touch, channel_subscriptions, channel_subscriptions_by_session, swarm_plans, @@ -1287,8 +1282,7 @@ pub(super) async fn handle_resume_session( channel_subscriptions_by_session, ) .await; - remove_session_file_touches(&old_session_id, file_touches, files_touched_by_session) - .await; + file_touch.clear_session(&old_session_id).await; { let mut coordinators = swarm_coordinators.write().await; for coordinator in coordinators.values_mut() { diff --git a/crates/jcode-app-core/src/server/client_session_tests.rs b/crates/jcode-app-core/src/server/client_session_tests.rs index d8fd02226..36af63c14 100644 --- a/crates/jcode-app-core/src/server/client_session_tests.rs +++ b/crates/jcode-app-core/src/server/client_session_tests.rs @@ -9,7 +9,7 @@ use crate::message::{Message, ToolDefinition}; use crate::protocol::ServerEvent; use crate::provider::{EventStream, Provider}; use crate::server::{ - ClientConnectionInfo, ClientDebugState, FileAccess, SessionInterruptQueues, SwarmEvent, + ClientConnectionInfo, ClientDebugState, FileTouchService, SessionInterruptQueues, SwarmEvent, SwarmMember, VersionedPlan, }; use crate::tool::Registry; @@ -17,7 +17,6 @@ use anyhow::Result; use async_trait::async_trait; use jcode_agent_runtime::InterruptSignal; use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; diff --git a/crates/jcode-app-core/src/server/client_session_tests/clear.rs b/crates/jcode-app-core/src/server/client_session_tests/clear.rs index 758515e19..9a7e6f47c 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/clear.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/clear.rs @@ -58,9 +58,7 @@ async fn handle_clear_session_replaces_runtime_handles_and_updates_shutdown_regi )]))); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -90,8 +88,7 @@ async fn handle_clear_session_replaces_runtime_handles_and_updates_shutdown_regi &client_connections, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/attach_without_local_history.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/attach_without_local_history.rs index d04acd44e..f3dff29ce 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/attach_without_local_history.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/attach_without_local_history.rs @@ -71,9 +71,7 @@ async fn handle_resume_session_allows_attach_without_local_history() -> Result<( let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -114,8 +112,7 @@ async fn handle_resume_session_allows_attach_without_local_history() -> Result<( &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs index fc5cb93ff..3a9d7ba71 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs @@ -75,9 +75,7 @@ async fn handle_resume_session_allows_live_attach_when_existing_agent_is_busy() ]))); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -119,8 +117,7 @@ async fn handle_resume_session_allows_live_attach_when_existing_agent_is_busy() &Arc::new(RwLock::new(ClientDebugState::default())), &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/different_client_attach.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/different_client_attach.rs index 96040ce38..fcf6e34b1 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/different_client_attach.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/different_client_attach.rs @@ -71,9 +71,7 @@ async fn handle_resume_session_allows_attach_from_different_client_instance() -> let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -114,8 +112,7 @@ async fn handle_resume_session_allows_attach_from_different_client_instance() -> &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/live_events_before_history.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/live_events_before_history.rs index 97558cbdd..c9bca36e2 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/live_events_before_history.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/live_events_before_history.rs @@ -66,9 +66,7 @@ async fn handle_resume_session_registers_live_events_before_history_replay() -> }, )]))); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -102,8 +100,7 @@ async fn handle_resume_session_registers_live_events_before_history_replay() -> let client_debug_state = Arc::clone(&client_debug_state); let swarm_members = Arc::clone(&swarm_members); let swarms_by_id = Arc::clone(&swarms_by_id); - let file_touches = Arc::clone(&file_touches); - let files_touched_by_session = Arc::clone(&files_touched_by_session); + let file_touch = file_touch.clone(); let channel_subscriptions = Arc::clone(&channel_subscriptions); let channel_subscriptions_by_session = Arc::clone(&channel_subscriptions_by_session); let swarm_plans = Arc::clone(&swarm_plans); @@ -135,8 +132,7 @@ async fn handle_resume_session_registers_live_events_before_history_replay() -> &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/multiple_live_attach.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/multiple_live_attach.rs index 4dd0edd5a..ab374dde6 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/multiple_live_attach.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/multiple_live_attach.rs @@ -62,9 +62,7 @@ async fn handle_resume_session_allows_multiple_live_tui_attach() -> Result<()> { ]))); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -105,8 +103,7 @@ async fn handle_resume_session_allows_multiple_live_tui_attach() -> Result<()> { &Arc::new(RwLock::new(ClientDebugState::default())), &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/reconnect_takeover_with_history.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/reconnect_takeover_with_history.rs index 77aa96899..6acaa2a21 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/reconnect_takeover_with_history.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/reconnect_takeover_with_history.rs @@ -71,9 +71,7 @@ async fn handle_resume_session_allows_reconnect_takeover_with_local_history() -> let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -114,8 +112,7 @@ async fn handle_resume_session_allows_reconnect_takeover_with_local_history() -> &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/same_client_takeover.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/same_client_takeover.rs index c044f0f48..c0444ce9b 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/same_client_takeover.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/same_client_takeover.rs @@ -73,9 +73,7 @@ async fn handle_resume_session_allows_same_client_instance_takeover_without_loca let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -116,8 +114,7 @@ async fn handle_resume_session_allows_same_client_instance_takeover_without_loca &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/comm_session.rs b/crates/jcode-app-core/src/server/comm_session.rs index 6071ba691..070f6e76a 100644 --- a/crates/jcode-app-core/src/server/comm_session.rs +++ b/crates/jcode-app-core/src/server/comm_session.rs @@ -246,16 +246,12 @@ fn explicit_route_for_configured_model(model: &str) -> Option "openai-api-key", - "openai-oauth:" => "openai-oauth", - "claude-api:" => "anthropic-api-key", - "claude-oauth:" => "claude-oauth", - _ => return None, - }; + // Only the dual-auth (Anthropic/OpenAI OAuth-vs-API) prefixes carry an + // explicit credential decision worth pinning. The canonical parser maps the + // prefix to its stable route id, which `ModelRouteApiMethod::parse` round- + // trips back to the exact auth method when the spawned session is restored. + let route_id = jcode_provider_core::AuthRoute::parse_explicit_credential_prefix(prefix)? + .route_api_method(); Some(SwarmSpawnSelection { model: Some(bare.to_string()), provider_key: Some(route_id.to_string()), @@ -311,9 +307,10 @@ fn resolve_swarm_spawn_selection( } None => SwarmSpawnSelection { model: coordinator.model.clone(), - provider_key: coordinator.provider_key.clone().or_else(|| { - provider_key_for_spawn_model(coordinator.model.as_deref(), None) - }), + provider_key: coordinator + .provider_key + .clone() + .or_else(|| provider_key_for_spawn_model(coordinator.model.as_deref(), None)), route_api_method: coordinator.route_api_method.clone(), }, } diff --git a/crates/jcode-app-core/src/server/comm_session_tests.rs b/crates/jcode-app-core/src/server/comm_session_tests.rs index f8df50428..f62cb846f 100644 --- a/crates/jcode-app-core/src/server/comm_session_tests.rs +++ b/crates/jcode-app-core/src/server/comm_session_tests.rs @@ -466,7 +466,11 @@ fn resolve_swarm_spawn_model_inherits_coordinator_auth_route_for_oauth_vs_api() // the same API route, not Claude OAuth (the config default). let selection = resolve_swarm_spawn_selection( None, - &coordinator_identity(Some("claude-opus-4-6"), Some("claude-api"), Some("claude-api")), + &coordinator_identity( + Some("claude-opus-4-6"), + Some("claude-api"), + Some("claude-api"), + ), ); assert_eq!(selection.model.as_deref(), Some("claude-opus-4-6")); @@ -478,7 +482,11 @@ fn resolve_swarm_spawn_model_inherits_coordinator_auth_route_for_oauth_vs_api() fn resolve_swarm_spawn_model_keeps_provider_key_when_config_matches_coordinator() { let selection = resolve_swarm_spawn_selection( Some("custom-model".to_string()), - &coordinator_identity(Some("custom-model"), Some("custom-provider"), Some("custom-route")), + &coordinator_identity( + Some("custom-model"), + Some("custom-provider"), + Some("custom-route"), + ), ); assert_eq!(selection.model.as_deref(), Some("custom-model")); @@ -501,7 +509,10 @@ fn resolve_swarm_spawn_model_openai_api_prefix_pins_api_route_over_coordinator() assert_eq!(selection.model.as_deref(), Some("gpt-5.5")); assert_eq!(selection.provider_key.as_deref(), Some("openai-api-key")); - assert_eq!(selection.route_api_method.as_deref(), Some("openai-api-key")); + assert_eq!( + selection.route_api_method.as_deref(), + Some("openai-api-key") + ); } #[test] @@ -509,12 +520,24 @@ fn resolve_swarm_spawn_model_auth_route_prefixes_pin_expected_routes() { for (configured, expected_model, expected_key) in [ ("openai-api:gpt-5.5", "gpt-5.5", "openai-api-key"), ("openai-oauth:gpt-5.5", "gpt-5.5", "openai-oauth"), - ("claude-api:claude-opus-4-8", "claude-opus-4-8", "anthropic-api-key"), - ("claude-oauth:claude-opus-4-8", "claude-opus-4-8", "claude-oauth"), + ( + "claude-api:claude-opus-4-8", + "claude-opus-4-8", + "anthropic-api-key", + ), + ( + "claude-oauth:claude-opus-4-8", + "claude-opus-4-8", + "claude-oauth", + ), ] { let selection = resolve_swarm_spawn_selection( Some(configured.to_string()), - &coordinator_identity(Some("some-other-model"), Some("some-key"), Some("some-route")), + &coordinator_identity( + Some("some-other-model"), + Some("some-key"), + Some("some-route"), + ), ); assert_eq!( selection.model.as_deref(), @@ -589,8 +612,7 @@ async fn coordinator_identity_falls_back_to_persisted_session_when_agent_busy() // Persist a coordinator session that records a concrete model + auth route. // Persist after the agent is built so it reflects the authoritative on-disk // snapshot the spawn path will read when the agent lock is unavailable. - let mut session = - crate::session::Session::create_with_id("coord_busy".to_string(), None, None); + let mut session = crate::session::Session::create_with_id("coord_busy".to_string(), None, None); session.model = Some("claude-opus-4-6".to_string()); session.provider_key = Some("claude-api".to_string()); session.route_api_method = Some("claude-api".to_string()); diff --git a/crates/jcode-app-core/src/server/comm_sync.rs b/crates/jcode-app-core/src/server/comm_sync.rs index bd18c44a9..2f5ff327a 100644 --- a/crates/jcode-app-core/src/server/comm_sync.rs +++ b/crates/jcode-app-core/src/server/comm_sync.rs @@ -1,20 +1,17 @@ use super::{ - ClientConnectionInfo, SwarmEvent, SwarmEventType, SwarmMember, SwarmState, VersionedPlan, - broadcast_swarm_plan, persist_swarm_state_for, record_swarm_event, + ClientConnectionInfo, FileTouchService, SwarmEvent, SwarmEventType, SwarmMember, SwarmState, + VersionedPlan, broadcast_swarm_plan, persist_swarm_state_for, record_swarm_event, }; use crate::agent::Agent; use crate::protocol::{ AgentStatusSnapshot, NotificationType, PlanGraphStatus, ServerEvent, SessionActivitySnapshot, }; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; type SessionAgents = Arc>>>>; -type SessionFilesTouched = Arc>>>; - pub(super) struct CommResyncPlanContext<'a> { pub(super) client_event_tx: &'a mpsc::UnboundedSender, pub(super) swarm_members: &'a Arc>>, @@ -142,7 +139,6 @@ pub(super) async fn member_runtime_extras( } } - async fn ensure_same_swarm_access( id: u64, req_session_id: &str, @@ -255,7 +251,7 @@ pub(super) async fn handle_comm_status( sessions: &SessionAgents, swarm_members: &Arc>>, client_connections: &Arc>>, - files_touched_by_session: &SessionFilesTouched, + file_touch: &FileTouchService, client_event_tx: &mpsc::UnboundedSender, ) { if !ensure_same_swarm_access( @@ -281,17 +277,9 @@ pub(super) async fn handle_comm_status( return; }; - let files_touched = { - let touches = files_touched_by_session.read().await; - let mut files: Vec = touches - .get(&target_session) - .into_iter() - .flat_map(|paths| paths.iter()) - .map(|path| path.display().to_string()) - .collect(); - files.sort(); - files - }; + let files_touched = file_touch + .sorted_file_strings_for_session(&target_session) + .await; let activity = { let connections = client_connections.read().await; diff --git a/crates/jcode-app-core/src/server/debug.rs b/crates/jcode-app-core/src/server/debug.rs index 97e49d3ed..496798cc5 100644 --- a/crates/jcode-app-core/src/server/debug.rs +++ b/crates/jcode-app-core/src/server/debug.rs @@ -18,7 +18,7 @@ use super::debug_swarm_read::maybe_handle_swarm_read_command; use super::debug_swarm_write::{DebugSwarmWriteContext, maybe_handle_swarm_write_command}; use super::debug_testers::execute_tester_command; use super::{ - FileAccess, ServerIdentity, SharedContext, SwarmEvent, SwarmMember, VersionedPlan, + FileTouchService, ServerIdentity, SharedContext, SwarmEvent, SwarmMember, VersionedPlan, debug_control_allowed, fanout_session_event, }; use crate::agent::Agent; @@ -29,7 +29,6 @@ use crate::transport::Stream; use anyhow::Result; use jcode_agent_runtime::InterruptSignal; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -256,8 +255,7 @@ pub(super) async fn handle_debug_client( shared_context: Arc>>>, swarm_plans: Arc>>, swarm_coordinators: Arc>>, - file_touches: Arc>>>, - files_touched_by_session: Arc>>>, + file_touch: FileTouchService, channel_subscriptions: ChannelSubscriptions, channel_subscriptions_by_session: ChannelSubscriptions, client_debug_state: Arc>, @@ -457,8 +455,7 @@ pub(super) async fn handle_debug_client( &shared_context, &swarm_plans, &swarm_coordinators, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &debug_jobs, @@ -477,7 +474,7 @@ pub(super) async fn handle_debug_client( &shared_context, &swarm_plans, &swarm_coordinators, - &file_touches, + &file_touch, &channel_subscriptions, &server_identity, ) diff --git a/crates/jcode-app-core/src/server/debug_server_state.rs b/crates/jcode-app-core/src/server/debug_server_state.rs index 2abab4aaa..c14bf5545 100644 --- a/crates/jcode-app-core/src/server/debug_server_state.rs +++ b/crates/jcode-app-core/src/server/debug_server_state.rs @@ -1,11 +1,10 @@ use super::{ - ClientConnectionInfo, ClientDebugState, DebugJob, FileAccess, ServerIdentity, + ClientConnectionInfo, ClientDebugState, DebugJob, FileAccess, FileTouchService, ServerIdentity, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, VersionedPlan, }; use crate::agent::Agent; use anyhow::Result; use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tokio::sync::{Mutex, RwLock}; @@ -29,8 +28,7 @@ pub(super) async fn maybe_handle_server_state_command( shared_context: &Arc>>>, swarm_plans: &Arc>>, swarm_coordinators: &Arc>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, debug_jobs: &Arc>>, @@ -124,8 +122,7 @@ pub(super) async fn maybe_handle_server_state_command( shared_context, swarm_plans, swarm_coordinators, - file_touches, - files_touched_by_session, + file_touch, channel_subscriptions, channel_subscriptions_by_session, debug_jobs, @@ -262,8 +259,7 @@ async fn build_server_memory_payload( shared_context: &Arc>>>, swarm_plans: &Arc>>, swarm_coordinators: &Arc>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, debug_jobs: &Arc>>, @@ -460,7 +456,7 @@ async fn build_server_memory_payload( .sum(); drop(coordinators); - let touches = file_touches.read().await; + let touches = file_touch.snapshot().await; let file_touch_path_count = touches.len(); let file_touch_entry_count: usize = touches.values().map(|entries| entries.len()).sum(); let file_touch_estimate_bytes: usize = touches @@ -475,7 +471,7 @@ async fn build_server_memory_payload( .sum(); drop(touches); - let touched_by_session = files_touched_by_session.read().await; + let touched_by_session = file_touch.reverse_snapshot().await; let touched_session_count = touched_by_session.len(); let touched_session_estimate_bytes: usize = touched_by_session .iter() diff --git a/crates/jcode-app-core/src/server/debug_swarm_read.rs b/crates/jcode-app-core/src/server/debug_swarm_read.rs index 42e4c1e66..ee25a47b4 100644 --- a/crates/jcode-app-core/src/server/debug_swarm_read.rs +++ b/crates/jcode-app-core/src/server/debug_swarm_read.rs @@ -1,6 +1,6 @@ use super::swarm_channels::list_channels_for_swarm; use super::{ - FileAccess, ServerIdentity, SharedContext, SwarmMember, SwarmState, VersionedPlan, + FileTouchService, ServerIdentity, SharedContext, SwarmMember, SwarmState, VersionedPlan, git_common_dir_for, swarm_id_for_dir, }; use crate::agent::Agent; @@ -26,7 +26,7 @@ pub(super) async fn maybe_handle_swarm_read_command( shared_context: &Arc>>>, swarm_plans: &Arc>>, swarm_coordinators: &Arc>>, - file_touches: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, server_identity: &ServerIdentity, ) -> Result> { @@ -388,7 +388,7 @@ pub(super) async fn maybe_handle_swarm_read_command( } if cmd == "swarm:touches" { - let touches = file_touches.read().await; + let touches = file_touch.snapshot().await; let members = swarm_members.read().await; let mut out: Vec = Vec::new(); for (path, accesses) in touches.iter() { @@ -419,7 +419,7 @@ pub(super) async fn maybe_handle_swarm_read_command( if cmd.starts_with("swarm:touches:") { let arg = cmd.strip_prefix("swarm:touches:").unwrap_or("").trim(); - let touches = file_touches.read().await; + let touches = file_touch.snapshot().await; let members = swarm_members.read().await; let output = if arg.starts_with("swarm:") { let swarm_id = arg.strip_prefix("swarm:").unwrap_or(""); @@ -485,7 +485,7 @@ pub(super) async fn maybe_handle_swarm_read_command( } if cmd == "swarm:conflicts" { - let touches = file_touches.read().await; + let touches = file_touch.snapshot().await; let members = swarm_members.read().await; let mut out: Vec = Vec::new(); for (path, accesses) in touches.iter() { @@ -615,7 +615,7 @@ pub(super) async fn maybe_handle_swarm_read_command( let members = swarm_members.read().await; let plans = swarm_plans.read().await; let ctx = shared_context.read().await; - let touches = file_touches.read().await; + let touches = file_touch.snapshot().await; let output = if let Some(session_ids) = swarms.get(swarm_id) { let coordinator = coordinators.get(swarm_id); diff --git a/crates/jcode-app-core/src/server/file_touch_service.rs b/crates/jcode-app-core/src/server/file_touch_service.rs new file mode 100644 index 000000000..ba0a0cabc --- /dev/null +++ b/crates/jcode-app-core/src/server/file_touch_service.rs @@ -0,0 +1,147 @@ +//! Service handle that owns the server's file-touch tracking state. +//! +//! Historically the [`Server`](super::Server) struct held two raw +//! `Arc>` maps for file-touch tracking and every call site reached +//! directly into them. This service consolidates that state behind +//! intention-revealing methods so the rest of the server no longer needs to +//! know the internal map shapes or locking order. +//! +//! The two indexes are kept in sync: +//! * forward: `path -> chronological accesses` +//! * reverse: `session_id -> set of touched paths` + +use super::FileAccess; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +/// Shared ownership of the server's file-touch tracking indexes. +/// +/// Cloning is cheap: every clone shares the same underlying `Arc`-backed maps, +/// matching the previous behavior where the raw `Arc>` fields were +/// cloned and passed around. +#[derive(Clone)] +pub(crate) struct FileTouchService { + /// Forward index: path -> list of accesses (chronological order). + touches: Arc>>>, + /// Reverse index: session_id -> set of paths the session has touched. + by_session: Arc>>>, +} + +impl FileTouchService { + /// Create an empty file-touch tracker. + pub(crate) fn new() -> Self { + Self { + touches: Arc::new(RwLock::new(HashMap::new())), + by_session: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Record a single file access, updating both the forward and reverse + /// indexes. The forward index is updated first (and its lock released) + /// before the reverse index, preserving the original locking order. + pub(crate) async fn record_touch(&self, path: PathBuf, access: FileAccess) { + let session_id = access.session_id.clone(); + { + let mut touches = self.touches.write().await; + touches + .entry(path.clone()) + .or_insert_with(Vec::new) + .push(access); + } + { + let mut by_session = self.by_session.write().await; + by_session.entry(session_id).or_default().insert(path); + } + } + + /// Cloned snapshot of all accesses recorded for `path`, or `None` if the + /// path has not been touched. Callers rely on the `Some`/`None` distinction + /// (e.g. for logging "no touches yet" vs computing peer touches). + pub(crate) async fn accesses_for_path(&self, path: &Path) -> Option> { + self.touches.read().await.get(path).cloned() + } + + /// Sorted, display-formatted list of the distinct files a session has + /// touched (empty if the session has touched nothing). + pub(crate) async fn sorted_file_strings_for_session(&self, session_id: &str) -> Vec { + let by_session = self.by_session.read().await; + let mut files: Vec = by_session + .get(session_id) + .into_iter() + .flat_map(|paths| paths.iter()) + .map(|path| path.display().to_string()) + .collect(); + files.sort(); + files + } + + /// Cloned snapshot of the entire forward (`path -> accesses`) index. + /// + /// Used by read-only reporting paths (debug commands, memory accounting) + /// that need to iterate the whole map. + pub(crate) async fn snapshot(&self) -> HashMap> { + self.touches.read().await.clone() + } + + /// Cloned snapshot of the reverse (`session_id -> paths`) index. + pub(crate) async fn reverse_snapshot(&self) -> HashMap> { + self.by_session.read().await.clone() + } + + /// Remove every touch recorded for a session from both indexes. + /// + /// Uses the reverse index to bound the forward-index work to only the + /// paths the session actually touched, falling back to a full scan if the + /// reverse entry is missing. + pub(crate) async fn clear_session(&self, session_id: &str) { + let touched_paths = { + let mut reverse = self.by_session.write().await; + reverse.remove(session_id) + }; + + let mut touches = self.touches.write().await; + if let Some(paths) = touched_paths { + for path in paths { + let mut remove_path = false; + if let Some(accesses) = touches.get_mut(&path) { + accesses.retain(|access| access.session_id != session_id); + remove_path = accesses.is_empty(); + } + if remove_path { + touches.remove(&path); + } + } + return; + } + + touches.retain(|_, accesses| { + accesses.retain(|access| access.session_id != session_id); + !accesses.is_empty() + }); + } + + /// Drop accesses older than `max_age` and rebuild the reverse index from the + /// surviving forward entries. + pub(crate) async fn expire_older_than(&self, max_age: Duration) { + let mut touches = self.touches.write().await; + let now = Instant::now(); + touches.retain(|_, accesses| { + accesses.retain(|access| now.duration_since(access.timestamp) < max_age); + !accesses.is_empty() + }); + let mut rebuilt_reverse_index: HashMap> = HashMap::new(); + for (path, accesses) in touches.iter() { + for access in accesses { + rebuilt_reverse_index + .entry(access.session_id.clone()) + .or_default() + .insert(path.clone()); + } + } + drop(touches); + *self.by_session.write().await = rebuilt_reverse_index; + } +} diff --git a/crates/jcode-app-core/src/server/reload_recovery.rs b/crates/jcode-app-core/src/server/reload_recovery.rs index 20f2c4fa8..b73625d52 100644 --- a/crates/jcode-app-core/src/server/reload_recovery.rs +++ b/crates/jcode-app-core/src/server/reload_recovery.rs @@ -274,15 +274,9 @@ mod tests { // the recovery directory or collide with sibling paths. assert_eq!(sanitize_session_id("../../etc/passwd"), "______etc_passwd"); assert_eq!(sanitize_session_id("a/b\\c"), "a_b_c"); - assert_eq!( - sanitize_session_id("sess.with space"), - "sess_with_space" - ); + assert_eq!(sanitize_session_id("sess.with space"), "sess_with_space"); // Already-safe ids are preserved verbatim. - assert_eq!( - sanitize_session_id("session-abc_123"), - "session-abc_123" - ); + assert_eq!(sanitize_session_id("session-abc_123"), "session-abc_123"); } #[test] @@ -360,8 +354,7 @@ mod tests { // Reading the directive (for History payloads) must leave the durable // intent pending so a lost frame can be retried after reconnect. for _ in 0..3 { - let directive = - pending_directive_for_session(session_id)?.expect("directive present"); + let directive = pending_directive_for_session(session_id)?.expect("directive present"); assert_eq!(directive.continuation_message, "continue please"); assert!(has_pending_for_session(session_id)); } diff --git a/crates/jcode-app-core/src/server/runtime.rs b/crates/jcode-app-core/src/server/runtime.rs index 54b8d31b6..4536a8d23 100644 --- a/crates/jcode-app-core/src/server/runtime.rs +++ b/crates/jcode-app-core/src/server/runtime.rs @@ -3,7 +3,7 @@ use super::debug::{ClientConnectionInfo, ClientDebugState, handle_debug_client}; use super::debug_jobs::DebugJob; use super::util::get_shared_mcp_pool; use super::{ - AwaitMembersRuntime, FileAccess, ServerIdentity, SessionInterruptQueues, SharedContext, + AwaitMembersRuntime, FileTouchService, ServerIdentity, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMutationRuntime, SwarmState, }; use crate::agent::Agent; @@ -14,7 +14,6 @@ use crate::provider::Provider; use crate::transport::{Listener, Stream}; use jcode_agent_runtime::InterruptSignal; use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::time::Instant; @@ -33,8 +32,7 @@ pub(super) struct ServerRuntime { client_connections: Arc>>, swarm_state: SwarmState, shared_context: Arc>>>, - file_touches: Arc>>>, - files_touched_by_session: Arc>>>, + file_touch: FileTouchService, channel_subscriptions: ChannelSubscriptions, channel_subscriptions_by_session: ChannelSubscriptions, client_debug_state: Arc>, @@ -66,8 +64,7 @@ impl ServerRuntime { client_connections: Arc::clone(&server.client_connections), swarm_state: server.swarm_state.clone(), shared_context: Arc::clone(&server.shared_context), - file_touches: Arc::clone(&server.file_touches), - files_touched_by_session: Arc::clone(&server.files_touched_by_session), + file_touch: server.file_touch.clone(), channel_subscriptions: Arc::clone(&server.channel_subscriptions), channel_subscriptions_by_session: Arc::clone(&server.channel_subscriptions_by_session), client_debug_state: Arc::clone(&server.client_debug_state), @@ -217,8 +214,7 @@ impl ServerRuntime { Arc::clone(&self.shared_context), Arc::clone(&self.swarm_state.plans), Arc::clone(&self.swarm_state.coordinators), - Arc::clone(&self.file_touches), - Arc::clone(&self.files_touched_by_session), + self.file_touch.clone(), Arc::clone(&self.channel_subscriptions), Arc::clone(&self.channel_subscriptions_by_session), Arc::clone(&self.client_debug_state), @@ -262,8 +258,7 @@ impl ServerRuntime { Arc::clone(&self.shared_context), Arc::clone(&self.swarm_state.plans), Arc::clone(&self.swarm_state.coordinators), - Arc::clone(&self.file_touches), - Arc::clone(&self.files_touched_by_session), + self.file_touch.clone(), Arc::clone(&self.channel_subscriptions), Arc::clone(&self.channel_subscriptions_by_session), Arc::clone(&self.client_debug_state), diff --git a/crates/jcode-app-core/src/server/swarm.rs b/crates/jcode-app-core/src/server/swarm.rs index a53be2419..b29380418 100644 --- a/crates/jcode-app-core/src/server/swarm.rs +++ b/crates/jcode-app-core/src/server/swarm.rs @@ -1,5 +1,5 @@ use super::state::{MAX_EVENT_HISTORY, fanout_session_event}; -use super::{FileAccess, SwarmEvent, SwarmEventType, SwarmMember, SwarmState, VersionedPlan}; +use super::{SwarmEvent, SwarmEventType, SwarmMember, SwarmState, VersionedPlan}; use super::{persist_swarm_state_for, remove_persisted_swarm_state_for}; use crate::agent::Agent; use crate::plan::{PlanItem, newly_ready_item_ids}; @@ -497,37 +497,6 @@ pub(super) async fn remove_plan_participant( } } -pub(super) async fn remove_session_file_touches( - session_id: &str, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, -) { - let touched_paths = { - let mut reverse = files_touched_by_session.write().await; - reverse.remove(session_id) - }; - - let mut touches = file_touches.write().await; - if let Some(paths) = touched_paths { - for path in paths { - let mut remove_path = false; - if let Some(accesses) = touches.get_mut(&path) { - accesses.retain(|access| access.session_id != session_id); - remove_path = accesses.is_empty(); - } - if remove_path { - touches.remove(&path); - } - } - return; - } - - touches.retain(|_, accesses| { - accesses.retain(|access| access.session_id != session_id); - !accesses.is_empty() - }); -} - pub(super) async fn remove_session_from_swarm( session_id: &str, swarm_id: &str, diff --git a/crates/jcode-app-core/src/tool/agentgrep/context.rs b/crates/jcode-app-core/src/tool/agentgrep/context.rs index 3811a6229..1d1c1c2aa 100644 --- a/crates/jcode-app-core/src/tool/agentgrep/context.rs +++ b/crates/jcode-app-core/src/tool/agentgrep/context.rs @@ -112,7 +112,9 @@ fn collect_tool_exposures(session: &Session) -> Vec { for (message_index, msg) in session.messages.iter().enumerate() { for block in &msg.content { match block { - ContentBlock::ToolUse { id, name, input, .. } => { + ContentBlock::ToolUse { + id, name, input, .. + } => { tool_map.insert( id.clone(), ToolCall { diff --git a/crates/jcode-app-core/src/tool/agentgrep_tests.rs b/crates/jcode-app-core/src/tool/agentgrep_tests.rs index b1976c3bc..ee9aa5d12 100644 --- a/crates/jcode-app-core/src/tool/agentgrep_tests.rs +++ b/crates/jcode-app-core/src/tool/agentgrep_tests.rs @@ -653,7 +653,9 @@ fn bash_exposure_collects_file_and_line_hits() { input: json!({ "command": "cat src/tool/lsp.rs && rg -n auth_status src/tool/lsp.rs" }), - intent: None, thought_signature: None, }; + intent: None, + thought_signature: None, + }; let content = "src/tool/lsp.rs:42:let status = auth_status();\n"; collect_bash_exposure( diff --git a/crates/jcode-app-core/src/tool/selfdev/setup.rs b/crates/jcode-app-core/src/tool/selfdev/setup.rs index 3f07483fb..496329daf 100644 --- a/crates/jcode-app-core/src/tool/selfdev/setup.rs +++ b/crates/jcode-app-core/src/tool/selfdev/setup.rs @@ -21,11 +21,7 @@ impl SetupCheck { } } - fn missing( - name: &'static str, - detail: impl Into, - fix: impl Into, - ) -> Self { + fn missing(name: &'static str, detail: impl Into, fix: impl Into) -> Self { Self { name, ok: false, @@ -102,36 +98,25 @@ impl SelfDevTool { if repo_dir.is_none() { // Only attempt a clone when git is available and we're not in a // synthetic test session. - let git_available = checks - .iter() - .any(|check| check.name == "git" && check.ok); + let git_available = checks.iter().any(|check| check.name == "git" && check.ok); if SelfDevTool::is_test_session() { - clone_note = Some( - "Test mode: skipped cloning the jcode source.".to_string(), - ); + clone_note = Some("Test mode: skipped cloning the jcode source.".to_string()); } else if git_available { match Self::clone_selfdev_source() { Ok(path) => { - clone_note = Some(format!( - "Cloned jcode source into {}.", - path.display() - )); + clone_note = Some(format!("Cloned jcode source into {}.", path.display())); repo_dir = Some(path); } Err(err) => { - clone_note = Some(format!( - "Could not clone jcode source automatically: {err}", - )); + clone_note = + Some(format!("Could not clone jcode source automatically: {err}",)); } } } } match &repo_dir { - Some(path) => checks.push(SetupCheck::ok( - "repository", - path.display().to_string(), - )), + Some(path) => checks.push(SetupCheck::ok("repository", path.display().to_string())), None => { let target = Self::selfdev_clone_dir() .map(|p| p.display().to_string()) @@ -152,10 +137,9 @@ impl SelfDevTool { // build before `selfdev reload`/`enter` can hand off into a dev binary. if let Some(repo) = repo_dir.as_deref() { match build::find_dev_binary(repo) { - Some(binary) => checks.push(SetupCheck::ok( - "dev binary", - binary.display().to_string(), - )), + Some(binary) => { + checks.push(SetupCheck::ok("dev binary", binary.display().to_string())) + } None => checks.push(SetupCheck::missing( "dev binary", "no built binary in target/selfdev or target/release", @@ -222,7 +206,11 @@ impl SelfDevTool { let format_path = |path: Option<&std::path::Path>| match path { Some(p) => { let exists = p.exists(); - format!("{} {}", p.display(), if exists { "(exists)" } else { "(missing)" }) + format!( + "{} {}", + p.display(), + if exists { "(exists)" } else { "(missing)" } + ) } None => "unavailable".to_string(), }; @@ -293,9 +281,7 @@ impl SelfDevTool { /// is strictly newer than the running process). pub(super) async fn do_reload_to_newer_build(&self, _ctx: &ToolContext) -> Result { if SelfDevTool::is_test_session() { - return Ok(ToolOutput::new( - "Test mode: skipped reload-to-newer-build.", - )); + return Ok(ToolOutput::new("Test mode: skipped reload-to-newer-build.")); } if !server::server_has_newer_binary() { diff --git a/crates/jcode-app-core/src/tool/selfdev/tests.rs b/crates/jcode-app-core/src/tool/selfdev/tests.rs index 31a3b8dc3..bc6ca6509 100644 --- a/crates/jcode-app-core/src/tool/selfdev/tests.rs +++ b/crates/jcode-app-core/src/tool/selfdev/tests.rs @@ -324,7 +324,13 @@ fn non_selfdev_schema_only_exposes_onramp_actions() { sorted, vec!["enter", "find-config", "reload", "setup", "status"] ); - for hidden in ["build", "test", "cancel-build", "socket-info", "socket-help"] { + for hidden in [ + "build", + "test", + "cancel-build", + "socket-info", + "socket-help", + ] { assert!( !actions.contains(&hidden), "on-ramp schema should not expose {hidden}" diff --git a/crates/jcode-app-core/src/tool/session_search_tests.rs b/crates/jcode-app-core/src/tool/session_search_tests.rs index dad26e350..e33f321bc 100644 --- a/crates/jcode-app-core/src/tool/session_search_tests.rs +++ b/crates/jcode-app-core/src/tool/session_search_tests.rs @@ -91,7 +91,9 @@ fn tool_use_input_is_hidden_by_default_and_searchable_when_requested() { name: "websearch".to_string(), input: json!({ "query": "best time post hackernews visibility upvotes" - }), thought_signature: None, }], + }), + thought_signature: None, + }], )], ); diff --git a/crates/jcode-app-core/src/update.rs b/crates/jcode-app-core/src/update.rs index 1e4aa1bbf..1fabccef8 100644 --- a/crates/jcode-app-core/src/update.rs +++ b/crates/jcode-app-core/src/update.rs @@ -1157,6 +1157,7 @@ pub fn check_and_maybe_update(auto_install: bool) -> UpdateCheckResult { } } Ok(None) => { + repair_stale_shared_server_after_no_update(); Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::UpToDate)); let mut metadata = UpdateMetadata::load().unwrap_or_default(); metadata.last_check = SystemTime::now(); @@ -1171,6 +1172,27 @@ pub fn check_and_maybe_update(auto_install: bool) -> UpdateCheckResult { } } +fn repair_stale_shared_server_after_no_update() { + match build::repair_stale_shared_server_channel() { + Ok(build::SharedServerRepair::Repaired { + previous, + repaired_to, + }) => { + crate::logging::info(&format!( + "update: repaired stale shared-server channel {:?} -> {} after no-op update check", + previous, repaired_to + )); + } + Ok(build::SharedServerRepair::AlreadyCurrent) => {} + Err(error) => { + crate::logging::warn(&format!( + "update: failed to repair stale shared-server channel after no-op update check: {}", + error + )); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index 9a5287ad0..47224e1e8 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -28,7 +28,6 @@ async-trait = "0.1" # HTTP client reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "blocking", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] } -rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs"] } tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] } # Serialization @@ -37,9 +36,6 @@ serde_json = { version = "1", features = ["raw_value"] } serde_yaml = "0.9" toml = "0.8" -# CLI -clap = { version = "4", features = ["derive"] } - # File operations glob = "0.3" ignore = "0.4" # gitignore-aware file walking @@ -49,7 +45,6 @@ similar = "2" # diffing for edits # Utilities dirs = "5" # home directory anyhow = "1" -thiserror = "1" libc = "0.2" # Unix system calls (flock) chrono = { version = "0.4", features = ["serde"] } regex = "1" @@ -73,56 +68,44 @@ open = "5" # Open URLs in browser jcode-auth-types = { path = "../jcode-auth-types" } jcode-azure-auth = { path = "../jcode-azure-auth" } jcode-agent-runtime = { path = "../jcode-agent-runtime" } -jcode-ambient-types = { path = "../jcode-ambient-types" } -jcode-notify-email = { path = "../jcode-notify-email" } jcode-provider-metadata = { path = "../jcode-provider-metadata" } +jcode-provider-env = { path = "../jcode-provider-env" } jcode-provider-core = { path = "../jcode-provider-core" } +jcode-provider-bedrock = { path = "../jcode-provider-bedrock" } jcode-provider-openai = { path = "../jcode-provider-openai" } jcode-provider-openrouter = { path = "../jcode-provider-openrouter" } jcode-provider-gemini = { path = "../jcode-provider-gemini" } -jcode-tui-markdown = { path = "../jcode-tui-markdown" } -jcode-tui-messages = { path = "../jcode-tui-messages" } -jcode-tui-core = { path = "../jcode-tui-core" } -jcode-tui-mermaid = { path = "../jcode-tui-mermaid" } -jcode-tui-account-picker = { path = "../jcode-tui-account-picker" } -jcode-tui-render = { path = "../jcode-tui-render" } -jcode-tui-session-picker = { path = "../jcode-tui-session-picker", features = ["serde"] } -jcode-tui-style = { path = "../jcode-tui-style" } -jcode-tui-tool-display = { path = "../jcode-tui-tool-display" } -jcode-tui-usage-overlay = { path = "../jcode-tui-usage-overlay" } -jcode-update-core = { path = "../jcode-update-core" } +# NOTE: this foundation layer intentionally does NOT depend on any `jcode-tui-*` +# crate. The two pure-data/string symbols it used to reach for (`ResumeTarget`, +# `reasoning_line_markup`) were moved to `jcode-session-types` and +# `jcode-render-core` respectively, so the layering inversion (foundation +# depending on presentation) is gone. +jcode-render-core = { path = "../jcode-render-core" } jcode-terminal-launch = { path = "../jcode-terminal-launch" } jcode-terminal-image = { path = "../jcode-terminal-image" } -jcode-tui-workspace = { path = "../jcode-tui-workspace" } jcode-usage-types = { path = "../jcode-usage-types" } # Streaming tokio-stream = "0.1" bytes = "1" -# TUI -ratatui = "0.30" +# Terminal I/O (event stream for interactive auth/secret prompts). NOTE: the +# heavier presentation deps (ratatui widgets, arboard clipboard) were dropped +# from this foundation layer; they are unused here and live only in `jcode-tui`. crossterm = { version = "0.29", features = ["event-stream"] } -arboard = "3" # Clipboard support image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # Only PNG/JPEG (skip avif/rav1e, exr, gif, tiff, etc) -# Markdown & syntax highlighting -unicode-width = "0.2" # Unicode character display width - # NOTE: PDF text extraction (jcode-pdf) lives in the upper jcode-app-core crate # (tool/read.rs), not here. jcode-background-types = { path = "../jcode-background-types" } jcode-batch-types = { path = "../jcode-batch-types" } -jcode-build-support = { path = "../jcode-build-support" } jcode-build-meta = { path = "../jcode-build-meta" } jcode-compaction-core = { path = "../jcode-compaction-core" } jcode-config-types = { path = "../jcode-config-types" } jcode-core = { path = "../jcode-core" } jcode-memory-types = { path = "../jcode-memory-types" } jcode-message-types = { path = "../jcode-message-types" } -jcode-overnight-core = { path = "../jcode-overnight-core" } jcode-plan = { path = "../jcode-plan" } -jcode-swarm-core = { path = "../jcode-swarm-core" } jcode-protocol = { path = "../jcode-protocol" } jcode-selfdev-types = { path = "../jcode-selfdev-types" } jcode-session-types = { path = "../jcode-session-types" } @@ -132,19 +115,11 @@ jcode-tool-core = { path = "../jcode-tool-core" } jcode-tool-types = { path = "../jcode-tool-types" } jcode-side-panel-types = { path = "../jcode-side-panel-types" } -# Archive extraction (for auto-update) +# Gzip decoding (used by provider import/helpers) flate2 = "1" -tar = "0.4" tempfile = "3" agentgrep = { git = "https://github.com/1jehuang/agentgrep.git", tag = "v0.1.2" } qrcode = { version = "0.14.1", default-features = false } -aws-config = "1.8.16" -aws-credential-types = "1.2.14" -aws-sdk-bedrockruntime = "1.130.0" -aws-types = "1.3.15" -aws-smithy-types = "1.4.7" -aws-sdk-bedrock = "1.141.0" -aws-sdk-sts = "1.103.0" [features] default = ["embeddings"] diff --git a/crates/jcode-base/src/auth/active_method.rs b/crates/jcode-base/src/auth/active_method.rs index 7e7389419..74dd90f79 100644 --- a/crates/jcode-base/src/auth/active_method.rs +++ b/crates/jcode-base/src/auth/active_method.rs @@ -72,33 +72,32 @@ pub fn resolve_dual_credential_auth( auth: &AuthStatus, runtime_provider: Option<&str>, ) -> Option { - let runtime = runtime_provider.map(|value| value.trim().to_ascii_lowercase()); - - let (has_oauth, has_api_key, forced) = match provider { - ActiveProvider::Claude => { + // Map the execution slot onto the canonical dual-auth provider. Anything + // without an OAuth-vs-API decision (Copilot, Gemini, ...) returns None. + let dual = jcode_provider_core::DualAuthProvider::from_active_provider(provider)?; + + // A single canonical parser decides whether `runtime_provider` explicitly + // pins OAuth or API key for *this* provider. This replaces the per-provider + // hand-written alias matches that used to drift apart. + let forced = jcode_provider_core::pinned_mode_for(dual, runtime_provider).map(|mode| match mode + { + jcode_provider_core::AuthMode::Oauth => ActiveCredential::OAuth, + jcode_provider_core::AuthMode::ApiKey => ActiveCredential::ApiKey, + }); + + let (has_oauth, has_api_key) = match dual { + jcode_provider_core::DualAuthProvider::Anthropic => { let has_oauth = auth.anthropic.has_oauth; // `has_api_key` already folds in the ANTHROPIC_API_KEY env var via the // auth probe, but re-check defensively so an env-only key set after the // cached snapshot still reports honestly. - let has_api_key = auth.anthropic.has_api_key || std::env::var("ANTHROPIC_API_KEY").is_ok(); - let forced = match runtime.as_deref() { - Some("claude-api" | "anthropic-api") => Some(ActiveCredential::ApiKey), - Some("claude" | "anthropic") => Some(ActiveCredential::OAuth), - _ => None, - }; - (has_oauth, has_api_key, forced) + let has_api_key = + auth.anthropic.has_api_key || std::env::var("ANTHROPIC_API_KEY").is_ok(); + (has_oauth, has_api_key) } - ActiveProvider::OpenAI => { - let has_oauth = auth.openai_has_oauth; - let has_api_key = auth.openai_has_api_key; - let forced = match runtime.as_deref() { - Some("openai-api") => Some(ActiveCredential::ApiKey), - Some("openai") => Some(ActiveCredential::OAuth), - _ => None, - }; - (has_oauth, has_api_key, forced) + jcode_provider_core::DualAuthProvider::OpenAI => { + (auth.openai_has_oauth, auth.openai_has_api_key) } - _ => return None, }; let active = match forced { diff --git a/crates/jcode-base/src/build.rs b/crates/jcode-base/src/build.rs deleted file mode 100644 index 6ab7ebd76..000000000 --- a/crates/jcode-base/src/build.rs +++ /dev/null @@ -1 +0,0 @@ -pub use jcode_build_support::*; diff --git a/crates/jcode-base/src/import.rs b/crates/jcode-base/src/import.rs index 3d8ce85a6..f5eb0f695 100644 --- a/crates/jcode-base/src/import.rs +++ b/crates/jcode-base/src/import.rs @@ -375,31 +375,31 @@ pub fn import_session(session_id: &str) -> Result { } pub fn imported_session_id_for_target( - target: &jcode_tui_session_picker::ResumeTarget, + target: &jcode_session_types::ResumeTarget, ) -> Option { match target { - jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } => { + jcode_session_types::ResumeTarget::JcodeSession { session_id } => { Some(session_id.clone()) } - jcode_tui_session_picker::ResumeTarget::ClaudeCodeSession { session_id, .. } => { + jcode_session_types::ResumeTarget::ClaudeCodeSession { session_id, .. } => { Some(imported_claude_code_session_id(session_id)) } - jcode_tui_session_picker::ResumeTarget::CodexSession { session_id, .. } => { + jcode_session_types::ResumeTarget::CodexSession { session_id, .. } => { Some(imported_codex_session_id(session_id)) } - jcode_tui_session_picker::ResumeTarget::PiSession { session_path } => { + jcode_session_types::ResumeTarget::PiSession { session_path } => { Some(imported_pi_session_id(session_path)) } - jcode_tui_session_picker::ResumeTarget::OpenCodeSession { session_id, .. } => { + jcode_session_types::ResumeTarget::OpenCodeSession { session_id, .. } => { Some(imported_opencode_session_id(session_id)) } } } pub fn resolve_resume_target_to_jcode( - target: &jcode_tui_session_picker::ResumeTarget, -) -> Result { - use jcode_tui_session_picker::ResumeTarget; + target: &jcode_session_types::ResumeTarget, +) -> Result { + use jcode_session_types::ResumeTarget; let session_id = match target { ResumeTarget::JcodeSession { session_id } => { diff --git a/crates/jcode-base/src/import_tests.rs b/crates/jcode-base/src/import_tests.rs index aa292d7ce..e3677ce71 100644 --- a/crates/jcode-base/src/import_tests.rs +++ b/crates/jcode-base/src/import_tests.rs @@ -420,7 +420,7 @@ fn test_resolve_resume_target_to_jcode_imports_codex_session() { .unwrap(); let resolved = - resolve_resume_target_to_jcode(&jcode_tui_session_picker::ResumeTarget::CodexSession { + resolve_resume_target_to_jcode(&jcode_session_types::ResumeTarget::CodexSession { session_id: "codex-resolve-test".to_string(), session_path: codex_dir .join("rollout.jsonl") @@ -431,7 +431,7 @@ fn test_resolve_resume_target_to_jcode_imports_codex_session() { assert_eq!( resolved, - jcode_tui_session_picker::ResumeTarget::JcodeSession { + jcode_session_types::ResumeTarget::JcodeSession { session_id: imported_codex_session_id("codex-resolve-test"), } ); diff --git a/crates/jcode-base/src/lib.rs b/crates/jcode-base/src/lib.rs index e3bec7725..f4b3040e8 100644 --- a/crates/jcode-base/src/lib.rs +++ b/crates/jcode-base/src/lib.rs @@ -20,7 +20,6 @@ pub mod auth; pub mod background; pub mod browser; -pub mod build; pub mod bus; pub mod cache_tracker; pub mod client_input; diff --git a/crates/jcode-base/src/provider/anthropic.rs b/crates/jcode-base/src/provider/anthropic.rs index a323d6be8..6f25a75fe 100644 --- a/crates/jcode-base/src/provider/anthropic.rs +++ b/crates/jcode-base/src/provider/anthropic.rs @@ -415,21 +415,44 @@ pub(crate) enum AnthropicCredentialMode { impl AnthropicCredentialMode { fn from_runtime_env() -> Self { - match std::env::var("JCODE_RUNTIME_PROVIDER") - .ok() - .map(|value| value.trim().to_ascii_lowercase()) - .as_deref() - { - Some("claude-api" | "anthropic-api") => Self::ApiKey, - Some("claude" | "anthropic") => Self::OAuth, - _ => Self::Auto, + // Canonical parse: recognizes every runtime/route/CLI/prefix alias for + // the Anthropic OAuth-vs-API decision in one place, so this can never + // drift from the other vocabularies (see jcode_provider_core::auth_mode). + match jcode_provider_core::runtime_env_pinned_mode( + jcode_provider_core::DualAuthProvider::Anthropic, + ) { + Some(jcode_provider_core::AuthMode::ApiKey) => Self::ApiKey, + Some(jcode_provider_core::AuthMode::Oauth) => Self::OAuth, + None => Self::Auto, + } + } + + /// The canonical dual-auth route this explicit mode pins, if any. + /// `Auto` has no explicit pin and returns `None`. + pub(crate) fn auth_route(self) -> Option { + use jcode_provider_core::{AuthMode, AuthRoute}; + match self { + Self::Auto => None, + Self::OAuth => Some(AuthRoute::anthropic(AuthMode::Oauth)), + Self::ApiKey => Some(AuthRoute::anthropic(AuthMode::ApiKey)), } } } pub(crate) fn load_anthropic_api_key() -> Result { - crate::provider_catalog::load_api_key_from_env_or_config("ANTHROPIC_API_KEY", "anthropic.env") - .context("No Anthropic API key found") + let key = crate::provider_catalog::load_api_key_from_env_or_config( + "ANTHROPIC_API_KEY", + "anthropic.env", + ) + .context("No Anthropic API key found")?; + if std::env::var("JCODE_LOG_SERVICE_TIER").is_ok() { + let prefix: String = key.chars().take(14).collect(); + eprintln!( + "[anthropic] resolved API key prefix={prefix}... (len={})", + key.len() + ); + } + Ok(key) } pub(crate) fn has_anthropic_api_key() -> bool { @@ -844,14 +867,8 @@ impl AnthropicProvider { // choice so UI surfaces (model picker, header widget) report the auth // method that requests will actually use, instead of inferring it from // credential presence. `Auto` leaves the existing identity untouched. - match mode { - AnthropicCredentialMode::OAuth => { - crate::env::set_var("JCODE_RUNTIME_PROVIDER", "claude"); - } - AnthropicCredentialMode::ApiKey => { - crate::env::set_var("JCODE_RUNTIME_PROVIDER", "claude-api"); - } - AnthropicCredentialMode::Auto => {} + if let Some(route) = mode.auth_route() { + crate::env::set_var("JCODE_RUNTIME_PROVIDER", route.runtime_provider_key()); } Ok(()) } @@ -1067,7 +1084,9 @@ impl AnthropicProvider { signature: signature.clone(), }); } - ContentBlock::ToolUse { id, name, input, .. } => { + ContentBlock::ToolUse { + id, name, input, .. + } => { result.push(ApiContentBlock::ToolUse { id: crate::message::sanitize_tool_id(id), name: if is_oauth { @@ -2094,6 +2113,12 @@ fn process_sse_event( *input_tokens = usage.input_tokens.map(|t| t as u64); *cache_read_input_tokens = usage.cache_read_input_tokens.map(|t| t as u64); *cache_creation_input_tokens = usage.cache_creation_input_tokens.map(|t| t as u64); + if let Some(tier) = usage.service_tier.as_deref() { + crate::logging::info(&format!("Anthropic granted service_tier={}", tier)); + if std::env::var("JCODE_LOG_SERVICE_TIER").is_ok() { + eprintln!("[anthropic] granted service_tier={tier}"); + } + } } } "content_block_start" => { @@ -2591,6 +2616,7 @@ struct UsageInfo { output_tokens: Option, cache_read_input_tokens: Option, cache_creation_input_tokens: Option, + service_tier: Option, } #[cfg(test)] diff --git a/crates/jcode-base/src/provider/bedrock.rs b/crates/jcode-base/src/provider/bedrock.rs index cfdd524f4..712f7768d 100644 --- a/crates/jcode-base/src/provider/bedrock.rs +++ b/crates/jcode-base/src/provider/bedrock.rs @@ -1,1757 +1 @@ -use super::{ - DEFAULT_CONTEXT_LIMIT, EventStream, ModelCatalogRefreshSummary, ModelRoute, Provider, - RouteCheapnessEstimate, RouteCostConfidence, RouteCostSource, summarize_model_catalog_refresh, -}; -use crate::message::{ - ContentBlock as JContentBlock, Message as JMessage, Role as JRole, StreamEvent, ToolDefinition, -}; -use anyhow::{Context, Result}; -use async_trait::async_trait; -use aws_config::BehaviorVersion; -use aws_credential_types::Credentials; -use aws_sdk_bedrock::Client as BedrockControlClient; -use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient; -use aws_sdk_bedrockruntime::types::{ - ContentBlock, ContentBlockDelta, ContentBlockStart, ConversationRole, ConverseStreamOutput, - ImageBlock, ImageFormat, ImageSource, InferenceConfiguration, Message, - ReasoningContentBlockDelta, SystemContentBlock, Tool, ToolConfiguration, ToolInputSchema, - ToolSpecification, -}; -use aws_smithy_types::Blob; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::collections::{HashMap, HashSet}; -use std::pin::Pin; -use std::sync::{Arc, RwLock}; -use tokio::sync::mpsc; -use tokio_stream::wrappers::ReceiverStream; - -const DEFAULT_MODEL: &str = "anthropic.claude-3-5-sonnet-20241022-v2:0"; -const DEFAULT_MAX_OUTPUT_TOKENS: usize = 4096; -pub const ENV_FILE: &str = "bedrock.env"; -pub const API_KEY_ENV: &str = "AWS_BEARER_TOKEN_BEDROCK"; -pub const REGION_ENV: &str = "JCODE_BEDROCK_REGION"; - -#[derive(Debug, Clone)] -struct BedrockModelInfo { - context_tokens: usize, - max_output_tokens: usize, - supports_tools: bool, - supports_vision: bool, - supports_reasoning: bool, - pricing: Option<(u64, u64)>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct PersistedCatalog { - models: Vec, - inference_profiles: Vec, - #[serde(default)] - profile_required_models: Vec, - #[serde(default)] - inference_profile_routes: HashMap, - #[serde(default)] - legacy_models: Vec, - region: Option, - fetched_at_rfc3339: String, -} - -pub struct BedrockProvider { - model: Arc>, - fetched_models: Arc>>, - fetched_inference_profiles: Arc>>, - profile_required_models: Arc>>, - inference_profile_routes: Arc>>, - legacy_models: Arc>>, -} - -impl BedrockProvider { - pub fn new() -> Self { - let model = - std::env::var("JCODE_BEDROCK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); - let provider = Self { - model: Arc::new(RwLock::new(model)), - fetched_models: Arc::new(RwLock::new(Vec::new())), - fetched_inference_profiles: Arc::new(RwLock::new(Vec::new())), - profile_required_models: Arc::new(RwLock::new(HashSet::new())), - inference_profile_routes: Arc::new(RwLock::new(HashMap::new())), - legacy_models: Arc::new(RwLock::new(HashSet::new())), - }; - provider.seed_cached_catalog(); - provider - } - - pub fn has_credentials() -> bool { - let explicitly_enabled = std::env::var("JCODE_BEDROCK_ENABLE") - .ok() - .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) - .unwrap_or(false); - if explicitly_enabled { - return true; - } - - let has_region = Self::configured_region().is_some(); - let has_credential_hint = Self::configured_bearer_token().is_some() - || std::env::var_os("AWS_ACCESS_KEY_ID").is_some() - || std::env::var_os("AWS_PROFILE").is_some() - || std::env::var_os("JCODE_BEDROCK_PROFILE").is_some() - || std::env::var_os("AWS_WEB_IDENTITY_TOKEN_FILE").is_some() - || std::env::var_os("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI").is_some() - || std::env::var_os("AWS_CONTAINER_CREDENTIALS_FULL_URI").is_some() - || std::env::var_os("AWS_SHARED_CREDENTIALS_FILE").is_some() - || std::env::var_os("AWS_CONFIG_FILE").is_some(); - - has_region && has_credential_hint - } - - async fn sdk_config() -> aws_types::SdkConfig { - let mut loader = aws_config::defaults(BehaviorVersion::latest()); - if let Some(token) = Self::configured_bearer_token() { - crate::env::set_var(API_KEY_ENV, token); - } - if let Some(region) = Self::configured_region() { - loader = loader.region(aws_types::region::Region::new(region)); - } - if let Ok(profile) = - std::env::var("JCODE_BEDROCK_PROFILE").or_else(|_| std::env::var("AWS_PROFILE")) - { - if let Some(credentials) = Self::credentials_from_aws_login_profile(&profile).await { - loader = loader.credentials_provider(credentials); - } - loader = loader.profile_name(profile); - } - loader.load().await - } - - async fn credentials_from_aws_login_profile(profile: &str) -> Option { - if std::env::var_os("AWS_ACCESS_KEY_ID").is_some() - || std::env::var_os("AWS_SECRET_ACCESS_KEY").is_some() - || std::env::var_os("AWS_BEARER_TOKEN_BEDROCK").is_some() - { - return None; - } - - let output = tokio::process::Command::new("aws") - .args([ - "configure", - "export-credentials", - "--profile", - profile, - "--format", - "env-no-export", - ]) - .output() - .await - .ok()?; - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8(output.stdout).ok()?; - let mut access_key_id = None; - let mut secret_access_key = None; - let mut session_token = None; - for line in stdout.lines() { - let Some((key, value)) = line.split_once('=') else { - continue; - }; - match key.trim() { - "AWS_ACCESS_KEY_ID" => access_key_id = Some(value.trim().to_string()), - "AWS_SECRET_ACCESS_KEY" => secret_access_key = Some(value.trim().to_string()), - "AWS_SESSION_TOKEN" => session_token = Some(value.trim().to_string()), - _ => {} - } - } - - Some(Credentials::new( - access_key_id?, - secret_access_key?, - session_token, - None, - "aws-cli-export-credentials", - )) - } - - async fn runtime_client() -> BedrockRuntimeClient { - let config = Self::sdk_config().await; - BedrockRuntimeClient::new(&config) - } - - async fn control_client() -> BedrockControlClient { - let config = Self::sdk_config().await; - BedrockControlClient::new(&config) - } - - async fn validate_credentials_if_requested() -> Result<()> { - let validate = std::env::var("JCODE_BEDROCK_VALIDATE_STS") - .ok() - .map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no")) - .unwrap_or(false); - if !validate { - return Ok(()); - } - let config = Self::sdk_config().await; - let client = aws_sdk_sts::Client::new(&config); - client - .get_caller_identity() - .send() - .await - .map(|_| ()) - .map_err(|err| { - anyhow::anyhow!(Self::classify_error_message(&Self::sdk_error_message(&err))) - }) - } - - fn configured_region() -> Option { - Self::env_or_config(REGION_ENV) - .or_else(|| Self::env_or_config("AWS_REGION")) - .or_else(|| Self::env_or_config("AWS_DEFAULT_REGION")) - } - - pub fn configured_bearer_token() -> Option { - crate::provider_catalog::load_api_key_from_env_or_config(API_KEY_ENV, ENV_FILE) - } - - fn env_or_config(name: &str) -> Option { - std::env::var(name) - .ok() - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .or_else(|| crate::provider_catalog::load_env_value_from_env_or_config(name, ENV_FILE)) - } - - fn persisted_catalog_path() -> Result { - Ok(crate::storage::app_config_dir()?.join("bedrock_models_cache.json")) - } - - fn load_persisted_catalog() -> Option { - let path = Self::persisted_catalog_path().ok()?; - crate::storage::read_json(&path).ok() - } - - fn persist_catalog( - models: &[String], - inference_profiles: &[String], - profile_required_models: &HashSet, - inference_profile_routes: &HashMap, - legacy_models: &HashSet, - ) { - let Ok(path) = Self::persisted_catalog_path() else { - return; - }; - let payload = PersistedCatalog { - models: models.to_vec(), - inference_profiles: inference_profiles.to_vec(), - profile_required_models: profile_required_models.iter().cloned().collect(), - inference_profile_routes: inference_profile_routes.clone(), - legacy_models: legacy_models.iter().cloned().collect(), - region: Self::configured_region(), - fetched_at_rfc3339: chrono::Utc::now().to_rfc3339(), - }; - if let Err(err) = crate::storage::write_json(&path, &payload) { - crate::logging::warn(&format!( - "Failed to persist Bedrock model catalog {}: {}", - path.display(), - err - )); - } - } - - fn seed_cached_catalog(&self) { - if let Some(catalog) = Self::load_persisted_catalog() { - let configured_region = Self::configured_region(); - if catalog.region.as_deref() != configured_region.as_deref() { - crate::logging::info(&format!( - "Ignoring Bedrock model cache for region {:?}; configured region is {:?}", - catalog.region, configured_region - )); - return; - } - let PersistedCatalog { - models: cached_models, - inference_profiles, - profile_required_models, - inference_profile_routes, - legacy_models, - .. - } = catalog; - let mut inference_profile_routes = inference_profile_routes; - Self::merge_profile_routes_from_profile_ids( - &mut inference_profile_routes, - inference_profiles.iter(), - ); - if let Ok(mut guard) = self.fetched_models.write() { - *guard = cached_models; - } - if let Ok(mut profiles) = self.fetched_inference_profiles.write() { - *profiles = inference_profiles; - } - if let Ok(mut required) = self.profile_required_models.write() { - *required = profile_required_models.into_iter().collect(); - } - if let Ok(mut routes) = self.inference_profile_routes.write() { - *routes = inference_profile_routes; - } - if let Ok(mut legacy) = self.legacy_models.write() { - *legacy = legacy_models.into_iter().collect(); - } - } - } - - fn classify_error_message(raw: &str) -> String { - let lower = raw.to_ascii_lowercase(); - let is_legacy_model_error = lower.contains("marked by provider as legacy") - || lower.contains("model is marked") && lower.contains("legacy") - || lower.contains("have not been actively using the model in the last 30 days"); - if is_legacy_model_error { - return format!( - "{} Original error: {}", - "This Bedrock model is marked as legacy for this account. Choose an active Bedrock model or an active inference profile instead.", - raw.trim() - ); - } else if lower.contains("doesn't support tool use") - || lower.contains("does not support tool use") - || lower.contains("tool use in streaming mode") - { - return format!( - "{} Original error: {}", - "This Bedrock model does not support tool use with streaming. Choose a Bedrock model with tool support, such as a Claude or Nova profile, or use a no-tools Bedrock model route.", - raw.trim() - ); - } else if lower.contains("no credentials") - || lower.contains("could not load credentials") - || lower.contains("credentials") && lower.contains("not loaded") - { - return "AWS credentials were not found. Set AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE, AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, or run `aws sso login`.".to_string(); - } else if lower.contains("expired") || lower.contains("sso") && lower.contains("token") { - return "AWS SSO/session credentials look expired. Run `aws sso login --profile ` and retry.".to_string(); - } - - let hint = if lower.contains("accessdenied") - || lower.contains("access denied") - || lower.contains("not authorized") - { - "AWS IAM denied the Bedrock request. Ensure the principal can call bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream, bedrock:ListFoundationModels, and bedrock:ListInferenceProfiles as needed." - } else if lower.contains("validationexception") && lower.contains("model") - || lower.contains("model") && lower.contains("not found") - || lower.contains("resource not found") - { - "Bedrock did not recognize this model in the selected region/account. Check model ID, inference profile ID, region, and model access." - } else if lower.contains("throttl") - || lower.contains("too many requests") - || lower.contains("rate exceeded") - { - "Bedrock throttled the request. Retry later or request a quota increase." - } else if lower.contains("region") && lower.contains("missing") { - "AWS region is missing. Set AWS_REGION or JCODE_BEDROCK_REGION." - } else { - "Bedrock request failed. Check AWS credentials, region, model access, and IAM permissions." - }; - format!("{} Original error: {}", hint, raw.trim()) - } - - fn sdk_error_message(err: &(impl std::fmt::Display + std::fmt::Debug)) -> String { - let display = err.to_string(); - let trimmed = display.trim(); - if trimmed.is_empty() - || trimmed.eq_ignore_ascii_case("service error") - || trimmed.eq_ignore_ascii_case("dispatch failure") - { - format!("{err:?}") - } else { - display - } - } - - fn json_to_document(value: &serde_json::Value) -> aws_smithy_types::Document { - match value { - serde_json::Value::Null => aws_smithy_types::Document::Null, - serde_json::Value::Bool(v) => aws_smithy_types::Document::Bool(*v), - serde_json::Value::Number(n) => { - if let Some(v) = n.as_u64() { - aws_smithy_types::Document::from(v) - } else if let Some(v) = n.as_i64() { - aws_smithy_types::Document::from(v) - } else if let Some(v) = n.as_f64() { - aws_smithy_types::Document::from(v) - } else { - aws_smithy_types::Document::Null - } - } - serde_json::Value::String(v) => aws_smithy_types::Document::String(v.clone()), - serde_json::Value::Array(values) => aws_smithy_types::Document::Array( - values.iter().map(Self::json_to_document).collect(), - ), - serde_json::Value::Object(map) => aws_smithy_types::Document::Object( - map.iter() - .map(|(key, value)| (key.clone(), Self::json_to_document(value))) - .collect::>(), - ), - } - } - - fn image_format_for_media_type(media_type: &str) -> Option { - match media_type.trim().to_ascii_lowercase().as_str() { - "image/png" => Some(ImageFormat::Png), - "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), - "image/gif" => Some(ImageFormat::Gif), - "image/webp" => Some(ImageFormat::Webp), - _ => None, - } - } - - fn image_block(media_type: &str, data: &str) -> Result { - let format = Self::image_format_for_media_type(media_type).ok_or_else(|| { - anyhow::anyhow!( - "Bedrock image input does not support media type `{}`", - media_type - ) - })?; - let bytes = BASE64.decode(data).with_context(|| { - format!("Failed to decode {} image payload for Bedrock", media_type) - })?; - ImageBlock::builder() - .format(format) - .source(ImageSource::Bytes(Blob::new(bytes))) - .build() - .context("Failed to build Bedrock image block") - } - - fn to_bedrock_messages(messages: &[JMessage], allow_images: bool) -> Result> { - messages - .iter() - .filter_map(|msg| { - let role = match msg.role { - JRole::User => ConversationRole::User, - JRole::Assistant => ConversationRole::Assistant, - }; - let mut content = Vec::new(); - for block in &msg.content { - match block { - JContentBlock::Text { text, .. } => { - content.push(ContentBlock::Text(text.clone())) - } - JContentBlock::Image { media_type, data } => { - if !allow_images { - return Some(Err(anyhow::anyhow!( - "Current Bedrock model does not advertise image input support" - ))); - } - match Self::image_block(media_type, data) { - Ok(image) => content.push(ContentBlock::Image(image)), - Err(err) => return Some(Err(err)), - } - } - JContentBlock::ToolResult { - tool_use_id, - content: text, - is_error, - } => { - let status = if is_error.unwrap_or(false) { - aws_sdk_bedrockruntime::types::ToolResultStatus::Error - } else { - aws_sdk_bedrockruntime::types::ToolResultStatus::Success - }; - let result = - match aws_sdk_bedrockruntime::types::ToolResultBlock::builder() - .tool_use_id(tool_use_id) - .status(status) - .content( - aws_sdk_bedrockruntime::types::ToolResultContentBlock::Text( - text.clone(), - ), - ) - .build() - { - Ok(result) => result, - Err(err) => return Some(Err(anyhow::anyhow!(err))), - }; - content.push(ContentBlock::ToolResult(result)); - } - JContentBlock::ToolUse { - id, name, input, .. - } => { - let tool_use = - match aws_sdk_bedrockruntime::types::ToolUseBlock::builder() - .tool_use_id(id) - .name(name) - .input(Self::json_to_document(input)) - .build() - { - Ok(tool_use) => tool_use, - Err(err) => return Some(Err(anyhow::anyhow!(err))), - }; - content.push(ContentBlock::ToolUse(tool_use)); - } - _ => {} - } - } - if content.is_empty() { - return None; - } - Some( - Message::builder() - .role(role) - .set_content(Some(content)) - .build() - .map_err(|err| anyhow::anyhow!(err)), - ) - }) - .collect() - } - - fn tool_config(tools: &[ToolDefinition]) -> Option { - if tools.is_empty() { - return None; - } - let bedrock_tools = tools - .iter() - .filter_map(|tool| { - let schema = ToolInputSchema::Json(Self::json_to_document(&tool.input_schema)); - ToolSpecification::builder() - .name(&tool.name) - .description(tool.description.clone()) - .input_schema(schema) - .build() - .ok() - .map(Tool::ToolSpec) - }) - .collect::>(); - if bedrock_tools.is_empty() { - None - } else { - ToolConfiguration::builder() - .set_tools(Some(bedrock_tools)) - .build() - .ok() - } - } - - fn inference_config() -> Option { - let max_tokens = std::env::var("JCODE_BEDROCK_MAX_TOKENS") - .ok() - .and_then(|v| v.trim().parse::().ok()) - .filter(|v| *v > 0); - let temperature = std::env::var("JCODE_BEDROCK_TEMPERATURE") - .ok() - .and_then(|v| v.trim().parse::().ok()) - .filter(|v| (0.0..=1.0).contains(v)); - let top_p = std::env::var("JCODE_BEDROCK_TOP_P") - .ok() - .and_then(|v| v.trim().parse::().ok()) - .filter(|v| (0.0..=1.0).contains(v)); - let stop_sequences = std::env::var("JCODE_BEDROCK_STOP_SEQUENCES") - .ok() - .map(|v| { - v.split(',') - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(str::to_string) - .collect::>() - }) - .filter(|v| !v.is_empty()); - if max_tokens.is_none() - && temperature.is_none() - && top_p.is_none() - && stop_sequences.is_none() - { - return None; - } - Some( - InferenceConfiguration::builder() - .set_max_tokens(max_tokens) - .set_temperature(temperature) - .set_top_p(top_p) - .set_stop_sequences(stop_sequences) - .build(), - ) - } - - fn normalize_model_id(model: &str) -> String { - let mut value = model.trim().to_string(); - if let Some((_, tail)) = value.rsplit_once('/') { - value = tail.to_string(); - } - for prefix in ["us.", "eu.", "apac.", "global."] { - if let Some(stripped) = value.strip_prefix(prefix) { - value = stripped.to_string(); - break; - } - } - value - } - - fn foundation_model_id_from_arn(arn: &str) -> Option { - arn.rsplit_once("foundation-model/") - .map(|(_, model)| model.trim()) - .filter(|model| !model.is_empty()) - .map(str::to_string) - } - - fn inference_profile_id_from_arn(arn: &str) -> Option { - arn.rsplit_once("inference-profile/") - .map(|(_, profile)| profile.trim()) - .filter(|profile| !profile.is_empty()) - .map(str::to_string) - } - - fn foundation_model_id_from_profile_id(profile_id: &str) -> Option { - let id = profile_id.trim(); - let id = Self::inference_profile_id_from_arn(id).unwrap_or_else(|| id.to_string()); - for prefix in ["us.", "eu.", "apac.", "global."] { - if let Some(model) = id.strip_prefix(prefix) - && !model.is_empty() - { - return Some(model.to_string()); - } - } - None - } - - fn region_profile_prefix() -> Option<&'static str> { - let region = Self::configured_region()?; - if region.starts_with("us-") { - Some("us.") - } else if region.starts_with("eu-") { - Some("eu.") - } else if region.starts_with("ap-") { - Some("apac.") - } else { - None - } - } - - fn inference_profile_priority(profile_id: &str) -> u8 { - let id = profile_id.trim().to_ascii_lowercase(); - if let Some(prefix) = Self::region_profile_prefix() - && id.starts_with(prefix) - { - return 0; - } - if id.starts_with("us.") || id.starts_with("eu.") || id.starts_with("apac.") { - 1 - } else if id.starts_with("global.") { - 2 - } else { - 3 - } - } - - fn insert_preferred_profile_route( - routes: &mut HashMap, - foundation_model: &str, - profile_id: &str, - ) { - let foundation_model = foundation_model.trim(); - let profile_id = profile_id.trim(); - if foundation_model.is_empty() || profile_id.is_empty() { - return; - } - let should_replace = routes - .get(foundation_model) - .map(|current| { - Self::inference_profile_priority(profile_id) - < Self::inference_profile_priority(current) - }) - .unwrap_or(true); - if should_replace { - routes.insert(foundation_model.to_string(), profile_id.to_string()); - } - } - - fn merge_profile_routes_from_profile_ids( - routes: &mut HashMap, - profiles: impl IntoIterator>, - ) { - for profile in profiles { - let profile = profile.as_ref().trim(); - let Some(foundation_model) = Self::foundation_model_id_from_profile_id(profile) else { - continue; - }; - let profile_id = - Self::inference_profile_id_from_arn(profile).unwrap_or_else(|| profile.to_string()); - Self::insert_preferred_profile_route(routes, &foundation_model, &profile_id); - } - } - - fn profile_route_for_model(&self, model: &str) -> Option { - let model = model.trim(); - if model.is_empty() { - return None; - } - - if let Ok(routes) = self.inference_profile_routes.read() - && let Some(route) = routes.get(model).cloned() - { - return Some(route); - } - - if let Ok(profiles) = self.fetched_inference_profiles.read() { - let mut derived = HashMap::new(); - Self::merge_profile_routes_from_profile_ids(&mut derived, profiles.iter()); - if let Some(route) = derived.get(model).cloned() { - return Some(route); - } - } - - None - } - - pub fn is_bedrock_model_id(model: &str) -> bool { - let trimmed = model.trim(); - if trimmed.is_empty() { - return false; - } - if trimmed.starts_with("arn:aws:bedrock:") { - return true; - } - - let id = Self::normalize_model_id(trimmed).to_ascii_lowercase(); - id.starts_with("anthropic.") - || id.starts_with("amazon.") - || id.starts_with("cohere.") - || id.starts_with("ai21.") - || id.starts_with("meta.") - || id.starts_with("mistral.") - || id.starts_with("stability.") - || id.starts_with("writer.") - || id.starts_with("deepseek.") - || id.starts_with("openai.") - || id.starts_with("qwen.") - || id.starts_with("moonshot.") - || id.starts_with("moonshotai.") - || id.starts_with("minimax.") - || id.starts_with("zai.") - || id.starts_with("google.") - || id.starts_with("nvidia.") - } - - fn model_info(model: &str) -> BedrockModelInfo { - let id = Self::normalize_model_id(model).to_ascii_lowercase(); - if id.contains("claude-opus-4") || id.contains("claude-sonnet-4") { - BedrockModelInfo { - context_tokens: 200_000, - max_output_tokens: 64_000, - supports_tools: true, - supports_vision: true, - supports_reasoning: true, - pricing: Some((3_000_000, 15_000_000)), - } - } else if id.contains("claude-3-7-sonnet") || id.contains("claude-3-5-sonnet") { - BedrockModelInfo { - context_tokens: 200_000, - max_output_tokens: 8_192, - supports_tools: true, - supports_vision: true, - supports_reasoning: id.contains("3-7"), - pricing: Some((3_000_000, 15_000_000)), - } - } else if id.contains("claude-3-5-haiku") || id.contains("claude-3-haiku") { - BedrockModelInfo { - context_tokens: 200_000, - max_output_tokens: 8_192, - supports_tools: true, - supports_vision: true, - supports_reasoning: false, - pricing: Some((800_000, 4_000_000)), - } - } else if id.contains("amazon.nova-pro") { - BedrockModelInfo { - context_tokens: 300_000, - max_output_tokens: 5_120, - supports_tools: true, - supports_vision: true, - supports_reasoning: false, - pricing: Some((800_000, 3_200_000)), - } - } else if id.contains("amazon.nova-2-lite") || id.contains("amazon.nova-lite") { - BedrockModelInfo { - context_tokens: 300_000, - max_output_tokens: 5_120, - supports_tools: true, - supports_vision: true, - supports_reasoning: false, - pricing: Some((60_000, 240_000)), - } - } else if id.contains("amazon.nova-micro") { - BedrockModelInfo { - context_tokens: 128_000, - max_output_tokens: 5_120, - supports_tools: true, - supports_vision: false, - supports_reasoning: false, - pricing: Some((35_000, 140_000)), - } - } else if id.starts_with("deepseek.") { - BedrockModelInfo { - context_tokens: 128_000, - max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, - supports_tools: false, - supports_vision: false, - supports_reasoning: true, - pricing: None, - } - } else if id.contains("llama3-1-405b") || id.starts_with("meta.") { - BedrockModelInfo { - context_tokens: 128_000, - max_output_tokens: 4_096, - supports_tools: false, - supports_vision: false, - supports_reasoning: false, - pricing: Some((5_320_000, 16_000_000)), - } - } else if id.starts_with("mistral.") { - BedrockModelInfo { - context_tokens: 128_000, - max_output_tokens: 8_192, - supports_tools: false, - supports_vision: false, - supports_reasoning: false, - pricing: Some((4_000_000, 12_000_000)), - } - } else if id.starts_with("openai.") - || id.starts_with("qwen.") - || id.starts_with("moonshot.") - || id.starts_with("moonshotai.") - || id.starts_with("minimax.") - || id.starts_with("zai.") - || id.starts_with("google.") - || id.starts_with("nvidia.") - || id.starts_with("writer.") - { - BedrockModelInfo { - context_tokens: DEFAULT_CONTEXT_LIMIT, - max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, - supports_tools: false, - supports_vision: false, - supports_reasoning: id.contains("thinking") - || id.contains("reason") - || id.contains("gpt-oss"), - pricing: None, - } - } else { - BedrockModelInfo { - context_tokens: DEFAULT_CONTEXT_LIMIT, - max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, - supports_tools: false, - supports_vision: false, - supports_reasoning: false, - pricing: None, - } - } - } - - fn route_pricing(model: &str) -> Option { - let info = Self::model_info(model); - info.pricing.map(|(input, output)| { - RouteCheapnessEstimate::metered( - RouteCostSource::Heuristic, - RouteCostConfidence::Medium, - input, - output, - None, - Some("AWS Bedrock public on-demand pricing heuristic; verify for your region/account".to_string()), - ) - }) - } - - fn known_models() -> Vec<&'static str> { - vec![ - "anthropic.claude-3-5-sonnet-20241022-v2:0", - "anthropic.claude-3-5-haiku-20241022-v1:0", - "anthropic.claude-3-7-sonnet-20250219-v1:0", - "anthropic.claude-sonnet-4-20250514-v1:0", - "anthropic.claude-opus-4-20250514-v1:0", - "amazon.nova-pro-v1:0", - "amazon.nova-lite-v1:0", - "amazon.nova-micro-v1:0", - "meta.llama3-1-405b-instruct-v1:0", - "mistral.mistral-large-2407-v1:0", - ] - } - - fn all_display_models(&self) -> Vec { - let mut seen = HashSet::new(); - let mut models = Vec::new(); - let inference_profile_routes = self - .inference_profile_routes - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); - let should_hide_duplicate_foundation_model = - |model: &str| inference_profile_routes.contains_key(model); - for model in Self::known_models().into_iter().map(str::to_string) { - if should_hide_duplicate_foundation_model(&model) { - continue; - } - if seen.insert(model.clone()) { - models.push(model); - } - } - if let Ok(fetched) = self.fetched_models.read() { - for model in fetched.iter() { - if should_hide_duplicate_foundation_model(model) { - continue; - } - if seen.insert(model.clone()) { - models.push(model.clone()); - } - } - } - if let Ok(profiles) = self.fetched_inference_profiles.read() { - for profile in profiles.iter() { - if seen.insert(profile.clone()) { - models.push(profile.clone()); - } - } - } - models - } - - async fn refresh_catalog(&self) -> Result<(Vec, Vec)> { - let client = Self::control_client().await; - let mut models = Vec::new(); - let mut profile_required_models = HashSet::new(); - let mut legacy_models = HashSet::new(); - let model_resp = client - .list_foundation_models() - .send() - .await - .map_err(|err| { - anyhow::anyhow!(Self::classify_error_message(&Self::sdk_error_message(&err))) - })?; - for summary in model_resp.model_summaries() { - let model_id = summary.model_id(); - if !model_id.is_empty() { - models.push(model_id.to_string()); - let inference_types = summary.inference_types_supported(); - let supports_on_demand = inference_types - .iter() - .any(|kind| kind.as_str() == "ON_DEMAND"); - let supports_inference_profile = inference_types - .iter() - .any(|kind| kind.as_str() == "INFERENCE_PROFILE"); - if supports_inference_profile && !supports_on_demand { - profile_required_models.insert(model_id.to_string()); - } - if summary - .model_lifecycle() - .map(|lifecycle| lifecycle.status().as_str() == "LEGACY") - .unwrap_or(false) - { - legacy_models.insert(model_id.to_string()); - } - } - } - models.sort(); - models.dedup(); - - let mut profiles = Vec::new(); - let mut inference_profile_routes = HashMap::new(); - match client.list_inference_profiles().send().await { - Ok(resp) => { - for summary in resp.inference_profile_summaries() { - let id = summary.inference_profile_id(); - if !id.is_empty() { - profiles.push(id.to_string()); - } - let arn = summary.inference_profile_arn(); - if !arn.is_empty() { - profiles.push(arn.to_string()); - } - if summary.status().as_str() == "ACTIVE" && !id.is_empty() { - for model in summary.models() { - if let Some(model_arn) = model.model_arn() - && let Some(foundation_model) = - Self::foundation_model_id_from_arn(model_arn) - { - Self::insert_preferred_profile_route( - &mut inference_profile_routes, - &foundation_model, - id, - ); - } - } - } - } - profiles.sort(); - profiles.dedup(); - Self::merge_profile_routes_from_profile_ids( - &mut inference_profile_routes, - profiles.iter(), - ); - } - Err(err) => { - crate::logging::info(&format!( - "Bedrock inference profile discovery skipped: {}", - Self::classify_error_message(&Self::sdk_error_message(&err)) - )); - } - } - - if let Ok(mut guard) = self.fetched_models.write() { - *guard = models.clone(); - } - if let Ok(mut guard) = self.fetched_inference_profiles.write() { - *guard = profiles.clone(); - } - if let Ok(mut guard) = self.profile_required_models.write() { - *guard = profile_required_models.clone(); - } - if let Ok(mut guard) = self.inference_profile_routes.write() { - *guard = inference_profile_routes.clone(); - } - if let Ok(mut guard) = self.legacy_models.write() { - *guard = legacy_models.clone(); - } - Self::persist_catalog( - &models, - &profiles, - &profile_required_models, - &inference_profile_routes, - &legacy_models, - ); - Ok((models, profiles)) - } -} - -impl Default for BedrockProvider { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl Provider for BedrockProvider { - async fn complete( - &self, - messages: &[JMessage], - tools: &[ToolDefinition], - system: &str, - _resume_session_id: Option<&str>, - ) -> Result { - Self::validate_credentials_if_requested().await?; - let model = self.model(); - let info = Self::model_info(&model); - let request_messages = Self::to_bedrock_messages(messages, info.supports_vision)?; - let tool_config = if info.supports_tools { - Self::tool_config(tools) - } else { - None - }; - let inference_config = Self::inference_config(); - let system_blocks = if system.trim().is_empty() { - None - } else { - Some(vec![SystemContentBlock::Text(system.to_string())]) - }; - let message_items = serde_json::to_value(messages) - .ok() - .and_then(|value| value.as_array().cloned()) - .unwrap_or_default(); - let system_value = (!system.trim().is_empty()).then(|| Value::String(system.to_string())); - let tools_value = if info.supports_tools && !tools.is_empty() { - serde_json::to_value(tools).ok() - } else { - None - }; - let payload = json!({ - "model": &model, - "system": system_value.as_ref(), - "messages": &message_items, - "tools": tools_value.as_ref(), - "supports_tools": info.supports_tools, - "supports_vision": info.supports_vision, - "inference_config_present": inference_config.is_some(), - }); - super::fingerprint::log_provider_canonical_input( - "bedrock", - &model, - "bedrock_converse_logical", - &payload, - &message_items, - system_value.as_ref(), - tools_value.as_ref(), - Some(if info.supports_tools { tools.len() } else { 0 }), - &[ - ("supports_tools", info.supports_tools.to_string()), - ("supports_vision", info.supports_vision.to_string()), - ( - "inference_config_present", - inference_config.is_some().to_string(), - ), - ], - ); - let (tx, rx) = mpsc::channel::>(64); - tokio::spawn(async move { - let client = Self::runtime_client().await; - let mut req = client - .converse_stream() - .model_id(model.clone()) - .set_messages(Some(request_messages)); - if let Some(system_blocks) = system_blocks { - req = req.set_system(Some(system_blocks)); - } - if let Some(tool_config) = tool_config { - req = req.tool_config(tool_config); - } - if let Some(inference_config) = inference_config { - req = req.inference_config(inference_config); - } - let resp = match req.send().await { - Ok(resp) => resp, - Err(err) => { - let _ = tx - .send(Err(anyhow::anyhow!(Self::classify_error_message( - &Self::sdk_error_message(&err) - )))) - .await; - return; - } - }; - let mut stream = resp.stream; - let mut current_tool: Option<(String, String, String)> = None; - loop { - match stream.recv().await { - Ok(Some(event)) => match event { - ConverseStreamOutput::ContentBlockStart(start) => { - if let Some(ContentBlockStart::ToolUse(tool)) = start.start { - let id = tool.tool_use_id().to_string(); - let name = tool.name().to_string(); - current_tool = Some((id.clone(), name.clone(), String::new())); - let _ = tx.send(Ok(StreamEvent::ToolUseStart { id, name })).await; - } - } - ConverseStreamOutput::ContentBlockDelta(delta) => { - if let Some(d) = delta.delta { - match d { - ContentBlockDelta::Text(text) => { - let _ = tx.send(Ok(StreamEvent::TextDelta(text))).await; - } - ContentBlockDelta::ToolUse(tool_delta) => { - let input = tool_delta.input(); - if !input.is_empty() { - if let Some((_, _, buf)) = current_tool.as_mut() { - buf.push_str(input); - } - let _ = tx - .send(Ok(StreamEvent::ToolInputDelta( - input.to_string(), - ))) - .await; - } - } - ContentBlockDelta::ReasoningContent(reasoning) => { - if let ReasoningContentBlockDelta::Text(text) = reasoning { - let _ = - tx.send(Ok(StreamEvent::ThinkingDelta(text))).await; - } - } - _ => {} - } - } - } - ConverseStreamOutput::ContentBlockStop(_) => { - if current_tool.take().is_some() { - let _ = tx.send(Ok(StreamEvent::ToolUseEnd)).await; - } - } - ConverseStreamOutput::MessageStop(stop) => { - let reason = Some(format!("{:?}", stop.stop_reason())); - let _ = tx - .send(Ok(StreamEvent::MessageEnd { - stop_reason: reason, - })) - .await; - } - ConverseStreamOutput::Metadata(meta) => { - if let Some(usage) = meta.usage() { - let _ = tx - .send(Ok(StreamEvent::TokenUsage { - input_tokens: Some(usage.input_tokens() as u64), - output_tokens: Some(usage.output_tokens() as u64), - cache_read_input_tokens: None, - cache_creation_input_tokens: None, - })) - .await; - } - } - _ => {} - }, - Ok(None) => break, - Err(err) => { - let _ = tx - .send(Err(anyhow::anyhow!(Self::classify_error_message( - &Self::sdk_error_message(&err) - )))) - .await; - break; - } - } - } - }); - Ok(Box::pin(ReceiverStream::new(rx)) - as Pin< - Box> + Send>, - >) - } - - fn name(&self) -> &str { - "bedrock" - } - - fn model(&self) -> String { - self.model.read().unwrap_or_else(|p| p.into_inner()).clone() - } - - fn supports_image_input(&self) -> bool { - Self::model_info(&self.model()).supports_vision - } - - fn set_model(&self, model: &str) -> Result<()> { - let model = model.trim(); - let model = self - .profile_route_for_model(model) - .unwrap_or_else(|| model.to_string()); - *self.model.write().unwrap_or_else(|p| p.into_inner()) = model; - Ok(()) - } - - fn available_models(&self) -> Vec<&'static str> { - Self::known_models() - } - - fn available_models_display(&self) -> Vec { - self.all_display_models() - } - - fn available_models_for_switching(&self) -> Vec { - self.all_display_models() - } - - fn model_routes(&self) -> Vec { - let legacy_models = self - .legacy_models - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); - let profile_required_models = self - .profile_required_models - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); - self.all_display_models() - .into_iter() - .map(|model| { - let info = Self::model_info(&model); - let is_legacy = legacy_models.contains(&model); - let profile_foundation = Self::foundation_model_id_from_profile_id(&model); - let missing_required_profile = profile_foundation.is_none() - && profile_required_models.contains(&model) - && self.profile_route_for_model(&model).is_none(); - let mut features = Vec::new(); - if info.supports_tools { - features.push("tools"); - } else { - features.push("no tools"); - } - if info.supports_vision { - features.push("vision"); - } - if info.supports_reasoning { - features.push("reasoning"); - } - ModelRoute { - model: model.clone(), - provider: "AWS Bedrock".to_string(), - api_method: "bedrock".to_string(), - available: !is_legacy && !missing_required_profile, - detail: if is_legacy { - "legacy Bedrock model; choose an active model or inference profile" - .to_string() - } else if missing_required_profile { - "requires an inference profile; run /refresh-model-list or allow bedrock:ListInferenceProfiles" - .to_string() - } else { - let mut parts = Vec::new(); - if let Some(foundation) = profile_foundation { - parts.push(format!("inference profile for {}", foundation)); - } - parts.push(format!("context ~{} tokens", info.context_tokens)); - parts.push(format!("max output ~{}", info.max_output_tokens)); - parts.push(features.join(", ")); - format!( - "ConverseStream · {}", - parts - .into_iter() - .filter(|part| !part.trim().is_empty()) - .collect::>() - .join(" · ") - ) - }, - cheapness: Self::route_pricing(&model), - } - }) - .collect() - } - - async fn prefetch_models(&self) -> Result<()> { - self.refresh_catalog().await.map(|_| ()) - } - - async fn refresh_model_catalog(&self) -> Result { - let before_models = self.available_models_display(); - let before_routes = self.model_routes(); - self.refresh_catalog().await?; - let after_models = self.available_models_display(); - let after_routes = self.model_routes(); - Ok(summarize_model_catalog_refresh( - before_models, - after_models, - before_routes, - after_routes, - )) - } - - fn context_window(&self) -> usize { - Self::model_info(&self.model()).context_tokens - } - - fn supports_compaction(&self) -> bool { - true - } - - fn uses_jcode_compaction(&self) -> bool { - true - } - - fn fork(&self) -> Arc { - Arc::new(Self { - model: Arc::new(RwLock::new(self.model())), - fetched_models: self.fetched_models.clone(), - fetched_inference_profiles: self.fetched_inference_profiles.clone(), - profile_required_models: self.profile_required_models.clone(), - inference_profile_routes: self.inference_profile_routes.clone(), - legacy_models: self.legacy_models.clone(), - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::ffi::{OsStr, OsString}; - - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - crate::env::set_var(key, value); - Self { key, previous } - } - - fn remove(key: &'static str) -> Self { - let previous = std::env::var_os(key); - crate::env::remove_var(key); - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - if let Some(value) = self.previous.as_ref() { - crate::env::set_var(self.key, value); - } else { - crate::env::remove_var(self.key); - } - } - } - - #[test] - fn detects_env_credentials_requires_region_and_credential_hint() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let _removed = [ - "JCODE_BEDROCK_ENABLE", - API_KEY_ENV, - REGION_ENV, - "AWS_REGION", - "AWS_DEFAULT_REGION", - "AWS_PROFILE", - "JCODE_BEDROCK_PROFILE", - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_SHARED_CREDENTIALS_FILE", - "AWS_CONFIG_FILE", - ] - .map(EnvVarGuard::remove); - crate::env::set_var(REGION_ENV, "us-east-1"); - assert!(!BedrockProvider::has_credentials()); - crate::env::set_var("AWS_PROFILE", "test"); - assert!(BedrockProvider::has_credentials()); - } - - #[test] - fn explicit_enable_marks_configured_for_instance_metadata_credentials() { - let _guard = crate::storage::lock_test_env(); - crate::env::set_var("JCODE_BEDROCK_ENABLE", "1"); - assert!(BedrockProvider::has_credentials()); - crate::env::remove_var("JCODE_BEDROCK_ENABLE"); - } - - #[test] - fn detects_bedrock_login_env_file_credentials() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - for key in [ - "JCODE_BEDROCK_ENABLE", - API_KEY_ENV, - REGION_ENV, - "AWS_REGION", - "AWS_DEFAULT_REGION", - "AWS_PROFILE", - "JCODE_BEDROCK_PROFILE", - "AWS_ACCESS_KEY_ID", - ] { - crate::env::remove_var(key); - } - - assert!(!BedrockProvider::has_credentials()); - crate::provider_catalog::save_env_value_to_env_file( - API_KEY_ENV, - ENV_FILE, - Some("test-key"), - ) - .unwrap(); - crate::env::remove_var(API_KEY_ENV); - assert!(!BedrockProvider::has_credentials()); - - crate::provider_catalog::save_env_value_to_env_file( - REGION_ENV, - ENV_FILE, - Some("us-east-2"), - ) - .unwrap(); - crate::env::remove_var(REGION_ENV); - - assert_eq!( - BedrockProvider::configured_bearer_token().as_deref(), - Some("test-key") - ); - assert_eq!( - BedrockProvider::configured_region().as_deref(), - Some("us-east-2") - ); - assert!(BedrockProvider::has_credentials()); - } - - #[test] - fn switches_arbitrary_model_ids() { - let p = BedrockProvider::new(); - p.set_model("us.anthropic.claude-3-5-sonnet-20241022-v2:0") - .unwrap(); - assert_eq!(p.model(), "us.anthropic.claude-3-5-sonnet-20241022-v2:0"); - } - - #[test] - fn maps_profile_required_foundation_model_to_inference_profile() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - p.profile_required_models - .write() - .unwrap() - .insert("amazon.nova-2-lite-v1:0".to_string()); - p.inference_profile_routes.write().unwrap().insert( - "amazon.nova-2-lite-v1:0".to_string(), - "us.amazon.nova-2-lite-v1:0".to_string(), - ); - - p.set_model("amazon.nova-2-lite-v1:0").unwrap(); - - assert_eq!(p.model(), "us.amazon.nova-2-lite-v1:0"); - } - - #[test] - fn maps_foundation_model_from_stale_cached_profile_list() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - *p.fetched_inference_profiles.write().unwrap() = vec![ - "global.amazon.nova-2-lite-v1:0".to_string(), - "us.amazon.nova-2-lite-v1:0".to_string(), - ]; - - p.set_model("amazon.nova-2-lite-v1:0").unwrap(); - - assert_eq!(p.model(), "us.amazon.nova-2-lite-v1:0"); - } - - #[test] - fn hides_profile_required_foundation_model_when_profile_route_exists() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; - *p.fetched_inference_profiles.write().unwrap() = - vec!["us.amazon.nova-2-lite-v1:0".to_string()]; - p.profile_required_models - .write() - .unwrap() - .insert("amazon.nova-2-lite-v1:0".to_string()); - p.inference_profile_routes.write().unwrap().insert( - "amazon.nova-2-lite-v1:0".to_string(), - "us.amazon.nova-2-lite-v1:0".to_string(), - ); - - let display = p.all_display_models(); - - assert!( - !display - .iter() - .any(|model| model == "amazon.nova-2-lite-v1:0") - ); - assert!( - display - .iter() - .any(|model| model == "us.amazon.nova-2-lite-v1:0") - ); - } - - #[test] - fn hides_foundation_model_when_profile_route_exists() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; - *p.fetched_inference_profiles.write().unwrap() = - vec!["us.amazon.nova-2-lite-v1:0".to_string()]; - p.inference_profile_routes.write().unwrap().insert( - "amazon.nova-2-lite-v1:0".to_string(), - "us.amazon.nova-2-lite-v1:0".to_string(), - ); - - let display = p.all_display_models(); - - assert!( - !display - .iter() - .any(|model| model == "amazon.nova-2-lite-v1:0") - ); - assert!( - display - .iter() - .any(|model| model == "us.amazon.nova-2-lite-v1:0") - ); - } - - #[test] - fn profile_required_foundation_model_without_profile_route_is_disabled() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; - p.profile_required_models - .write() - .unwrap() - .insert("amazon.nova-2-lite-v1:0".to_string()); - - let route = p - .model_routes() - .into_iter() - .find(|route| route.model == "amazon.nova-2-lite-v1:0") - .expect("profile-required foundation model should be listed with a reason"); - - assert!(!route.available); - assert!(route.detail.contains("requires an inference profile")); - } - - #[test] - fn global_inference_profiles_use_foundation_capabilities_and_detail() { - let p = BedrockProvider::new(); - *p.fetched_inference_profiles.write().unwrap() = - vec!["global.amazon.nova-2-lite-v1:0".to_string()]; - - let route = p - .model_routes() - .into_iter() - .find(|route| route.model == "global.amazon.nova-2-lite-v1:0") - .expect("global inference profile should be listed"); - - assert!(route.available); - assert!( - route - .detail - .contains("inference profile for amazon.nova-2-lite-v1:0") - ); - assert!(route.detail.contains("tools")); - assert!(!route.detail.contains("no tools")); - } - - #[test] - fn ignores_persisted_bedrock_catalog_from_different_region() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - { - let _region = EnvVarGuard::set(REGION_ENV, "us-east-1"); - BedrockProvider::persist_catalog( - &["openai.gpt-oss-120b-1:0".to_string()], - &[], - &HashSet::new(), - &HashMap::new(), - &HashSet::new(), - ); - } - let _region = EnvVarGuard::set(REGION_ENV, "us-east-2"); - - let p = BedrockProvider::new(); - - assert!(p.fetched_models.read().unwrap().is_empty()); - } - - #[test] - fn prefers_region_inference_profile_over_global_profile() { - let _guard = crate::storage::lock_test_env(); - let _region = EnvVarGuard::set(REGION_ENV, "us-east-2"); - let mut routes = HashMap::new(); - - BedrockProvider::insert_preferred_profile_route( - &mut routes, - "amazon.nova-2-lite-v1:0", - "global.amazon.nova-2-lite-v1:0", - ); - BedrockProvider::insert_preferred_profile_route( - &mut routes, - "amazon.nova-2-lite-v1:0", - "us.amazon.nova-2-lite-v1:0", - ); - - assert_eq!( - routes.get("amazon.nova-2-lite-v1:0").map(String::as_str), - Some("us.amazon.nova-2-lite-v1:0") - ); - } - - #[test] - fn known_context_and_vision_capabilities() { - let p = BedrockProvider::new(); - p.set_model("anthropic.claude-3-5-sonnet-20241022-v2:0") - .unwrap(); - assert!(p.supports_image_input()); - assert_eq!(p.context_window(), 200_000); - p.set_model("amazon.nova-micro-v1:0").unwrap(); - assert!(!p.supports_image_input()); - assert_eq!(p.context_window(), 128_000); - } - - #[test] - fn known_no_tool_models_do_not_advertise_tools() { - assert!(!BedrockProvider::model_info("us.deepseek.r1-v1:0").supports_tools); - assert!(!BedrockProvider::model_info("deepseek.v3.2").supports_tools); - assert!( - !BedrockProvider::model_info("mistral.mistral-large-3-675b-instruct").supports_tools - ); - assert!(!BedrockProvider::model_info("openai.gpt-oss-120b-1:0").supports_tools); - assert!(BedrockProvider::model_info("us.amazon.nova-2-lite-v1:0").supports_tools); - assert!(BedrockProvider::model_info("us.anthropic.claude-sonnet-4-6").supports_tools); - } - - #[test] - fn error_classification_mentions_model_access() { - let message = BedrockProvider::classify_error_message( - "ValidationException: The provided model identifier is invalid", - ); - assert!(message.contains("model")); - assert!(message.contains("region")); - } - - #[test] - fn error_classification_mentions_legacy_models() { - let message = BedrockProvider::classify_error_message( - "Access denied. This Model is marked by provider as Legacy and you have not been actively using the model in the last 30 days", - ); - assert!(message.contains("legacy")); - assert!(message.contains("active")); - assert!(!message.starts_with("AWS IAM denied")); - } - - #[test] - fn tool_use_streaming_error_is_not_classified_as_legacy_sdk_type_name() { - let message = BedrockProvider::classify_error_message( - "ValidationException: This model doesn't support tool use in streaming mode. extensions_1x: {hyper_util::client::legacy::connect::http::HttpInfo}", - ); - assert!(message.contains("does not support tool use")); - assert!(!message.starts_with("This Bedrock model is marked as legacy")); - } - - #[test] - fn expired_sso_error_is_concise_and_actionable() { - let message = BedrockProvider::classify_error_message( - "ServiceError(ServiceError { source: AccessDeniedException(AccessDeniedException { message: Some(\"Bearer Token has expired\") }) })", - ); - assert_eq!( - message, - "AWS SSO/session credentials look expired. Run `aws sso login --profile ` and retry." - ); - } - - #[test] - fn missing_credentials_error_omits_sdk_blob() { - let message = BedrockProvider::classify_error_message( - "CredentialsNotLoaded: could not load credentials from any provider; extensions_1x: noisy sdk internals", - ); - assert!(message.contains("AWS credentials were not found")); - assert!(!message.contains("extensions_1x")); - } - - #[test] - fn legacy_model_route_is_unavailable_with_reason() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - *p.fetched_models.write().unwrap() = - vec!["anthropic.claude-3-haiku-20240307-v1:0".to_string()]; - p.legacy_models - .write() - .unwrap() - .insert("anthropic.claude-3-haiku-20240307-v1:0".to_string()); - - let route = p - .model_routes() - .into_iter() - .find(|route| route.model == "anthropic.claude-3-haiku-20240307-v1:0") - .expect("legacy route should be listed"); - - assert!(!route.available); - assert!(route.detail.contains("legacy")); - } - - #[tokio::test] - #[ignore = "requires AWS credentials and enabled Bedrock model access"] - async fn bedrock_live_smoke_test() { - if std::env::var("JCODE_BEDROCK_LIVE_TEST").ok().as_deref() != Some("1") { - return; - } - let provider = BedrockProvider::new(); - let output = provider - .complete_simple("say bedrock ok and nothing else", "") - .await - .expect("live Bedrock completion"); - assert!(output.to_ascii_lowercase().contains("bedrock ok")); - } -} +pub use jcode_provider_bedrock::{API_KEY_ENV, BedrockProvider, ENV_FILE, REGION_ENV}; diff --git a/crates/jcode-base/src/provider/fingerprint.rs b/crates/jcode-base/src/provider/fingerprint.rs index 78360a37f..38773b2b5 100644 --- a/crates/jcode-base/src/provider/fingerprint.rs +++ b/crates/jcode-base/src/provider/fingerprint.rs @@ -1,202 +1,3 @@ -use serde::Serialize; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::HashMap; -use std::sync::{LazyLock, Mutex}; -use std::time::Instant; - -#[derive(Debug, Clone)] -struct ProviderInputSnapshot { - request_hash: u64, - item_hashes: Vec, - item_hashes_hash: u64, - system_hash: Option, - tools_hash: Option, - captured_at: Instant, -} - -static PROVIDER_INPUT_BASELINES: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -pub(crate) fn stable_hash_str(value: &str) -> u64 { - let digest = Sha256::digest(value.as_bytes()); - let mut bytes = [0_u8; 8]; - bytes.copy_from_slice(&digest[..8]); - u64::from_be_bytes(bytes) -} - -pub(crate) fn stable_hash_json(value: &T) -> u64 { - let encoded = serde_json::to_string(value).unwrap_or_default(); - stable_hash_str(&encoded) -} - -fn stable_json_len(value: &T) -> usize { - serde_json::to_string(value) - .map(|encoded| encoded.len()) - .unwrap_or_default() -} - -fn item_hashes(items: &[Value]) -> Vec { - items.iter().map(stable_hash_json).collect() -} - -fn prefix_matches(current: &[u64], previous: &[u64]) -> bool { - if previous.len() > current.len() { - return false; - } - current[..previous.len()] == *previous -} - -fn common_prefix_len(current: &[u64], previous: &[u64]) -> usize { - current - .iter() - .zip(previous.iter()) - .take_while(|(current, previous)| current == previous) - .count() -} - -/// Log a privacy-preserving fingerprint of the provider-specific prompt payload. -/// -/// `payload` should be the prompt/cache-relevant request shape after provider-specific -/// normalization, not the high-level Jcode message list. Do not include volatile transport -/// IDs unless they are intentionally part of the cache key. `items` should be the ordered -/// provider-visible message/content array so prefix drift can be diagnosed by index. -#[allow(clippy::too_many_arguments)] -pub(crate) fn log_provider_canonical_input( - provider: &str, - model: &str, - format: &str, - payload: &Value, - items: &[Value], - system: Option<&Value>, - tools: Option<&Value>, - tool_count: Option, - extra_fields: &[(&str, String)], -) { - let request_hash = stable_hash_json(payload); - let request_json_chars = stable_json_len(payload); - let item_hashes = item_hashes(items); - let item_hashes_hash = stable_hash_json(&item_hashes); - let input_hash = stable_hash_json(items); - let system_hash = system.map(stable_hash_json); - let system_json_chars = system.map(stable_json_len); - let tools_hash = tools.map(stable_hash_json); - let tools_json_chars = tools.map(stable_json_len); - let first_item_hash = item_hashes.first().copied(); - let last_item_hash = item_hashes.last().copied(); - - let log_context = crate::logging::current_context_snapshot(); - let session_key = log_context.session.as_deref().unwrap_or("no-session"); - let key = format!( - "{}\u{1f}{}\u{1f}{}\u{1f}{}", - session_key, provider, model, format - ); - let snapshot = ProviderInputSnapshot { - request_hash, - item_hashes: item_hashes.clone(), - item_hashes_hash, - system_hash, - tools_hash, - captured_at: Instant::now(), - }; - - let previous = PROVIDER_INPUT_BASELINES - .lock() - .map(|mut baselines| baselines.insert(key, snapshot)) - .ok() - .flatten(); - - let previous_age_secs = previous - .as_ref() - .map(|previous| previous.captured_at.elapsed().as_secs()); - let request_changed = previous - .as_ref() - .map(|previous| previous.request_hash != request_hash); - let item_hashes_changed = previous - .as_ref() - .map(|previous| previous.item_hashes_hash != item_hashes_hash); - let prefix_matches = previous - .as_ref() - .map(|previous| prefix_matches(&item_hashes, &previous.item_hashes)); - let common_prefix_items = previous - .as_ref() - .map(|previous| common_prefix_len(&item_hashes, &previous.item_hashes)); - let first_changed_item_index = common_prefix_items - .zip(previous.as_ref().map(|previous| previous.item_hashes.len())) - .and_then(|(common, previous_len)| (common < previous_len).then_some(common)); - let previous_item_count = previous.as_ref().map(|previous| previous.item_hashes.len()); - let system_changed = previous - .as_ref() - .map(|previous| previous.system_hash != system_hash); - let tools_changed = previous - .as_ref() - .map(|previous| previous.tools_hash != tools_hash); - - let mut extras = String::new(); - for (key, value) in extra_fields { - if !key.is_empty() && !value.is_empty() { - extras.push(' '); - extras.push_str(key); - extras.push('='); - extras.push_str(value); - } - } - - crate::logging::info(&format!( - "PROVIDER_CANONICAL_INPUT: provider={} model={} format={} request_hash={} request_json_chars={} \ - input_hash={} item_count={} previous_item_count={:?} item_hashes_hash={} first_item_hash={:?} last_item_hash={:?} \ - previous_age_secs={:?} prefix_matches={:?} common_prefix_items={:?} first_changed_item_index={:?} \ - request_changed={:?} item_hashes_changed={:?} system_hash={:?} system_json_chars={:?} system_changed={:?} \ - tools_hash={:?} tools_json_chars={:?} tool_count={:?} tools_changed={:?}{}", - provider, - model, - format, - request_hash, - request_json_chars, - input_hash, - items.len(), - previous_item_count, - item_hashes_hash, - first_item_hash, - last_item_hash, - previous_age_secs, - prefix_matches, - common_prefix_items, - first_changed_item_index, - request_changed, - item_hashes_changed, - system_hash, - system_json_chars, - system_changed, - tools_hash, - tools_json_chars, - tool_count, - tools_changed, - extras, - )); -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn prefix_matching_allows_append_only_growth() { - assert!(prefix_matches(&[1, 2, 3], &[1, 2])); - } - - #[test] - fn prefix_matching_detects_changed_prefix() { - assert!(!prefix_matches(&[1, 9, 3], &[1, 2])); - assert_eq!(common_prefix_len(&[1, 9, 3], &[1, 2]), 1); - } - - #[test] - fn json_hashes_are_content_sensitive() { - assert_ne!( - stable_hash_json(&json!({"a": 1})), - stable_hash_json(&json!({"a": 2})) - ); - } -} +pub(crate) use jcode_provider_core::fingerprint::{ + log_provider_canonical_input, stable_hash_json, stable_hash_str, +}; diff --git a/crates/jcode-base/src/provider/mod.rs b/crates/jcode-base/src/provider/mod.rs index 633575798..0a9b2d7d2 100644 --- a/crates/jcode-base/src/provider/mod.rs +++ b/crates/jcode-base/src/provider/mod.rs @@ -934,22 +934,24 @@ impl MultiProvider { let prefix = match active { ActiveProvider::Claude => { if let Some(anthropic) = self.anthropic_provider() { - match anthropic.credential_mode_snapshot() { - anthropic::AnthropicCredentialMode::OAuth => "claude-oauth", - anthropic::AnthropicCredentialMode::ApiKey => "claude-api", - anthropic::AnthropicCredentialMode::Auto => "claude", - } + // OAuth/ApiKey emit their canonical model prefix; Auto keeps + // the bare provider key (route without pinning a credential). + anthropic + .credential_mode_snapshot() + .auth_route() + .map(|route| route.model_prefix()) + .unwrap_or("claude") } else { "claude" } } ActiveProvider::OpenAI => { if let Some(openai) = self.openai_provider() { - match openai.credential_mode_snapshot() { - openai::OpenAICredentialMode::OAuth => "openai-oauth", - openai::OpenAICredentialMode::ApiKey => "openai-api", - openai::OpenAICredentialMode::Auto => "openai", - } + openai + .credential_mode_snapshot() + .auth_route() + .map(|route| route.model_prefix()) + .unwrap_or("openai") } else { "openai" } @@ -1197,16 +1199,30 @@ impl Provider for MultiProvider { explicit_model_provider_prefix(requested_model) { self.ensure_provider_lock_allows_model_target(target, requested_model)?; - let openai_credential_mode = match prefix { - "openai-api:" => Some(openai::OpenAICredentialMode::ApiKey), - "openai-oauth:" => Some(openai::OpenAICredentialMode::OAuth), - _ => None, - }; - let anthropic_credential_mode = match prefix { - "claude-api:" => Some(anthropic::AnthropicCredentialMode::ApiKey), - "claude-oauth:" => Some(anthropic::AnthropicCredentialMode::OAuth), - _ => None, - }; + // The single canonical parser decides whether this prefix pins a + // dual-auth credential (and which provider/mode). Bare `claude:` / + // `openai:` prefixes route without pinning a credential. + let pinned = jcode_provider_core::AuthRoute::parse_explicit_credential_prefix(prefix); + let openai_credential_mode = pinned.and_then(|route| { + matches!(route.provider, jcode_provider_core::DualAuthProvider::OpenAI).then( + || match route.mode { + jcode_provider_core::AuthMode::ApiKey => openai::OpenAICredentialMode::ApiKey, + jcode_provider_core::AuthMode::Oauth => openai::OpenAICredentialMode::OAuth, + }, + ) + }); + let anthropic_credential_mode = pinned.and_then(|route| { + matches!(route.provider, jcode_provider_core::DualAuthProvider::Anthropic).then( + || match route.mode { + jcode_provider_core::AuthMode::ApiKey => { + anthropic::AnthropicCredentialMode::ApiKey + } + jcode_provider_core::AuthMode::Oauth => { + anthropic::AnthropicCredentialMode::OAuth + } + }, + ) + }); if openai_credential_mode.is_some() || anthropic_credential_mode.is_some() { return self.set_model_on_provider_with_credential_modes( target, diff --git a/crates/jcode-base/src/provider/openai.rs b/crates/jcode-base/src/provider/openai.rs index f2d2babd7..b0bf0f935 100644 --- a/crates/jcode-base/src/provider/openai.rs +++ b/crates/jcode-base/src/provider/openai.rs @@ -13,7 +13,6 @@ use reqwest::{Client, StatusCode}; use serde_json::Value; use std::collections::{HashMap, HashSet, VecDeque}; use std::panic::AssertUnwindSafe; -use std::sync::atomic::AtomicU64; use std::sync::{Arc, LazyLock, RwLock as StdRwLock}; use std::time::{Duration, Instant}; use tokio::net::TcpStream; @@ -37,10 +36,7 @@ const MAX_RETRIES: u32 = 3; /// Base delay for exponential backoff (in milliseconds) const RETRY_BASE_DELAY_MS: u64 = 1000; const WEBSOCKET_UPGRADE_REQUIRED_ERROR: StatusCode = StatusCode::UPGRADE_REQUIRED; -const WEBSOCKET_FALLBACK_NOTICE: &str = "falling back from websockets to https transport"; const WEBSOCKET_CONNECT_TIMEOUT_SECS: u64 = 8; -const WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS: u64 = 8; -const WEBSOCKET_COMPLETION_TIMEOUT_SECS: u64 = 300; /// Maximum age of a persistent WebSocket connection before forcing reconnect const WEBSOCKET_PERSISTENT_MAX_AGE_SECS: u64 = 3000; // 50 min (server limit is 60 min) /// Default idle window after which we reconnect instead of reusing the socket. @@ -92,15 +88,7 @@ static WEBSOCKET_PERSISTENT_IDLE_RECONNECT_SECS: LazyLock> = LazyLoc } }); const WEBSOCKET_PERSISTENT_HEALTHCHECK_TIMEOUT_MS: u64 = 1500; -/// Base websocket cooldown after a fallback in auto mode. -/// Keep this short so one flaky attempt does not pin the TUI to HTTPS for a long time. -const WEBSOCKET_MODEL_COOLDOWN_BASE_SECS: u64 = 60; -/// Maximum websocket cooldown after repeated fallback streaks. -const WEBSOCKET_MODEL_COOLDOWN_MAX_SECS: u64 = 600; const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 32_768; -static FALLBACK_TOOL_CALL_COUNTER: AtomicU64 = AtomicU64::new(1); -static RECOVERED_TEXT_WRAPPED_TOOL_CALLS: AtomicU64 = AtomicU64::new(0); -static NORMALIZED_NULL_TOOL_ARGUMENTS: AtomicU64 = AtomicU64::new(0); static WEBSOCKET_COOLDOWNS: LazyLock>>> = LazyLock::new(|| Arc::new(RwLock::new(HashMap::new()))); static WEBSOCKET_FAILURE_STREAKS: LazyLock>>> = @@ -183,14 +171,26 @@ pub(crate) enum OpenAICredentialMode { impl OpenAICredentialMode { fn from_runtime_env() -> Self { - match std::env::var("JCODE_RUNTIME_PROVIDER") - .ok() - .map(|value| value.trim().to_ascii_lowercase()) - .as_deref() - { - Some("openai-api") => Self::ApiKey, - Some("openai") => Self::OAuth, - _ => Self::Auto, + // Canonical parse: recognizes every runtime/route/CLI/prefix alias for + // the OpenAI OAuth-vs-API decision in one place, so this can never drift + // from the other vocabularies (see jcode_provider_core::auth_mode). + match jcode_provider_core::runtime_env_pinned_mode( + jcode_provider_core::DualAuthProvider::OpenAI, + ) { + Some(jcode_provider_core::AuthMode::ApiKey) => Self::ApiKey, + Some(jcode_provider_core::AuthMode::Oauth) => Self::OAuth, + None => Self::Auto, + } + } + + /// The canonical dual-auth route this explicit mode pins, if any. + /// `Auto` has no explicit pin and returns `None`. + pub(crate) fn auth_route(self) -> Option { + use jcode_provider_core::{AuthMode, AuthRoute}; + match self { + Self::Auto => None, + Self::OAuth => Some(AuthRoute::openai(AuthMode::Oauth)), + Self::ApiKey => Some(AuthRoute::openai(AuthMode::ApiKey)), } } @@ -688,14 +688,8 @@ impl OpenAIProvider { // Keep the runtime provider identity in sync with the explicit credential // choice so UI surfaces report the auth method requests will actually use. // `Auto` leaves the existing identity untouched. - match mode { - OpenAICredentialMode::OAuth => { - crate::env::set_var("JCODE_RUNTIME_PROVIDER", "openai"); - } - OpenAICredentialMode::ApiKey => { - crate::env::set_var("JCODE_RUNTIME_PROVIDER", "openai-api"); - } - OpenAICredentialMode::Auto => {} + if let Some(route) = mode.auth_route() { + crate::env::set_var("JCODE_RUNTIME_PROVIDER", route.runtime_provider_key()); } Ok(()) } @@ -1002,9 +996,7 @@ impl OpenAIProvider { mod stream; -use self::openai_stream_runtime::{ - PersistentWsResult, extract_error_with_retry, is_retryable_error, openai_access_token, -}; +use self::openai_stream_runtime::{PersistentWsResult, is_retryable_error, openai_access_token}; use self::stream::{OpenAIResponsesStream, parse_openai_response_event}; #[cfg(test)] @@ -1017,16 +1009,19 @@ mod openai_stream_runtime; mod websocket_health; -#[cfg(test)] use self::websocket_health::{ - WebsocketFallbackReason, clear_websocket_cooldown, normalize_transport_model, - set_websocket_cooldown, websocket_cooldown_for_streak, websocket_remaining_timeout_secs, + WEBSOCKET_COMPLETION_TIMEOUT_SECS, WEBSOCKET_FALLBACK_NOTICE, + WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS, classify_websocket_fallback_reason, + is_stream_activity_event, is_websocket_activity_payload, is_websocket_fallback_notice, + is_websocket_first_activity_payload, record_websocket_fallback, record_websocket_success, + summarize_websocket_fallback_reason, websocket_activity_timeout_kind, + websocket_cooldown_remaining, websocket_next_activity_timeout_secs, }; +#[cfg(test)] use self::websocket_health::{ - classify_websocket_fallback_reason, is_stream_activity_event, is_websocket_activity_payload, - is_websocket_fallback_notice, is_websocket_first_activity_payload, record_websocket_fallback, - record_websocket_success, summarize_websocket_fallback_reason, websocket_activity_timeout_kind, - websocket_cooldown_remaining, websocket_next_activity_timeout_secs, + WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, WebsocketFallbackReason, + clear_websocket_cooldown, normalize_transport_model, set_websocket_cooldown, + websocket_cooldown_for_streak, websocket_remaining_timeout_secs, }; #[cfg(test)] diff --git a/crates/jcode-base/src/provider/openai/stream.rs b/crates/jcode-base/src/provider/openai/stream.rs index edb15def1..00212c5a7 100644 --- a/crates/jcode-base/src/provider/openai/stream.rs +++ b/crates/jcode-base/src/provider/openai/stream.rs @@ -1,819 +1,8 @@ -use super::{ - FALLBACK_TOOL_CALL_COUNTER, NORMALIZED_NULL_TOOL_ARGUMENTS, RECOVERED_TEXT_WRAPPED_TOOL_CALLS, - extract_error_with_retry, is_websocket_fallback_notice, +pub(super) use jcode_provider_openai::stream::{ + OpenAIResponsesStream, parse_openai_response_event, }; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; - -fn truncated_stream_payload_context(data: &str) -> String { - crate::util::truncate_str(&data.trim().replace("\n", "\\n"), 240).to_string() -} -use crate::message::StreamEvent; -use anyhow::Result; -use bytes::Bytes; -use futures::Stream; -use serde::Deserialize; -use serde_json::Value; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::pin::Pin; -use std::sync::atomic::Ordering; -use std::task::{Context as TaskContext, Poll}; -use std::time::{SystemTime, UNIX_EPOCH}; - -pub(super) fn parse_text_wrapped_tool_call(text: &str) -> Option<(String, String, String, String)> { - let marker = "to=functions."; - let marker_idx = text.find(marker)?; - let after_marker = &text[marker_idx + marker.len()..]; - - let mut tool_name_end = 0usize; - for (idx, ch) in after_marker.char_indices() { - if ch.is_ascii_alphanumeric() || ch == '_' { - tool_name_end = idx + ch.len_utf8(); - } else { - break; - } - } - if tool_name_end == 0 { - return None; - } - - let tool_name = after_marker[..tool_name_end].to_string(); - let remaining = &after_marker[tool_name_end..]; - let mut fallback: Option<(String, String, String, String)> = None; - for (brace_idx, ch) in remaining.char_indices() { - if ch != '{' { - continue; - } - let slice = &remaining[brace_idx..]; - let mut stream = serde_json::Deserializer::from_str(slice).into_iter::(); - let parsed = match stream.next() { - Some(Ok(value)) => value, - Some(Err(_)) => continue, - None => continue, - }; - let consumed = stream.byte_offset(); - if !parsed.is_object() { - continue; - } - - let prefix = text[..marker_idx].trim_end().to_string(); - let suffix = remaining[brace_idx + consumed..].trim().to_string(); - let args = serde_json::to_string(&parsed).ok()?; - if suffix.is_empty() { - return Some((prefix, tool_name.clone(), args, suffix)); - } - if fallback.is_none() { - fallback = Some((prefix, tool_name.clone(), args, suffix)); - } - } - - fallback -} - -fn stream_text_or_recovered_tool_call( - text: &str, - pending: &mut VecDeque, -) -> Option { - if text.is_empty() { - return None; - } - - if let Some((prefix, tool_name, arguments, suffix)) = parse_text_wrapped_tool_call(text) { - let total = RECOVERED_TEXT_WRAPPED_TOOL_CALLS.fetch_add(1, Ordering::Relaxed) + 1; - crate::logging::warn(&format!( - "[openai] Recovered text-wrapped tool call for '{}' (total={})", - tool_name, total - )); - let suffix = sanitize_recovered_tool_suffix(&suffix); - if !prefix.is_empty() { - pending.push_back(StreamEvent::TextDelta(prefix)); - } - pending.push_back(StreamEvent::ToolUseStart { - id: format!( - "fallback_text_call_{}", - FALLBACK_TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed) - ), - name: tool_name, - }); - pending.push_back(StreamEvent::ToolInputDelta(arguments)); - pending.push_back(StreamEvent::ToolUseEnd); - if !suffix.is_empty() { - pending.push_back(StreamEvent::TextDelta(suffix)); - } - return pending.pop_front(); - } - - Some(StreamEvent::TextDelta(text.to_string())) -} - -fn sanitize_recovered_tool_suffix(suffix: &str) -> String { - let trimmed = suffix.trim(); - if trimmed.is_empty() { - return String::new(); - } - - let normalized = trimmed.trim_start_matches('"'); - - if normalized.starts_with(",\"item_id\"") - || normalized.starts_with(",\"output_index\"") - || normalized.starts_with(",\"sequence_number\"") - || normalized.starts_with(",\"call_id\"") - || normalized.starts_with(",\"type\":\"response.") - || (normalized.starts_with(',') - && normalized.contains("\"item_id\"") - && (normalized.contains("\"output_index\"") - || normalized.contains("\"sequence_number\""))) - { - return String::new(); - } - - suffix.to_string() -} - -#[derive(Deserialize, Debug)] -struct ResponseSseEvent { - #[serde(rename = "type")] - kind: String, - item: Option, - delta: Option, - item_id: Option, - call_id: Option, - name: Option, - arguments: Option, - response: Option, - error: Option, -} - -#[derive(Debug, Clone, Default)] -pub(super) struct StreamingToolCallState { - call_id: Option, - name: Option, - arguments: String, -} - -fn normalize_openai_tool_arguments(raw_arguments: String) -> String { - let trimmed = raw_arguments.trim(); - if trimmed.is_empty() || trimmed == "null" { - let total = NORMALIZED_NULL_TOOL_ARGUMENTS.fetch_add(1, Ordering::Relaxed) + 1; - crate::logging::warn(&format!( - "[openai] Normalized empty/null tool arguments to empty object (total={})", - total - )); - "{}".to_string() - } else { - raw_arguments - } -} - -fn streaming_tool_item_id(item: &Value) -> Option { - item.get("id") - .and_then(|v| v.as_str()) - .or_else(|| item.get("item_id").and_then(|v| v.as_str())) - .map(|id| id.to_string()) -} - -fn stream_tool_call_from_state( - item_id: Option, - mut state: StreamingToolCallState, - pending: &mut VecDeque, -) -> Option { - let tool_name = state.name.take().filter(|name| !name.is_empty())?; - let raw_call_id = state - .call_id - .take() - .filter(|id| !id.is_empty()) - .or(item_id) - .unwrap_or_else(|| { - format!( - "fallback_text_call_{}", - FALLBACK_TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed) - ) - }); - let call_id = crate::message::sanitize_tool_id(&raw_call_id); - let arguments = normalize_openai_tool_arguments(if state.arguments.is_empty() { - "{}".to_string() - } else { - state.arguments - }); - - pending.push_back(StreamEvent::ToolUseStart { - id: call_id, - name: tool_name, - }); - pending.push_back(StreamEvent::ToolInputDelta(arguments)); - pending.push_back(StreamEvent::ToolUseEnd); - pending.pop_front() -} - -pub(super) fn parse_openai_response_event( - data: &str, - saw_text_delta: &mut bool, - streaming_tool_calls: &mut HashMap, - completed_tool_items: &mut HashSet, - pending: &mut VecDeque, -) -> Option { - if data == "[DONE]" { - return Some(StreamEvent::MessageEnd { stop_reason: None }); - } - - if is_websocket_fallback_notice(data) { - crate::logging::warn(&format!("OpenAI stream transport notice: {}", data.trim())); - return None; - } - - if data - .to_lowercase() - .contains("stream disconnected before completion") - { - return Some(StreamEvent::Error { - message: data.to_string(), - retry_after_secs: None, - }); - } - - let event: ResponseSseEvent = match serde_json::from_str(data) { - Ok(parsed) => parsed, - Err(error) => { - crate::logging::warn(&format!( - "OpenAI SSE JSON parse failed: {} payload={}", - error, - truncated_stream_payload_context(data) - )); - return None; - } - }; - - match event.kind.as_str() { - "response.output_text.delta" => { - if let Some(delta) = event.delta { - *saw_text_delta = true; - return stream_text_or_recovered_tool_call(&delta, pending); - } - } - "response.reasoning.delta" | "response.reasoning_summary_text.delta" => { - if let Some(delta) = event.delta { - return Some(StreamEvent::ThinkingDelta(delta)); - } - } - "response.reasoning.done" | "response.output_item.added" => { - if let Some(item) = &event.item { - if item.get("type").and_then(|v| v.as_str()) == Some("reasoning") { - return Some(StreamEvent::ThinkingStart); - } - if matches!( - item.get("type").and_then(|v| v.as_str()), - Some("function_call") | Some("custom_tool_call") - ) && let Some(item_id) = streaming_tool_item_id(item) - { - let state = streaming_tool_calls.entry(item_id).or_default(); - state.call_id = item - .get("call_id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| state.call_id.clone()); - state.name = item - .get("name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| state.name.clone()); - if let Some(arguments) = item - .get("arguments") - .and_then(|v| v.as_str()) - .or_else(|| item.get("input").and_then(|v| v.as_str())) - { - state.arguments = arguments.to_string(); - } else if let Some(input) = item.get("input") - && (input.is_object() || input.is_array()) - { - state.arguments = input.to_string(); - } - } - } - } - "response.function_call_arguments.delta" => { - if let Some(item_id) = event.item_id { - let state = streaming_tool_calls.entry(item_id).or_default(); - if let Some(call_id) = event.call_id { - state.call_id = Some(call_id); - } - if let Some(name) = event.name { - state.name = Some(name); - } - if let Some(delta) = event.delta { - state.arguments.push_str(&delta); - } - } - } - "response.function_call_arguments.done" => { - if let Some(item_id) = event.item_id { - let mut state = streaming_tool_calls.remove(&item_id).unwrap_or_default(); - if let Some(call_id) = event.call_id { - state.call_id = Some(call_id); - } - if let Some(name) = event.name { - state.name = Some(name); - } - if let Some(arguments) = event.arguments { - state.arguments = arguments; - } - if let Some(tool_event) = - stream_tool_call_from_state(Some(item_id.clone()), state.clone(), pending) - { - completed_tool_items.insert(item_id); - return Some(tool_event); - } - streaming_tool_calls.insert(item_id, state); - } - } - "response.output_item.done" => { - if let Some(item) = event.item { - if let Some(item_id) = streaming_tool_item_id(&item) - && completed_tool_items.contains(&item_id) - && matches!( - item.get("type").and_then(|v| v.as_str()), - Some("function_call") | Some("custom_tool_call") - ) - { - completed_tool_items.remove(&item_id); - return None; - } - if let Some(event) = handle_openai_output_item(item, saw_text_delta, pending) { - return Some(event); - } - } - } - "response.incomplete" => { - let stop_reason = event - .response - .as_ref() - .and_then(extract_stop_reason_from_response) - .or_else(|| Some("incomplete".to_string())); - if let Some(response) = event.response - && let Some(usage_event) = extract_usage_from_response(&response) - { - pending.push_back(usage_event); - } - pending.push_back(StreamEvent::MessageEnd { stop_reason }); - return pending.pop_front(); - } - "response.completed" => { - let stop_reason = event - .response - .as_ref() - .and_then(extract_stop_reason_from_response); - if let Some(response) = event.response - && let Some(usage_event) = extract_usage_from_response(&response) - { - pending.push_back(usage_event); - } - pending.push_back(StreamEvent::MessageEnd { stop_reason }); - return pending.pop_front(); - } - "response.failed" | "response.error" | "error" => { - crate::logging::warn(&format!( - "OpenAI stream error event (type={}): response={:?}, error={:?}", - event.kind, event.response, event.error - )); - let (message, retry_after_secs) = - extract_error_with_retry(&event.response, &event.error); - return Some(StreamEvent::Error { - message, - retry_after_secs, - }); - } - _ => {} - } - - None -} - -fn extract_last_assistant_message_phase(response: &Value) -> Option { - let output = response.get("output")?.as_array()?; - output.iter().rev().find_map(|item| { - if item.get("type").and_then(|v| v.as_str()) != Some("message") { - return None; - } - if item.get("role").and_then(|v| v.as_str()) != Some("assistant") { - return None; - } - item.get("phase") - .and_then(|v| v.as_str()) - .map(|phase| phase.to_string()) - }) -} - -fn extract_stop_reason_from_response(response: &Value) -> Option { - let status = response.get("status").and_then(|v| v.as_str()); - if status == Some("completed") { - if extract_last_assistant_message_phase(response).as_deref() == Some("commentary") { - return Some("commentary".to_string()); - } - return None; - } - - let incomplete_reason = response - .get("incomplete_details") - .and_then(|v| v.get("reason")) - .and_then(|v| v.as_str()); - - if let Some(reason) = incomplete_reason { - return Some(reason.to_string()); - } - - status - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()) -} - -pub(super) fn handle_openai_output_item( - item: Value, - saw_text_delta: &mut bool, - pending: &mut VecDeque, -) -> Option { - let item_type = item.get("type")?.as_str()?; - match item_type { - "compaction" => { - let encrypted_content = item - .get("encrypted_content") - .and_then(|v| v.as_str()) - .map(|value| value.to_string())?; - return Some(StreamEvent::Compaction { - trigger: "openai_native_auto".to_string(), - pre_tokens: None, - openai_encrypted_content: Some(encrypted_content), - }); - } - "function_call" | "custom_tool_call" => { - let call_id = item - .get("call_id") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let name = item - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let raw_arguments = item - .get("arguments") - .and_then(|v| v.as_str().map(|s| s.to_string())) - .or_else(|| { - item.get("input").and_then(|v| { - if v.is_object() || v.is_array() { - Some(v.to_string()) - } else { - v.as_str().map(|s| s.to_string()) - } - }) - }) - .unwrap_or_else(|| "{}".to_string()); - let arguments = normalize_openai_tool_arguments(raw_arguments); - - pending.push_back(StreamEvent::ToolUseStart { - id: call_id.clone(), - name, - }); - pending.push_back(StreamEvent::ToolInputDelta(arguments)); - pending.push_back(StreamEvent::ToolUseEnd); - return pending.pop_front(); - } - "image_generation_call" => { - if let Some(event) = handle_openai_image_generation_item(&item, pending) { - return Some(event); - } - } - "message" => { - if *saw_text_delta { - return None; - } - let mut text = String::new(); - if let Some(content) = item.get("content").and_then(|v| v.as_array()) { - for entry in content { - let entry_type = entry.get("type").and_then(|v| v.as_str()); - if matches!(entry_type, Some("output_text") | Some("text")) - && let Some(t) = entry.get("text").and_then(|v| v.as_str()) - { - text.push_str(t); - } - } - } - return stream_text_or_recovered_tool_call(&text, pending); - } - "reasoning" => { - let id = item - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let mut summary = Vec::new(); - if let Some(summary_arr) = item.get("summary").and_then(|v| v.as_array()) { - for summary_item in summary_arr { - if summary_item.get("type").and_then(|v| v.as_str()) == Some("summary_text") - && let Some(text) = summary_item.get("text").and_then(|v| v.as_str()) - { - summary.push(text.to_string()); - } - } - } - let encrypted_content = item - .get("encrypted_content") - .and_then(|v| v.as_str()) - .map(|value| value.to_string()); - let status = item - .get("status") - .and_then(|v| v.as_str()) - .map(|value| value.to_string()); - - if !id.is_empty() && (encrypted_content.is_some() || !summary.is_empty()) { - pending.push_back(StreamEvent::OpenAIReasoning { - id, - summary: summary.clone(), - encrypted_content, - status, - }); - } - - if !summary.is_empty() { - pending.push_back(StreamEvent::ThinkingStart); - pending.push_back(StreamEvent::ThinkingDelta(summary.join("\n"))); - pending.push_back(StreamEvent::ThinkingEnd); - return pending.pop_front(); - } - return pending.pop_front(); - } - _ => {} - } - - None -} - -fn handle_openai_image_generation_item( - item: &Value, - pending: &mut VecDeque, -) -> Option { - let result_b64 = item.get("result")?.as_str()?; - if result_b64.is_empty() { - return None; - } - - let image_bytes = match BASE64_STANDARD.decode(result_b64) { - Ok(bytes) => bytes, - Err(err) => { - crate::logging::warn(&format!( - "OpenAI image_generation_call returned invalid base64: {}", - err - )); - return Some(StreamEvent::TextDelta( - "\n[Generated image received, but Jcode could not decode it.]\n".to_string(), - )); - } - }; - - let output_format = item - .get("output_format") - .and_then(|v| v.as_str()) - .unwrap_or("png"); - let extension = match output_format { - "jpeg" | "jpg" => "jpg", - "webp" => "webp", - _ => "png", - }; - let item_id = item.get("id").and_then(|v| v.as_str()).unwrap_or("image"); - let safe_id: String = item_id - .chars() - .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '_' || *ch == '-') - .take(80) - .collect(); - let safe_id = if safe_id.is_empty() { - "image".to_string() - } else { - safe_id - }; - let timestamp_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or_default(); - let dir = std::env::current_dir() - .unwrap_or_else(|_| std::env::temp_dir()) - .join(".jcode") - .join("generated-images"); - if let Err(err) = std::fs::create_dir_all(&dir) { - crate::logging::warn(&format!( - "Failed to create OpenAI generated image directory: {}", - err - )); - return Some(StreamEvent::TextDelta(format!( - "\n[Generated image received ({} bytes), but Jcode could not save it.]\n", - image_bytes.len() - ))); - } - - let filename = format!("{}-{}.{}", timestamp_ms, safe_id, extension); - let path = dir.join(filename); - if let Err(err) = std::fs::write(&path, image_bytes) { - crate::logging::warn(&format!("Failed to save OpenAI generated image: {}", err)); - return Some(StreamEvent::TextDelta( - "\n[Generated image received, but Jcode could not save it.]\n".to_string(), - )); - } - - let metadata_path = path.with_extension("json"); - let mut response_item = item.clone(); - if let Some(object) = response_item.as_object_mut() { - object.remove("result"); - } - let revised_prompt = item - .get("revised_prompt") - .and_then(|v| v.as_str()) - .map(str::to_string); - let metadata = serde_json::json!({ - "schema_version": 1, - "provider": "openai", - "native_tool": "image_generation", - "id": item_id, - "status": item.get("status").and_then(|v| v.as_str()), - "created_at_unix_ms": timestamp_ms, - "image_path": path.display().to_string(), - "output_format": output_format, - "byte_count": std::fs::metadata(&path).map(|m| m.len()).unwrap_or_default(), - "revised_prompt": revised_prompt, - "response_item": response_item, - }); - let metadata_path_string = match serde_json::to_vec_pretty(&metadata).ok().and_then(|bytes| { - std::fs::write(&metadata_path, bytes) - .ok() - .map(|_| metadata_path.clone()) - }) { - Some(path) => Some(path.display().to_string()), - None => { - crate::logging::warn("Failed to save OpenAI generated image metadata"); - None - } - }; - - let mut markdown = format!( - "\n![Generated image]({})\n\nGenerated image saved to `{}`.", - path.display(), - path.display() - ); - if let Some(metadata_path) = metadata_path_string.as_deref() { - markdown.push_str(&format!("\nMetadata saved to `{}`.", metadata_path)); - } - markdown.push('\n'); - - pending.push_back(StreamEvent::TextDelta(markdown)); - - Some(StreamEvent::GeneratedImage { - id: item_id.to_string(), - path: path.display().to_string(), - metadata_path: metadata_path_string, - output_format: output_format.to_string(), - revised_prompt, - }) -} - -pub(super) struct OpenAIResponsesStream { - inner: Pin> + Send>>, - buffer: String, - pending: VecDeque, - saw_text_delta: bool, - streaming_tool_calls: HashMap, - completed_tool_items: HashSet, -} - -impl OpenAIResponsesStream { - pub(super) fn new( - stream: impl Stream> + Send + 'static, - ) -> Self { - Self { - inner: Box::pin(stream), - buffer: String::new(), - pending: VecDeque::new(), - saw_text_delta: false, - streaming_tool_calls: HashMap::new(), - completed_tool_items: HashSet::new(), - } - } - - fn parse_next_event(&mut self) -> Option { - if let Some(event) = self.pending.pop_front() { - return Some(event); - } - - while let Some(pos) = self.buffer.find("\n\n") { - let event_str = self.buffer[..pos].to_string(); - self.buffer = self.buffer[pos + 2..].to_string(); - - let mut data_lines = Vec::new(); - for line in event_str.lines() { - if let Some(data) = crate::util::sse_data_line(line) { - data_lines.push(data); - } - } - - if data_lines.is_empty() { - continue; - } - - let data = data_lines.join("\n"); - if let Some(event) = parse_openai_response_event( - &data, - &mut self.saw_text_delta, - &mut self.streaming_tool_calls, - &mut self.completed_tool_items, - &mut self.pending, - ) { - return Some(event); - } - } - - None - } -} - -fn extract_cached_input_tokens(usage: &Value) -> Option { - usage - .get("input_tokens_details") - .or_else(|| usage.get("prompt_tokens_details")) - .and_then(|details| details.get("cached_tokens")) - .and_then(|v| v.as_u64()) -} - -fn extract_usage_from_response(response: &Value) -> Option { - let usage = response.get("usage")?; - let input_tokens = usage.get("input_tokens").and_then(|v| v.as_u64()); - let output_tokens = usage.get("output_tokens").and_then(|v| v.as_u64()); - let cache_read_input_tokens = extract_cached_input_tokens(usage); - if input_tokens.is_some() || output_tokens.is_some() || cache_read_input_tokens.is_some() { - Some(StreamEvent::TokenUsage { - input_tokens, - output_tokens, - cache_read_input_tokens, - cache_creation_input_tokens: None, - }) - } else { - None - } -} - -impl Stream for OpenAIResponsesStream { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - loop { - if let Some(event) = self.parse_next_event() { - return Poll::Ready(Some(Ok(event))); - } - - match self.inner.as_mut().poll_next(cx) { - Poll::Ready(Some(Ok(bytes))) => { - if let Ok(text) = std::str::from_utf8(&bytes) { - self.buffer.push_str(text); - } - } - Poll::Ready(Some(Err(e))) => { - return Poll::Ready(Some(Err(anyhow::anyhow!("Stream error: {}", e)))); - } - Poll::Ready(None) => { - return Poll::Ready(None); - } - Poll::Pending => { - return Poll::Pending; - } - } - } - } -} #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_text_wrapped_tool_call_rejects_non_object_json() { - let text = "prefix to=functions.read [1,2,3]"; - let parsed = parse_text_wrapped_tool_call(text); - assert!(parsed.is_none()); - } - - #[test] - fn parse_openai_response_event_ignores_malformed_json_chunks() { - let mut saw_text_delta = false; - let mut streaming_tool_calls = HashMap::new(); - let mut completed_tool_items = HashSet::new(); - let mut pending = VecDeque::new(); - - let event = parse_openai_response_event( - "{not-json}", - &mut saw_text_delta, - &mut streaming_tool_calls, - &mut completed_tool_items, - &mut pending, - ); - - assert!(event.is_none()); - assert!(!saw_text_delta); - assert!(streaming_tool_calls.is_empty()); - assert!(completed_tool_items.is_empty()); - assert!(pending.is_empty()); - } -} +pub(super) use jcode_provider_openai::stream::{ + handle_openai_output_item, parse_text_wrapped_tool_call, +}; diff --git a/crates/jcode-base/src/provider/openai/websocket_health.rs b/crates/jcode-base/src/provider/openai/websocket_health.rs index 7361d54ca..e486285b2 100644 --- a/crates/jcode-base/src/provider/openai/websocket_health.rs +++ b/crates/jcode-base/src/provider/openai/websocket_health.rs @@ -1,276 +1,15 @@ -use super::{ +pub(super) use jcode_provider_openai::websocket_health::{ WEBSOCKET_COMPLETION_TIMEOUT_SECS, WEBSOCKET_FALLBACK_NOTICE, - WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS, WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, - WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, + WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS, classify_websocket_fallback_reason, + is_stream_activity_event, is_websocket_activity_payload, is_websocket_fallback_notice, + is_websocket_first_activity_payload, record_websocket_fallback, record_websocket_success, + summarize_websocket_fallback_reason, websocket_activity_timeout_kind, + websocket_cooldown_remaining, websocket_next_activity_timeout_secs, }; -use crate::message::StreamEvent; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum WebsocketFallbackReason { - ConnectTimeout, - FirstResponseTimeout, - StreamTimeout, - ServerRequestedHttps, - ConnectFailed, - StreamClosedEarly, - WebsocketError, -} - -impl WebsocketFallbackReason { - pub(super) fn summary(self) -> &'static str { - match self { - Self::ConnectTimeout => "connect timeout", - Self::FirstResponseTimeout => "first response timeout", - Self::StreamTimeout => "stream timeout", - Self::ServerRequestedHttps => "server requested https", - Self::ConnectFailed => "connect failed", - Self::StreamClosedEarly => "stream closed early", - Self::WebsocketError => "websocket error", - } - } -} - -pub(super) fn is_websocket_fallback_notice(data: &str) -> bool { - data.to_lowercase().contains(WEBSOCKET_FALLBACK_NOTICE) -} - -pub(super) fn is_stream_activity_event(_event: &StreamEvent) -> bool { - true -} - -pub(super) fn is_websocket_activity_payload(data: &str) -> bool { - let Ok(value) = serde_json::from_str::(data) else { - return false; - }; - let Some(kind) = value.get("type").and_then(|kind| kind.as_str()) else { - return false; - }; - kind.starts_with("response.") || kind == "error" -} - -pub(super) fn is_websocket_first_activity_payload(data: &str) -> bool { - let Ok(value) = serde_json::from_str::(data) else { - return false; - }; - value - .get("type") - .and_then(|kind| kind.as_str()) - .map(|kind| !kind.is_empty()) - .unwrap_or(false) -} - -pub(super) fn websocket_remaining_timeout_secs(since: Instant, timeout_secs: u64) -> Option { - let timeout = Duration::from_secs(timeout_secs); - let elapsed = since.elapsed(); - if elapsed >= timeout { - return None; - } - - Some(timeout_secs.saturating_sub(elapsed.as_secs()).max(1)) -} - -pub(super) fn websocket_next_activity_timeout_secs( - ws_started_at: Instant, - last_api_activity_at: Instant, - saw_api_activity: bool, -) -> Option { - if !saw_api_activity { - websocket_remaining_timeout_secs(ws_started_at, WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS) - } else { - websocket_remaining_timeout_secs(last_api_activity_at, WEBSOCKET_COMPLETION_TIMEOUT_SECS) - } -} - -pub(super) fn websocket_activity_timeout_kind(saw_api_activity: bool) -> &'static str { - if saw_api_activity { "next" } else { "first" } -} - -pub(super) fn classify_websocket_fallback_reason(error: &str) -> WebsocketFallbackReason { - let error = error.to_ascii_lowercase(); - if error.contains("connect timed out") { - WebsocketFallbackReason::ConnectTimeout - } else if error.contains("did not emit api activity within") - || error.contains("timed out waiting for first websocket activity") - { - WebsocketFallbackReason::FirstResponseTimeout - } else if error.contains("timed out waiting for next websocket activity") - || error.contains("did not complete within") - { - WebsocketFallbackReason::StreamTimeout - } else if error.contains("upgrade required") - || error.contains("server requested fallback") - || error.contains(WEBSOCKET_FALLBACK_NOTICE) - { - WebsocketFallbackReason::ServerRequestedHttps - } else if error.contains("failed to connect websocket stream") { - WebsocketFallbackReason::ConnectFailed - } else if error.contains("ended before response.completed") - || error.contains("closed before response.completed") - { - WebsocketFallbackReason::StreamClosedEarly - } else { - WebsocketFallbackReason::WebsocketError - } -} - -pub(super) fn summarize_websocket_fallback_reason(error: &str) -> &'static str { - classify_websocket_fallback_reason(error).summary() -} - -fn websocket_cooldown_bounds_for_reason(reason: WebsocketFallbackReason) -> (u64, u64) { - match reason { - WebsocketFallbackReason::ServerRequestedHttps => ( - WEBSOCKET_MODEL_COOLDOWN_BASE_SECS.saturating_mul(5), - WEBSOCKET_MODEL_COOLDOWN_MAX_SECS.saturating_mul(3), - ), - WebsocketFallbackReason::StreamTimeout => ( - WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, - WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, - ), - WebsocketFallbackReason::ConnectTimeout - | WebsocketFallbackReason::FirstResponseTimeout - | WebsocketFallbackReason::ConnectFailed - | WebsocketFallbackReason::StreamClosedEarly - | WebsocketFallbackReason::WebsocketError => ( - (WEBSOCKET_MODEL_COOLDOWN_BASE_SECS / 2).max(1), - (WEBSOCKET_MODEL_COOLDOWN_MAX_SECS / 2).max(1), - ), - } -} - -pub(super) fn normalize_transport_model(model: &str) -> Option { - let normalized = model.trim().to_ascii_lowercase(); - if normalized.is_empty() { - None - } else { - Some(normalized) - } -} - -pub(super) async fn websocket_cooldown_remaining( - websocket_cooldowns: &Arc>>, - model: &str, -) -> Option { - let key = normalize_transport_model(model)?; - let now = Instant::now(); - - { - let guard = websocket_cooldowns.read().await; - if let Some(until) = guard.get(&key) - && *until > now - { - return Some(*until - now); - } - } - - let mut guard = websocket_cooldowns.write().await; - if let Some(until) = guard.get(&key) - && *until > now - { - return Some(*until - now); - } - if guard.get(&key).is_some() { - guard.remove(&key); - } - None -} #[cfg(test)] -pub(super) async fn set_websocket_cooldown( - websocket_cooldowns: &Arc>>, - model: &str, -) { - let Some(key) = normalize_transport_model(model) else { - return; - }; - - let cooldown = Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS); - let until = Instant::now() + cooldown; - let mut guard = websocket_cooldowns.write().await; - guard.insert(key, until); -} - -pub(super) async fn set_websocket_cooldown_for( - websocket_cooldowns: &Arc>>, - model: &str, - cooldown: Duration, -) { - let Some(key) = normalize_transport_model(model) else { - return; - }; - - let until = Instant::now() + cooldown; - let mut guard = websocket_cooldowns.write().await; - guard.insert(key, until); -} - -pub(super) async fn clear_websocket_cooldown( - websocket_cooldowns: &Arc>>, - model: &str, -) { - let Some(key) = normalize_transport_model(model) else { - return; - }; - - let mut guard = websocket_cooldowns.write().await; - guard.remove(&key); -} - -pub(super) fn websocket_cooldown_for_streak( - streak: u32, - reason: WebsocketFallbackReason, -) -> Duration { - let (base, max) = websocket_cooldown_bounds_for_reason(reason); - let base = base as u128; - let max = max as u128; - let shift = streak.saturating_sub(1).min(16); - let scaled = base.saturating_mul(1u128 << shift); - Duration::from_secs(scaled.min(max) as u64) -} - -pub(super) async fn record_websocket_fallback( - websocket_cooldowns: &Arc>>, - websocket_failure_streaks: &Arc>>, - model: &str, - reason: WebsocketFallbackReason, -) -> (u32, Duration) { - let Some(key) = normalize_transport_model(model) else { - return (0, websocket_cooldown_for_streak(1, reason)); - }; - - let streak = { - let mut guard = websocket_failure_streaks.write().await; - let entry = guard.entry(key).or_insert(0); - *entry = entry.saturating_add(1); - *entry - }; - - let cooldown = websocket_cooldown_for_streak(streak, reason); - set_websocket_cooldown_for(websocket_cooldowns, model, cooldown).await; - (streak, cooldown) -} - -pub(super) async fn record_websocket_success( - websocket_cooldowns: &Arc>>, - websocket_failure_streaks: &Arc>>, - model: &str, -) { - clear_websocket_cooldown(websocket_cooldowns, model).await; - let Some(key) = normalize_transport_model(model) else { - return; - }; - let streak = { - let mut guard = websocket_failure_streaks.write().await; - guard.remove(&key).unwrap_or(0) - }; - if streak > 0 { - crate::logging::info(&format!( - "OpenAI websocket health reset for model='{}' after successful stream (previous streak={})", - model, streak - )); - } -} +pub(super) use jcode_provider_openai::websocket_health::{ + WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, WebsocketFallbackReason, + clear_websocket_cooldown, normalize_transport_model, set_websocket_cooldown, + websocket_cooldown_for_streak, websocket_remaining_timeout_secs, +}; diff --git a/crates/jcode-base/src/provider/openai_stream_runtime.rs b/crates/jcode-base/src/provider/openai_stream_runtime.rs index 5de52c39a..b5817813b 100644 --- a/crates/jcode-base/src/provider/openai_stream_runtime.rs +++ b/crates/jcode-base/src/provider/openai_stream_runtime.rs @@ -1443,75 +1443,6 @@ fn classify_unavailable_model_error(status: StatusCode, body: &str) -> Option, - top_level_error: &Option, -) -> (String, Option) { - // For "response.failed" events, the error is nested: response.error.message - // For "error"/"response.error" events, the error is top-level: error.message - let error = response - .as_ref() - .and_then(|r| r.get("error")) - .or(top_level_error.as_ref()); - - let error = match error { - Some(e) => e, - None => { - // Last resort: check if response itself has a status_message or message - if let Some(resp) = response.as_ref() - && let Some(msg) = resp - .get("status_message") - .or_else(|| resp.get("message")) - .and_then(|v| v.as_str()) - { - return (msg.to_string(), None); - } - return ( - "OpenAI response stream error (no error details)".to_string(), - None, - ); - } - }; - - let message = error - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("OpenAI response stream error (unknown)") - .to_string(); - let error_type = error.get("type").and_then(|v| v.as_str()); - let code = error.get("code").and_then(|v| v.as_str()); - - let message_lower = message.to_lowercase(); - let message = match (error_type, code) { - (Some(error_type), Some(code)) - if !message_lower.contains(&error_type.to_lowercase()) - && !message_lower.contains(&code.to_lowercase()) => - { - format!("{} ({}): {}", error_type, code, message) - } - (Some(error_type), _) if !message_lower.contains(&error_type.to_lowercase()) => { - format!("{}: {}", error_type, message) - } - (_, Some(code)) if !message_lower.contains(&code.to_lowercase()) => { - format!("{}: {}", code, message) - } - _ => message, - }; - - // Try to extract retry_after from error object or response metadata - let retry_after = error - .get("retry_after") - .and_then(|v| v.as_u64()) - .or_else(|| { - response - .as_ref() - .and_then(|r| r.get("retry_after")) - .and_then(|v| v.as_u64()) - }); - - (message, retry_after) -} - /// Check if an error is transient and should be retried pub(super) fn is_retryable_error(error_str: &str) -> bool { // Network/connection errors diff --git a/crates/jcode-base/src/provider/pricing.rs b/crates/jcode-base/src/provider/pricing.rs index c2fa5df92..9cc3eb3e7 100644 --- a/crates/jcode-base/src/provider/pricing.rs +++ b/crates/jcode-base/src/provider/pricing.rs @@ -111,21 +111,39 @@ pub(crate) fn cheapness_for_route( provider: &str, api_method: &str, ) -> Option { - match api_method { - "claude-oauth" => Some(anthropic_oauth_pricing(model)), - "api-key" | "claude-api" | "anthropic-api-key" if provider == "Anthropic" => { - anthropic_api_pricing(model) - } - "openai-api-key" => { - Some(openai_api_pricing(model).unwrap_or_else(|| openai_oauth_pricing(model))) - } - "openai-oauth" => { - if openai_effective_auth_mode() == "api-key" { + use jcode_provider_core::{AuthMode, AuthRoute, DualAuthProvider}; + + // Dual-auth (Anthropic/OpenAI OAuth-vs-API) methods are recognized through + // the single shared parser so pricing never disagrees with the routing + // layer about whether a route is subscription (OAuth) or metered (API key). + if let Some(route) = AuthRoute::parse(api_method) { + return match (route.provider, route.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => Some(anthropic_oauth_pricing(model)), + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => { + // Bare `api-key` only means Anthropic when the route's provider + // label says so; otherwise fall through to the non-dual arms. + if provider == "Anthropic" { + anthropic_api_pricing(model) + } else { + None + } + } + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => { Some(openai_api_pricing(model).unwrap_or_else(|| openai_oauth_pricing(model))) - } else { - Some(openai_oauth_pricing(model)) } - } + (DualAuthProvider::OpenAI, AuthMode::Oauth) => { + // An "OAuth" route still bills per token when only an API key is + // actually configured, so honor the live effective auth mode. + if openai_effective_auth_mode() == "api-key" { + Some(openai_api_pricing(model).unwrap_or_else(|| openai_oauth_pricing(model))) + } else { + Some(openai_oauth_pricing(model)) + } + } + }; + } + + match api_method { "copilot" => Some(copilot_pricing(model)), "openrouter" => { let model_id = if model.contains('/') { diff --git a/crates/jcode-base/src/provider/selection.rs b/crates/jcode-base/src/provider/selection.rs index 40a9ac453..fb3ded75d 100644 --- a/crates/jcode-base/src/provider/selection.rs +++ b/crates/jcode-base/src/provider/selection.rs @@ -211,13 +211,13 @@ impl MultiProvider { /// child/forked session) still reconstructs the Anthropic API-key route /// instead of falling through to Auto (which prefers OAuth). pub(crate) fn canonical_session_provider_key(provider_key: &str) -> &str { - match provider_key.trim() { - "claude-oauth" => "claude", - "anthropic-api-key" => "claude-api", - "openai-oauth" => "openai", - "openai-api-key" => "openai-api", - other => other, + // Fold any dual-auth (Anthropic/OpenAI OAuth-vs-API) alias onto its + // canonical session key via the single shared parser, so this never + // drifts from the route/runtime vocabularies. Non-dual keys pass through. + if let Some(route) = jcode_provider_core::AuthRoute::parse(provider_key) { + return route.session_provider_key(); } + provider_key.trim() } fn explicit_session_provider_key_for_model_request(model_request: &str) -> Option { @@ -225,13 +225,12 @@ impl MultiProvider { if let Some((prefix, rest)) = model_request.split_once(':') { let prefix = prefix.trim(); if !prefix.is_empty() && !rest.trim().is_empty() { + // Dual-auth (Anthropic/OpenAI) prefixes fold onto their canonical + // session key via the single shared parser. + if let Some(route) = jcode_provider_core::AuthRoute::parse(prefix) { + return Some(route.session_provider_key().to_string()); + } match prefix { - "claude-api" => return Some("claude-api".to_string()), - "claude-oauth" | "claude" | "anthropic" => { - return Some("claude".to_string()); - } - "openai-api" => return Some("openai-api".to_string()), - "openai-oauth" | "openai" => return Some("openai".to_string()), "copilot" | "antigravity" | "gemini" | "cursor" | "bedrock" | "openrouter" => { return Some(prefix.to_string()); } @@ -376,11 +375,13 @@ impl MultiProvider { // forked/child session that inherited it without `route_api_method`). let provider_key = Self::canonical_session_provider_key(provider_key); + // Dual-auth keys map to their canonical model prefix via the single + // shared parser, keeping the emitted prefix in lockstep with the parsers. + if let Some(route) = jcode_provider_core::AuthRoute::parse(provider_key) { + return format!("{}:{model}", route.model_prefix()); + } + match provider_key { - "claude-api" => format!("claude-api:{model}"), - "claude-oauth" | "claude" | "anthropic" => format!("claude-oauth:{model}"), - "openai-api" => format!("openai-api:{model}"), - "openai-oauth" | "openai" => format!("openai-oauth:{model}"), "copilot" | "antigravity" | "gemini" | "cursor" | "bedrock" | "openrouter" => { format!("{provider_key}:{model}") } diff --git a/crates/jcode-base/src/provider/tests/model_resolution.rs b/crates/jcode-base/src/provider/tests/model_resolution.rs index 478fc7b7c..86538702b 100644 --- a/crates/jcode-base/src/provider/tests/model_resolution.rs +++ b/crates/jcode-base/src/provider/tests/model_resolution.rs @@ -1073,8 +1073,10 @@ fn test_anthropic_auth_mode_prefixed_model_switch_changes_credentials() { assert_eq!( rt.block_on(anthropic.test_access_token_and_oauth_mode()) .expect("default token"), - ("sk-ant-test-api-key".to_string(), false), - "default Anthropic credentials should keep existing API-key-first behavior" + ("oauth-access-token".to_string(), true), + "default (Auto) Anthropic credentials prefer OAuth/subscription when an \ + OAuth account is available, matching the canonical OAuth-first Auto \ + behavior shared with the OpenAI provider and resolve_dual_credential_auth" ); provider diff --git a/crates/jcode-base/src/provider_catalog.rs b/crates/jcode-base/src/provider_catalog.rs index 2115f6e7a..f1f5c881a 100644 --- a/crates/jcode-base/src/provider_catalog.rs +++ b/crates/jcode-base/src/provider_catalog.rs @@ -1,39 +1,9 @@ +pub use jcode_provider_env::{ + load_api_key_from_env_or_config, load_env_value_from_env_or_config, + register_api_key_fallback_resolver, save_env_value_to_env_file, +}; pub use jcode_provider_metadata::*; use std::collections::{HashMap, HashSet}; -use std::sync::{LazyLock, RwLock}; - -/// Fallback resolvers consulted by [`load_api_key_from_env_or_config`] after the -/// environment and config-file lookups fail. Higher-level crates (notably -/// `auth`, which scans trusted external CLI credential stores) register a -/// resolver at startup so `provider_catalog` does not need to depend on `auth`. -type ApiKeyFallbackResolver = fn(&str) -> Option; - -static API_KEY_FALLBACK_RESOLVERS: LazyLock>> = - LazyLock::new(|| RwLock::new(Vec::new())); - -/// Register a fallback API-key resolver consulted when env/config lookups miss. -/// -/// This inverts the historical `provider_catalog -> auth` dependency: `auth` -/// (the higher layer) now registers its external-credential scan here, keeping -/// `provider_catalog` free of upward references. -pub fn register_api_key_fallback_resolver(resolver: ApiKeyFallbackResolver) { - API_KEY_FALLBACK_RESOLVERS - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push(resolver); -} - -fn resolve_api_key_fallback(env_key: &str) -> Option { - let resolvers = API_KEY_FALLBACK_RESOLVERS - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - for resolver in resolvers.iter() { - if let Some(key) = resolver(env_key) { - return Some(key); - } - } - None -} pub const OPENAI_COMPAT_LOCAL_ENABLED_ENV: &str = "JCODE_OPENAI_COMPAT_LOCAL_ENABLED"; pub const MINIMAX_CHINA_API_BASE: &str = "https://api.minimaxi.com/v1"; @@ -855,134 +825,6 @@ pub fn configured_api_key_source( Some((env_key, file_name)) } -pub fn load_api_key_from_env_or_config(env_key: &str, file_name: &str) -> Option { - if !is_safe_env_key_name(env_key) { - crate::logging::warn(&format!( - "Ignoring invalid API key variable name '{}' while loading credentials", - env_key - )); - return None; - } - if !is_safe_env_file_name(file_name) { - crate::logging::warn(&format!( - "Ignoring invalid env file name '{}' while loading credentials", - file_name - )); - return None; - } - - if let Ok(key) = std::env::var(env_key) { - let key = key.trim(); - if !key.is_empty() { - return Some(key.to_string()); - } - } - - let config_path = crate::storage::app_config_dir().ok()?.join(file_name); - crate::storage::harden_secret_file_permissions(&config_path); - let content = std::fs::read_to_string(config_path).ok()?; - let prefix = format!("{}=", env_key); - - for line in content.lines() { - if let Some(key) = line.strip_prefix(&prefix) { - let key = key.trim().trim_matches('"').trim_matches('\''); - if !key.is_empty() { - return Some(key.to_string()); - } - } - } - - if env_key == "ZHIPU_API_KEY" { - if let Ok(key) = std::env::var("ZAI_API_KEY") { - let key = key.trim(); - if !key.is_empty() { - return Some(key.to_string()); - } - } - - let legacy_prefix = "ZAI_API_KEY="; - for line in content.lines() { - if let Some(key) = line.strip_prefix(legacy_prefix) { - let key = key.trim().trim_matches('"').trim_matches('\''); - if !key.is_empty() { - return Some(key.to_string()); - } - } - } - } - - if let Some(key) = resolve_api_key_fallback(env_key) { - return Some(key); - } - - None -} - -pub fn load_env_value_from_env_or_config(env_key: &str, file_name: &str) -> Option { - if !is_safe_env_key_name(env_key) { - crate::logging::warn(&format!( - "Ignoring invalid variable name '{}' while loading config value", - env_key - )); - return None; - } - if !is_safe_env_file_name(file_name) { - crate::logging::warn(&format!( - "Ignoring invalid env file name '{}' while loading config value", - file_name - )); - return None; - } - - if let Ok(value) = std::env::var(env_key) { - let value = value.trim(); - if !value.is_empty() { - return Some(value.to_string()); - } - } - - let config_path = crate::storage::app_config_dir().ok()?.join(file_name); - crate::storage::harden_secret_file_permissions(&config_path); - let content = std::fs::read_to_string(config_path).ok()?; - let prefix = format!("{}=", env_key); - - for line in content.lines() { - if let Some(value) = line.strip_prefix(&prefix) { - let value = value.trim().trim_matches('"').trim_matches('\''); - if !value.is_empty() { - return Some(value.to_string()); - } - } - } - - None -} - -pub fn save_env_value_to_env_file( - env_key: &str, - file_name: &str, - value: Option<&str>, -) -> anyhow::Result<()> { - if !is_safe_env_key_name(env_key) { - anyhow::bail!("Invalid variable name: {}", env_key); - } - if !is_safe_env_file_name(file_name) { - anyhow::bail!("Invalid env file name: {}", file_name); - } - - let config_dir = crate::storage::app_config_dir()?; - let file_path = config_dir.join(file_name); - crate::storage::upsert_env_file_value(&file_path, env_key, value)?; - - if let Some(value) = value { - crate::env::set_var(env_key, value); - } else { - crate::env::remove_var(env_key); - } - - Ok(()) -} - fn env_override(name: &str) -> Option { std::env::var(name) .ok() diff --git a/crates/jcode-base/src/session/render.rs b/crates/jcode-base/src/session/render.rs index 76e7ecc6d..516d53fcc 100644 --- a/crates/jcode-base/src/session/render.rs +++ b/crates/jcode-base/src/session/render.rs @@ -21,9 +21,9 @@ pub const DEFAULT_VISIBLE_COMPACTED_HISTORY_MESSAGES: usize = 64; /// Honors the active `reasoning_display` mode so re-rendered history (reload, /// resume, remote sync, compaction-window expand) matches the live behavior: /// - `Off`: persisted reasoning is hidden entirely. -/// - `Current`: the block folds down to a single `▸ thought (N lines)` trace, -/// matching the live collapse animation's end state rather than replaying the -/// full reasoning back into the transcript on every reload. +/// - `Current`: only the *live* reasoning block is ever shown, so historical +/// reasoning is hidden on re-render (the live block already streamed and was +/// discarded once the model answered), matching the ephemeral live behavior. /// - `Full`: every reasoning line is shown (classic behavior). fn format_reasoning_markup(text: &str) -> String { if text.trim().is_empty() { @@ -31,19 +31,15 @@ fn format_reasoning_markup(text: &str) -> String { } let mode = crate::config::config().display.reasoning_display(); match mode { - ReasoningDisplayMode::Off => return String::new(), - ReasoningDisplayMode::Current => { - let line_count = text.lines().filter(|l| !l.trim().is_empty()).count(); - let mut out = jcode_tui_markdown::reasoning_summary_line_markup(line_count); - // Blank line terminates the reasoning block. - out.push('\n'); - return out; - } + // In both `Off` and `Current` modes persisted reasoning is not re-rendered: + // `Current` only ever shows the live block, which is discarded once the + // model answers, so reloaded history shows no past reasoning. + ReasoningDisplayMode::Off | ReasoningDisplayMode::Current => return String::new(), ReasoningDisplayMode::Full => {} } let mut out = String::new(); for line in text.split('\n') { - out.push_str(&jcode_tui_markdown::reasoning_line_markup(line)); + out.push_str(&jcode_render_core::reasoning_line_markup(line)); } // Blank line terminates the reasoning block. out.push('\n'); diff --git a/crates/jcode-base/src/session_tests/cases.rs b/crates/jcode-base/src/session_tests/cases.rs index 3b82ae892..31e23cdd6 100644 --- a/crates/jcode-base/src/session_tests/cases.rs +++ b/crates/jcode-base/src/session_tests/cases.rs @@ -1059,7 +1059,7 @@ fn test_render_messages_honors_system_display_role_override() { #[test] fn test_render_messages_renders_persisted_reasoning() { - use jcode_tui_markdown::REASONING_SENTINEL; + use jcode_render_core::REASONING_SENTINEL; let _env_lock = lock_env(); let _mode = EnvVarGuard::set("JCODE_REASONING_DISPLAY", "full"); @@ -1108,7 +1108,7 @@ fn test_render_messages_renders_persisted_reasoning() { #[test] fn test_render_messages_renders_legacy_reasoning_variant() { - use jcode_tui_markdown::REASONING_SENTINEL; + use jcode_render_core::REASONING_SENTINEL; let _env_lock = lock_env(); let _mode = EnvVarGuard::set("JCODE_REASONING_DISPLAY", "full"); @@ -1139,8 +1139,8 @@ fn test_render_messages_renders_legacy_reasoning_variant() { } #[test] -fn test_render_messages_collapses_persisted_reasoning_in_current_mode() { - use jcode_tui_markdown::REASONING_SENTINEL; +fn test_render_messages_hides_persisted_reasoning_in_current_mode() { + use jcode_render_core::REASONING_SENTINEL; let _env_lock = lock_env(); let _mode = EnvVarGuard::set("JCODE_REASONING_DISPLAY", "current"); @@ -1168,23 +1168,26 @@ fn test_render_messages_collapses_persisted_reasoning_in_current_mode() { let rendered = render_messages(&session); assert_eq!(rendered.len(), 1); let content = &rendered[0].content; - // In `current` mode re-rendered history folds the whole reasoning block down - // to a single dim/italic trace line, matching the live collapse end state. + // In `current` mode only the *live* reasoning block is ever shown; it streams + // then is discarded once the model answers. Re-rendered history therefore + // shows no past reasoning at all (no trace line, no lines, no sentinel). assert!( - content.contains(&format!("*{0}▸ thought (3 lines){0}*", REASONING_SENTINEL)), - "expected collapsed reasoning summary, got: {content:?}" + !content.contains(REASONING_SENTINEL), + "no reasoning markup expected in current mode on reload: {content:?}" ); assert!( - !content.contains("step one") && !content.contains("step two"), - "individual reasoning lines must not be replayed in current mode: {content:?}" + !content.contains("step one") + && !content.contains("step two") + && !content.contains("thought"), + "individual reasoning lines/trace must not be replayed in current mode: {content:?}" ); - // The answer text is preserved and follows the collapsed trace. + // The answer text is preserved. assert!(content.contains("Here is the answer.")); } #[test] fn test_render_messages_hides_persisted_reasoning_in_off_mode() { - use jcode_tui_markdown::REASONING_SENTINEL; + use jcode_render_core::REASONING_SENTINEL; let _env_lock = lock_env(); let _mode = EnvVarGuard::set("JCODE_REASONING_DISPLAY", "off"); diff --git a/crates/jcode-base/src/telemetry.rs b/crates/jcode-base/src/telemetry.rs index c39714de0..07242da4c 100644 --- a/crates/jcode-base/src/telemetry.rs +++ b/crates/jcode-base/src/telemetry.rs @@ -502,7 +502,7 @@ fn detect_project_profile() -> ProjectProfile { let Some(root) = cwd.as_deref() else { return profile; }; - profile.repo_present = root.join(".git").exists() || crate::build::is_jcode_repo(root); + profile.repo_present = root.join(".git").exists() || is_jcode_repo_dir(root); let mut scanned_files = 0usize; for entry in walkdir::WalkDir::new(root) .max_depth(3) diff --git a/crates/jcode-base/src/telemetry/state_support.rs b/crates/jcode-base/src/telemetry/state_support.rs index dd725f75d..ad63b2888 100644 --- a/crates/jcode-base/src/telemetry/state_support.rs +++ b/crates/jcode-base/src/telemetry/state_support.rs @@ -1,7 +1,7 @@ use super::{SESSION_STATE, sanitize_telemetry_label}; use crate::storage; use chrono::{DateTime, Datelike, Timelike, Utc}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; pub(super) fn telemetry_id_path() -> Option { @@ -263,6 +263,52 @@ pub(super) fn new_event_id() -> String { uuid::Uuid::new_v4().to_string() } +pub(super) fn is_jcode_repo_dir(dir: &Path) -> bool { + let cargo_toml = dir.join("Cargo.toml"); + if !cargo_toml.exists() || !dir.join(".git").exists() { + return false; + } + + std::fs::read_to_string(cargo_toml) + .map(|content| content.contains("name = \"jcode\"")) + .unwrap_or(false) +} + +fn find_jcode_repo_in_ancestors(start: &Path) -> Option { + start + .ancestors() + .find(|dir| is_jcode_repo_dir(dir)) + .map(Path::to_path_buf) +} + +fn telemetry_jcode_repo_dir() -> Option { + if let Ok(path) = std::env::var("JCODE_REPO_DIR") { + let path = PathBuf::from(path); + if is_jcode_repo_dir(&path) { + return Some(path); + } + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + if let Some(repo) = find_jcode_repo_in_ancestors(&manifest_dir) { + return Some(repo); + } + + if let Ok(exe) = std::env::current_exe() + && let Some(repo) = exe + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .filter(|dir| is_jcode_repo_dir(dir)) + { + return Some(repo.to_path_buf()); + } + + std::env::current_dir() + .ok() + .and_then(|cwd| find_jcode_repo_in_ancestors(&cwd)) +} + pub(super) fn build_channel() -> String { if std::env::var(jcode_selfdev_types::CLIENT_SELFDEV_ENV).is_ok() { return "selfdev".to_string(); @@ -276,14 +322,14 @@ pub(super) fn build_channel() -> String { return "local_build".to_string(); } } - if crate::build::get_repo_dir().is_some() { + if telemetry_jcode_repo_dir().is_some() { return "git_checkout".to_string(); } "release".to_string() } pub(super) fn is_git_checkout() -> bool { - crate::build::get_repo_dir().is_some() + telemetry_jcode_repo_dir().is_some() } pub(super) fn is_ci() -> bool { diff --git a/crates/jcode-base/src/telemetry/tests.rs b/crates/jcode-base/src/telemetry/tests.rs index f204793f5..581ba140a 100644 --- a/crates/jcode-base/src/telemetry/tests.rs +++ b/crates/jcode-base/src/telemetry/tests.rs @@ -30,12 +30,25 @@ fn test_do_not_track() { fn test_is_ci_detects_ci_env() { let _guard = lock_test_env(); // Clear any inherited CI markers so the baseline is deterministic. - for key in ["CI", "GITHUB_ACTIONS", "BUILDKITE", "JENKINS_URL", "GITLAB_CI", "CIRCLECI"] { + for key in [ + "CI", + "GITHUB_ACTIONS", + "BUILDKITE", + "JENKINS_URL", + "GITLAB_CI", + "CIRCLECI", + ] { crate::env::remove_var(key); } - assert!(!is_ci(), "expected non-CI baseline after clearing CI markers"); + assert!( + !is_ci(), + "expected non-CI baseline after clearing CI markers" + ); crate::env::set_var("CI", "true"); - assert!(is_ci(), "CI env var should mark the run as CI (gates install skip)"); + assert!( + is_ci(), + "CI env var should mark the run as CI (gates install skip)" + ); crate::env::remove_var("CI"); assert!(!is_ci()); } diff --git a/crates/jcode-build-support/src/lib.rs b/crates/jcode-build-support/src/lib.rs index ad8cc2013..27ceff98a 100644 --- a/crates/jcode-build-support/src/lib.rs +++ b/crates/jcode-build-support/src/lib.rs @@ -818,6 +818,14 @@ pub fn repair_stale_shared_server_channel() -> Result { if previous.as_deref().map(str::trim).filter(|s| !s.is_empty()) == Some(stable_version) { return Ok(SharedServerRepair::AlreadyCurrent); } + if previous + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .is_some_and(|previous| !is_release_channel_marker(previous)) + { + return Ok(SharedServerRepair::AlreadyCurrent); + } // Only repair when stable is strictly newer than the current shared-server // binary on disk. This never downgrades, and it preserves a self-dev pin @@ -838,6 +846,15 @@ pub fn repair_stale_shared_server_channel() -> Result { }) } +fn is_release_channel_marker(marker: &str) -> bool { + let marker = marker.trim(); + let marker = marker.strip_prefix('v').unwrap_or(marker); + marker.starts_with("main-") + || marker + .split('.') + .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())) +} + /// True when `shared` exists and is strictly older (by mtime) than `stable`, or /// when `shared` is missing entirely (nothing to protect). Any mtime /// uncertainty on an existing shared binary is treated as "not older" so we diff --git a/crates/jcode-build-support/src/tests.rs b/crates/jcode-build-support/src/tests.rs index 88af9652f..739346d94 100644 --- a/crates/jcode-build-support/src/tests.rs +++ b/crates/jcode-build-support/src/tests.rs @@ -806,6 +806,30 @@ fn repair_preserves_fresher_selfdev_pin() { }); } +#[test] +fn repair_preserves_older_selfdev_pin() { + use std::time::{Duration, SystemTime}; + with_temp_jcode_home(|| { + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + let selfdev_old = "56f43c3d-dirty-deadbeef"; + let stable_new = "0.22.0"; + write_versioned_binary(selfdev_old, base); + write_versioned_binary(stable_new, base + Duration::from_secs(120)); + update_shared_server_symlink(selfdev_old).expect("pin older self-dev"); + update_stable_symlink(stable_new).expect("stable new"); + + assert_eq!( + repair_stale_shared_server_channel().expect("repair"), + SharedServerRepair::AlreadyCurrent, + "repair must not overwrite a deliberately-pinned self-dev build" + ); + assert_eq!( + read_shared_server_version().unwrap().as_deref(), + Some(selfdev_old), + ); + }); +} + #[test] fn repair_never_downgrades_when_stable_is_older() { use std::time::{Duration, SystemTime}; diff --git a/crates/jcode-memory-types/Cargo.toml b/crates/jcode-memory-types/Cargo.toml index e992ed5d9..e81b1a726 100644 --- a/crates/jcode-memory-types/Cargo.toml +++ b/crates/jcode-memory-types/Cargo.toml @@ -6,6 +6,6 @@ publish = false [dependencies] chrono = { version = "0.4", features = ["serde"] } -jcode-core = { path = "../jcode-core" } +rand = "0.9.3" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/jcode-memory-types/src/lib.rs b/crates/jcode-memory-types/src/lib.rs index a9891841d..341ffe5d2 100644 --- a/crates/jcode-memory-types/src/lib.rs +++ b/crates/jcode-memory-types/src/lib.rs @@ -273,12 +273,18 @@ fn default_active() -> bool { true } +fn new_memory_id() -> String { + let ts = Utc::now().timestamp_millis(); + let rand: u64 = rand::random(); + format!("mem_{ts}_{rand}") +} + impl MemoryEntry { pub fn new(category: MemoryCategory, content: impl Into) -> Self { let now = Utc::now(); let content = content.into(); Self { - id: jcode_core::id::new_id("mem"), + id: new_memory_id(), category, search_text: normalize_memory_search_text(&content, &[]), content, diff --git a/crates/jcode-provider-bedrock/Cargo.toml b/crates/jcode-provider-bedrock/Cargo.toml new file mode 100644 index 000000000..4a7dd5c0d --- /dev/null +++ b/crates/jcode-provider-bedrock/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "jcode-provider-bedrock" +version = "0.1.0" +edition = "2024" + +[lib] +name = "jcode_provider_bedrock" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +async-trait = "0.1" +aws-config = "1.8.16" +aws-credential-types = "1.2.14" +aws-sdk-bedrock = "1.141.0" +aws-sdk-bedrockruntime = "1.130.0" +aws-sdk-sts = "1.103.0" +aws-smithy-types = "1.4.7" +aws-types = "1.3.15" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +futures = "0.3" +jcode-core = { path = "../jcode-core" } +jcode-logging = { path = "../jcode-logging" } +jcode-message-types = { path = "../jcode-message-types" } +jcode-provider-core = { path = "../jcode-provider-core" } +jcode-provider-env = { path = "../jcode-provider-env" } +jcode-storage = { path = "../jcode-storage" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "process", "rt", "sync"] } +tokio-stream = "0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/jcode-provider-bedrock/src/lib.rs b/crates/jcode-provider-bedrock/src/lib.rs new file mode 100644 index 000000000..9a6f93472 --- /dev/null +++ b/crates/jcode-provider-bedrock/src/lib.rs @@ -0,0 +1,1758 @@ +use anyhow::{Context, Result}; +use async_trait::async_trait; +use aws_config::BehaviorVersion; +use aws_credential_types::Credentials; +use aws_sdk_bedrock::Client as BedrockControlClient; +use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient; +use aws_sdk_bedrockruntime::types::{ + ContentBlock, ContentBlockDelta, ContentBlockStart, ConversationRole, ConverseStreamOutput, + ImageBlock, ImageFormat, ImageSource, InferenceConfiguration, Message, + ReasoningContentBlockDelta, SystemContentBlock, Tool, ToolConfiguration, ToolInputSchema, + ToolSpecification, +}; +use aws_smithy_types::Blob; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use jcode_message_types::{ + ContentBlock as JContentBlock, Message as JMessage, Role as JRole, StreamEvent, ToolDefinition, +}; +use jcode_provider_core::{ + DEFAULT_CONTEXT_LIMIT, EventStream, ModelCatalogRefreshSummary, ModelRoute, Provider, + RouteCheapnessEstimate, RouteCostConfidence, RouteCostSource, summarize_model_catalog_refresh, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::pin::Pin; +use std::sync::{Arc, RwLock}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; + +const DEFAULT_MODEL: &str = "anthropic.claude-3-5-sonnet-20241022-v2:0"; +const DEFAULT_MAX_OUTPUT_TOKENS: usize = 4096; +pub const ENV_FILE: &str = "bedrock.env"; +pub const API_KEY_ENV: &str = "AWS_BEARER_TOKEN_BEDROCK"; +pub const REGION_ENV: &str = "JCODE_BEDROCK_REGION"; + +#[derive(Debug, Clone)] +struct BedrockModelInfo { + context_tokens: usize, + max_output_tokens: usize, + supports_tools: bool, + supports_vision: bool, + supports_reasoning: bool, + pricing: Option<(u64, u64)>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedCatalog { + models: Vec, + inference_profiles: Vec, + #[serde(default)] + profile_required_models: Vec, + #[serde(default)] + inference_profile_routes: HashMap, + #[serde(default)] + legacy_models: Vec, + region: Option, + fetched_at_rfc3339: String, +} + +pub struct BedrockProvider { + model: Arc>, + fetched_models: Arc>>, + fetched_inference_profiles: Arc>>, + profile_required_models: Arc>>, + inference_profile_routes: Arc>>, + legacy_models: Arc>>, +} + +impl BedrockProvider { + pub fn new() -> Self { + let model = + std::env::var("JCODE_BEDROCK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + let provider = Self { + model: Arc::new(RwLock::new(model)), + fetched_models: Arc::new(RwLock::new(Vec::new())), + fetched_inference_profiles: Arc::new(RwLock::new(Vec::new())), + profile_required_models: Arc::new(RwLock::new(HashSet::new())), + inference_profile_routes: Arc::new(RwLock::new(HashMap::new())), + legacy_models: Arc::new(RwLock::new(HashSet::new())), + }; + provider.seed_cached_catalog(); + provider + } + + pub fn has_credentials() -> bool { + let explicitly_enabled = std::env::var("JCODE_BEDROCK_ENABLE") + .ok() + .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false); + if explicitly_enabled { + return true; + } + + let has_region = Self::configured_region().is_some(); + let has_credential_hint = Self::configured_bearer_token().is_some() + || std::env::var_os("AWS_ACCESS_KEY_ID").is_some() + || std::env::var_os("AWS_PROFILE").is_some() + || std::env::var_os("JCODE_BEDROCK_PROFILE").is_some() + || std::env::var_os("AWS_WEB_IDENTITY_TOKEN_FILE").is_some() + || std::env::var_os("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI").is_some() + || std::env::var_os("AWS_CONTAINER_CREDENTIALS_FULL_URI").is_some() + || std::env::var_os("AWS_SHARED_CREDENTIALS_FILE").is_some() + || std::env::var_os("AWS_CONFIG_FILE").is_some(); + + has_region && has_credential_hint + } + + async fn sdk_config() -> aws_types::SdkConfig { + let mut loader = aws_config::defaults(BehaviorVersion::latest()); + if let Some(token) = Self::configured_bearer_token() { + jcode_core::env::set_var(API_KEY_ENV, token); + } + if let Some(region) = Self::configured_region() { + loader = loader.region(aws_types::region::Region::new(region)); + } + if let Ok(profile) = + std::env::var("JCODE_BEDROCK_PROFILE").or_else(|_| std::env::var("AWS_PROFILE")) + { + if let Some(credentials) = Self::credentials_from_aws_login_profile(&profile).await { + loader = loader.credentials_provider(credentials); + } + loader = loader.profile_name(profile); + } + loader.load().await + } + + async fn credentials_from_aws_login_profile(profile: &str) -> Option { + if std::env::var_os("AWS_ACCESS_KEY_ID").is_some() + || std::env::var_os("AWS_SECRET_ACCESS_KEY").is_some() + || std::env::var_os("AWS_BEARER_TOKEN_BEDROCK").is_some() + { + return None; + } + + let output = tokio::process::Command::new("aws") + .args([ + "configure", + "export-credentials", + "--profile", + profile, + "--format", + "env-no-export", + ]) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + let mut access_key_id = None; + let mut secret_access_key = None; + let mut session_token = None; + for line in stdout.lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + match key.trim() { + "AWS_ACCESS_KEY_ID" => access_key_id = Some(value.trim().to_string()), + "AWS_SECRET_ACCESS_KEY" => secret_access_key = Some(value.trim().to_string()), + "AWS_SESSION_TOKEN" => session_token = Some(value.trim().to_string()), + _ => {} + } + } + + Some(Credentials::new( + access_key_id?, + secret_access_key?, + session_token, + None, + "aws-cli-export-credentials", + )) + } + + async fn runtime_client() -> BedrockRuntimeClient { + let config = Self::sdk_config().await; + BedrockRuntimeClient::new(&config) + } + + async fn control_client() -> BedrockControlClient { + let config = Self::sdk_config().await; + BedrockControlClient::new(&config) + } + + async fn validate_credentials_if_requested() -> Result<()> { + let validate = std::env::var("JCODE_BEDROCK_VALIDATE_STS") + .ok() + .map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no")) + .unwrap_or(false); + if !validate { + return Ok(()); + } + let config = Self::sdk_config().await; + let client = aws_sdk_sts::Client::new(&config); + client + .get_caller_identity() + .send() + .await + .map(|_| ()) + .map_err(|err| { + anyhow::anyhow!(Self::classify_error_message(&Self::sdk_error_message(&err))) + }) + } + + fn configured_region() -> Option { + Self::env_or_config(REGION_ENV) + .or_else(|| Self::env_or_config("AWS_REGION")) + .or_else(|| Self::env_or_config("AWS_DEFAULT_REGION")) + } + + pub fn configured_bearer_token() -> Option { + jcode_provider_env::load_api_key_from_env_or_config(API_KEY_ENV, ENV_FILE) + } + + fn env_or_config(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| jcode_provider_env::load_env_value_from_env_or_config(name, ENV_FILE)) + } + + fn persisted_catalog_path() -> Result { + Ok(jcode_storage::app_config_dir()?.join("bedrock_models_cache.json")) + } + + fn load_persisted_catalog() -> Option { + let path = Self::persisted_catalog_path().ok()?; + jcode_storage::read_json(&path).ok() + } + + fn persist_catalog( + models: &[String], + inference_profiles: &[String], + profile_required_models: &HashSet, + inference_profile_routes: &HashMap, + legacy_models: &HashSet, + ) { + let Ok(path) = Self::persisted_catalog_path() else { + return; + }; + let payload = PersistedCatalog { + models: models.to_vec(), + inference_profiles: inference_profiles.to_vec(), + profile_required_models: profile_required_models.iter().cloned().collect(), + inference_profile_routes: inference_profile_routes.clone(), + legacy_models: legacy_models.iter().cloned().collect(), + region: Self::configured_region(), + fetched_at_rfc3339: chrono::Utc::now().to_rfc3339(), + }; + if let Err(err) = jcode_storage::write_json(&path, &payload) { + jcode_logging::warn(&format!( + "Failed to persist Bedrock model catalog {}: {}", + path.display(), + err + )); + } + } + + fn seed_cached_catalog(&self) { + if let Some(catalog) = Self::load_persisted_catalog() { + let configured_region = Self::configured_region(); + if catalog.region.as_deref() != configured_region.as_deref() { + jcode_logging::info(&format!( + "Ignoring Bedrock model cache for region {:?}; configured region is {:?}", + catalog.region, configured_region + )); + return; + } + let PersistedCatalog { + models: cached_models, + inference_profiles, + profile_required_models, + inference_profile_routes, + legacy_models, + .. + } = catalog; + let mut inference_profile_routes = inference_profile_routes; + Self::merge_profile_routes_from_profile_ids( + &mut inference_profile_routes, + inference_profiles.iter(), + ); + if let Ok(mut guard) = self.fetched_models.write() { + *guard = cached_models; + } + if let Ok(mut profiles) = self.fetched_inference_profiles.write() { + *profiles = inference_profiles; + } + if let Ok(mut required) = self.profile_required_models.write() { + *required = profile_required_models.into_iter().collect(); + } + if let Ok(mut routes) = self.inference_profile_routes.write() { + *routes = inference_profile_routes; + } + if let Ok(mut legacy) = self.legacy_models.write() { + *legacy = legacy_models.into_iter().collect(); + } + } + } + + fn classify_error_message(raw: &str) -> String { + let lower = raw.to_ascii_lowercase(); + let is_legacy_model_error = lower.contains("marked by provider as legacy") + || lower.contains("model is marked") && lower.contains("legacy") + || lower.contains("have not been actively using the model in the last 30 days"); + if is_legacy_model_error { + return format!( + "{} Original error: {}", + "This Bedrock model is marked as legacy for this account. Choose an active Bedrock model or an active inference profile instead.", + raw.trim() + ); + } else if lower.contains("doesn't support tool use") + || lower.contains("does not support tool use") + || lower.contains("tool use in streaming mode") + { + return format!( + "{} Original error: {}", + "This Bedrock model does not support tool use with streaming. Choose a Bedrock model with tool support, such as a Claude or Nova profile, or use a no-tools Bedrock model route.", + raw.trim() + ); + } else if lower.contains("no credentials") + || lower.contains("could not load credentials") + || lower.contains("credentials") && lower.contains("not loaded") + { + return "AWS credentials were not found. Set AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE, AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, or run `aws sso login`.".to_string(); + } else if lower.contains("expired") || lower.contains("sso") && lower.contains("token") { + return "AWS SSO/session credentials look expired. Run `aws sso login --profile ` and retry.".to_string(); + } + + let hint = if lower.contains("accessdenied") + || lower.contains("access denied") + || lower.contains("not authorized") + { + "AWS IAM denied the Bedrock request. Ensure the principal can call bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream, bedrock:ListFoundationModels, and bedrock:ListInferenceProfiles as needed." + } else if lower.contains("validationexception") && lower.contains("model") + || lower.contains("model") && lower.contains("not found") + || lower.contains("resource not found") + { + "Bedrock did not recognize this model in the selected region/account. Check model ID, inference profile ID, region, and model access." + } else if lower.contains("throttl") + || lower.contains("too many requests") + || lower.contains("rate exceeded") + { + "Bedrock throttled the request. Retry later or request a quota increase." + } else if lower.contains("region") && lower.contains("missing") { + "AWS region is missing. Set AWS_REGION or JCODE_BEDROCK_REGION." + } else { + "Bedrock request failed. Check AWS credentials, region, model access, and IAM permissions." + }; + format!("{} Original error: {}", hint, raw.trim()) + } + + fn sdk_error_message(err: &(impl std::fmt::Display + std::fmt::Debug)) -> String { + let display = err.to_string(); + let trimmed = display.trim(); + if trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("service error") + || trimmed.eq_ignore_ascii_case("dispatch failure") + { + format!("{err:?}") + } else { + display + } + } + + fn json_to_document(value: &serde_json::Value) -> aws_smithy_types::Document { + match value { + serde_json::Value::Null => aws_smithy_types::Document::Null, + serde_json::Value::Bool(v) => aws_smithy_types::Document::Bool(*v), + serde_json::Value::Number(n) => { + if let Some(v) = n.as_u64() { + aws_smithy_types::Document::from(v) + } else if let Some(v) = n.as_i64() { + aws_smithy_types::Document::from(v) + } else if let Some(v) = n.as_f64() { + aws_smithy_types::Document::from(v) + } else { + aws_smithy_types::Document::Null + } + } + serde_json::Value::String(v) => aws_smithy_types::Document::String(v.clone()), + serde_json::Value::Array(values) => aws_smithy_types::Document::Array( + values.iter().map(Self::json_to_document).collect(), + ), + serde_json::Value::Object(map) => aws_smithy_types::Document::Object( + map.iter() + .map(|(key, value)| (key.clone(), Self::json_to_document(value))) + .collect::>(), + ), + } + } + + fn image_format_for_media_type(media_type: &str) -> Option { + match media_type.trim().to_ascii_lowercase().as_str() { + "image/png" => Some(ImageFormat::Png), + "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), + "image/gif" => Some(ImageFormat::Gif), + "image/webp" => Some(ImageFormat::Webp), + _ => None, + } + } + + fn image_block(media_type: &str, data: &str) -> Result { + let format = Self::image_format_for_media_type(media_type).ok_or_else(|| { + anyhow::anyhow!( + "Bedrock image input does not support media type `{}`", + media_type + ) + })?; + let bytes = BASE64.decode(data).with_context(|| { + format!("Failed to decode {} image payload for Bedrock", media_type) + })?; + ImageBlock::builder() + .format(format) + .source(ImageSource::Bytes(Blob::new(bytes))) + .build() + .context("Failed to build Bedrock image block") + } + + fn to_bedrock_messages(messages: &[JMessage], allow_images: bool) -> Result> { + messages + .iter() + .filter_map(|msg| { + let role = match msg.role { + JRole::User => ConversationRole::User, + JRole::Assistant => ConversationRole::Assistant, + }; + let mut content = Vec::new(); + for block in &msg.content { + match block { + JContentBlock::Text { text, .. } => { + content.push(ContentBlock::Text(text.clone())) + } + JContentBlock::Image { media_type, data } => { + if !allow_images { + return Some(Err(anyhow::anyhow!( + "Current Bedrock model does not advertise image input support" + ))); + } + match Self::image_block(media_type, data) { + Ok(image) => content.push(ContentBlock::Image(image)), + Err(err) => return Some(Err(err)), + } + } + JContentBlock::ToolResult { + tool_use_id, + content: text, + is_error, + } => { + let status = if is_error.unwrap_or(false) { + aws_sdk_bedrockruntime::types::ToolResultStatus::Error + } else { + aws_sdk_bedrockruntime::types::ToolResultStatus::Success + }; + let result = + match aws_sdk_bedrockruntime::types::ToolResultBlock::builder() + .tool_use_id(tool_use_id) + .status(status) + .content( + aws_sdk_bedrockruntime::types::ToolResultContentBlock::Text( + text.clone(), + ), + ) + .build() + { + Ok(result) => result, + Err(err) => return Some(Err(anyhow::anyhow!(err))), + }; + content.push(ContentBlock::ToolResult(result)); + } + JContentBlock::ToolUse { + id, name, input, .. + } => { + let tool_use = + match aws_sdk_bedrockruntime::types::ToolUseBlock::builder() + .tool_use_id(id) + .name(name) + .input(Self::json_to_document(input)) + .build() + { + Ok(tool_use) => tool_use, + Err(err) => return Some(Err(anyhow::anyhow!(err))), + }; + content.push(ContentBlock::ToolUse(tool_use)); + } + _ => {} + } + } + if content.is_empty() { + return None; + } + Some( + Message::builder() + .role(role) + .set_content(Some(content)) + .build() + .map_err(|err| anyhow::anyhow!(err)), + ) + }) + .collect() + } + + fn tool_config(tools: &[ToolDefinition]) -> Option { + if tools.is_empty() { + return None; + } + let bedrock_tools = tools + .iter() + .filter_map(|tool| { + let schema = ToolInputSchema::Json(Self::json_to_document(&tool.input_schema)); + ToolSpecification::builder() + .name(&tool.name) + .description(tool.description.clone()) + .input_schema(schema) + .build() + .ok() + .map(Tool::ToolSpec) + }) + .collect::>(); + if bedrock_tools.is_empty() { + None + } else { + ToolConfiguration::builder() + .set_tools(Some(bedrock_tools)) + .build() + .ok() + } + } + + fn inference_config() -> Option { + let max_tokens = std::env::var("JCODE_BEDROCK_MAX_TOKENS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| *v > 0); + let temperature = std::env::var("JCODE_BEDROCK_TEMPERATURE") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| (0.0..=1.0).contains(v)); + let top_p = std::env::var("JCODE_BEDROCK_TOP_P") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| (0.0..=1.0).contains(v)); + let stop_sequences = std::env::var("JCODE_BEDROCK_STOP_SEQUENCES") + .ok() + .map(|v| { + v.split(',') + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + .collect::>() + }) + .filter(|v| !v.is_empty()); + if max_tokens.is_none() + && temperature.is_none() + && top_p.is_none() + && stop_sequences.is_none() + { + return None; + } + Some( + InferenceConfiguration::builder() + .set_max_tokens(max_tokens) + .set_temperature(temperature) + .set_top_p(top_p) + .set_stop_sequences(stop_sequences) + .build(), + ) + } + + fn normalize_model_id(model: &str) -> String { + let mut value = model.trim().to_string(); + if let Some((_, tail)) = value.rsplit_once('/') { + value = tail.to_string(); + } + for prefix in ["us.", "eu.", "apac.", "global."] { + if let Some(stripped) = value.strip_prefix(prefix) { + value = stripped.to_string(); + break; + } + } + value + } + + fn foundation_model_id_from_arn(arn: &str) -> Option { + arn.rsplit_once("foundation-model/") + .map(|(_, model)| model.trim()) + .filter(|model| !model.is_empty()) + .map(str::to_string) + } + + fn inference_profile_id_from_arn(arn: &str) -> Option { + arn.rsplit_once("inference-profile/") + .map(|(_, profile)| profile.trim()) + .filter(|profile| !profile.is_empty()) + .map(str::to_string) + } + + fn foundation_model_id_from_profile_id(profile_id: &str) -> Option { + let id = profile_id.trim(); + let id = Self::inference_profile_id_from_arn(id).unwrap_or_else(|| id.to_string()); + for prefix in ["us.", "eu.", "apac.", "global."] { + if let Some(model) = id.strip_prefix(prefix) + && !model.is_empty() + { + return Some(model.to_string()); + } + } + None + } + + fn region_profile_prefix() -> Option<&'static str> { + let region = Self::configured_region()?; + if region.starts_with("us-") { + Some("us.") + } else if region.starts_with("eu-") { + Some("eu.") + } else if region.starts_with("ap-") { + Some("apac.") + } else { + None + } + } + + fn inference_profile_priority(profile_id: &str) -> u8 { + let id = profile_id.trim().to_ascii_lowercase(); + if let Some(prefix) = Self::region_profile_prefix() + && id.starts_with(prefix) + { + return 0; + } + if id.starts_with("us.") || id.starts_with("eu.") || id.starts_with("apac.") { + 1 + } else if id.starts_with("global.") { + 2 + } else { + 3 + } + } + + fn insert_preferred_profile_route( + routes: &mut HashMap, + foundation_model: &str, + profile_id: &str, + ) { + let foundation_model = foundation_model.trim(); + let profile_id = profile_id.trim(); + if foundation_model.is_empty() || profile_id.is_empty() { + return; + } + let should_replace = routes + .get(foundation_model) + .map(|current| { + Self::inference_profile_priority(profile_id) + < Self::inference_profile_priority(current) + }) + .unwrap_or(true); + if should_replace { + routes.insert(foundation_model.to_string(), profile_id.to_string()); + } + } + + fn merge_profile_routes_from_profile_ids( + routes: &mut HashMap, + profiles: impl IntoIterator>, + ) { + for profile in profiles { + let profile = profile.as_ref().trim(); + let Some(foundation_model) = Self::foundation_model_id_from_profile_id(profile) else { + continue; + }; + let profile_id = + Self::inference_profile_id_from_arn(profile).unwrap_or_else(|| profile.to_string()); + Self::insert_preferred_profile_route(routes, &foundation_model, &profile_id); + } + } + + fn profile_route_for_model(&self, model: &str) -> Option { + let model = model.trim(); + if model.is_empty() { + return None; + } + + if let Ok(routes) = self.inference_profile_routes.read() + && let Some(route) = routes.get(model).cloned() + { + return Some(route); + } + + if let Ok(profiles) = self.fetched_inference_profiles.read() { + let mut derived = HashMap::new(); + Self::merge_profile_routes_from_profile_ids(&mut derived, profiles.iter()); + if let Some(route) = derived.get(model).cloned() { + return Some(route); + } + } + + None + } + + pub fn is_bedrock_model_id(model: &str) -> bool { + let trimmed = model.trim(); + if trimmed.is_empty() { + return false; + } + if trimmed.starts_with("arn:aws:bedrock:") { + return true; + } + + let id = Self::normalize_model_id(trimmed).to_ascii_lowercase(); + id.starts_with("anthropic.") + || id.starts_with("amazon.") + || id.starts_with("cohere.") + || id.starts_with("ai21.") + || id.starts_with("meta.") + || id.starts_with("mistral.") + || id.starts_with("stability.") + || id.starts_with("writer.") + || id.starts_with("deepseek.") + || id.starts_with("openai.") + || id.starts_with("qwen.") + || id.starts_with("moonshot.") + || id.starts_with("moonshotai.") + || id.starts_with("minimax.") + || id.starts_with("zai.") + || id.starts_with("google.") + || id.starts_with("nvidia.") + } + + fn model_info(model: &str) -> BedrockModelInfo { + let id = Self::normalize_model_id(model).to_ascii_lowercase(); + if id.contains("claude-opus-4") || id.contains("claude-sonnet-4") { + BedrockModelInfo { + context_tokens: 200_000, + max_output_tokens: 64_000, + supports_tools: true, + supports_vision: true, + supports_reasoning: true, + pricing: Some((3_000_000, 15_000_000)), + } + } else if id.contains("claude-3-7-sonnet") || id.contains("claude-3-5-sonnet") { + BedrockModelInfo { + context_tokens: 200_000, + max_output_tokens: 8_192, + supports_tools: true, + supports_vision: true, + supports_reasoning: id.contains("3-7"), + pricing: Some((3_000_000, 15_000_000)), + } + } else if id.contains("claude-3-5-haiku") || id.contains("claude-3-haiku") { + BedrockModelInfo { + context_tokens: 200_000, + max_output_tokens: 8_192, + supports_tools: true, + supports_vision: true, + supports_reasoning: false, + pricing: Some((800_000, 4_000_000)), + } + } else if id.contains("amazon.nova-pro") { + BedrockModelInfo { + context_tokens: 300_000, + max_output_tokens: 5_120, + supports_tools: true, + supports_vision: true, + supports_reasoning: false, + pricing: Some((800_000, 3_200_000)), + } + } else if id.contains("amazon.nova-2-lite") || id.contains("amazon.nova-lite") { + BedrockModelInfo { + context_tokens: 300_000, + max_output_tokens: 5_120, + supports_tools: true, + supports_vision: true, + supports_reasoning: false, + pricing: Some((60_000, 240_000)), + } + } else if id.contains("amazon.nova-micro") { + BedrockModelInfo { + context_tokens: 128_000, + max_output_tokens: 5_120, + supports_tools: true, + supports_vision: false, + supports_reasoning: false, + pricing: Some((35_000, 140_000)), + } + } else if id.starts_with("deepseek.") { + BedrockModelInfo { + context_tokens: 128_000, + max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, + supports_tools: false, + supports_vision: false, + supports_reasoning: true, + pricing: None, + } + } else if id.contains("llama3-1-405b") || id.starts_with("meta.") { + BedrockModelInfo { + context_tokens: 128_000, + max_output_tokens: 4_096, + supports_tools: false, + supports_vision: false, + supports_reasoning: false, + pricing: Some((5_320_000, 16_000_000)), + } + } else if id.starts_with("mistral.") { + BedrockModelInfo { + context_tokens: 128_000, + max_output_tokens: 8_192, + supports_tools: false, + supports_vision: false, + supports_reasoning: false, + pricing: Some((4_000_000, 12_000_000)), + } + } else if id.starts_with("openai.") + || id.starts_with("qwen.") + || id.starts_with("moonshot.") + || id.starts_with("moonshotai.") + || id.starts_with("minimax.") + || id.starts_with("zai.") + || id.starts_with("google.") + || id.starts_with("nvidia.") + || id.starts_with("writer.") + { + BedrockModelInfo { + context_tokens: DEFAULT_CONTEXT_LIMIT, + max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, + supports_tools: false, + supports_vision: false, + supports_reasoning: id.contains("thinking") + || id.contains("reason") + || id.contains("gpt-oss"), + pricing: None, + } + } else { + BedrockModelInfo { + context_tokens: DEFAULT_CONTEXT_LIMIT, + max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, + supports_tools: false, + supports_vision: false, + supports_reasoning: false, + pricing: None, + } + } + } + + fn route_pricing(model: &str) -> Option { + let info = Self::model_info(model); + info.pricing.map(|(input, output)| { + RouteCheapnessEstimate::metered( + RouteCostSource::Heuristic, + RouteCostConfidence::Medium, + input, + output, + None, + Some("AWS Bedrock public on-demand pricing heuristic; verify for your region/account".to_string()), + ) + }) + } + + fn known_models() -> Vec<&'static str> { + vec![ + "anthropic.claude-3-5-sonnet-20241022-v2:0", + "anthropic.claude-3-5-haiku-20241022-v1:0", + "anthropic.claude-3-7-sonnet-20250219-v1:0", + "anthropic.claude-sonnet-4-20250514-v1:0", + "anthropic.claude-opus-4-20250514-v1:0", + "amazon.nova-pro-v1:0", + "amazon.nova-lite-v1:0", + "amazon.nova-micro-v1:0", + "meta.llama3-1-405b-instruct-v1:0", + "mistral.mistral-large-2407-v1:0", + ] + } + + fn all_display_models(&self) -> Vec { + let mut seen = HashSet::new(); + let mut models = Vec::new(); + let inference_profile_routes = self + .inference_profile_routes + .read() + .map(|guard| guard.clone()) + .unwrap_or_default(); + let should_hide_duplicate_foundation_model = + |model: &str| inference_profile_routes.contains_key(model); + for model in Self::known_models().into_iter().map(str::to_string) { + if should_hide_duplicate_foundation_model(&model) { + continue; + } + if seen.insert(model.clone()) { + models.push(model); + } + } + if let Ok(fetched) = self.fetched_models.read() { + for model in fetched.iter() { + if should_hide_duplicate_foundation_model(model) { + continue; + } + if seen.insert(model.clone()) { + models.push(model.clone()); + } + } + } + if let Ok(profiles) = self.fetched_inference_profiles.read() { + for profile in profiles.iter() { + if seen.insert(profile.clone()) { + models.push(profile.clone()); + } + } + } + models + } + + async fn refresh_catalog(&self) -> Result<(Vec, Vec)> { + let client = Self::control_client().await; + let mut models = Vec::new(); + let mut profile_required_models = HashSet::new(); + let mut legacy_models = HashSet::new(); + let model_resp = client + .list_foundation_models() + .send() + .await + .map_err(|err| { + anyhow::anyhow!(Self::classify_error_message(&Self::sdk_error_message(&err))) + })?; + for summary in model_resp.model_summaries() { + let model_id = summary.model_id(); + if !model_id.is_empty() { + models.push(model_id.to_string()); + let inference_types = summary.inference_types_supported(); + let supports_on_demand = inference_types + .iter() + .any(|kind| kind.as_str() == "ON_DEMAND"); + let supports_inference_profile = inference_types + .iter() + .any(|kind| kind.as_str() == "INFERENCE_PROFILE"); + if supports_inference_profile && !supports_on_demand { + profile_required_models.insert(model_id.to_string()); + } + if summary + .model_lifecycle() + .map(|lifecycle| lifecycle.status().as_str() == "LEGACY") + .unwrap_or(false) + { + legacy_models.insert(model_id.to_string()); + } + } + } + models.sort(); + models.dedup(); + + let mut profiles = Vec::new(); + let mut inference_profile_routes = HashMap::new(); + match client.list_inference_profiles().send().await { + Ok(resp) => { + for summary in resp.inference_profile_summaries() { + let id = summary.inference_profile_id(); + if !id.is_empty() { + profiles.push(id.to_string()); + } + let arn = summary.inference_profile_arn(); + if !arn.is_empty() { + profiles.push(arn.to_string()); + } + if summary.status().as_str() == "ACTIVE" && !id.is_empty() { + for model in summary.models() { + if let Some(model_arn) = model.model_arn() + && let Some(foundation_model) = + Self::foundation_model_id_from_arn(model_arn) + { + Self::insert_preferred_profile_route( + &mut inference_profile_routes, + &foundation_model, + id, + ); + } + } + } + } + profiles.sort(); + profiles.dedup(); + Self::merge_profile_routes_from_profile_ids( + &mut inference_profile_routes, + profiles.iter(), + ); + } + Err(err) => { + jcode_logging::info(&format!( + "Bedrock inference profile discovery skipped: {}", + Self::classify_error_message(&Self::sdk_error_message(&err)) + )); + } + } + + if let Ok(mut guard) = self.fetched_models.write() { + *guard = models.clone(); + } + if let Ok(mut guard) = self.fetched_inference_profiles.write() { + *guard = profiles.clone(); + } + if let Ok(mut guard) = self.profile_required_models.write() { + *guard = profile_required_models.clone(); + } + if let Ok(mut guard) = self.inference_profile_routes.write() { + *guard = inference_profile_routes.clone(); + } + if let Ok(mut guard) = self.legacy_models.write() { + *guard = legacy_models.clone(); + } + Self::persist_catalog( + &models, + &profiles, + &profile_required_models, + &inference_profile_routes, + &legacy_models, + ); + Ok((models, profiles)) + } +} + +impl Default for BedrockProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for BedrockProvider { + async fn complete( + &self, + messages: &[JMessage], + tools: &[ToolDefinition], + system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + Self::validate_credentials_if_requested().await?; + let model = self.model(); + let info = Self::model_info(&model); + let request_messages = Self::to_bedrock_messages(messages, info.supports_vision)?; + let tool_config = if info.supports_tools { + Self::tool_config(tools) + } else { + None + }; + let inference_config = Self::inference_config(); + let system_blocks = if system.trim().is_empty() { + None + } else { + Some(vec![SystemContentBlock::Text(system.to_string())]) + }; + let message_items = serde_json::to_value(messages) + .ok() + .and_then(|value| value.as_array().cloned()) + .unwrap_or_default(); + let system_value = (!system.trim().is_empty()).then(|| Value::String(system.to_string())); + let tools_value = if info.supports_tools && !tools.is_empty() { + serde_json::to_value(tools).ok() + } else { + None + }; + let payload = json!({ + "model": &model, + "system": system_value.as_ref(), + "messages": &message_items, + "tools": tools_value.as_ref(), + "supports_tools": info.supports_tools, + "supports_vision": info.supports_vision, + "inference_config_present": inference_config.is_some(), + }); + jcode_provider_core::log_provider_canonical_input( + "bedrock", + &model, + "bedrock_converse_logical", + &payload, + &message_items, + system_value.as_ref(), + tools_value.as_ref(), + Some(if info.supports_tools { tools.len() } else { 0 }), + &[ + ("supports_tools", info.supports_tools.to_string()), + ("supports_vision", info.supports_vision.to_string()), + ( + "inference_config_present", + inference_config.is_some().to_string(), + ), + ], + ); + let (tx, rx) = mpsc::channel::>(64); + tokio::spawn(async move { + let client = Self::runtime_client().await; + let mut req = client + .converse_stream() + .model_id(model.clone()) + .set_messages(Some(request_messages)); + if let Some(system_blocks) = system_blocks { + req = req.set_system(Some(system_blocks)); + } + if let Some(tool_config) = tool_config { + req = req.tool_config(tool_config); + } + if let Some(inference_config) = inference_config { + req = req.inference_config(inference_config); + } + let resp = match req.send().await { + Ok(resp) => resp, + Err(err) => { + let _ = tx + .send(Err(anyhow::anyhow!(Self::classify_error_message( + &Self::sdk_error_message(&err) + )))) + .await; + return; + } + }; + let mut stream = resp.stream; + let mut current_tool: Option<(String, String, String)> = None; + loop { + match stream.recv().await { + Ok(Some(event)) => match event { + ConverseStreamOutput::ContentBlockStart(start) => { + if let Some(ContentBlockStart::ToolUse(tool)) = start.start { + let id = tool.tool_use_id().to_string(); + let name = tool.name().to_string(); + current_tool = Some((id.clone(), name.clone(), String::new())); + let _ = tx.send(Ok(StreamEvent::ToolUseStart { id, name })).await; + } + } + ConverseStreamOutput::ContentBlockDelta(delta) => { + if let Some(d) = delta.delta { + match d { + ContentBlockDelta::Text(text) => { + let _ = tx.send(Ok(StreamEvent::TextDelta(text))).await; + } + ContentBlockDelta::ToolUse(tool_delta) => { + let input = tool_delta.input(); + if !input.is_empty() { + if let Some((_, _, buf)) = current_tool.as_mut() { + buf.push_str(input); + } + let _ = tx + .send(Ok(StreamEvent::ToolInputDelta( + input.to_string(), + ))) + .await; + } + } + ContentBlockDelta::ReasoningContent(reasoning) => { + if let ReasoningContentBlockDelta::Text(text) = reasoning { + let _ = + tx.send(Ok(StreamEvent::ThinkingDelta(text))).await; + } + } + _ => {} + } + } + } + ConverseStreamOutput::ContentBlockStop(_) => { + if current_tool.take().is_some() { + let _ = tx.send(Ok(StreamEvent::ToolUseEnd)).await; + } + } + ConverseStreamOutput::MessageStop(stop) => { + let reason = Some(format!("{:?}", stop.stop_reason())); + let _ = tx + .send(Ok(StreamEvent::MessageEnd { + stop_reason: reason, + })) + .await; + } + ConverseStreamOutput::Metadata(meta) => { + if let Some(usage) = meta.usage() { + let _ = tx + .send(Ok(StreamEvent::TokenUsage { + input_tokens: Some(usage.input_tokens() as u64), + output_tokens: Some(usage.output_tokens() as u64), + cache_read_input_tokens: None, + cache_creation_input_tokens: None, + })) + .await; + } + } + _ => {} + }, + Ok(None) => break, + Err(err) => { + let _ = tx + .send(Err(anyhow::anyhow!(Self::classify_error_message( + &Self::sdk_error_message(&err) + )))) + .await; + break; + } + } + } + }); + Ok(Box::pin(ReceiverStream::new(rx)) + as Pin< + Box> + Send>, + >) + } + + fn name(&self) -> &str { + "bedrock" + } + + fn model(&self) -> String { + self.model.read().unwrap_or_else(|p| p.into_inner()).clone() + } + + fn supports_image_input(&self) -> bool { + Self::model_info(&self.model()).supports_vision + } + + fn set_model(&self, model: &str) -> Result<()> { + let model = model.trim(); + let model = self + .profile_route_for_model(model) + .unwrap_or_else(|| model.to_string()); + *self.model.write().unwrap_or_else(|p| p.into_inner()) = model; + Ok(()) + } + + fn available_models(&self) -> Vec<&'static str> { + Self::known_models() + } + + fn available_models_display(&self) -> Vec { + self.all_display_models() + } + + fn available_models_for_switching(&self) -> Vec { + self.all_display_models() + } + + fn model_routes(&self) -> Vec { + let legacy_models = self + .legacy_models + .read() + .map(|guard| guard.clone()) + .unwrap_or_default(); + let profile_required_models = self + .profile_required_models + .read() + .map(|guard| guard.clone()) + .unwrap_or_default(); + self.all_display_models() + .into_iter() + .map(|model| { + let info = Self::model_info(&model); + let is_legacy = legacy_models.contains(&model); + let profile_foundation = Self::foundation_model_id_from_profile_id(&model); + let missing_required_profile = profile_foundation.is_none() + && profile_required_models.contains(&model) + && self.profile_route_for_model(&model).is_none(); + let mut features = Vec::new(); + if info.supports_tools { + features.push("tools"); + } else { + features.push("no tools"); + } + if info.supports_vision { + features.push("vision"); + } + if info.supports_reasoning { + features.push("reasoning"); + } + ModelRoute { + model: model.clone(), + provider: "AWS Bedrock".to_string(), + api_method: "bedrock".to_string(), + available: !is_legacy && !missing_required_profile, + detail: if is_legacy { + "legacy Bedrock model; choose an active model or inference profile" + .to_string() + } else if missing_required_profile { + "requires an inference profile; run /refresh-model-list or allow bedrock:ListInferenceProfiles" + .to_string() + } else { + let mut parts = Vec::new(); + if let Some(foundation) = profile_foundation { + parts.push(format!("inference profile for {}", foundation)); + } + parts.push(format!("context ~{} tokens", info.context_tokens)); + parts.push(format!("max output ~{}", info.max_output_tokens)); + parts.push(features.join(", ")); + format!( + "ConverseStream · {}", + parts + .into_iter() + .filter(|part| !part.trim().is_empty()) + .collect::>() + .join(" · ") + ) + }, + cheapness: Self::route_pricing(&model), + } + }) + .collect() + } + + async fn prefetch_models(&self) -> Result<()> { + self.refresh_catalog().await.map(|_| ()) + } + + async fn refresh_model_catalog(&self) -> Result { + let before_models = self.available_models_display(); + let before_routes = self.model_routes(); + self.refresh_catalog().await?; + let after_models = self.available_models_display(); + let after_routes = self.model_routes(); + Ok(summarize_model_catalog_refresh( + before_models, + after_models, + before_routes, + after_routes, + )) + } + + fn context_window(&self) -> usize { + Self::model_info(&self.model()).context_tokens + } + + fn supports_compaction(&self) -> bool { + true + } + + fn uses_jcode_compaction(&self) -> bool { + true + } + + fn fork(&self) -> Arc { + Arc::new(Self { + model: Arc::new(RwLock::new(self.model())), + fetched_models: self.fetched_models.clone(), + fetched_inference_profiles: self.fetched_inference_profiles.clone(), + profile_required_models: self.profile_required_models.clone(), + inference_profile_routes: self.inference_profile_routes.clone(), + legacy_models: self.legacy_models.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::{OsStr, OsString}; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + fn lock_test_env() -> MutexGuard<'static, ()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + jcode_core::env::set_var(key, value); + Self { key, previous } + } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var_os(key); + jcode_core::env::remove_var(key); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = self.previous.as_ref() { + jcode_core::env::set_var(self.key, value); + } else { + jcode_core::env::remove_var(self.key); + } + } + } + + #[test] + fn detects_env_credentials_requires_region_and_credential_hint() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let _removed = [ + "JCODE_BEDROCK_ENABLE", + API_KEY_ENV, + REGION_ENV, + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", + "JCODE_BEDROCK_PROFILE", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_CONFIG_FILE", + ] + .map(EnvVarGuard::remove); + jcode_core::env::set_var(REGION_ENV, "us-east-1"); + assert!(!BedrockProvider::has_credentials()); + jcode_core::env::set_var("AWS_PROFILE", "test"); + assert!(BedrockProvider::has_credentials()); + } + + #[test] + fn explicit_enable_marks_configured_for_instance_metadata_credentials() { + let _guard = lock_test_env(); + jcode_core::env::set_var("JCODE_BEDROCK_ENABLE", "1"); + assert!(BedrockProvider::has_credentials()); + jcode_core::env::remove_var("JCODE_BEDROCK_ENABLE"); + } + + #[test] + fn detects_bedrock_login_env_file_credentials() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + for key in [ + "JCODE_BEDROCK_ENABLE", + API_KEY_ENV, + REGION_ENV, + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", + "JCODE_BEDROCK_PROFILE", + "AWS_ACCESS_KEY_ID", + ] { + jcode_core::env::remove_var(key); + } + + assert!(!BedrockProvider::has_credentials()); + jcode_provider_env::save_env_value_to_env_file(API_KEY_ENV, ENV_FILE, Some("test-key")) + .unwrap(); + jcode_core::env::remove_var(API_KEY_ENV); + assert!(!BedrockProvider::has_credentials()); + + jcode_provider_env::save_env_value_to_env_file(REGION_ENV, ENV_FILE, Some("us-east-2")) + .unwrap(); + jcode_core::env::remove_var(REGION_ENV); + + assert_eq!( + BedrockProvider::configured_bearer_token().as_deref(), + Some("test-key") + ); + assert_eq!( + BedrockProvider::configured_region().as_deref(), + Some("us-east-2") + ); + assert!(BedrockProvider::has_credentials()); + } + + #[test] + fn switches_arbitrary_model_ids() { + let p = BedrockProvider::new(); + p.set_model("us.anthropic.claude-3-5-sonnet-20241022-v2:0") + .unwrap(); + assert_eq!(p.model(), "us.anthropic.claude-3-5-sonnet-20241022-v2:0"); + } + + #[test] + fn maps_profile_required_foundation_model_to_inference_profile() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + p.profile_required_models + .write() + .unwrap() + .insert("amazon.nova-2-lite-v1:0".to_string()); + p.inference_profile_routes.write().unwrap().insert( + "amazon.nova-2-lite-v1:0".to_string(), + "us.amazon.nova-2-lite-v1:0".to_string(), + ); + + p.set_model("amazon.nova-2-lite-v1:0").unwrap(); + + assert_eq!(p.model(), "us.amazon.nova-2-lite-v1:0"); + } + + #[test] + fn maps_foundation_model_from_stale_cached_profile_list() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + *p.fetched_inference_profiles.write().unwrap() = vec![ + "global.amazon.nova-2-lite-v1:0".to_string(), + "us.amazon.nova-2-lite-v1:0".to_string(), + ]; + + p.set_model("amazon.nova-2-lite-v1:0").unwrap(); + + assert_eq!(p.model(), "us.amazon.nova-2-lite-v1:0"); + } + + #[test] + fn hides_profile_required_foundation_model_when_profile_route_exists() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; + *p.fetched_inference_profiles.write().unwrap() = + vec!["us.amazon.nova-2-lite-v1:0".to_string()]; + p.profile_required_models + .write() + .unwrap() + .insert("amazon.nova-2-lite-v1:0".to_string()); + p.inference_profile_routes.write().unwrap().insert( + "amazon.nova-2-lite-v1:0".to_string(), + "us.amazon.nova-2-lite-v1:0".to_string(), + ); + + let display = p.all_display_models(); + + assert!( + !display + .iter() + .any(|model| model == "amazon.nova-2-lite-v1:0") + ); + assert!( + display + .iter() + .any(|model| model == "us.amazon.nova-2-lite-v1:0") + ); + } + + #[test] + fn hides_foundation_model_when_profile_route_exists() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; + *p.fetched_inference_profiles.write().unwrap() = + vec!["us.amazon.nova-2-lite-v1:0".to_string()]; + p.inference_profile_routes.write().unwrap().insert( + "amazon.nova-2-lite-v1:0".to_string(), + "us.amazon.nova-2-lite-v1:0".to_string(), + ); + + let display = p.all_display_models(); + + assert!( + !display + .iter() + .any(|model| model == "amazon.nova-2-lite-v1:0") + ); + assert!( + display + .iter() + .any(|model| model == "us.amazon.nova-2-lite-v1:0") + ); + } + + #[test] + fn profile_required_foundation_model_without_profile_route_is_disabled() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; + p.profile_required_models + .write() + .unwrap() + .insert("amazon.nova-2-lite-v1:0".to_string()); + + let route = p + .model_routes() + .into_iter() + .find(|route| route.model == "amazon.nova-2-lite-v1:0") + .expect("profile-required foundation model should be listed with a reason"); + + assert!(!route.available); + assert!(route.detail.contains("requires an inference profile")); + } + + #[test] + fn global_inference_profiles_use_foundation_capabilities_and_detail() { + let p = BedrockProvider::new(); + *p.fetched_inference_profiles.write().unwrap() = + vec!["global.amazon.nova-2-lite-v1:0".to_string()]; + + let route = p + .model_routes() + .into_iter() + .find(|route| route.model == "global.amazon.nova-2-lite-v1:0") + .expect("global inference profile should be listed"); + + assert!(route.available); + assert!( + route + .detail + .contains("inference profile for amazon.nova-2-lite-v1:0") + ); + assert!(route.detail.contains("tools")); + assert!(!route.detail.contains("no tools")); + } + + #[test] + fn ignores_persisted_bedrock_catalog_from_different_region() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + { + let _region = EnvVarGuard::set(REGION_ENV, "us-east-1"); + BedrockProvider::persist_catalog( + &["openai.gpt-oss-120b-1:0".to_string()], + &[], + &HashSet::new(), + &HashMap::new(), + &HashSet::new(), + ); + } + let _region = EnvVarGuard::set(REGION_ENV, "us-east-2"); + + let p = BedrockProvider::new(); + + assert!(p.fetched_models.read().unwrap().is_empty()); + } + + #[test] + fn prefers_region_inference_profile_over_global_profile() { + let _guard = lock_test_env(); + let _region = EnvVarGuard::set(REGION_ENV, "us-east-2"); + let mut routes = HashMap::new(); + + BedrockProvider::insert_preferred_profile_route( + &mut routes, + "amazon.nova-2-lite-v1:0", + "global.amazon.nova-2-lite-v1:0", + ); + BedrockProvider::insert_preferred_profile_route( + &mut routes, + "amazon.nova-2-lite-v1:0", + "us.amazon.nova-2-lite-v1:0", + ); + + assert_eq!( + routes.get("amazon.nova-2-lite-v1:0").map(String::as_str), + Some("us.amazon.nova-2-lite-v1:0") + ); + } + + #[test] + fn known_context_and_vision_capabilities() { + let p = BedrockProvider::new(); + p.set_model("anthropic.claude-3-5-sonnet-20241022-v2:0") + .unwrap(); + assert!(p.supports_image_input()); + assert_eq!(p.context_window(), 200_000); + p.set_model("amazon.nova-micro-v1:0").unwrap(); + assert!(!p.supports_image_input()); + assert_eq!(p.context_window(), 128_000); + } + + #[test] + fn known_no_tool_models_do_not_advertise_tools() { + assert!(!BedrockProvider::model_info("us.deepseek.r1-v1:0").supports_tools); + assert!(!BedrockProvider::model_info("deepseek.v3.2").supports_tools); + assert!( + !BedrockProvider::model_info("mistral.mistral-large-3-675b-instruct").supports_tools + ); + assert!(!BedrockProvider::model_info("openai.gpt-oss-120b-1:0").supports_tools); + assert!(BedrockProvider::model_info("us.amazon.nova-2-lite-v1:0").supports_tools); + assert!(BedrockProvider::model_info("us.anthropic.claude-sonnet-4-6").supports_tools); + } + + #[test] + fn error_classification_mentions_model_access() { + let message = BedrockProvider::classify_error_message( + "ValidationException: The provided model identifier is invalid", + ); + assert!(message.contains("model")); + assert!(message.contains("region")); + } + + #[test] + fn error_classification_mentions_legacy_models() { + let message = BedrockProvider::classify_error_message( + "Access denied. This Model is marked by provider as Legacy and you have not been actively using the model in the last 30 days", + ); + assert!(message.contains("legacy")); + assert!(message.contains("active")); + assert!(!message.starts_with("AWS IAM denied")); + } + + #[test] + fn tool_use_streaming_error_is_not_classified_as_legacy_sdk_type_name() { + let message = BedrockProvider::classify_error_message( + "ValidationException: This model doesn't support tool use in streaming mode. extensions_1x: {hyper_util::client::legacy::connect::http::HttpInfo}", + ); + assert!(message.contains("does not support tool use")); + assert!(!message.starts_with("This Bedrock model is marked as legacy")); + } + + #[test] + fn expired_sso_error_is_concise_and_actionable() { + let message = BedrockProvider::classify_error_message( + "ServiceError(ServiceError { source: AccessDeniedException(AccessDeniedException { message: Some(\"Bearer Token has expired\") }) })", + ); + assert_eq!( + message, + "AWS SSO/session credentials look expired. Run `aws sso login --profile ` and retry." + ); + } + + #[test] + fn missing_credentials_error_omits_sdk_blob() { + let message = BedrockProvider::classify_error_message( + "CredentialsNotLoaded: could not load credentials from any provider; extensions_1x: noisy sdk internals", + ); + assert!(message.contains("AWS credentials were not found")); + assert!(!message.contains("extensions_1x")); + } + + #[test] + fn legacy_model_route_is_unavailable_with_reason() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + *p.fetched_models.write().unwrap() = + vec!["anthropic.claude-3-haiku-20240307-v1:0".to_string()]; + p.legacy_models + .write() + .unwrap() + .insert("anthropic.claude-3-haiku-20240307-v1:0".to_string()); + + let route = p + .model_routes() + .into_iter() + .find(|route| route.model == "anthropic.claude-3-haiku-20240307-v1:0") + .expect("legacy route should be listed"); + + assert!(!route.available); + assert!(route.detail.contains("legacy")); + } + + #[tokio::test] + #[ignore = "requires AWS credentials and enabled Bedrock model access"] + async fn bedrock_live_smoke_test() { + if std::env::var("JCODE_BEDROCK_LIVE_TEST").ok().as_deref() != Some("1") { + return; + } + let provider = BedrockProvider::new(); + let output = provider + .complete_simple("say bedrock ok and nothing else", "") + .await + .expect("live Bedrock completion"); + assert!(output.to_ascii_lowercase().contains("bedrock ok")); + } +} diff --git a/crates/jcode-provider-core/Cargo.toml b/crates/jcode-provider-core/Cargo.toml index f6a606214..00c7d85b1 100644 --- a/crates/jcode-provider-core/Cargo.toml +++ b/crates/jcode-provider-core/Cargo.toml @@ -11,8 +11,10 @@ path = "src/lib.rs" anyhow = "1" async-trait = "0.1" futures = "0.3" +jcode-logging = { path = "../jcode-logging" } jcode-message-types = { path = "../jcode-message-types" } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" tokio = { version = "1", features = ["sync"] } diff --git a/crates/jcode-provider-core/src/auth_mode.rs b/crates/jcode-provider-core/src/auth_mode.rs new file mode 100644 index 000000000..511c3a655 --- /dev/null +++ b/crates/jcode-provider-core/src/auth_mode.rs @@ -0,0 +1,430 @@ +//! Canonical source of truth for the OAuth-vs-API-key credential decision of +//! the two "dual-auth" providers: Anthropic/Claude and OpenAI. +//! +//! These providers each support *both* a subscription/OAuth login and a direct +//! API key, so every request needs an explicit "which credential" decision. +//! +//! Historically jcode encoded that decision as free-form strings spread across +//! several overlapping vocabularies: +//! +//! | concept | runtime env (`JCODE_RUNTIME_PROVIDER`) | route / stable-id | CLI `--provider` | model prefix | +//! |----------------------|----------------------------------------|----------------------|------------------|------------------| +//! | Claude, OAuth | `claude` | `claude-oauth` | `claude` | `claude-oauth:` | +//! | Claude, API key | `claude-api` | `anthropic-api-key` | `anthropic-api` | `claude-api:` | +//! | OpenAI, OAuth | `openai` | `openai-oauth` | `openai` | `openai-oauth:` | +//! | OpenAI, API key | `openai-api` | `openai-api-key` | `openai-api` | `openai-api:` | +//! +//! Each call site used to parse its own subset of these aliases by hand, and the +//! subsets drifted: e.g. one parser accepted `openai` but not `openai-oauth`, +//! another accepted `claude`/`anthropic` but silently ignored `claude-oauth`. +//! When a string from one vocabulary leaked into a parser that only knew +//! another, the OAuth and API-key paths got mixed up. +//! +//! This module is the single place that: +//! * parses *any* alias from *any* vocabulary into a structured +//! [`AuthRoute`] (`provider` + `mode`), and +//! * emits the canonical string for each vocabulary. +//! +//! Every credential-mode parser and every UI/billing surface should go through +//! here instead of re-deriving the decision from ad-hoc string matches. + +use crate::ResolvedCredential; +use crate::selection::ActiveProvider; + +/// A provider that supports *both* a subscription/OAuth login and a direct +/// API-key credential, and therefore needs an explicit OAuth-vs-API decision. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DualAuthProvider { + /// Anthropic / Claude (Claude subscription OAuth vs `ANTHROPIC_API_KEY`). + Anthropic, + /// OpenAI (ChatGPT/Codex OAuth vs `OPENAI_API_KEY`). + OpenAI, +} + +impl DualAuthProvider { + /// The dual-auth provider backing an [`ActiveProvider`], if any. Returns + /// `None` for providers with no OAuth-vs-API-key ambiguity. + pub const fn from_active_provider(provider: ActiveProvider) -> Option { + match provider { + ActiveProvider::Claude => Some(Self::Anthropic), + ActiveProvider::OpenAI => Some(Self::OpenAI), + _ => None, + } + } + + /// The execution slot this credential decision routes through. + pub const fn active_provider(self) -> ActiveProvider { + match self { + Self::Anthropic => ActiveProvider::Claude, + Self::OpenAI => ActiveProvider::OpenAI, + } + } +} + +/// Which credential a dual-auth provider will actually use for a request. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AuthMode { + /// OAuth / subscription login (Claude subscription, ChatGPT/Codex login). + Oauth, + /// Direct provider API key (metered / cost-based billing). + ApiKey, +} + +impl AuthMode { + /// True when requests bill against a subscription rather than a metered key. + pub const fn is_subscription(self) -> bool { + matches!(self, Self::Oauth) + } + + /// Map to the wire-level [`ResolvedCredential`] billing identity. + pub const fn resolved_credential(self) -> ResolvedCredential { + match self { + Self::Oauth => ResolvedCredential::Oauth, + Self::ApiKey => ResolvedCredential::ApiKey, + } + } +} + +impl From for ResolvedCredential { + fn from(mode: AuthMode) -> Self { + mode.resolved_credential() + } +} + +/// A fully resolved dual-auth credential decision: *which provider* and *which +/// credential*. This is the structured value that every vocabulary string maps +/// to and is generated from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AuthRoute { + pub provider: DualAuthProvider, + pub mode: AuthMode, +} + +impl AuthRoute { + pub const fn new(provider: DualAuthProvider, mode: AuthMode) -> Self { + Self { provider, mode } + } + + pub const fn anthropic(mode: AuthMode) -> Self { + Self::new(DualAuthProvider::Anthropic, mode) + } + + pub const fn openai(mode: AuthMode) -> Self { + Self::new(DualAuthProvider::OpenAI, mode) + } + + /// Parse a dual-auth token from *any* of jcode's overlapping vocabularies + /// (runtime env, route stable-id, CLI `--provider`, or bare model prefix). + /// + /// Returns `None` for tokens that do not pin a dual-auth credential route, + /// including bare aliases for non-dual providers (`openrouter`, `copilot`, + /// ...), unknown strings, and the empty string. A `None` result is what the + /// providers treat as "auto" (no explicit OAuth-vs-API pin). + /// + /// A single trailing `:` is tolerated so callers can pass a model prefix + /// such as `claude-oauth:` directly. Full prefixed model specs + /// (`claude-oauth:model`) are *not* parsed here; resolve the prefix with + /// `explicit_model_provider_prefix` first. + pub fn parse(token: &str) -> Option { + let token = token.trim().strip_suffix(':').unwrap_or(token.trim()); + match token.trim().to_ascii_lowercase().as_str() { + // Anthropic / Claude -- OAuth / subscription. + "claude" | "anthropic" | "claude-oauth" | "anthropic-oauth" => { + Some(Self::anthropic(AuthMode::Oauth)) + } + // Anthropic / Claude -- direct API key. + // + // Bare `api-key` historically resolves to Anthropic in the route + // vocabulary (see `ModelRouteApiMethod::parse`), so keep that. + "claude-api" | "anthropic-api" | "anthropic-api-key" | "claude-api-key" + | "anthropic-key" | "claude-key" | "api-key" => { + Some(Self::anthropic(AuthMode::ApiKey)) + } + // OpenAI -- OAuth / ChatGPT-Codex login. + "openai" | "openai-oauth" => Some(Self::openai(AuthMode::Oauth)), + // OpenAI -- direct API key. + "openai-api" | "openai-api-key" | "openai-key" | "openai-apikey" + | "openai-platform" | "platform-openai" => Some(Self::openai(AuthMode::ApiKey)), + _ => None, + } + } + + /// The execution slot this route runs through. + pub const fn active_provider(self) -> ActiveProvider { + self.provider.active_provider() + } + + /// The wire-level billing identity for this route. + pub const fn resolved_credential(self) -> ResolvedCredential { + self.mode.resolved_credential() + } + + /// Parse a *model prefix* that explicitly pins a dual-auth credential. + /// + /// This differs from [`AuthRoute::parse`] in the bare-provider cases: in the + /// model-prefix vocabulary `claude:` / `anthropic:` / `openai:` mean "route + /// to this provider but keep the current credential (auto)", so they do NOT + /// pin a credential and return `None` here. Only the explicit credential + /// prefixes (`claude-oauth:`, `claude-api:`, `openai-oauth:`, `openai-api:`, + /// and their stable-id spellings) pin one. + /// + /// A single trailing `:` is tolerated so callers can pass the raw prefix. + pub fn parse_explicit_credential_prefix(prefix: &str) -> Option { + let token = prefix.trim().strip_suffix(':').unwrap_or(prefix.trim()); + match token.trim().to_ascii_lowercase().as_str() { + // Bare provider aliases do not pin a credential in this vocabulary. + "claude" | "anthropic" | "openai" => None, + other => Self::parse(other), + } + } + + /// Canonical `JCODE_RUNTIME_PROVIDER` value that pins this route. + pub const fn runtime_provider_key(self) -> &'static str { + match (self.provider, self.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude", + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => "claude-api", + (DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai", + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api", + } + } + + /// Canonical route `api_method` / [`crate::RuntimeKey`] stable-id. + pub const fn route_api_method(self) -> &'static str { + match (self.provider, self.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude-oauth", + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => "anthropic-api-key", + (DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai-oauth", + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api-key", + } + } + + /// Canonical model-switch prefix (without the trailing colon). + pub const fn model_prefix(self) -> &'static str { + match (self.provider, self.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude-oauth", + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => "claude-api", + (DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai-oauth", + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api", + } + } + + /// Canonical session `provider_key` (the folded, route-free form). + pub const fn session_provider_key(self) -> &'static str { + // Identical to the runtime-env vocabulary today; kept as its own method + // so the session-key meaning is explicit at call sites. + self.runtime_provider_key() + } + + /// Canonical CLI `--provider` argument value. + pub const fn cli_provider_arg(self) -> &'static str { + match (self.provider, self.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude", + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => "anthropic-api", + (DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai", + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api", + } + } +} + +/// Resolve the explicit dual-auth mode that `runtime_provider` pins for a +/// specific provider. +/// +/// Returns `None` (i.e. "auto") when `runtime_provider` is absent, does not pin +/// a dual-auth route, or pins the *other* dual-auth provider. +pub fn pinned_mode_for( + provider: DualAuthProvider, + runtime_provider: Option<&str>, +) -> Option { + let route = AuthRoute::parse(runtime_provider?)?; + (route.provider == provider).then_some(route.mode) +} + +/// Read `JCODE_RUNTIME_PROVIDER` and return the dual-auth route it pins, if any. +pub fn runtime_env_auth_route() -> Option { + let value = std::env::var("JCODE_RUNTIME_PROVIDER").ok()?; + AuthRoute::parse(&value) +} + +/// Read `JCODE_RUNTIME_PROVIDER` and resolve the dual-auth mode it pins for a +/// specific provider (or `None` for "auto"). +pub fn runtime_env_pinned_mode(provider: DualAuthProvider) -> Option { + pinned_mode_for( + provider, + std::env::var("JCODE_RUNTIME_PROVIDER").ok().as_deref(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL_ROUTES: [AuthRoute; 4] = [ + AuthRoute::anthropic(AuthMode::Oauth), + AuthRoute::anthropic(AuthMode::ApiKey), + AuthRoute::openai(AuthMode::Oauth), + AuthRoute::openai(AuthMode::ApiKey), + ]; + + #[test] + fn every_vocabulary_string_round_trips_back_to_the_same_route() { + for route in ALL_ROUTES { + for token in [ + route.runtime_provider_key(), + route.route_api_method(), + route.model_prefix(), + route.cli_provider_arg(), + route.session_provider_key(), + ] { + assert_eq!( + AuthRoute::parse(token), + Some(route), + "token {token:?} should parse back to {route:?}", + ); + // Trailing-colon (model-prefix) form must parse identically. + assert_eq!( + AuthRoute::parse(&format!("{token}:")), + Some(route), + "token {token:?}: should parse back to {route:?}", + ); + } + } + } + + #[test] + fn parse_is_case_and_whitespace_insensitive() { + assert_eq!( + AuthRoute::parse(" Claude-OAuth "), + Some(AuthRoute::anthropic(AuthMode::Oauth)) + ); + assert_eq!( + AuthRoute::parse("ANTHROPIC-API-KEY"), + Some(AuthRoute::anthropic(AuthMode::ApiKey)) + ); + } + + #[test] + fn bare_provider_aliases_pin_oauth() { + assert_eq!( + AuthRoute::parse("claude").map(|r| r.mode), + Some(AuthMode::Oauth) + ); + assert_eq!( + AuthRoute::parse("anthropic").map(|r| r.mode), + Some(AuthMode::Oauth) + ); + assert_eq!( + AuthRoute::parse("openai").map(|r| r.mode), + Some(AuthMode::Oauth) + ); + } + + #[test] + fn cross_vocabulary_aliases_resolve_consistently() { + // The whole point: route-vocabulary strings and runtime-env strings for + // the same concept resolve to the same structured route. + for (a, b) in [ + ("claude", "claude-oauth"), + ("claude-api", "anthropic-api-key"), + ("openai", "openai-oauth"), + ("openai-api", "openai-api-key"), + ] { + assert_eq!( + AuthRoute::parse(a), + AuthRoute::parse(b), + "{a:?} and {b:?} must resolve to the same route", + ); + } + } + + #[test] + fn non_dual_and_unknown_tokens_are_none() { + for token in ["", "openrouter", "copilot", "gemini", "bedrock", "jcode", "nonsense"] { + assert_eq!(AuthRoute::parse(token), None, "{token:?} must be None"); + } + } + + #[test] + fn explicit_credential_prefix_ignores_bare_provider_aliases() { + // Bare provider prefixes route without pinning a credential. + for token in ["claude", "claude:", "anthropic:", "openai", "openai:"] { + assert_eq!( + AuthRoute::parse_explicit_credential_prefix(token), + None, + "{token:?} must not pin a credential", + ); + } + // Explicit credential prefixes still pin. + assert_eq!( + AuthRoute::parse_explicit_credential_prefix("claude-oauth:"), + Some(AuthRoute::anthropic(AuthMode::Oauth)) + ); + assert_eq!( + AuthRoute::parse_explicit_credential_prefix("claude-api:"), + Some(AuthRoute::anthropic(AuthMode::ApiKey)) + ); + assert_eq!( + AuthRoute::parse_explicit_credential_prefix("openai-oauth:"), + Some(AuthRoute::openai(AuthMode::Oauth)) + ); + assert_eq!( + AuthRoute::parse_explicit_credential_prefix("openai-api:"), + Some(AuthRoute::openai(AuthMode::ApiKey)) + ); + } + + #[test] + fn pinned_mode_only_matches_its_own_provider() { + assert_eq!( + pinned_mode_for(DualAuthProvider::Anthropic, Some("claude-api")), + Some(AuthMode::ApiKey) + ); + // A pin for the *other* dual-auth provider is "auto" here. + assert_eq!( + pinned_mode_for(DualAuthProvider::Anthropic, Some("openai")), + None + ); + assert_eq!(pinned_mode_for(DualAuthProvider::OpenAI, Some("claude")), None); + assert_eq!(pinned_mode_for(DualAuthProvider::OpenAI, None), None); + } + + #[test] + fn resolved_credential_mapping() { + assert_eq!( + AuthMode::Oauth.resolved_credential(), + ResolvedCredential::Oauth + ); + assert_eq!( + AuthMode::ApiKey.resolved_credential(), + ResolvedCredential::ApiKey + ); + } + + #[test] + fn route_api_method_round_trips_through_model_route_api_method() { + use crate::ModelRouteApiMethod; + // The route-vocabulary parser (`ModelRouteApiMethod`) must agree with the + // canonical auth-mode parser for every dual-auth route, so routing and + // billing never disagree about OAuth-vs-API-key. + for route in ALL_ROUTES { + let parsed = ModelRouteApiMethod::parse(route.route_api_method()); + assert_eq!( + parsed, + ModelRouteApiMethod::from_auth_route(route), + "route {route:?} api_method must round-trip through ModelRouteApiMethod", + ); + // And every alias vocabulary maps to the same ModelRouteApiMethod. + for token in [ + route.runtime_provider_key(), + route.model_prefix(), + route.cli_provider_arg(), + route.session_provider_key(), + ] { + assert_eq!( + ModelRouteApiMethod::parse(token), + parsed, + "token {token:?} must map to the same ModelRouteApiMethod as {route:?}", + ); + } + } + } +} diff --git a/crates/jcode-provider-core/src/fingerprint.rs b/crates/jcode-provider-core/src/fingerprint.rs new file mode 100644 index 000000000..ed16135bf --- /dev/null +++ b/crates/jcode-provider-core/src/fingerprint.rs @@ -0,0 +1,202 @@ +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; +use std::time::Instant; + +#[derive(Debug, Clone)] +struct ProviderInputSnapshot { + request_hash: u64, + item_hashes: Vec, + item_hashes_hash: u64, + system_hash: Option, + tools_hash: Option, + captured_at: Instant, +} + +static PROVIDER_INPUT_BASELINES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +pub fn stable_hash_str(value: &str) -> u64 { + let digest = Sha256::digest(value.as_bytes()); + let mut bytes = [0_u8; 8]; + bytes.copy_from_slice(&digest[..8]); + u64::from_be_bytes(bytes) +} + +pub fn stable_hash_json(value: &T) -> u64 { + let encoded = serde_json::to_string(value).unwrap_or_default(); + stable_hash_str(&encoded) +} + +fn stable_json_len(value: &T) -> usize { + serde_json::to_string(value) + .map(|encoded| encoded.len()) + .unwrap_or_default() +} + +fn item_hashes(items: &[Value]) -> Vec { + items.iter().map(stable_hash_json).collect() +} + +fn prefix_matches(current: &[u64], previous: &[u64]) -> bool { + if previous.len() > current.len() { + return false; + } + current[..previous.len()] == *previous +} + +fn common_prefix_len(current: &[u64], previous: &[u64]) -> usize { + current + .iter() + .zip(previous.iter()) + .take_while(|(current, previous)| current == previous) + .count() +} + +/// Log a privacy-preserving fingerprint of the provider-specific prompt payload. +/// +/// `payload` should be the prompt/cache-relevant request shape after provider-specific +/// normalization, not the high-level Jcode message list. Do not include volatile transport +/// IDs unless they are intentionally part of the cache key. `items` should be the ordered +/// provider-visible message/content array so prefix drift can be diagnosed by index. +#[allow(clippy::too_many_arguments)] +pub fn log_provider_canonical_input( + provider: &str, + model: &str, + format: &str, + payload: &Value, + items: &[Value], + system: Option<&Value>, + tools: Option<&Value>, + tool_count: Option, + extra_fields: &[(&str, String)], +) { + let request_hash = stable_hash_json(payload); + let request_json_chars = stable_json_len(payload); + let item_hashes = item_hashes(items); + let item_hashes_hash = stable_hash_json(&item_hashes); + let input_hash = stable_hash_json(items); + let system_hash = system.map(stable_hash_json); + let system_json_chars = system.map(stable_json_len); + let tools_hash = tools.map(stable_hash_json); + let tools_json_chars = tools.map(stable_json_len); + let first_item_hash = item_hashes.first().copied(); + let last_item_hash = item_hashes.last().copied(); + + let log_context = jcode_logging::current_context_snapshot(); + let session_key = log_context.session.as_deref().unwrap_or("no-session"); + let key = format!( + "{}\u{1f}{}\u{1f}{}\u{1f}{}", + session_key, provider, model, format + ); + let snapshot = ProviderInputSnapshot { + request_hash, + item_hashes: item_hashes.clone(), + item_hashes_hash, + system_hash, + tools_hash, + captured_at: Instant::now(), + }; + + let previous = PROVIDER_INPUT_BASELINES + .lock() + .map(|mut baselines| baselines.insert(key, snapshot)) + .ok() + .flatten(); + + let previous_age_secs = previous + .as_ref() + .map(|previous| previous.captured_at.elapsed().as_secs()); + let request_changed = previous + .as_ref() + .map(|previous| previous.request_hash != request_hash); + let item_hashes_changed = previous + .as_ref() + .map(|previous| previous.item_hashes_hash != item_hashes_hash); + let prefix_matches = previous + .as_ref() + .map(|previous| prefix_matches(&item_hashes, &previous.item_hashes)); + let common_prefix_items = previous + .as_ref() + .map(|previous| common_prefix_len(&item_hashes, &previous.item_hashes)); + let first_changed_item_index = common_prefix_items + .zip(previous.as_ref().map(|previous| previous.item_hashes.len())) + .and_then(|(common, previous_len)| (common < previous_len).then_some(common)); + let previous_item_count = previous.as_ref().map(|previous| previous.item_hashes.len()); + let system_changed = previous + .as_ref() + .map(|previous| previous.system_hash != system_hash); + let tools_changed = previous + .as_ref() + .map(|previous| previous.tools_hash != tools_hash); + + let mut extras = String::new(); + for (key, value) in extra_fields { + if !key.is_empty() && !value.is_empty() { + extras.push(' '); + extras.push_str(key); + extras.push('='); + extras.push_str(value); + } + } + + jcode_logging::info(&format!( + "PROVIDER_CANONICAL_INPUT: provider={} model={} format={} request_hash={} request_json_chars={} \ + input_hash={} item_count={} previous_item_count={:?} item_hashes_hash={} first_item_hash={:?} last_item_hash={:?} \ + previous_age_secs={:?} prefix_matches={:?} common_prefix_items={:?} first_changed_item_index={:?} \ + request_changed={:?} item_hashes_changed={:?} system_hash={:?} system_json_chars={:?} system_changed={:?} \ + tools_hash={:?} tools_json_chars={:?} tool_count={:?} tools_changed={:?}{}", + provider, + model, + format, + request_hash, + request_json_chars, + input_hash, + items.len(), + previous_item_count, + item_hashes_hash, + first_item_hash, + last_item_hash, + previous_age_secs, + prefix_matches, + common_prefix_items, + first_changed_item_index, + request_changed, + item_hashes_changed, + system_hash, + system_json_chars, + system_changed, + tools_hash, + tools_json_chars, + tool_count, + tools_changed, + extras, + )); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn prefix_matching_allows_append_only_growth() { + assert!(prefix_matches(&[1, 2, 3], &[1, 2])); + } + + #[test] + fn prefix_matching_detects_changed_prefix() { + assert!(!prefix_matches(&[1, 9, 3], &[1, 2])); + assert_eq!(common_prefix_len(&[1, 9, 3], &[1, 2]), 1); + } + + #[test] + fn json_hashes_are_content_sensitive() { + assert_ne!( + stable_hash_json(&json!({"a": 1})), + stable_hash_json(&json!({"a": 2})) + ); + } +} diff --git a/crates/jcode-provider-core/src/lib.rs b/crates/jcode-provider-core/src/lib.rs index da5bb929d..18fa451fb 100644 --- a/crates/jcode-provider-core/src/lib.rs +++ b/crates/jcode-provider-core/src/lib.rs @@ -1,6 +1,8 @@ pub mod anthropic; +pub mod auth_mode; pub mod catalog_refresh; pub mod failover; +pub mod fingerprint; pub mod models; pub mod openai_schema; pub mod pricing; @@ -13,11 +15,16 @@ pub use anthropic::{ anthropic_oauth_beta_headers, anthropic_stainless_arch, anthropic_stainless_os, anthropic_strip_1m_suffix, }; +pub use auth_mode::{ + AuthMode, AuthRoute, DualAuthProvider, pinned_mode_for, runtime_env_auth_route, + runtime_env_pinned_mode, +}; pub use catalog_refresh::{ModelCatalogRefreshSummary, summarize_model_catalog_refresh}; pub use failover::{ FailoverDecision, ProviderFailoverPrompt, classify_failover_error_message, parse_failover_prompt_message, }; +pub use fingerprint::{log_provider_canonical_input, stable_hash_json, stable_hash_str}; pub use models::{ ALL_CLAUDE_MODELS, ALL_OPENAI_MODELS, DEFAULT_CONTEXT_LIMIT, ModelCapabilities, context_limit_for_model, context_limit_for_model_with_provider, @@ -26,10 +33,10 @@ pub use models::{ provider_for_model_with_hint as core_provider_for_model_with_hint, provider_key_from_hint, }; pub use selection::{ - ActiveProvider, ProviderAvailability, auto_default_provider, - cli_provider_arg_for_session_key, dedupe_model_routes, explicit_model_provider_prefix, - fallback_sequence, model_name_for_provider, parse_provider_hint, provider_from_model_key, - provider_key, provider_label, + ActiveProvider, ProviderAvailability, auto_default_provider, cli_provider_arg_for_session_key, + dedupe_model_routes, explicit_model_provider_prefix, fallback_sequence, + model_name_for_provider, parse_provider_hint, provider_from_model_key, provider_key, + provider_label, }; use anyhow::Result; @@ -611,14 +618,27 @@ pub enum ModelRouteApiMethod { } impl ModelRouteApiMethod { + /// The route-vocabulary api_method for a canonical dual-auth route. + pub fn from_auth_route(route: crate::auth_mode::AuthRoute) -> Self { + use crate::auth_mode::{AuthMode, DualAuthProvider}; + match (route.provider, route.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => Self::ClaudeOAuth, + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => Self::AnthropicApiKey, + (DualAuthProvider::OpenAI, AuthMode::Oauth) => Self::OpenAIOAuth, + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => Self::OpenAIApiKey, + } + } + pub fn parse(value: &str) -> Self { let trimmed = value.trim(); let lower = trimmed.to_ascii_lowercase(); + // Dual-auth (Anthropic/OpenAI OAuth-vs-API) tokens share one canonical + // alias table so the route vocabulary never drifts from the runtime/CLI + // vocabularies. Anything else falls through to the route-only methods. + if let Some(route) = crate::auth_mode::AuthRoute::parse(&lower) { + return Self::from_auth_route(route); + } match lower.as_str() { - "claude" | "claude-oauth" => Self::ClaudeOAuth, - "api-key" | "claude-api" | "anthropic-api-key" => Self::AnthropicApiKey, - "openai" | "openai-oauth" => Self::OpenAIOAuth, - "openai-api" | "openai-api-key" => Self::OpenAIApiKey, "openrouter" => Self::OpenRouter, "openai-compatible" => Self::OpenAiCompatible { profile_id: None }, "copilot" => Self::Copilot, diff --git a/crates/jcode-provider-core/src/selection.rs b/crates/jcode-provider-core/src/selection.rs index 1c4139cba..7689f25da 100644 --- a/crates/jcode-provider-core/src/selection.rs +++ b/crates/jcode-provider-core/src/selection.rs @@ -141,11 +141,12 @@ pub fn cli_provider_arg_for_session_key(key: &str) -> Option<&'static str> { .split_once(':') .map(|(prefix, _rest)| prefix) .unwrap_or(normalized.as_str()); + // Dual-auth (Anthropic/OpenAI OAuth-vs-API) keys share one canonical alias + // table, so the CLI arg never drifts from the route/runtime vocabularies. + if let Some(route) = crate::auth_mode::AuthRoute::parse(base) { + return Some(route.cli_provider_arg()); + } match base { - "claude" | "claude-oauth" | "anthropic" => Some("claude"), - "anthropic-api-key" | "claude-api" | "api-key" | "anthropic-api" => Some("anthropic-api"), - "openai" | "openai-oauth" => Some("openai"), - "openai-api-key" | "openai-api" => Some("openai-api"), "openrouter" => Some("openrouter"), "copilot" => Some("copilot"), "gemini" => Some("gemini"), diff --git a/crates/jcode-provider-env/Cargo.toml b/crates/jcode-provider-env/Cargo.toml new file mode 100644 index 000000000..09d6004df --- /dev/null +++ b/crates/jcode-provider-env/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "jcode-provider-env" +version = "0.1.0" +edition = "2024" + +[lib] +name = "jcode_provider_env" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +jcode-core = { path = "../jcode-core" } +jcode-logging = { path = "../jcode-logging" } +jcode-provider-metadata = { path = "../jcode-provider-metadata" } +jcode-storage = { path = "../jcode-storage" } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/jcode-provider-env/src/lib.rs b/crates/jcode-provider-env/src/lib.rs new file mode 100644 index 000000000..4d0e75855 --- /dev/null +++ b/crates/jcode-provider-env/src/lib.rs @@ -0,0 +1,275 @@ +use std::sync::{LazyLock, RwLock}; + +use jcode_provider_metadata::{is_safe_env_file_name, is_safe_env_key_name}; + +/// Fallback resolvers consulted by [`load_api_key_from_env_or_config`] after the +/// environment and config-file lookups fail. Higher-level crates register +/// resolvers at startup so this leaf crate does not need to depend on auth. +type ApiKeyFallbackResolver = fn(&str) -> Option; + +static API_KEY_FALLBACK_RESOLVERS: LazyLock>> = + LazyLock::new(|| RwLock::new(Vec::new())); + +/// Register a fallback API-key resolver consulted when env/config lookups miss. +pub fn register_api_key_fallback_resolver(resolver: ApiKeyFallbackResolver) { + API_KEY_FALLBACK_RESOLVERS + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(resolver); +} + +fn resolve_api_key_fallback(env_key: &str) -> Option { + let resolvers = API_KEY_FALLBACK_RESOLVERS + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for resolver in resolvers.iter() { + if let Some(key) = resolver(env_key) { + return Some(key); + } + } + None +} + +pub fn load_api_key_from_env_or_config(env_key: &str, file_name: &str) -> Option { + if !is_safe_env_key_name(env_key) { + jcode_logging::warn(&format!( + "Ignoring invalid API key variable name '{}' while loading credentials", + env_key + )); + return None; + } + if !is_safe_env_file_name(file_name) { + jcode_logging::warn(&format!( + "Ignoring invalid env file name '{}' while loading credentials", + file_name + )); + return None; + } + + if let Ok(key) = std::env::var(env_key) { + let key = key.trim(); + if !key.is_empty() { + return Some(key.to_string()); + } + } + + let config_path = jcode_storage::app_config_dir().ok()?.join(file_name); + jcode_storage::harden_secret_file_permissions(&config_path); + let content = std::fs::read_to_string(config_path).ok()?; + let prefix = format!("{}=", env_key); + + for line in content.lines() { + if let Some(key) = line.strip_prefix(&prefix) { + let key = key.trim().trim_matches('"').trim_matches('\''); + if !key.is_empty() { + return Some(key.to_string()); + } + } + } + + if env_key == "ZHIPU_API_KEY" { + if let Ok(key) = std::env::var("ZAI_API_KEY") { + let key = key.trim(); + if !key.is_empty() { + return Some(key.to_string()); + } + } + + let legacy_prefix = "ZAI_API_KEY="; + for line in content.lines() { + if let Some(key) = line.strip_prefix(legacy_prefix) { + let key = key.trim().trim_matches('"').trim_matches('\''); + if !key.is_empty() { + return Some(key.to_string()); + } + } + } + } + + if let Some(key) = resolve_api_key_fallback(env_key) { + return Some(key); + } + + None +} + +pub fn load_env_value_from_env_or_config(env_key: &str, file_name: &str) -> Option { + if !is_safe_env_key_name(env_key) { + jcode_logging::warn(&format!( + "Ignoring invalid variable name '{}' while loading config value", + env_key + )); + return None; + } + if !is_safe_env_file_name(file_name) { + jcode_logging::warn(&format!( + "Ignoring invalid env file name '{}' while loading config value", + file_name + )); + return None; + } + + if let Ok(value) = std::env::var(env_key) { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + + let config_path = jcode_storage::app_config_dir().ok()?.join(file_name); + jcode_storage::harden_secret_file_permissions(&config_path); + let content = std::fs::read_to_string(config_path).ok()?; + let prefix = format!("{}=", env_key); + + for line in content.lines() { + if let Some(value) = line.strip_prefix(&prefix) { + let value = value.trim().trim_matches('"').trim_matches('\''); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + + None +} + +pub fn save_env_value_to_env_file( + env_key: &str, + file_name: &str, + value: Option<&str>, +) -> anyhow::Result<()> { + if !is_safe_env_key_name(env_key) { + anyhow::bail!("Invalid variable name: {}", env_key); + } + if !is_safe_env_file_name(file_name) { + anyhow::bail!("Invalid env file name: {}", file_name); + } + + let config_dir = jcode_storage::app_config_dir()?; + let file_path = config_dir.join(file_name); + jcode_storage::upsert_env_file_value(&file_path, env_key, value)?; + + if let Some(value) = value { + jcode_core::env::set_var(env_key, value); + } else { + jcode_core::env::remove_var(env_key); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + use std::sync::{Mutex, MutexGuard}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + _lock: MutexGuard<'static, ()>, + saved: Vec<(&'static str, Option)>, + } + + impl EnvGuard { + fn new(keys: &[&'static str]) -> Self { + let lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let saved = keys + .iter() + .map(|key| (*key, std::env::var_os(key))) + .collect::>(); + for key in keys { + jcode_core::env::remove_var(key); + } + Self { _lock: lock, saved } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in self.saved.drain(..) { + match value { + Some(value) => jcode_core::env::set_var(key, value), + None => jcode_core::env::remove_var(key), + } + } + } + } + + #[test] + fn loads_api_key_from_env_before_config_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::new(&["JCODE_HOME", "JCODE_PROVIDER_ENV_TEST_KEY"]); + jcode_core::env::set_var("JCODE_HOME", temp.path()); + + save_env_value_to_env_file( + "JCODE_PROVIDER_ENV_TEST_KEY", + "provider-env-test.env", + Some("file-key"), + ) + .expect("save file key"); + jcode_core::env::set_var("JCODE_PROVIDER_ENV_TEST_KEY", "env-key"); + + assert_eq!( + load_api_key_from_env_or_config("JCODE_PROVIDER_ENV_TEST_KEY", "provider-env-test.env") + .as_deref(), + Some("env-key") + ); + } + + #[test] + fn loads_and_removes_values_from_sandboxed_config_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::new(&["JCODE_HOME", "JCODE_PROVIDER_ENV_TEST_VALUE"]); + jcode_core::env::set_var("JCODE_HOME", temp.path()); + + save_env_value_to_env_file( + "JCODE_PROVIDER_ENV_TEST_VALUE", + "provider-env-test.env", + Some("file-value"), + ) + .expect("save file value"); + + jcode_core::env::remove_var("JCODE_PROVIDER_ENV_TEST_VALUE"); + assert_eq!( + load_env_value_from_env_or_config( + "JCODE_PROVIDER_ENV_TEST_VALUE", + "provider-env-test.env" + ) + .as_deref(), + Some("file-value") + ); + + save_env_value_to_env_file( + "JCODE_PROVIDER_ENV_TEST_VALUE", + "provider-env-test.env", + None, + ) + .expect("remove file value"); + assert_eq!( + load_env_value_from_env_or_config( + "JCODE_PROVIDER_ENV_TEST_VALUE", + "provider-env-test.env" + ), + None + ); + } + + #[test] + fn accepts_legacy_zai_key_for_zhipu() { + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::new(&["JCODE_HOME", "ZHIPU_API_KEY", "ZAI_API_KEY"]); + jcode_core::env::set_var("JCODE_HOME", temp.path()); + + save_env_value_to_env_file("ZAI_API_KEY", "zai.env", Some("legacy-zai-key")) + .expect("save legacy key"); + jcode_core::env::remove_var("ZAI_API_KEY"); + + assert_eq!( + load_api_key_from_env_or_config("ZHIPU_API_KEY", "zai.env").as_deref(), + Some("legacy-zai-key") + ); + } +} diff --git a/crates/jcode-provider-openai/Cargo.toml b/crates/jcode-provider-openai/Cargo.toml index eea97ad15..f9392aca7 100644 --- a/crates/jcode-provider-openai/Cargo.toml +++ b/crates/jcode-provider-openai/Cargo.toml @@ -5,6 +5,15 @@ edition = "2024" publish = false [dependencies] +anyhow = "1" +base64 = "0.22" +bytes = "1" +futures = "0.3" +jcode-core = { path = "../jcode-core" } +jcode-logging = { path = "../jcode-logging" } jcode-message-types = { path = "../jcode-message-types" } jcode-provider-core = { path = "../jcode-provider-core" } +reqwest = { version = "0.12", default-features = false, features = ["stream"] } +serde = { version = "1", features = ["derive"] } serde_json = "1" +tokio = { version = "1", features = ["sync"] } diff --git a/crates/jcode-provider-openai/src/lib.rs b/crates/jcode-provider-openai/src/lib.rs index a7f5b51e9..81ea2650a 100644 --- a/crates/jcode-provider-openai/src/lib.rs +++ b/crates/jcode-provider-openai/src/lib.rs @@ -1,4 +1,6 @@ pub mod request; +pub mod stream; +pub mod websocket_health; pub use request::{ OPENAI_ENCRYPTED_CONTENT_PROVIDER_MAX_CHARS, OPENAI_ENCRYPTED_CONTENT_SAFE_MAX_CHARS, diff --git a/crates/jcode-provider-openai/src/request.rs b/crates/jcode-provider-openai/src/request.rs index 69949a766..12ae14149 100644 --- a/crates/jcode-provider-openai/src/request.rs +++ b/crates/jcode-provider-openai/src/request.rs @@ -258,7 +258,9 @@ pub fn build_responses_input_with_logger( } items.push(item); } - ContentBlock::ToolUse { id, name, input, .. } => { + ContentBlock::ToolUse { + id, name, input, .. + } => { let arguments = if input.is_object() { serde_json::to_string(&input).unwrap_or_default() } else { diff --git a/crates/jcode-provider-openai/src/stream.rs b/crates/jcode-provider-openai/src/stream.rs new file mode 100644 index 000000000..85328b9e1 --- /dev/null +++ b/crates/jcode-provider-openai/src/stream.rs @@ -0,0 +1,993 @@ +use anyhow::Result; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use bytes::Bytes; +use futures::Stream; +use jcode_message_types::{StreamEvent, sanitize_tool_id}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::task::{Context as TaskContext, Poll}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const WEBSOCKET_FALLBACK_NOTICE: &str = "falling back from websockets to https transport"; +static FALLBACK_TOOL_CALL_COUNTER: AtomicU64 = AtomicU64::new(1); +static RECOVERED_TEXT_WRAPPED_TOOL_CALLS: AtomicU64 = AtomicU64::new(0); +static NORMALIZED_NULL_TOOL_ARGUMENTS: AtomicU64 = AtomicU64::new(0); + +fn truncated_stream_payload_context(data: &str) -> String { + jcode_core::util::truncate_str(&data.trim().replace("\n", "\\n"), 240).to_string() +} + +fn is_structured_response_event(data: &str) -> bool { + let Ok(value) = serde_json::from_str::(data) else { + return false; + }; + let Some(kind) = value.get("type").and_then(|kind| kind.as_str()) else { + return false; + }; + kind.starts_with("response.") || kind == "error" +} + +fn is_websocket_fallback_notice(data: &str) -> bool { + // The proxy injects the fallback notice as a plain-text control frame, not a + // structured Responses API event. A legitimate `response.*`/`error` event can + // contain this phrase in model output or tool-call arguments and must still be + // parsed normally. + if is_structured_response_event(data) { + return false; + } + data.to_lowercase().contains(WEBSOCKET_FALLBACK_NOTICE) +} + +fn extract_error_with_retry( + response: &Option, + top_level_error: &Option, +) -> (String, Option) { + let error = response + .as_ref() + .and_then(|r| r.get("error")) + .or(top_level_error.as_ref()); + + let error = match error { + Some(e) => e, + None => { + if let Some(resp) = response.as_ref() + && let Some(msg) = resp + .get("status_message") + .or_else(|| resp.get("message")) + .and_then(|v| v.as_str()) + { + return (msg.to_string(), None); + } + return ( + "OpenAI response stream error (no error details)".to_string(), + None, + ); + } + }; + + let message = error + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("OpenAI response stream error (unknown)") + .to_string(); + let error_type = error.get("type").and_then(|v| v.as_str()); + let code = error.get("code").and_then(|v| v.as_str()); + + let message_lower = message.to_lowercase(); + let message = match (error_type, code) { + (Some(error_type), Some(code)) + if !message_lower.contains(&error_type.to_lowercase()) + && !message_lower.contains(&code.to_lowercase()) => + { + format!("{} ({}): {}", error_type, code, message) + } + (Some(error_type), _) if !message_lower.contains(&error_type.to_lowercase()) => { + format!("{}: {}", error_type, message) + } + (_, Some(code)) if !message_lower.contains(&code.to_lowercase()) => { + format!("{}: {}", code, message) + } + _ => message, + }; + + let retry_after = error + .get("retry_after") + .and_then(|v| v.as_u64()) + .or_else(|| { + response + .as_ref() + .and_then(|r| r.get("retry_after")) + .and_then(|v| v.as_u64()) + }); + + (message, retry_after) +} +pub fn parse_text_wrapped_tool_call(text: &str) -> Option<(String, String, String, String)> { + let marker = "to=functions."; + let marker_idx = text.find(marker)?; + let after_marker = &text[marker_idx + marker.len()..]; + + let mut tool_name_end = 0usize; + for (idx, ch) in after_marker.char_indices() { + if ch.is_ascii_alphanumeric() || ch == '_' { + tool_name_end = idx + ch.len_utf8(); + } else { + break; + } + } + if tool_name_end == 0 { + return None; + } + + let tool_name = after_marker[..tool_name_end].to_string(); + let remaining = &after_marker[tool_name_end..]; + let mut fallback: Option<(String, String, String, String)> = None; + for (brace_idx, ch) in remaining.char_indices() { + if ch != '{' { + continue; + } + let slice = &remaining[brace_idx..]; + let mut stream = serde_json::Deserializer::from_str(slice).into_iter::(); + let parsed = match stream.next() { + Some(Ok(value)) => value, + Some(Err(_)) => continue, + None => continue, + }; + let consumed = stream.byte_offset(); + if !parsed.is_object() { + continue; + } + + let prefix = text[..marker_idx].trim_end().to_string(); + let suffix = remaining[brace_idx + consumed..].trim().to_string(); + let args = serde_json::to_string(&parsed).ok()?; + if suffix.is_empty() { + return Some((prefix, tool_name.clone(), args, suffix)); + } + if fallback.is_none() { + fallback = Some((prefix, tool_name.clone(), args, suffix)); + } + } + + fallback +} + +fn stream_text_or_recovered_tool_call( + text: &str, + pending: &mut VecDeque, +) -> Option { + if text.is_empty() { + return None; + } + + if let Some((prefix, tool_name, arguments, suffix)) = parse_text_wrapped_tool_call(text) { + let total = RECOVERED_TEXT_WRAPPED_TOOL_CALLS.fetch_add(1, Ordering::Relaxed) + 1; + jcode_logging::warn(&format!( + "[openai] Recovered text-wrapped tool call for '{}' (total={})", + tool_name, total + )); + let suffix = sanitize_recovered_tool_suffix(&suffix); + if !prefix.is_empty() { + pending.push_back(StreamEvent::TextDelta(prefix)); + } + pending.push_back(StreamEvent::ToolUseStart { + id: format!( + "fallback_text_call_{}", + FALLBACK_TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed) + ), + name: tool_name, + }); + pending.push_back(StreamEvent::ToolInputDelta(arguments)); + pending.push_back(StreamEvent::ToolUseEnd); + if !suffix.is_empty() { + pending.push_back(StreamEvent::TextDelta(suffix)); + } + return pending.pop_front(); + } + + Some(StreamEvent::TextDelta(text.to_string())) +} + +fn sanitize_recovered_tool_suffix(suffix: &str) -> String { + let trimmed = suffix.trim(); + if trimmed.is_empty() { + return String::new(); + } + + let normalized = trimmed.trim_start_matches('"'); + + if normalized.starts_with(",\"item_id\"") + || normalized.starts_with(",\"output_index\"") + || normalized.starts_with(",\"sequence_number\"") + || normalized.starts_with(",\"call_id\"") + || normalized.starts_with(",\"type\":\"response.") + || (normalized.starts_with(',') + && normalized.contains("\"item_id\"") + && (normalized.contains("\"output_index\"") + || normalized.contains("\"sequence_number\""))) + { + return String::new(); + } + + suffix.to_string() +} + +#[derive(Deserialize, Debug)] +struct ResponseSseEvent { + #[serde(rename = "type")] + kind: String, + item: Option, + delta: Option, + item_id: Option, + call_id: Option, + name: Option, + arguments: Option, + response: Option, + error: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct StreamingToolCallState { + call_id: Option, + name: Option, + arguments: String, +} + +fn normalize_openai_tool_arguments(raw_arguments: String) -> String { + let trimmed = raw_arguments.trim(); + if trimmed.is_empty() || trimmed == "null" { + let total = NORMALIZED_NULL_TOOL_ARGUMENTS.fetch_add(1, Ordering::Relaxed) + 1; + jcode_logging::warn(&format!( + "[openai] Normalized empty/null tool arguments to empty object (total={})", + total + )); + "{}".to_string() + } else { + raw_arguments + } +} + +fn streaming_tool_item_id(item: &Value) -> Option { + item.get("id") + .and_then(|v| v.as_str()) + .or_else(|| item.get("item_id").and_then(|v| v.as_str())) + .map(|id| id.to_string()) +} + +fn stream_tool_call_from_state( + item_id: Option, + mut state: StreamingToolCallState, + pending: &mut VecDeque, +) -> Option { + let tool_name = state.name.take().filter(|name| !name.is_empty())?; + let raw_call_id = state + .call_id + .take() + .filter(|id| !id.is_empty()) + .or(item_id) + .unwrap_or_else(|| { + format!( + "fallback_text_call_{}", + FALLBACK_TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed) + ) + }); + let call_id = sanitize_tool_id(&raw_call_id); + let arguments = normalize_openai_tool_arguments(if state.arguments.is_empty() { + "{}".to_string() + } else { + state.arguments + }); + + pending.push_back(StreamEvent::ToolUseStart { + id: call_id, + name: tool_name, + }); + pending.push_back(StreamEvent::ToolInputDelta(arguments)); + pending.push_back(StreamEvent::ToolUseEnd); + pending.pop_front() +} + +pub fn parse_openai_response_event( + data: &str, + saw_text_delta: &mut bool, + streaming_tool_calls: &mut HashMap, + completed_tool_items: &mut HashSet, + pending: &mut VecDeque, +) -> Option { + if data == "[DONE]" { + return Some(StreamEvent::MessageEnd { stop_reason: None }); + } + + if is_websocket_fallback_notice(data) { + jcode_logging::warn(&format!("OpenAI stream transport notice: {}", data.trim())); + return None; + } + + if data + .to_lowercase() + .contains("stream disconnected before completion") + && !is_structured_response_event(data) + { + return Some(StreamEvent::Error { + message: data.to_string(), + retry_after_secs: None, + }); + } + + let event: ResponseSseEvent = match serde_json::from_str(data) { + Ok(parsed) => parsed, + Err(error) => { + jcode_logging::warn(&format!( + "OpenAI SSE JSON parse failed: {} payload={}", + error, + truncated_stream_payload_context(data) + )); + return None; + } + }; + + match event.kind.as_str() { + "response.output_text.delta" => { + if let Some(delta) = event.delta { + *saw_text_delta = true; + return stream_text_or_recovered_tool_call(&delta, pending); + } + } + "response.reasoning.delta" | "response.reasoning_summary_text.delta" => { + if let Some(delta) = event.delta { + return Some(StreamEvent::ThinkingDelta(delta)); + } + } + "response.reasoning.done" | "response.output_item.added" => { + if let Some(item) = &event.item { + if item.get("type").and_then(|v| v.as_str()) == Some("reasoning") { + return Some(StreamEvent::ThinkingStart); + } + if matches!( + item.get("type").and_then(|v| v.as_str()), + Some("function_call") | Some("custom_tool_call") + ) && let Some(item_id) = streaming_tool_item_id(item) + { + let state = streaming_tool_calls.entry(item_id).or_default(); + state.call_id = item + .get("call_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| state.call_id.clone()); + state.name = item + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| state.name.clone()); + if let Some(arguments) = item + .get("arguments") + .and_then(|v| v.as_str()) + .or_else(|| item.get("input").and_then(|v| v.as_str())) + { + state.arguments = arguments.to_string(); + } else if let Some(input) = item.get("input") + && (input.is_object() || input.is_array()) + { + state.arguments = input.to_string(); + } + } + } + } + "response.function_call_arguments.delta" => { + if let Some(item_id) = event.item_id { + let state = streaming_tool_calls.entry(item_id).or_default(); + if let Some(call_id) = event.call_id { + state.call_id = Some(call_id); + } + if let Some(name) = event.name { + state.name = Some(name); + } + if let Some(delta) = event.delta { + state.arguments.push_str(&delta); + } + } + } + "response.function_call_arguments.done" => { + if let Some(item_id) = event.item_id { + let mut state = streaming_tool_calls.remove(&item_id).unwrap_or_default(); + if let Some(call_id) = event.call_id { + state.call_id = Some(call_id); + } + if let Some(name) = event.name { + state.name = Some(name); + } + if let Some(arguments) = event.arguments { + state.arguments = arguments; + } + if let Some(tool_event) = + stream_tool_call_from_state(Some(item_id.clone()), state.clone(), pending) + { + completed_tool_items.insert(item_id); + return Some(tool_event); + } + streaming_tool_calls.insert(item_id, state); + } + } + "response.output_item.done" => { + if let Some(item) = event.item { + if let Some(item_id) = streaming_tool_item_id(&item) + && completed_tool_items.contains(&item_id) + && matches!( + item.get("type").and_then(|v| v.as_str()), + Some("function_call") | Some("custom_tool_call") + ) + { + completed_tool_items.remove(&item_id); + return None; + } + if let Some(event) = handle_openai_output_item(item, saw_text_delta, pending) { + return Some(event); + } + } + } + "response.incomplete" => { + let stop_reason = event + .response + .as_ref() + .and_then(extract_stop_reason_from_response) + .or_else(|| Some("incomplete".to_string())); + if let Some(response) = event.response + && let Some(usage_event) = extract_usage_from_response(&response) + { + pending.push_back(usage_event); + } + pending.push_back(StreamEvent::MessageEnd { stop_reason }); + return pending.pop_front(); + } + "response.completed" => { + let stop_reason = event + .response + .as_ref() + .and_then(extract_stop_reason_from_response); + if let Some(response) = event.response + && let Some(usage_event) = extract_usage_from_response(&response) + { + pending.push_back(usage_event); + } + pending.push_back(StreamEvent::MessageEnd { stop_reason }); + return pending.pop_front(); + } + "response.failed" | "response.error" | "error" => { + jcode_logging::warn(&format!( + "OpenAI stream error event (type={}): response={:?}, error={:?}", + event.kind, event.response, event.error + )); + let (message, retry_after_secs) = + extract_error_with_retry(&event.response, &event.error); + return Some(StreamEvent::Error { + message, + retry_after_secs, + }); + } + _ => {} + } + + None +} + +fn extract_last_assistant_message_phase(response: &Value) -> Option { + let output = response.get("output")?.as_array()?; + output.iter().rev().find_map(|item| { + if item.get("type").and_then(|v| v.as_str()) != Some("message") { + return None; + } + if item.get("role").and_then(|v| v.as_str()) != Some("assistant") { + return None; + } + item.get("phase") + .and_then(|v| v.as_str()) + .map(|phase| phase.to_string()) + }) +} + +fn extract_stop_reason_from_response(response: &Value) -> Option { + let status = response.get("status").and_then(|v| v.as_str()); + if status == Some("completed") { + if extract_last_assistant_message_phase(response).as_deref() == Some("commentary") { + return Some("commentary".to_string()); + } + return None; + } + + let incomplete_reason = response + .get("incomplete_details") + .and_then(|v| v.get("reason")) + .and_then(|v| v.as_str()); + + if let Some(reason) = incomplete_reason { + return Some(reason.to_string()); + } + + status + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) +} + +pub fn handle_openai_output_item( + item: Value, + saw_text_delta: &mut bool, + pending: &mut VecDeque, +) -> Option { + let item_type = item.get("type")?.as_str()?; + match item_type { + "compaction" => { + let encrypted_content = item + .get("encrypted_content") + .and_then(|v| v.as_str()) + .map(|value| value.to_string())?; + return Some(StreamEvent::Compaction { + trigger: "openai_native_auto".to_string(), + pre_tokens: None, + openai_encrypted_content: Some(encrypted_content), + }); + } + "function_call" | "custom_tool_call" => { + let call_id = item + .get("call_id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let name = item + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let raw_arguments = item + .get("arguments") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .or_else(|| { + item.get("input").and_then(|v| { + if v.is_object() || v.is_array() { + Some(v.to_string()) + } else { + v.as_str().map(|s| s.to_string()) + } + }) + }) + .unwrap_or_else(|| "{}".to_string()); + let arguments = normalize_openai_tool_arguments(raw_arguments); + + pending.push_back(StreamEvent::ToolUseStart { + id: call_id.clone(), + name, + }); + pending.push_back(StreamEvent::ToolInputDelta(arguments)); + pending.push_back(StreamEvent::ToolUseEnd); + return pending.pop_front(); + } + "image_generation_call" => { + if let Some(event) = handle_openai_image_generation_item(&item, pending) { + return Some(event); + } + } + "message" => { + if *saw_text_delta { + return None; + } + let mut text = String::new(); + if let Some(content) = item.get("content").and_then(|v| v.as_array()) { + for entry in content { + let entry_type = entry.get("type").and_then(|v| v.as_str()); + if matches!(entry_type, Some("output_text") | Some("text")) + && let Some(t) = entry.get("text").and_then(|v| v.as_str()) + { + text.push_str(t); + } + } + } + return stream_text_or_recovered_tool_call(&text, pending); + } + "reasoning" => { + let id = item + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let mut summary = Vec::new(); + if let Some(summary_arr) = item.get("summary").and_then(|v| v.as_array()) { + for summary_item in summary_arr { + if summary_item.get("type").and_then(|v| v.as_str()) == Some("summary_text") + && let Some(text) = summary_item.get("text").and_then(|v| v.as_str()) + { + summary.push(text.to_string()); + } + } + } + let encrypted_content = item + .get("encrypted_content") + .and_then(|v| v.as_str()) + .map(|value| value.to_string()); + let status = item + .get("status") + .and_then(|v| v.as_str()) + .map(|value| value.to_string()); + + if !id.is_empty() && (encrypted_content.is_some() || !summary.is_empty()) { + pending.push_back(StreamEvent::OpenAIReasoning { + id, + summary: summary.clone(), + encrypted_content, + status, + }); + } + + if !summary.is_empty() { + pending.push_back(StreamEvent::ThinkingStart); + pending.push_back(StreamEvent::ThinkingDelta(summary.join("\n"))); + pending.push_back(StreamEvent::ThinkingEnd); + return pending.pop_front(); + } + return pending.pop_front(); + } + _ => {} + } + + None +} + +fn handle_openai_image_generation_item( + item: &Value, + pending: &mut VecDeque, +) -> Option { + let result_b64 = item.get("result")?.as_str()?; + if result_b64.is_empty() { + return None; + } + + let image_bytes = match BASE64_STANDARD.decode(result_b64) { + Ok(bytes) => bytes, + Err(err) => { + jcode_logging::warn(&format!( + "OpenAI image_generation_call returned invalid base64: {}", + err + )); + return Some(StreamEvent::TextDelta( + "\n[Generated image received, but Jcode could not decode it.]\n".to_string(), + )); + } + }; + + let output_format = item + .get("output_format") + .and_then(|v| v.as_str()) + .unwrap_or("png"); + let extension = match output_format { + "jpeg" | "jpg" => "jpg", + "webp" => "webp", + _ => "png", + }; + let item_id = item.get("id").and_then(|v| v.as_str()).unwrap_or("image"); + let safe_id: String = item_id + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '_' || *ch == '-') + .take(80) + .collect(); + let safe_id = if safe_id.is_empty() { + "image".to_string() + } else { + safe_id + }; + let timestamp_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let dir = std::env::current_dir() + .unwrap_or_else(|_| std::env::temp_dir()) + .join(".jcode") + .join("generated-images"); + if let Err(err) = std::fs::create_dir_all(&dir) { + jcode_logging::warn(&format!( + "Failed to create OpenAI generated image directory: {}", + err + )); + return Some(StreamEvent::TextDelta(format!( + "\n[Generated image received ({} bytes), but Jcode could not save it.]\n", + image_bytes.len() + ))); + } + + let filename = format!("{}-{}.{}", timestamp_ms, safe_id, extension); + let path = dir.join(filename); + if let Err(err) = std::fs::write(&path, image_bytes) { + jcode_logging::warn(&format!("Failed to save OpenAI generated image: {}", err)); + return Some(StreamEvent::TextDelta( + "\n[Generated image received, but Jcode could not save it.]\n".to_string(), + )); + } + + let metadata_path = path.with_extension("json"); + let mut response_item = item.clone(); + if let Some(object) = response_item.as_object_mut() { + object.remove("result"); + } + let revised_prompt = item + .get("revised_prompt") + .and_then(|v| v.as_str()) + .map(str::to_string); + let metadata = serde_json::json!({ + "schema_version": 1, + "provider": "openai", + "native_tool": "image_generation", + "id": item_id, + "status": item.get("status").and_then(|v| v.as_str()), + "created_at_unix_ms": timestamp_ms, + "image_path": path.display().to_string(), + "output_format": output_format, + "byte_count": std::fs::metadata(&path).map(|m| m.len()).unwrap_or_default(), + "revised_prompt": revised_prompt, + "response_item": response_item, + }); + let metadata_path_string = match serde_json::to_vec_pretty(&metadata).ok().and_then(|bytes| { + std::fs::write(&metadata_path, bytes) + .ok() + .map(|_| metadata_path.clone()) + }) { + Some(path) => Some(path.display().to_string()), + None => { + jcode_logging::warn("Failed to save OpenAI generated image metadata"); + None + } + }; + + let mut markdown = format!( + "\n![Generated image]({})\n\nGenerated image saved to `{}`.", + path.display(), + path.display() + ); + if let Some(metadata_path) = metadata_path_string.as_deref() { + markdown.push_str(&format!("\nMetadata saved to `{}`.", metadata_path)); + } + markdown.push('\n'); + + pending.push_back(StreamEvent::TextDelta(markdown)); + + Some(StreamEvent::GeneratedImage { + id: item_id.to_string(), + path: path.display().to_string(), + metadata_path: metadata_path_string, + output_format: output_format.to_string(), + revised_prompt, + }) +} + +pub struct OpenAIResponsesStream { + inner: Pin> + Send>>, + buffer: String, + pending: VecDeque, + saw_text_delta: bool, + streaming_tool_calls: HashMap, + completed_tool_items: HashSet, +} + +impl OpenAIResponsesStream { + pub fn new(stream: impl Stream> + Send + 'static) -> Self { + Self { + inner: Box::pin(stream), + buffer: String::new(), + pending: VecDeque::new(), + saw_text_delta: false, + streaming_tool_calls: HashMap::new(), + completed_tool_items: HashSet::new(), + } + } + + fn parse_next_event(&mut self) -> Option { + if let Some(event) = self.pending.pop_front() { + return Some(event); + } + + while let Some(pos) = self.buffer.find("\n\n") { + let event_str = self.buffer[..pos].to_string(); + self.buffer = self.buffer[pos + 2..].to_string(); + + let mut data_lines = Vec::new(); + for line in event_str.lines() { + if let Some(data) = jcode_core::util::sse_data_line(line) { + data_lines.push(data); + } + } + + if data_lines.is_empty() { + continue; + } + + let data = data_lines.join("\n"); + if let Some(event) = parse_openai_response_event( + &data, + &mut self.saw_text_delta, + &mut self.streaming_tool_calls, + &mut self.completed_tool_items, + &mut self.pending, + ) { + return Some(event); + } + } + + None + } +} + +fn extract_cached_input_tokens(usage: &Value) -> Option { + usage + .get("input_tokens_details") + .or_else(|| usage.get("prompt_tokens_details")) + .and_then(|details| details.get("cached_tokens")) + .and_then(|v| v.as_u64()) +} + +fn extract_usage_from_response(response: &Value) -> Option { + let usage = response.get("usage")?; + let input_tokens = usage.get("input_tokens").and_then(|v| v.as_u64()); + let output_tokens = usage.get("output_tokens").and_then(|v| v.as_u64()); + let cache_read_input_tokens = extract_cached_input_tokens(usage); + if input_tokens.is_some() || output_tokens.is_some() || cache_read_input_tokens.is_some() { + Some(StreamEvent::TokenUsage { + input_tokens, + output_tokens, + cache_read_input_tokens, + cache_creation_input_tokens: None, + }) + } else { + None + } +} + +impl Stream for OpenAIResponsesStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + loop { + if let Some(event) = self.parse_next_event() { + return Poll::Ready(Some(Ok(event))); + } + + match self.inner.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(bytes))) => { + if let Ok(text) = std::str::from_utf8(&bytes) { + self.buffer.push_str(text); + } + } + Poll::Ready(Some(Err(e))) => { + return Poll::Ready(Some(Err(anyhow::anyhow!("Stream error: {}", e)))); + } + Poll::Ready(None) => { + return Poll::Ready(None); + } + Poll::Pending => { + return Poll::Pending; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_text_wrapped_tool_call_rejects_non_object_json() { + let text = "prefix to=functions.read [1,2,3]"; + let parsed = parse_text_wrapped_tool_call(text); + assert!(parsed.is_none()); + } + + #[test] + fn parse_openai_response_event_ignores_malformed_json_chunks() { + let mut saw_text_delta = false; + let mut streaming_tool_calls = HashMap::new(); + let mut completed_tool_items = HashSet::new(); + let mut pending = VecDeque::new(); + + let event = parse_openai_response_event( + "{not-json}", + &mut saw_text_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ); + + assert!(event.is_none()); + assert!(!saw_text_delta); + assert!(streaming_tool_calls.is_empty()); + assert!(completed_tool_items.is_empty()); + assert!(pending.is_empty()); + } + + #[test] + fn response_completed_emits_message_end_even_when_payload_mentions_fallback() { + // Regression: when the model edits source that mentions the websocket + // fallback phrase, that text rides along inside structured events. A + // `response.completed` frame containing the phrase must still produce a + // MessageEnd, otherwise the stream "ends before the completion marker". + let mut saw_text_delta = false; + let mut streaming_tool_calls = HashMap::new(); + let mut completed_tool_items = HashSet::new(); + let mut pending = VecDeque::new(); + + let payload = serde_json::json!({ + "type": "response.completed", + "response": { + "status": "completed", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "falling back from websockets to https transport" + }] + }] + } + }) + .to_string(); + + let event = parse_openai_response_event( + &payload, + &mut saw_text_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ); + + assert!( + matches!(event, Some(StreamEvent::MessageEnd { .. })), + "expected MessageEnd, got {event:?}" + ); + } + + #[test] + fn function_call_arguments_with_fallback_phrase_still_emit_tool_call() { + let mut saw_text_delta = false; + let mut streaming_tool_calls = HashMap::new(); + let mut completed_tool_items = HashSet::new(); + let mut pending = VecDeque::new(); + + let payload = serde_json::json!({ + "type": "response.function_call_arguments.done", + "item_id": "fc_1", + "call_id": "call_1", + "name": "bash", + "arguments": "{\"command\":\"echo falling back from websockets to https transport\"}" + }) + .to_string(); + + let event = parse_openai_response_event( + &payload, + &mut saw_text_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ); + + assert!( + matches!(event, Some(StreamEvent::ToolUseStart { .. })), + "expected ToolUseStart, got {event:?}" + ); + } + + #[test] + fn plain_text_fallback_notice_is_still_dropped() { + let mut saw_text_delta = false; + let mut streaming_tool_calls = HashMap::new(); + let mut completed_tool_items = HashSet::new(); + let mut pending = VecDeque::new(); + + let event = parse_openai_response_event( + "falling back from websockets to https transport", + &mut saw_text_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ); + + assert!(event.is_none()); + } +} diff --git a/crates/jcode-provider-openai/src/websocket_health.rs b/crates/jcode-provider-openai/src/websocket_health.rs new file mode 100644 index 000000000..43d283d27 --- /dev/null +++ b/crates/jcode-provider-openai/src/websocket_health.rs @@ -0,0 +1,290 @@ +use jcode_message_types::StreamEvent; + +pub const WEBSOCKET_FALLBACK_NOTICE: &str = "falling back from websockets to https transport"; +pub const WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS: u64 = 8; +pub const WEBSOCKET_COMPLETION_TIMEOUT_SECS: u64 = 300; +pub const WEBSOCKET_MODEL_COOLDOWN_BASE_SECS: u64 = 60; +pub const WEBSOCKET_MODEL_COOLDOWN_MAX_SECS: u64 = 600; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WebsocketFallbackReason { + ConnectTimeout, + FirstResponseTimeout, + StreamTimeout, + ServerRequestedHttps, + ConnectFailed, + StreamClosedEarly, + WebsocketError, +} + +impl WebsocketFallbackReason { + pub fn summary(self) -> &'static str { + match self { + Self::ConnectTimeout => "connect timeout", + Self::FirstResponseTimeout => "first response timeout", + Self::StreamTimeout => "stream timeout", + Self::ServerRequestedHttps => "server requested https", + Self::ConnectFailed => "connect failed", + Self::StreamClosedEarly => "stream closed early", + Self::WebsocketError => "websocket error", + } + } +} + +pub fn is_websocket_fallback_notice(data: &str) -> bool { + // The proxy injects the fallback notice as a plain-text control frame, not + // a structured Responses API event. A legitimate `response.*`/`error` + // event can legitimately *contain* this phrase (for example inside + // tool-call arguments when the model is editing source that mentions + // websocket fallback), so a structured event must never be reinterpreted + // as a transport control frame. + if is_structured_response_event(data) { + return false; + } + data.to_lowercase().contains(WEBSOCKET_FALLBACK_NOTICE) +} + +pub fn is_stream_activity_event(_event: &StreamEvent) -> bool { + true +} + +/// Returns true when `data` parses as a structured Responses API stream event +/// (a JSON object whose `type` is a `response.*` event or a top-level `error`). +/// These frames carry model output and must be parsed as protocol events even +/// if their content happens to contain transport-control phrases. +pub fn is_structured_response_event(data: &str) -> bool { + is_websocket_activity_payload(data) +} + +pub fn is_websocket_activity_payload(data: &str) -> bool { + let Ok(value) = serde_json::from_str::(data) else { + return false; + }; + let Some(kind) = value.get("type").and_then(|kind| kind.as_str()) else { + return false; + }; + kind.starts_with("response.") || kind == "error" +} + +pub fn is_websocket_first_activity_payload(data: &str) -> bool { + let Ok(value) = serde_json::from_str::(data) else { + return false; + }; + value + .get("type") + .and_then(|kind| kind.as_str()) + .map(|kind| !kind.is_empty()) + .unwrap_or(false) +} + +pub fn websocket_remaining_timeout_secs(since: Instant, timeout_secs: u64) -> Option { + let timeout = Duration::from_secs(timeout_secs); + let elapsed = since.elapsed(); + if elapsed >= timeout { + return None; + } + + Some(timeout_secs.saturating_sub(elapsed.as_secs()).max(1)) +} + +pub fn websocket_next_activity_timeout_secs( + ws_started_at: Instant, + last_api_activity_at: Instant, + saw_api_activity: bool, +) -> Option { + if !saw_api_activity { + websocket_remaining_timeout_secs(ws_started_at, WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS) + } else { + websocket_remaining_timeout_secs(last_api_activity_at, WEBSOCKET_COMPLETION_TIMEOUT_SECS) + } +} + +pub fn websocket_activity_timeout_kind(saw_api_activity: bool) -> &'static str { + if saw_api_activity { "next" } else { "first" } +} + +pub fn classify_websocket_fallback_reason(error: &str) -> WebsocketFallbackReason { + let error = error.to_ascii_lowercase(); + if error.contains("connect timed out") { + WebsocketFallbackReason::ConnectTimeout + } else if error.contains("did not emit api activity within") + || error.contains("timed out waiting for first websocket activity") + { + WebsocketFallbackReason::FirstResponseTimeout + } else if error.contains("timed out waiting for next websocket activity") + || error.contains("did not complete within") + { + WebsocketFallbackReason::StreamTimeout + } else if error.contains("upgrade required") + || error.contains("server requested fallback") + || error.contains(WEBSOCKET_FALLBACK_NOTICE) + { + WebsocketFallbackReason::ServerRequestedHttps + } else if error.contains("failed to connect websocket stream") { + WebsocketFallbackReason::ConnectFailed + } else if error.contains("ended before response.completed") + || error.contains("closed before response.completed") + { + WebsocketFallbackReason::StreamClosedEarly + } else { + WebsocketFallbackReason::WebsocketError + } +} + +pub fn summarize_websocket_fallback_reason(error: &str) -> &'static str { + classify_websocket_fallback_reason(error).summary() +} + +fn websocket_cooldown_bounds_for_reason(reason: WebsocketFallbackReason) -> (u64, u64) { + match reason { + WebsocketFallbackReason::ServerRequestedHttps => ( + WEBSOCKET_MODEL_COOLDOWN_BASE_SECS.saturating_mul(5), + WEBSOCKET_MODEL_COOLDOWN_MAX_SECS.saturating_mul(3), + ), + WebsocketFallbackReason::StreamTimeout => ( + WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, + WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, + ), + WebsocketFallbackReason::ConnectTimeout + | WebsocketFallbackReason::FirstResponseTimeout + | WebsocketFallbackReason::ConnectFailed + | WebsocketFallbackReason::StreamClosedEarly + | WebsocketFallbackReason::WebsocketError => ( + (WEBSOCKET_MODEL_COOLDOWN_BASE_SECS / 2).max(1), + (WEBSOCKET_MODEL_COOLDOWN_MAX_SECS / 2).max(1), + ), + } +} + +pub fn normalize_transport_model(model: &str) -> Option { + let normalized = model.trim().to_ascii_lowercase(); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +pub async fn websocket_cooldown_remaining( + websocket_cooldowns: &Arc>>, + model: &str, +) -> Option { + let key = normalize_transport_model(model)?; + let now = Instant::now(); + + { + let guard = websocket_cooldowns.read().await; + if let Some(until) = guard.get(&key) + && *until > now + { + return Some(*until - now); + } + } + + let mut guard = websocket_cooldowns.write().await; + if let Some(until) = guard.get(&key) + && *until > now + { + return Some(*until - now); + } + if guard.get(&key).is_some() { + guard.remove(&key); + } + None +} + +pub async fn set_websocket_cooldown( + websocket_cooldowns: &Arc>>, + model: &str, +) { + let Some(key) = normalize_transport_model(model) else { + return; + }; + + let cooldown = Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS); + let until = Instant::now() + cooldown; + let mut guard = websocket_cooldowns.write().await; + guard.insert(key, until); +} + +pub async fn set_websocket_cooldown_for( + websocket_cooldowns: &Arc>>, + model: &str, + cooldown: Duration, +) { + let Some(key) = normalize_transport_model(model) else { + return; + }; + + let until = Instant::now() + cooldown; + let mut guard = websocket_cooldowns.write().await; + guard.insert(key, until); +} + +pub async fn clear_websocket_cooldown( + websocket_cooldowns: &Arc>>, + model: &str, +) { + let Some(key) = normalize_transport_model(model) else { + return; + }; + + let mut guard = websocket_cooldowns.write().await; + guard.remove(&key); +} + +pub fn websocket_cooldown_for_streak(streak: u32, reason: WebsocketFallbackReason) -> Duration { + let (base, max) = websocket_cooldown_bounds_for_reason(reason); + let base = base as u128; + let max = max as u128; + let shift = streak.saturating_sub(1).min(16); + let scaled = base.saturating_mul(1u128 << shift); + Duration::from_secs(scaled.min(max) as u64) +} + +pub async fn record_websocket_fallback( + websocket_cooldowns: &Arc>>, + websocket_failure_streaks: &Arc>>, + model: &str, + reason: WebsocketFallbackReason, +) -> (u32, Duration) { + let Some(key) = normalize_transport_model(model) else { + return (0, websocket_cooldown_for_streak(1, reason)); + }; + + let streak = { + let mut guard = websocket_failure_streaks.write().await; + let entry = guard.entry(key).or_insert(0); + *entry = entry.saturating_add(1); + *entry + }; + + let cooldown = websocket_cooldown_for_streak(streak, reason); + set_websocket_cooldown_for(websocket_cooldowns, model, cooldown).await; + (streak, cooldown) +} + +pub async fn record_websocket_success( + websocket_cooldowns: &Arc>>, + websocket_failure_streaks: &Arc>>, + model: &str, +) { + clear_websocket_cooldown(websocket_cooldowns, model).await; + let Some(key) = normalize_transport_model(model) else { + return; + }; + let streak = { + let mut guard = websocket_failure_streaks.write().await; + guard.remove(&key).unwrap_or(0) + }; + if streak > 0 { + jcode_logging::info(&format!( + "OpenAI websocket health reset for model='{}' after successful stream (previous streak={})", + model, streak + )); + } +} diff --git a/crates/jcode-render-core/src/lib.rs b/crates/jcode-render-core/src/lib.rs index e1716d489..effc25c11 100644 --- a/crates/jcode-render-core/src/lib.rs +++ b/crates/jcode-render-core/src/lib.rs @@ -22,10 +22,15 @@ pub mod markdown; pub mod model; pub mod preprocess; +pub mod reasoning; pub mod wrap; pub use markdown::parse_markdown; pub use preprocess::escape_currency_dollars; +pub use reasoning::{ + REASONING_SENTINEL, reasoning_line_markup, reasoning_partial_markup, + reasoning_summary_line_markup, +}; pub use model::{ Alignment, Block, BlockKind, Document, FillRole, StyleRole, StyledLine, StyledSpan, TextAttrs, }; diff --git a/crates/jcode-render-core/src/reasoning.rs b/crates/jcode-render-core/src/reasoning.rs new file mode 100644 index 000000000..fd5aa7903 --- /dev/null +++ b/crates/jcode-render-core/src/reasoning.rs @@ -0,0 +1,94 @@ +//! Reasoning-line markdown formatting. +//! +//! Pure string helpers shared by the server/streaming path and the TUI renderer +//! so the wrapping/escaping rules stay in lockstep with the renderer that +//! consumes them. These live in `jcode-render-core` (a backend-neutral, pure +//! crate) rather than in `jcode-tui-markdown` so the foundation/streaming layer +//! can format reasoning lines without depending on any `jcode-tui-*` crate. + +/// Invisible separator placed just inside both ends of an emphasis run so the +/// flanking `*` are always adjacent to non-whitespace (see +/// [`reasoning_line_markup`]). +pub const REASONING_SENTINEL: &str = "\u{2063}"; + +/// Escape the characters that would otherwise be interpreted as inline markdown +/// inside a reasoning line, so the body renders literally inside the dim/italic +/// emphasis run. +fn escape_reasoning_inline_markdown(line: &str) -> String { + let mut out = String::with_capacity(line.len() + 8); + for ch in line.chars() { + match ch { + '\\' | '*' | '_' | '`' | '[' | ']' | '<' | '>' | '&' | '~' | '|' | '$' => { + out.push('\\'); + out.push(ch); + } + _ => out.push(ch), + } + } + out +} + +/// Wrap a completed reasoning line as dim+italic markdown. +/// +/// Empty lines become a bare newline (no empty emphasis run). The result always +/// ends in a CommonMark hard break (`" \n"`). +/// +/// The trailing two spaces are a CommonMark *hard break*: without them, +/// consecutive reasoning lines (each terminated by a single `\n`) collapse into +/// one paragraph where the line breaks render as spaces, so multi-line thinking +/// shows up as a single run-on line. The hard break keeps each reasoning line on +/// its own visual row, matching the model's line structure. +/// +/// The sentinel must wrap both ends because CommonMark's emphasis flanking rules +/// require the opening `*` to not be followed by whitespace and the closing `*` +/// to not be preceded by whitespace. A reasoning line that starts or ends with +/// whitespace (or is whitespace-only) would otherwise leave the asterisks as +/// literal text and break the dim/italic styling. The zero-width sentinels +/// guarantee both asterisks are flanked by non-whitespace regardless of the body. +pub fn reasoning_line_markup(line: &str) -> String { + if line.is_empty() { + "\n".to_string() + } else { + format!( + "*{0}{1}{0}* \n", + REASONING_SENTINEL, + escape_reasoning_inline_markdown(line) + ) + } +} + +/// Wrap the in-progress (not yet newline-terminated) reasoning line as dim+italic +/// markdown, identical to [`reasoning_line_markup`] but *without* the trailing +/// newline so it renders as the live tail of the streaming buffer. Callers +/// truncate and re-emit this tail on each streamed delta so reasoning trickles in +/// token-by-token instead of one whole line at a time. An empty line yields an +/// empty string (nothing to render yet). +pub fn reasoning_partial_markup(line: &str) -> String { + if line.is_empty() { + String::new() + } else { + format!( + "*{0}{1}{0}*", + REASONING_SENTINEL, + escape_reasoning_inline_markdown(line) + ) + } +} + +/// One-line collapsed reasoning summary markup (e.g. `▸ thought (3 lines)`), +/// styled dim+italic like the live reasoning lines. Used to fold a persisted +/// reasoning block down to a single trace line when the transcript is +/// re-rendered from history in `current` reasoning-display mode (so reloaded / +/// resumed sessions match the live collapse instead of replaying every line). +/// +/// Lives here (a backend-neutral, pure crate) rather than in `jcode-tui-markdown` +/// so the foundation/streaming layer can format the summary without depending on +/// any `jcode-tui-*` crate. Re-exported from `jcode-tui-markdown` for the +/// existing `jcode_tui_markdown::reasoning_summary_line_markup` path. +pub fn reasoning_summary_line_markup(line_count: usize) -> String { + let label = match line_count { + 0 | 1 => "▸ thought".to_string(), + n => format!("▸ thought ({} lines)", n), + }; + reasoning_line_markup(&label) +} diff --git a/crates/jcode-session-types/src/lib.rs b/crates/jcode-session-types/src/lib.rs index f0472eec3..0116fb628 100644 --- a/crates/jcode-session-types/src/lib.rs +++ b/crates/jcode-session-types/src/lib.rs @@ -3,6 +3,44 @@ use jcode_message_types::{ContentBlock, Message, Role, ToolCall}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; +/// Identifies a session to resume, across the agent backends jcode can import +/// from. This is pure data (only ids/paths) with no UI dependency; it lives in +/// `jcode-session-types` so the foundation/import layer can match on it without +/// depending on any `jcode-tui-*` crate. The session-picker UI re-exports it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResumeTarget { + JcodeSession { + session_id: String, + }, + ClaudeCodeSession { + session_id: String, + session_path: String, + }, + CodexSession { + session_id: String, + session_path: String, + }, + PiSession { + session_path: String, + }, + OpenCodeSession { + session_id: String, + session_path: String, + }, +} + +impl ResumeTarget { + pub fn stable_id(&self) -> &str { + match self { + Self::JcodeSession { session_id } => session_id, + Self::ClaudeCodeSession { session_id, .. } => session_id, + Self::CodexSession { session_id, .. } => session_id, + Self::PiSession { session_path } => session_path, + Self::OpenCodeSession { session_id, .. } => session_id, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RenderedMessage { pub role: String, diff --git a/crates/jcode-tui-account-picker/Cargo.toml b/crates/jcode-tui-account-picker/Cargo.toml index 401efbad0..c8d142f74 100644 --- a/crates/jcode-tui-account-picker/Cargo.toml +++ b/crates/jcode-tui-account-picker/Cargo.toml @@ -5,6 +5,10 @@ edition = "2024" publish = false [dependencies] +anyhow = "1" +crossterm = "0.29" +ratatui = "0.30" +serde_json = "1" serde = { version = "1", features = ["derive"], optional = true } [features] diff --git a/crates/jcode-tui-account-picker/src/lib.rs b/crates/jcode-tui-account-picker/src/lib.rs index e291e336c..20133a046 100644 --- a/crates/jcode-tui-account-picker/src/lib.rs +++ b/crates/jcode-tui-account-picker/src/lib.rs @@ -135,3 +135,6 @@ mod tests { assert!(!item_matches_filter(&item, "claude")); } } + +mod overlay; +pub use overlay::{AccountPicker, OverlayAction}; diff --git a/crates/jcode-tui-account-picker/src/overlay.rs b/crates/jcode-tui-account-picker/src/overlay.rs new file mode 100644 index 000000000..e13664e62 --- /dev/null +++ b/crates/jcode-tui-account-picker/src/overlay.rs @@ -0,0 +1,1050 @@ +use anyhow::Result; +use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Paragraph, Wrap}, +}; +use std::collections::HashMap; + +use crate::{AccountPickerCommand, AccountPickerItem, AccountPickerSummary, AccountProviderKind}; + +#[path = "overlay_render.rs"] +mod render_support; +use render_support::{ + ActionSection, account_count_summary, account_is_active, action_icon, action_kind_badge, + action_kind_help, action_section, centered_rect, command_preview, compact_item_title, hotkey, + metric_span, provider_header_line, provider_style, truncate_with_ellipsis, +}; + +const PANEL_BG: Color = Color::Rgb(24, 28, 40); +const PANEL_BORDER: Color = Color::Rgb(90, 95, 110); +const PANEL_BORDER_ACTIVE: Color = Color::Rgb(120, 140, 190); +const SECTION_BORDER: Color = Color::Rgb(70, 78, 94); +const SELECTED_BG: Color = Color::Rgb(38, 42, 56); +const MUTED: Color = Color::Rgb(140, 146, 163); +const MUTED_DARK: Color = Color::Rgb(100, 106, 122); +const OVERLAY_PERCENT_X: u16 = 88; +const OVERLAY_PERCENT_Y: u16 = 74; + +#[derive(Debug, Clone)] +pub struct AccountPicker { + title: String, + items: Vec, + filtered: Vec, + selected: usize, + filter: String, + summary: Option, + last_action_list_area: Option, +} + +pub enum OverlayAction { + Continue, + Close, + Execute(AccountPickerCommand), +} + +impl AccountPicker { + pub fn new(title: impl Into, items: Vec) -> Self { + Self::with_summary(title, items, AccountPickerSummary::default()) + } + + pub fn debug_memory_profile(&self) -> serde_json::Value { + let items_estimate_bytes: usize = self.items.iter().map(estimate_item_bytes).sum(); + let filtered_estimate_bytes = self.filtered.capacity() * std::mem::size_of::(); + let filter_bytes = self.filter.capacity(); + let title_bytes = self.title.capacity(); + let summary_estimate_bytes = self + .summary + .as_ref() + .map(estimate_summary_bytes) + .unwrap_or(0); + let total_estimate_bytes = items_estimate_bytes + + filtered_estimate_bytes + + filter_bytes + + title_bytes + + summary_estimate_bytes; + + serde_json::json!({ + "items_count": self.items.len(), + "filtered_count": self.filtered.len(), + "selected": self.selected, + "title_bytes": title_bytes, + "filter_bytes": filter_bytes, + "summary_estimate_bytes": summary_estimate_bytes, + "items_estimate_bytes": items_estimate_bytes, + "filtered_estimate_bytes": filtered_estimate_bytes, + "total_estimate_bytes": total_estimate_bytes, + }) + } + + pub fn with_summary( + title: impl Into, + items: Vec, + summary: AccountPickerSummary, + ) -> Self { + let mut picker = Self { + title: title.into(), + items, + filtered: Vec::new(), + selected: 0, + filter: String::new(), + summary: Some(summary), + last_action_list_area: None, + }; + picker.apply_filter(); + picker + } + + fn selected_item(&self) -> Option<&AccountPickerItem> { + self.filtered + .get(self.selected) + .and_then(|idx| self.items.get(*idx)) + } + + fn visible_window_start(&self, available_items: usize) -> usize { + self.selected + .saturating_sub(available_items.saturating_sub(1).min(available_items / 2)) + } + + fn visible_index_for_action_row(&self, row: u16, list_height: u16) -> Option { + if self.filtered.is_empty() { + return None; + } + + let available_items = (list_height as usize).max(1); + let start = self.visible_window_start(available_items); + let end = (start + available_items).min(self.filtered.len()); + let mut current_provider: Option<&str> = None; + let mut rendered_row = 0u16; + + for visible_idx in start..end { + let item = &self.items[self.filtered[visible_idx]]; + if current_provider != Some(item.provider_id.as_str()) { + current_provider = Some(item.provider_id.as_str()); + if rendered_row == row { + return None; + } + rendered_row = rendered_row.saturating_add(1); + if rendered_row >= list_height { + return None; + } + } + + if rendered_row == row { + return Some(visible_idx); + } + rendered_row = rendered_row.saturating_add(1); + if rendered_row > row && rendered_row >= list_height { + return None; + } + } + + None + } + + fn apply_filter(&mut self) { + self.filtered = self + .items + .iter() + .enumerate() + .filter_map(|(idx, item)| crate::item_matches_filter(item, &self.filter).then_some(idx)) + .collect(); + let provider_order = self.provider_order(); + self.filtered.sort_by(|left, right| { + let left_item = &self.items[*left]; + let right_item = &self.items[*right]; + + provider_order + .get(&left_item.provider_id) + .cmp(&provider_order.get(&right_item.provider_id)) + .then_with(|| action_section(left_item).cmp(&action_section(right_item))) + .then_with(|| left_item.title.cmp(&right_item.title)) + .then_with(|| left.cmp(right)) + }); + if self.selected >= self.filtered.len() { + self.selected = self.filtered.len().saturating_sub(1); + } + } + + fn provider_order(&self) -> HashMap { + let mut order = HashMap::new(); + let mut next = 0usize; + for item in &self.items { + if order.contains_key(&item.provider_id) { + continue; + } + let rank = if item.provider_id == "defaults" { + usize::MAX / 2 + } else { + let current = next; + next += 1; + current + }; + order.insert(item.provider_id.clone(), rank); + } + order + } + + fn filtered_provider_switch_count(&self, provider_id: &str) -> usize { + self.filtered + .iter() + .filter(|idx| { + let item = &self.items[**idx]; + item.provider_id == provider_id + && matches!(action_section(item), ActionSection::Switch) + }) + .count() + } + + fn filtered_provider_secondary_count(&self, provider_id: &str) -> usize { + self.filtered + .iter() + .filter(|idx| { + let item = &self.items[**idx]; + item.provider_id == provider_id + && !matches!(action_section(item), ActionSection::Switch) + }) + .count() + } + + fn select_prev_provider_group(&mut self) { + let Some(current_idx) = self.filtered.get(self.selected).copied() else { + return; + }; + let current_provider = self.items[current_idx].provider_id.as_str(); + let mut target = None; + + for pos in (0..self.selected).rev() { + let provider_id = self.items[self.filtered[pos]].provider_id.as_str(); + if provider_id != current_provider { + target = Some(pos); + break; + } + } + + let Some(mut pos) = target else { + return; + }; + let provider_id = self.items[self.filtered[pos]].provider_id.clone(); + while pos > 0 && self.items[self.filtered[pos - 1]].provider_id == provider_id { + pos -= 1; + } + self.selected = pos; + } + + fn select_next_provider_group(&mut self) { + let Some(current_idx) = self.filtered.get(self.selected).copied() else { + return; + }; + let current_provider = self.items[current_idx].provider_id.as_str(); + + for pos in (self.selected + 1)..self.filtered.len() { + let provider_id = self.items[self.filtered[pos]].provider_id.as_str(); + if provider_id != current_provider { + self.selected = pos; + break; + } + } + } + + fn provider_overview_line(&self) -> Line<'static> { + let mut seen = Vec::new(); + let mut stats: HashMap = HashMap::new(); + + for item in &self.items { + if matches!(item.provider_id.as_str(), "defaults" | "account-flow") { + continue; + } + if !stats.contains_key(&item.provider_id) { + seen.push(item.provider_id.clone()); + stats.insert( + item.provider_id.clone(), + (item.provider_label.clone(), 0, 0), + ); + } + if let Some((_, accounts, actions)) = stats.get_mut(&item.provider_id) { + if matches!(action_section(item), ActionSection::Switch) { + *accounts += 1; + } else { + *actions += 1; + } + } + } + + let mut spans = vec![Span::styled("Providers ", Style::default().fg(MUTED_DARK))]; + let mut first = true; + for provider_id in seen { + let Some((label, accounts, actions)) = stats.get(&provider_id) else { + continue; + }; + if !first { + spans.push(Span::styled(" | ", Style::default().fg(MUTED_DARK))); + } + first = false; + let summary = if *accounts > 0 { + format!("{} {}", label, account_count_summary(*accounts)) + } else { + format!( + "{} {} control{}", + label, + actions, + if *actions == 1 { "" } else { "s" } + ) + }; + spans.push(Span::styled(summary, provider_style(&provider_id))); + } + if first { + spans.push(Span::styled( + "No providers available", + Style::default().fg(MUTED), + )); + } + Line::from(spans) + } + + pub fn handle_overlay_key( + &mut self, + code: KeyCode, + modifiers: KeyModifiers, + ) -> Result { + match code { + KeyCode::Esc => { + if !self.filter.is_empty() { + self.filter.clear(); + self.apply_filter(); + return Ok(OverlayAction::Continue); + } + return Ok(OverlayAction::Close); + } + KeyCode::Char('q') if !modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(OverlayAction::Close); + } + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(OverlayAction::Close); + } + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + let max = self.filtered.len().saturating_sub(1); + self.selected = (self.selected + 1).min(max); + } + KeyCode::Left => { + self.select_prev_provider_group(); + } + KeyCode::Right => { + self.select_next_provider_group(); + } + KeyCode::PageUp | KeyCode::Char('K') => { + self.selected = self.selected.saturating_sub(6); + } + KeyCode::PageDown | KeyCode::Char('J') => { + let max = self.filtered.len().saturating_sub(1); + self.selected = (self.selected + 6).min(max); + } + KeyCode::Home | KeyCode::Char('g') => { + self.selected = 0; + } + KeyCode::End | KeyCode::Char('G') => { + self.selected = self.filtered.len().saturating_sub(1); + } + KeyCode::Backspace => { + if self.filter.pop().is_some() { + self.apply_filter(); + } + } + KeyCode::Enter => { + if let Some(item) = self.selected_item() { + return Ok(OverlayAction::Execute(item.command.clone())); + } + return Ok(OverlayAction::Close); + } + KeyCode::Char(c) + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.filter.push(c); + self.apply_filter(); + } + _ => {} + } + Ok(OverlayAction::Continue) + } + + pub fn handle_overlay_mouse(&mut self, mouse: MouseEvent) { + let Some(list_inner) = self.last_action_list_area else { + return; + }; + let inside_list = mouse.column >= list_inner.x + && mouse.column < list_inner.x.saturating_add(list_inner.width) + && mouse.row >= list_inner.y + && mouse.row < list_inner.y.saturating_add(list_inner.height); + + match mouse.kind { + MouseEventKind::ScrollUp if inside_list => { + self.selected = self.selected.saturating_sub(1); + } + MouseEventKind::ScrollDown if inside_list => { + let max = self.filtered.len().saturating_sub(1); + self.selected = (self.selected + 1).min(max); + } + MouseEventKind::Down(MouseButton::Left) if inside_list => { + let row = mouse.row.saturating_sub(list_inner.y); + if let Some(visible_idx) = self.visible_index_for_action_row(row, list_inner.height) + { + self.selected = visible_idx; + } + } + _ => {} + } + } + + pub fn render(&mut self, frame: &mut Frame) { + let area = centered_rect(OVERLAY_PERCENT_X, OVERLAY_PERCENT_Y, frame.area()); + + let block = Block::default() + .title(format!(" {} ", self.title)) + .title_bottom(Line::from(vec![ + hotkey(" Enter "), + Span::styled(" run ", Style::default().fg(MUTED_DARK)), + hotkey(" Up/Down "), + Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), + hotkey(" Click "), + Span::styled(" select ", Style::default().fg(MUTED_DARK)), + hotkey(" type "), + Span::styled(" filter ", Style::default().fg(MUTED_DARK)), + hotkey(" Esc "), + Span::styled(" clear / close ", Style::default().fg(MUTED_DARK)), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(PANEL_BORDER)); + frame.render_widget(block, area); + + let inner = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(7), + Constraint::Min(10), + Constraint::Length(2), + ]) + .split(inner); + + self.render_header(frame, rows[0]); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(rows[1]); + + self.render_action_list(frame, body[0]); + self.render_detail_pane(frame, body[1]); + + let footer = Paragraph::new(Line::from(vec![ + Span::styled("Focus ", Style::default().fg(MUTED_DARK)), + Span::styled( + "saved accounts stay surfaced here; click actions to focus them, use Left/Right to jump provider groups, or use `/account settings` for the full text view.", + Style::default().fg(MUTED), + ), + ])); + frame.render_widget(footer, rows[2]); + } + + fn render_header(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .title(Span::styled( + " Overview ", + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(SECTION_BORDER)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lines = vec![ + Line::from(vec![ + Span::styled("Filter ", Style::default().fg(MUTED_DARK)), + Span::styled( + if self.filter.is_empty() { + "type provider or account name".to_string() + } else { + self.filter.clone() + }, + if self.filter.is_empty() { + Style::default().fg(Color::Gray).italic() + } else { + Style::default().fg(Color::White) + }, + ), + Span::styled( + format!(" - {} results", self.filtered.len()), + Style::default().fg(MUTED_DARK), + ), + ]), + self.provider_overview_line(), + self.summary_line(), + self.defaults_line(), + ]; + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } + + fn render_action_list(&mut self, frame: &mut Frame, area: Rect) { + let title = if self.filtered.is_empty() { + " Providers & Quick Actions ".to_string() + } else { + format!( + " Providers & Quick Actions ({}/{}) ", + self.selected + 1, + self.filtered.len() + ) + }; + let block = Block::default() + .title(Span::styled( + title, + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(PANEL_BORDER_ACTIVE)); + let list_inner = block.inner(area); + frame.render_widget(block, area); + self.last_action_list_area = Some(list_inner); + + let available_items = (list_inner.height as usize).max(1); + let start = self.visible_window_start(available_items); + let end = (start + available_items).min(self.filtered.len()); + + let mut lines = Vec::new(); + if self.filtered.is_empty() { + lines.push(Line::from(Span::styled( + "No matching account or provider actions.", + Style::default().fg(Color::Gray).italic(), + ))); + lines.push(Line::from(Span::styled( + "Try `openai`, `claude`, an account label, `login`, or `default`.", + Style::default().fg(MUTED), + ))); + } else { + let mut current_provider: Option<&str> = None; + for visible_idx in start..end { + let idx = self.filtered[visible_idx]; + let item = &self.items[idx]; + let selected = visible_idx == self.selected; + + if current_provider != Some(item.provider_id.as_str()) { + current_provider = Some(item.provider_id.as_str()); + lines.push(provider_header_line( + &item.provider_label, + self.filtered_provider_switch_count(&item.provider_id), + self.filtered_provider_secondary_count(&item.provider_id), + &item.provider_id, + )); + } + + let row_style = if selected { + Style::default().bg(SELECTED_BG) + } else { + Style::default() + }; + let (icon, icon_color) = action_icon(item); + let title = compact_item_title(item); + let meta_width = list_inner.width.saturating_sub(16) as usize; + let meta = truncate_with_ellipsis(&item.subtitle, meta_width); + lines.push(Line::from(vec![ + Span::styled( + if selected { "> " } else { " " }, + row_style.fg(Color::White), + ), + Span::styled(format!("{} ", icon), row_style.fg(icon_color).bold()), + Span::styled( + truncate_with_ellipsis(&title, 22), + row_style.fg(Color::White), + ), + Span::styled(" - ", row_style.fg(MUTED_DARK)), + Span::styled(meta, row_style.fg(MUTED)), + ])); + } + } + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), list_inner); + } + + fn render_detail_pane(&self, frame: &mut Frame, area: Rect) { + let title = self + .selected_item() + .map(|item| format!(" {} ", item.provider_label)) + .unwrap_or_else(|| " Details ".to_string()); + let block = Block::default() + .title(Span::styled( + title, + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(SECTION_BORDER)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let Some(item) = self.selected_item() else { + frame.render_widget( + Paragraph::new("No action selected").style(Style::default().fg(Color::DarkGray)), + inner, + ); + return; + }; + + let provider_items: Vec<&AccountPickerItem> = self + .items + .iter() + .filter(|candidate| candidate.provider_id == item.provider_id) + .collect(); + let mut account_items: Vec<&AccountPickerItem> = provider_items + .iter() + .copied() + .filter(|candidate| matches!(action_section(candidate), ActionSection::Switch)) + .collect(); + account_items.sort_by(|left, right| { + account_is_active(right) + .cmp(&account_is_active(left)) + .then_with(|| compact_item_title(left).cmp(&compact_item_title(right))) + }); + let mut secondary_items: Vec<&AccountPickerItem> = provider_items + .iter() + .copied() + .filter(|candidate| !matches!(action_section(candidate), ActionSection::Switch)) + .filter(|candidate| candidate.title != item.title) + .collect(); + secondary_items.sort_by(|left, right| { + action_section(left) + .cmp(&action_section(right)) + .then_with(|| compact_item_title(left).cmp(&compact_item_title(right))) + }); + secondary_items.truncate(6); + let (kind_label, kind_color) = action_kind_badge(&item.command); + + let mut lines = vec![ + Line::from(vec![ + Span::styled("Provider ", Style::default().fg(MUTED_DARK)), + Span::styled( + item.provider_label.clone(), + provider_style(&item.provider_id), + ), + ]), + Line::from(vec![ + Span::styled("Saved accounts ", Style::default().fg(MUTED_DARK)), + Span::styled( + account_count_summary(account_items.len()), + Style::default().fg(Color::White).bold(), + ), + ]), + Line::from(""), + Line::from(vec![Span::styled( + "Quick switch", + Style::default().fg(MUTED_DARK).bold(), + )]), + ]; + + if account_items.is_empty() { + lines.push(Line::from(vec![Span::styled( + "No saved accounts for this provider yet.", + Style::default().fg(MUTED), + )])); + } else { + for account in &account_items { + let is_selected = account.title == item.title; + let bullet = if account_is_active(account) { "*" } else { "o" }; + let note = if is_selected { " [selected]" } else { "" }; + lines.push(Line::from(vec![ + Span::styled( + format!("{} ", bullet), + Style::default().fg(if account_is_active(account) { + Color::Rgb(110, 214, 158) + } else { + MUTED_DARK + }), + ), + Span::styled( + compact_item_title(account), + Style::default().fg(Color::White).bold(), + ), + Span::styled( + note.to_string(), + Style::default().fg(Color::Rgb(170, 210, 255)), + ), + ])); + lines.push(Line::from(vec![Span::styled( + format!( + " {}", + truncate_with_ellipsis( + &account.subtitle, + inner.width.saturating_sub(3) as usize, + ) + ), + Style::default().fg(MUTED), + )])); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Selected action", + Style::default().fg(MUTED_DARK).bold(), + )])); + lines.push(Line::from(vec![ + Span::styled(kind_label, Style::default().fg(kind_color).bold()), + Span::styled(" - ", Style::default().fg(MUTED_DARK)), + Span::styled(item.title.clone(), Style::default().fg(Color::White).bold()), + ])); + lines.push(Line::from(vec![Span::styled( + item.subtitle.clone(), + Style::default().fg(MUTED), + )])); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Runs", + Style::default().fg(MUTED_DARK).bold(), + )])); + lines.push(Line::from(vec![Span::styled( + command_preview(&item.command), + Style::default().fg(Color::White), + )])); + lines.push(Line::from(vec![Span::styled( + action_kind_help(&item.command), + Style::default().fg(MUTED), + )])); + + if !secondary_items.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Other controls", + Style::default().fg(MUTED_DARK).bold(), + )])); + for related in secondary_items { + lines.push(Line::from(vec![ + Span::styled("- ", Style::default().fg(MUTED_DARK)), + Span::styled( + compact_item_title(related), + Style::default().fg(Color::White), + ), + ])); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Press Enter to run this action.", + Style::default().fg(Color::Rgb(170, 210, 255)), + )])); + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } + + fn summary_line(&self) -> Line<'static> { + if let Some(summary) = &self.summary { + let mut spans = vec![ + metric_span("ready", summary.ready_count, Color::Rgb(110, 214, 158)), + Span::raw(" "), + metric_span( + "attention", + summary.attention_count, + Color::Rgb(255, 192, 120), + ), + Span::raw(" "), + metric_span("setup", summary.setup_count, Color::Rgb(160, 168, 188)), + Span::raw(" "), + metric_span( + "providers", + summary.provider_count, + Color::Rgb(140, 176, 255), + ), + ]; + if summary.named_account_count > 0 { + spans.push(Span::raw(" ")); + spans.push(metric_span( + "accounts", + summary.named_account_count, + Color::Rgb(196, 170, 255), + )); + } + return Line::from(spans); + } + + Line::from(vec![Span::styled( + format!("{} actions available", self.filtered.len()), + Style::default().fg(MUTED), + )]) + } + + fn defaults_line(&self) -> Line<'static> { + let Some(summary) = &self.summary else { + return Line::from(vec![Span::styled( + "Type to narrow actions by provider, account label, or setting.", + Style::default().fg(MUTED), + )]); + }; + + let provider = summary.default_provider.as_deref().unwrap_or("auto"); + let model = summary + .default_model + .as_deref() + .unwrap_or("provider default"); + + Line::from(vec![ + Span::styled("Defaults ", Style::default().fg(MUTED_DARK)), + Span::styled("provider ", Style::default().fg(MUTED_DARK)), + Span::styled(provider.to_string(), Style::default().fg(Color::White)), + Span::styled(" - model ", Style::default().fg(MUTED_DARK)), + Span::styled(model.to_string(), Style::default().fg(Color::White)), + ]) + } +} + +fn estimate_optional_string_bytes(value: &Option) -> usize { + value.as_ref().map(|value| value.capacity()).unwrap_or(0) +} + +fn estimate_command_bytes(command: &AccountPickerCommand) -> usize { + match command { + AccountPickerCommand::SubmitInput(value) => value.capacity(), + AccountPickerCommand::OpenAccountCenter { provider_filter } + | AccountPickerCommand::OpenAddReplaceFlow { provider_filter } => { + estimate_optional_string_bytes(provider_filter) + } + AccountPickerCommand::PromptValue { + prompt, + command_prefix, + empty_value, + status_notice, + } => { + prompt.capacity() + + command_prefix.capacity() + + estimate_optional_string_bytes(empty_value) + + status_notice.capacity() + } + AccountPickerCommand::Switch { label, .. } + | AccountPickerCommand::Login { label, .. } + | AccountPickerCommand::Remove { label, .. } => label.capacity(), + AccountPickerCommand::PromptNew { .. } => 0, + } +} + +fn estimate_item_bytes(item: &AccountPickerItem) -> usize { + item.provider_id.capacity() + + item.provider_label.capacity() + + item.title.capacity() + + item.subtitle.capacity() + + estimate_command_bytes(&item.command) +} + +fn estimate_summary_bytes(summary: &AccountPickerSummary) -> usize { + estimate_optional_string_bytes(&summary.default_provider) + + estimate_optional_string_bytes(&summary.default_model) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph}; + + #[test] + fn test_account_picker_preserves_underlying_background_outside_panels() { + let mut picker = AccountPicker::new( + " Accounts ", + vec![AccountPickerItem::action( + "openai", + "OpenAI", + "Add account", + "Start login flow", + AccountPickerCommand::SubmitInput("/account openai add default".to_string()), + )], + ); + + let backend = TestBackend::new(40, 12); + let mut terminal = Terminal::new(backend).expect("failed to create terminal"); + terminal + .draw(|frame| { + let area = frame.area(); + let fill = vec![Line::from("X".repeat(area.width as usize)); area.height as usize]; + frame.render_widget(Paragraph::new(fill), area); + picker.render(frame); + }) + .expect("draw failed"); + + let overlay = centered_rect( + OVERLAY_PERCENT_X, + OVERLAY_PERCENT_Y, + Rect::new(0, 0, 40, 12), + ); + let probe = &terminal.backend().buffer()[(overlay.x + overlay.width - 3, overlay.y + 2)]; + assert_eq!(probe.symbol(), "X"); + assert_ne!(probe.bg, Color::Rgb(18, 21, 30)); + } + + #[test] + fn test_account_picker_mouse_click_selects_visible_action_after_group_header() { + let mut picker = AccountPicker::new( + " Accounts ", + vec![ + AccountPickerItem::action( + "openai", + "OpenAI", + "Provider settings", + "configured", + AccountPickerCommand::SubmitInput("/account openai settings".to_string()), + ), + AccountPickerItem::action( + "openai", + "OpenAI", + "Login / refresh", + "OAuth", + AccountPickerCommand::SubmitInput("/account openai login".to_string()), + ), + ], + ); + + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).expect("failed to create terminal"); + terminal + .draw(|frame| picker.render(frame)) + .expect("draw failed"); + + let list_area = picker + .last_action_list_area + .expect("render should record action list area"); + + let initially_selected = picker.selected; + picker.handle_overlay_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: list_area.x + 1, + row: list_area.y, + modifiers: KeyModifiers::empty(), + }); + assert_eq!( + picker.selected, initially_selected, + "provider group header rows should not be selectable" + ); + + let expected_first_action = picker.items[picker.filtered[0]].title.clone(); + // Row 0 is the provider group header; row 1 is the first sorted action. + picker.handle_overlay_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: list_area.x + 1, + row: list_area.y + 1, + modifiers: KeyModifiers::empty(), + }); + + assert_eq!( + picker.selected_item().map(|item| item.title.as_str()), + Some(expected_first_action.as_str()) + ); + } + + #[test] + fn test_prompt_value_command_preview_shows_placeholder() { + let preview = command_preview(&AccountPickerCommand::PromptValue { + prompt: "Enter default model".to_string(), + command_prefix: "/account default-model".to_string(), + empty_value: Some("clear".to_string()), + status_notice: "editing".to_string(), + }); + + assert!(preview.contains("/account default-model ")); + assert!(preview.contains("clear")); + } + + #[test] + fn test_account_picker_sorts_switches_before_settings() { + let picker = AccountPicker::new( + " Accounts ", + vec![ + AccountPickerItem::action( + "openai", + "OpenAI", + "Provider settings", + "configured", + AccountPickerCommand::SubmitInput("/account openai settings".to_string()), + ), + AccountPickerItem::action( + "openai", + "OpenAI", + "Switch account `work`", + "user@example.com - valid - active", + AccountPickerCommand::SubmitInput("/account openai switch work".to_string()), + ), + AccountPickerItem::action( + "defaults", + "Global", + "Default provider", + "Current: auto", + AccountPickerCommand::PromptValue { + prompt: "provider".to_string(), + command_prefix: "/account default-provider".to_string(), + empty_value: Some("auto".to_string()), + status_notice: "editing".to_string(), + }, + ), + ], + ); + + let ordered_titles: Vec = picker + .filtered + .iter() + .map(|idx| picker.items[*idx].title.clone()) + .collect(); + + assert_eq!(ordered_titles[0], "Switch account `work`"); + assert_eq!(ordered_titles[1], "Provider settings"); + assert_eq!(ordered_titles[2], "Default provider"); + } + + #[test] + fn test_account_picker_left_right_jump_by_provider_group() { + let mut picker = AccountPicker::new( + " Accounts ", + vec![ + AccountPickerItem::action( + "claude", + "Claude", + "Switch account `work`", + "a@example.com - valid - active", + AccountPickerCommand::SubmitInput("/account claude switch work".to_string()), + ), + AccountPickerItem::action( + "claude", + "Claude", + "Provider settings", + "configured", + AccountPickerCommand::SubmitInput("/account claude settings".to_string()), + ), + AccountPickerItem::action( + "openai", + "OpenAI", + "Switch account `default`", + "b@example.com - valid - active", + AccountPickerCommand::SubmitInput("/account openai switch default".to_string()), + ), + ], + ); + + picker.selected = 1; + let _ = picker.handle_overlay_key(KeyCode::Right, KeyModifiers::empty()); + assert_eq!( + picker.items[picker.filtered[picker.selected]].provider_id, + "openai" + ); + + let _ = picker.handle_overlay_key(KeyCode::Left, KeyModifiers::empty()); + assert_eq!( + picker.items[picker.filtered[picker.selected]].provider_id, + "claude" + ); + assert_eq!(picker.selected, 0); + } +} diff --git a/crates/jcode-tui/src/tui/account_picker_render.rs b/crates/jcode-tui-account-picker/src/overlay_render.rs similarity index 99% rename from crates/jcode-tui/src/tui/account_picker_render.rs rename to crates/jcode-tui-account-picker/src/overlay_render.rs index d7d068905..c1198d37d 100644 --- a/crates/jcode-tui/src/tui/account_picker_render.rs +++ b/crates/jcode-tui-account-picker/src/overlay_render.rs @@ -130,7 +130,7 @@ pub(super) fn account_count_summary(count: usize) -> String { } pub(super) fn action_kind_label(command: &AccountPickerCommand) -> &'static str { - jcode_tui_account_picker::action_kind_label(command) + crate::action_kind_label(command) } pub(super) fn action_kind_badge(command: &AccountPickerCommand) -> (&'static str, Color) { diff --git a/crates/jcode-tui-markdown/src/lib.rs b/crates/jcode-tui-markdown/src/lib.rs index 0a0ecfb29..e52b584b2 100644 --- a/crates/jcode-tui-markdown/src/lib.rs +++ b/crates/jcode-tui-markdown/src/lib.rs @@ -108,95 +108,22 @@ pub use render_full::render_markdown_with_width; pub use render_lazy::render_markdown_lazy; pub use render_support::extract_copy_targets_from_rendered_lines; -/// Invisible sentinel prepended (inside `*…*`) to streamed reasoning/thinking -/// lines. The renderer strips it and styles the line dim + italic with no -/// blockquote gutter. Kept zero-width so it stays invisible if it ever leaks -/// into copied text. Shared with the TUI/server reasoning formatters. -pub const REASONING_SENTINEL: &str = "\u{2063}"; - -/// Escape characters that pulldown-cmark would otherwise interpret as inline -/// markdown (emphasis, code, links, etc.). Reasoning lines are rendered inside a -/// single `*…*` emphasis run; without escaping, a stray `*`, `` ` ``, `[`, or -/// `_` in the model's thinking would prematurely close or nest that run and the -/// dim/italic styling would break partway through the line. -fn escape_reasoning_inline_markdown(line: &str) -> String { - let mut out = String::with_capacity(line.len() + 8); - for ch in line.chars() { - match ch { - '\\' | '*' | '_' | '`' | '[' | ']' | '<' | '>' | '&' | '~' | '|' | '$' => { - out.push('\\'); - out.push(ch); - } - _ => out.push(ch), - } - } - out -} - -/// Wrap one complete reasoning/thinking line as dim+italic markdown: the -/// invisible [`REASONING_SENTINEL`] is placed just inside *both* ends of an -/// `*…*` emphasis run that the renderer strips and styles dim, with no -/// blockquote gutter. The line body is escaped so embedded markdown cannot break -/// the styling. Empty lines become a bare newline (no empty emphasis run). The -/// result always ends in a CommonMark hard break (`" \n"`). -/// -/// The trailing two spaces are a CommonMark *hard break*: without them, -/// consecutive reasoning lines (each terminated by a single `\n`) collapse into -/// one paragraph where the line breaks render as spaces, so multi-line thinking -/// shows up as a single run-on line. The hard break keeps each reasoning line on -/// its own visual row, matching the model's line structure. +/// Reasoning-line markdown formatters and the zero-width sentinel they use. /// -/// The sentinel must wrap both ends because CommonMark's emphasis flanking rules -/// require the opening `*` to not be followed by whitespace and the closing `*` -/// to not be preceded by whitespace. A reasoning line that starts or ends with -/// whitespace (or is whitespace-only) would otherwise leave the asterisks as -/// literal text and break the dim/italic styling. The zero-width sentinels -/// guarantee both asterisks are flanked by non-whitespace regardless of the body. +/// These pure-string helpers were moved to `jcode-render-core` so the +/// foundation/streaming layer can format reasoning without depending on any +/// `jcode-tui-*` crate. Re-exported here so existing +/// `jcode_tui_markdown::{reasoning_line_markup, reasoning_partial_markup, +/// REASONING_SENTINEL}` paths keep working. +pub use jcode_render_core::{REASONING_SENTINEL, reasoning_line_markup, reasoning_partial_markup}; + +/// One-line collapsed reasoning summary markup (e.g. `▸ thought (3 lines)`). /// -/// Shared by the server and TUI reasoning formatters so the wrapping/escaping -/// rules stay in lockstep with the renderer that consumes them. -pub fn reasoning_line_markup(line: &str) -> String { - if line.is_empty() { - "\n".to_string() - } else { - format!( - "*{0}{1}{0}* \n", - REASONING_SENTINEL, - escape_reasoning_inline_markdown(line) - ) - } -} - -/// Wrap the in-progress (not yet newline-terminated) reasoning line as dim+italic -/// markdown, identical to [`reasoning_line_markup`] but *without* the trailing -/// newline so it renders as the live tail of the streaming buffer. Callers -/// truncate and re-emit this tail on each streamed delta so reasoning trickles in -/// token-by-token instead of one whole line at a time. An empty line yields an -/// empty string (nothing to render yet). -pub fn reasoning_partial_markup(line: &str) -> String { - if line.is_empty() { - String::new() - } else { - format!( - "*{0}{1}{0}*", - REASONING_SENTINEL, - escape_reasoning_inline_markdown(line) - ) - } -} - -/// One-line collapsed reasoning summary markup (e.g. `▸ thought (3 lines)`), -/// styled dim+italic like the live reasoning lines. Used to fold a persisted -/// reasoning block down to a single trace line when the transcript is -/// re-rendered from history in `current` reasoning-display mode (so reloaded / -/// resumed sessions match the live collapse instead of replaying every line). -pub fn reasoning_summary_line_markup(line_count: usize) -> String { - let label = match line_count { - 0 | 1 => "▸ thought".to_string(), - n => format!("▸ thought ({} lines)", n), - }; - reasoning_line_markup(&label) -} +/// Moved to `jcode-render-core` (pure/backend-neutral) so the foundation/ +/// streaming layer can format it without depending on any `jcode-tui-*` crate. +/// Re-exported here so the existing +/// `jcode_tui_markdown::reasoning_summary_line_markup` path keeps working. +pub use jcode_render_core::reasoning_summary_line_markup; use render_support::{ highlight_code_cached, line_plain_text, placeholder_code_block, ranges_overlap, render_table, diff --git a/crates/jcode-tui-render/Cargo.toml b/crates/jcode-tui-render/Cargo.toml index b70b90c37..65e04aa53 100644 --- a/crates/jcode-tui-render/Cargo.toml +++ b/crates/jcode-tui-render/Cargo.toml @@ -5,5 +5,6 @@ edition = "2024" publish = false [dependencies] +chrono = { version = "0.4", features = ["serde"] } ratatui = "0.30" unicode-width = "0.2" diff --git a/crates/jcode-tui-render/src/lib.rs b/crates/jcode-tui-render/src/lib.rs index ef9a1d55b..35629a84c 100644 --- a/crates/jcode-tui-render/src/lib.rs +++ b/crates/jcode-tui-render/src/lib.rs @@ -1,5 +1,6 @@ pub mod chrome; pub mod layout; +pub mod memory_tiles; use ratatui::prelude::{Line, Span, Style}; diff --git a/crates/jcode-tui-render/src/memory_tiles.rs b/crates/jcode-tui-render/src/memory_tiles.rs new file mode 100644 index 000000000..850383154 --- /dev/null +++ b/crates/jcode-tui-render/src/memory_tiles.rs @@ -0,0 +1,587 @@ +use chrono::{DateTime, Utc}; +use ratatui::prelude::*; + +#[derive(Clone)] +pub struct MemoryTilePlan { + pub lines: Vec>, + pub width: usize, + pub height: usize, + pub score: usize, +} + +pub struct MemoryTile { + category: String, + items: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MemoryTileItem { + pub content: String, + pub updated_at: Option>, +} + +impl From for MemoryTileItem { + fn from(content: String) -> Self { + Self { + content, + updated_at: None, + } + } +} + +impl From<&str> for MemoryTileItem { + fn from(content: &str) -> Self { + Self::from(content.to_string()) + } +} + +pub fn parse_memory_display_entries(content: &str) -> Vec<(String, MemoryTileItem)> { + let mut entries: Vec<(String, MemoryTileItem)> = Vec::new(); + let mut current_category = String::new(); + let mut last_entry_idx: Option = None; + + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.starts_with("# ") || line.is_empty() { + continue; + } + if let Some(category) = line.strip_prefix("## ") { + current_category = category.trim().to_string(); + continue; + } + if let Some(updated_at_raw) = line + .strip_prefix("")) + { + if let (Some(idx), Ok(updated_at)) = ( + last_entry_idx, + DateTime::parse_from_rfc3339(updated_at_raw.trim()), + ) { + entries[idx].1.updated_at = Some(updated_at.with_timezone(&Utc)); + } + continue; + } + + let content = if let Some(dot_pos) = line.find(". ") { + let prefix = &line[..dot_pos]; + if prefix.trim().chars().all(|c| c.is_ascii_digit()) { + line[dot_pos + 2..].trim() + } else { + line + } + } else { + line + }; + if content.is_empty() { + continue; + } + + let category = if current_category.is_empty() { + "memory".to_string() + } else { + current_category.clone() + }; + entries.push(( + category, + MemoryTileItem { + content: content.to_string(), + updated_at: None, + }, + )); + last_entry_idx = Some(entries.len() - 1); + } + + entries +} + +pub fn group_into_tiles(entries: Vec<(String, T)>) -> Vec +where + T: Into, +{ + let mut order: Vec = Vec::new(); + let mut map: std::collections::HashMap> = + std::collections::HashMap::new(); + for (cat, content) in entries { + if !map.contains_key(&cat) { + order.push(cat.clone()); + } + map.entry(cat).or_default().push(content.into()); + } + order + .into_iter() + .filter_map(|cat| { + map.remove(&cat).map(|items| MemoryTile { + category: cat, + items, + }) + }) + .collect() +} + +/// Split a string into chunks that each fit within `max_width` display columns, +/// respecting multi-column characters (CJK characters take 2 columns, etc.). +pub fn split_by_display_width(s: &str, max_width: usize) -> Vec { + use unicode_width::UnicodeWidthChar; + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_width = 0usize; + + for ch in s.chars() { + let cw = UnicodeWidthChar::width(ch).unwrap_or(0); + if current_width + cw > max_width && !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + current_width = 0; + } + current.push(ch); + current_width += cw; + } + if !current.is_empty() { + chunks.push(current); + } + if chunks.is_empty() { + chunks.push(String::new()); + } + chunks +} + +fn truncate_to_display_width(s: &str, max_width: usize) -> String { + use unicode_width::UnicodeWidthChar; + + if max_width == 0 { + return String::new(); + } + + let full_width = unicode_width::UnicodeWidthStr::width(s); + if full_width <= max_width { + return s.to_string(); + } + + let ellipsis = "…"; + let ellipsis_width = unicode_width::UnicodeWidthStr::width(ellipsis); + if ellipsis_width >= max_width { + return ellipsis.to_string(); + } + + let target_width = max_width - ellipsis_width; + let mut truncated = String::new(); + let mut width = 0usize; + for ch in s.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > target_width { + break; + } + truncated.push(ch); + width += ch_width; + } + truncated.push('…'); + truncated +} + +fn format_memory_updated_age(updated_at: DateTime) -> String { + let age = Utc::now().signed_duration_since(updated_at); + if age.num_seconds() < 2 { + "updated now".to_string() + } else if age.num_minutes() < 1 { + format!("updated {}s ago", age.num_seconds().max(1)) + } else if age.num_hours() < 1 { + format!("updated {}m ago", age.num_minutes()) + } else if age.num_days() < 1 { + format!("updated {}h ago", age.num_hours()) + } else if age.num_days() < 7 { + format!("updated {}d ago", age.num_days()) + } else if age.num_days() < 30 { + format!("updated {}w ago", (age.num_days() / 7).max(1)) + } else { + format!("updated {}mo ago", (age.num_days() / 30).max(1)) + } +} + +fn memory_age_text_tint(updated_at: Option>) -> Color { + let Some(updated_at) = updated_at else { + return Color::Rgb(140, 144, 152); + }; + let age = Utc::now().signed_duration_since(updated_at); + if age.num_hours() < 1 { + Color::Rgb(146, 156, 149) + } else if age.num_days() < 1 { + Color::Rgb(142, 148, 156) + } else if age.num_days() < 7 { + Color::Rgb(145, 144, 154) + } else if age.num_days() < 30 { + Color::Rgb(150, 143, 147) + } else { + Color::Rgb(154, 144, 144) + } +} + +fn memory_tile_content_lines( + items: &[MemoryTileItem], + inner_width: usize, + border_style: Style, + text_style: Style, +) -> Vec> { + let bullet = "· "; + let bullet_width = unicode_width::UnicodeWidthStr::width(bullet); + let item_width = inner_width.saturating_sub(bullet_width); + + let mut content_lines: Vec> = Vec::new(); + for item in items { + let text_fill_style = text_style.fg(memory_age_text_tint(item.updated_at)); + let meta_fill_style = Style::default().fg(Color::Rgb(160, 165, 172)); + let text_display_width = unicode_width::UnicodeWidthStr::width(item.content.as_str()); + if text_display_width <= item_width { + let text = item.content.to_string(); + let padding = inner_width.saturating_sub(bullet_width + text_display_width); + let mut spans = vec![ + Span::styled("│ ", border_style), + Span::styled(bullet.to_string(), text_fill_style), + Span::styled(text, text_fill_style), + ]; + if padding > 0 { + spans.push(Span::raw(" ".repeat(padding))); + } + spans.push(Span::styled(" │", border_style)); + content_lines.push(Line::from(spans)); + } else { + let indent = bullet_width; + let cont_width = inner_width.saturating_sub(indent); + let first_chunk_width = item_width; + let mut all_chunks: Vec = Vec::new(); + let first_chunks = split_by_display_width(&item.content, first_chunk_width); + if let Some(first) = first_chunks.first() { + all_chunks.push(first.clone()); + let remainder: String = item.content.chars().skip(first.chars().count()).collect(); + if !remainder.is_empty() { + all_chunks.extend(split_by_display_width(&remainder, cont_width)); + } + } + for (ci, chunk) in all_chunks.iter().enumerate() { + let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str()); + if ci == 0 { + let padding = inner_width.saturating_sub(bullet_width + chunk_width); + let mut spans = vec![ + Span::styled("│ ", border_style), + Span::styled(bullet.to_string(), text_fill_style), + Span::styled(chunk.clone(), text_fill_style), + ]; + if padding > 0 { + spans.push(Span::raw(" ".repeat(padding))); + } + spans.push(Span::styled(" │", border_style)); + content_lines.push(Line::from(spans)); + } else { + let padding = inner_width.saturating_sub(indent + chunk_width); + let mut spans = vec![ + Span::styled("│ ", border_style), + Span::raw(" ".repeat(indent)), + Span::styled(chunk.clone(), text_fill_style), + ]; + if padding > 0 { + spans.push(Span::raw(" ".repeat(padding))); + } + spans.push(Span::styled(" │", border_style)); + content_lines.push(Line::from(spans)); + } + } + } + + if let Some(updated_at) = item.updated_at { + let meta = format_memory_updated_age(updated_at); + let indent = bullet_width; + let meta_width = inner_width.saturating_sub(indent).max(1); + for chunk in split_by_display_width(&meta, meta_width) { + let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str()); + let padding = inner_width.saturating_sub(indent + chunk_width); + content_lines.push(Line::from(vec![ + Span::styled("│ ", border_style), + Span::raw(" ".repeat(indent)), + Span::styled(chunk, meta_fill_style), + Span::raw(" ".repeat(padding)), + Span::styled(" │", border_style), + ])); + } + } + } + + if content_lines.is_empty() { + content_lines.push(Line::from(vec![ + Span::styled("│ ", border_style), + Span::raw(" ".repeat(inner_width)), + Span::styled(" │", border_style), + ])); + } + + content_lines +} + +fn render_memory_tile_box( + tile: &MemoryTile, + box_width: usize, + border_style: Style, + text_style: Style, +) -> Vec> { + let inner_width = box_width.saturating_sub(4); + if inner_width < 4 { + return Vec::new(); + } + + let title_max_width = box_width.saturating_sub(4); + let title_label = truncate_to_display_width(&tile.category.to_lowercase(), title_max_width); + let title_text = format!(" {} ", title_label); + let title_len = unicode_width::UnicodeWidthStr::width(title_text.as_str()); + let border_chars = box_width.saturating_sub(title_len + 2); + let left_border = "─".repeat(border_chars / 2); + let right_border = "─".repeat(border_chars - border_chars / 2); + + let top = Line::from(Span::styled( + format!("╭{}{}{}╮", left_border, title_text, right_border), + border_style, + )); + let content_lines = + memory_tile_content_lines(&tile.items, inner_width, border_style, text_style); + let bottom = Line::from(Span::styled( + format!("╰{}╯", "─".repeat(box_width.saturating_sub(2))), + border_style, + )); + + let mut lines = Vec::with_capacity(content_lines.len() + 2); + lines.push(top); + lines.extend(content_lines); + lines.push(bottom); + lines +} + +pub fn plan_memory_tile( + tile: &MemoryTile, + box_width: usize, + border_style: Style, + text_style: Style, +) -> Option { + let lines = render_memory_tile_box(tile, box_width, border_style, text_style); + if lines.is_empty() { + return None; + } + let width = lines.first().map(Line::width).unwrap_or(box_width); + let height = lines.len(); + let score = tile.items.len() * 10 + + tile + .items + .iter() + .map(|item| unicode_width::UnicodeWidthStr::width(item.content.as_str()).min(80)) + .sum::(); + Some(MemoryTilePlan { + lines, + width, + height, + score, + }) +} + +pub fn choose_memory_tile_span( + tile: &MemoryTile, + column_width: usize, + gap: usize, + max_span: usize, + border_style: Style, + text_style: Style, +) -> Option<(MemoryTilePlan, usize)> { + let single = plan_memory_tile(tile, column_width, border_style, text_style)?; + let mut best_plan = single.clone(); + let mut best_span = 1usize; + + for span in 2..=max_span.max(1) { + let width = column_width * span + gap * span.saturating_sub(1); + let Some(plan) = plan_memory_tile(tile, width, border_style, text_style) else { + continue; + }; + + let single_area = single.width * single.height; + let span_area = plan.width * plan.height; + let height_gain = single.height.saturating_sub(plan.height); + let area_gain = single_area.saturating_sub(span_area); + + if height_gain >= 2 || (height_gain >= 1 && area_gain > column_width) { + best_plan = plan; + best_span = span; + break; + } + } + + Some((best_plan, best_span)) +} + +pub fn render_memory_tiles( + tiles: &[MemoryTile], + total_width: usize, + border_style: Style, + text_style: Style, + header_line: Option>, +) -> Vec> { + if tiles.is_empty() { + return Vec::new(); + } + + let mut all_lines: Vec> = Vec::new(); + + if let Some(header) = header_line { + all_lines.push(header); + } + + let min_box_inner = 16usize; + let min_box_width = min_box_inner + 4; + let gap = 2usize; + let row_gap = 0usize; + let usable_width = total_width.max(min_box_width); + + #[derive(Clone)] + struct Placement { + x: usize, + y: usize, + plan: MemoryTilePlan, + } + + #[derive(Clone)] + struct PlannedTile { + span: usize, + plan: MemoryTilePlan, + } + + let max_cols = ((usable_width + gap) / (min_box_width + gap)).clamp(1, 4); + let mut best_layout: Option<(Vec, usize, usize)> = None; + + for column_count in 1..=max_cols { + let column_width = (usable_width.saturating_sub((column_count - 1) * gap)) / column_count; + if column_width < min_box_width { + continue; + } + + let max_span = if column_count >= 2 { 2 } else { 1 }; + let mut planned: Vec = tiles + .iter() + .filter_map(|tile| { + let (plan, span) = choose_memory_tile_span( + tile, + column_width, + gap, + max_span, + border_style, + text_style, + )?; + Some(PlannedTile { span, plan }) + }) + .collect(); + + if planned.is_empty() { + continue; + } + + planned.sort_by(|a, b| { + b.plan + .score + .cmp(&a.plan.score) + .then_with(|| b.span.cmp(&a.span)) + .then_with(|| b.plan.height.cmp(&a.plan.height)) + .then_with(|| b.plan.width.cmp(&a.plan.width)) + }); + + let mut column_heights = vec![0usize; column_count]; + let mut placements: Vec = Vec::with_capacity(planned.len()); + + for planned_tile in planned { + let mut best_start = 0usize; + let mut best_y = usize::MAX; + + for start_col in 0..=column_count.saturating_sub(planned_tile.span) { + let y = column_heights[start_col..start_col + planned_tile.span] + .iter() + .copied() + .max() + .unwrap_or(0); + + if y < best_y || (y == best_y && start_col < best_start) { + best_start = start_col; + best_y = y; + } + } + + let x = best_start * (column_width + gap); + let next_height = best_y + planned_tile.plan.height + row_gap; + for height in &mut column_heights[best_start..best_start + planned_tile.span] { + *height = next_height; + } + + placements.push(Placement { + x, + y: best_y, + plan: planned_tile.plan, + }); + } + + let total_height = column_heights + .iter() + .copied() + .max() + .unwrap_or(0) + .saturating_sub(row_gap); + let imbalance = column_heights.iter().copied().max().unwrap_or(0) + - column_heights.iter().copied().min().unwrap_or(0); + let used_width = column_count * column_width + gap * column_count.saturating_sub(1); + let leftover_width = usable_width.saturating_sub(used_width); + + // Vertical centering: if this column arrangement has imbalanced columns, + // center shorter columns' tiles vertically within the available space. + let max_col_height = *column_heights.iter().max().unwrap_or(&0); + for (col_idx, col_height) in column_heights.iter().enumerate() { + if *col_height < max_col_height { + let extra = max_col_height - col_height; + let offset = extra / 2; + if offset > 0 { + for placed in placements.iter_mut() { + let start_col = placed.x / (column_width + gap); + if start_col == col_idx { + placed.y += offset; + } + } + } + } + } + + let layout_score = total_height * 100 + imbalance * 3 + leftover_width; + + match &best_layout { + Some((_, _, best_score)) if *best_score <= layout_score => {} + _ => best_layout = Some((placements, total_height, layout_score)), + } + } + + let Some((mut placements, total_height, _)) = best_layout else { + return all_lines; + }; + + placements.sort_by(|a, b| a.x.cmp(&b.x).then_with(|| a.y.cmp(&b.y))); + + for y in 0..total_height { + let mut spans: Vec> = Vec::new(); + let mut cursor = 0usize; + let mut row_has_content = false; + for placed in placements + .iter() + .filter(|placed| y >= placed.y && y < placed.y + placed.plan.height) + { + if placed.x > cursor { + spans.push(Span::raw(" ".repeat(placed.x - cursor))); + } + spans.extend(placed.plan.lines[y - placed.y].spans.clone()); + cursor = placed.x + placed.plan.width; + row_has_content = true; + } + if row_has_content { + if cursor < usable_width { + spans.push(Span::raw(" ".repeat(usable_width - cursor))); + } + all_lines.push(Line::from(spans)); + } + } + + all_lines +} diff --git a/crates/jcode-tui-session-picker/src/lib.rs b/crates/jcode-tui-session-picker/src/lib.rs index 44417f48b..c8f744c66 100644 --- a/crates/jcode-tui-session-picker/src/lib.rs +++ b/crates/jcode-tui-session-picker/src/lib.rs @@ -24,40 +24,11 @@ impl SessionSource { } } -#[derive(Clone, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum ResumeTarget { - JcodeSession { - session_id: String, - }, - ClaudeCodeSession { - session_id: String, - session_path: String, - }, - CodexSession { - session_id: String, - session_path: String, - }, - PiSession { - session_path: String, - }, - OpenCodeSession { - session_id: String, - session_path: String, - }, -} - -impl ResumeTarget { - pub fn stable_id(&self) -> &str { - match self { - Self::JcodeSession { session_id } => session_id, - Self::ClaudeCodeSession { session_id, .. } => session_id, - Self::CodexSession { session_id, .. } => session_id, - Self::PiSession { session_path } => session_path, - Self::OpenCodeSession { session_id, .. } => session_id, - } - } -} +// `ResumeTarget` is pure data and now lives in `jcode-session-types` so the +// foundation/import layer can use it without depending on this UI crate. It is +// re-exported here so existing `jcode_tui_session_picker::ResumeTarget` paths +// keep working. +pub use jcode_session_types::ResumeTarget; #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/crates/jcode-tui-usage-overlay/Cargo.toml b/crates/jcode-tui-usage-overlay/Cargo.toml index 65e174a44..c3403c3b7 100644 --- a/crates/jcode-tui-usage-overlay/Cargo.toml +++ b/crates/jcode-tui-usage-overlay/Cargo.toml @@ -5,8 +5,13 @@ edition = "2024" publish = false [dependencies] +anyhow = "1" +chrono = "0.4" +crossterm = "0.29" +jcode-usage-types = { path = "../jcode-usage-types" } ratatui = "0.30" serde = { version = "1", features = ["derive"], optional = true } +serde_json = "1" [features] default = [] diff --git a/crates/jcode-tui-usage-overlay/src/lib.rs b/crates/jcode-tui-usage-overlay/src/lib.rs index 977040893..c9b6a865f 100644 --- a/crates/jcode-tui-usage-overlay/src/lib.rs +++ b/crates/jcode-tui-usage-overlay/src/lib.rs @@ -1,4 +1,9 @@ -use ratatui::style::Color; +use anyhow::Result; +use crossterm::event::{KeyCode, KeyModifiers}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Paragraph, Wrap}, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -108,6 +113,765 @@ pub fn item_matches_filter(item: &UsageOverlayItem, filter: &str) -> bool { .all(|needle| haystack.contains(&needle.to_lowercase())) } +const PANEL_BG: Color = Color::Rgb(24, 28, 40); +const PANEL_BORDER: Color = Color::Rgb(90, 95, 110); +const PANEL_BORDER_ACTIVE: Color = Color::Rgb(120, 140, 190); +const SECTION_BORDER: Color = Color::Rgb(70, 78, 94); +const SELECTED_BG: Color = Color::Rgb(38, 42, 56); +const MUTED: Color = Color::Rgb(140, 146, 163); +const MUTED_DARK: Color = Color::Rgb(100, 106, 122); +const OVERLAY_PERCENT_X: u16 = 88; +const OVERLAY_PERCENT_Y: u16 = 74; + +#[derive(Debug, Clone)] +pub struct UsageOverlay { + title: String, + items: Vec, + filtered: Vec, + selected: usize, + filter: String, + summary: UsageOverlaySummary, +} + +pub enum OverlayAction { + Continue, + Close, +} + +impl UsageOverlay { + pub fn loading() -> Self { + Self::new( + " Usage ", + vec![UsageOverlayItem::new( + "loading", + "Refreshing usage", + "Fetching limits from connected providers", + UsageOverlayStatus::Loading, + vec![ + "Fetching usage limits from all connected providers...".to_string(), + "".to_string(), + "This view will update automatically when the usage report returns." + .to_string(), + ], + )], + UsageOverlaySummary::default(), + ) + } + + pub fn from_progress(progress: &jcode_usage_types::ProviderUsageProgress) -> Self { + Self::from_provider_reports( + &progress.results, + !progress.done, + progress.completed, + progress.total, + progress.from_cache, + ) + } + + pub fn from_provider_reports( + reports: &[jcode_usage_types::ProviderUsage], + refreshing: bool, + completed: usize, + total: usize, + from_cache: bool, + ) -> Self { + let mut items: Vec = reports.iter().map(provider_item).collect(); + + if refreshing { + let subtitle = if total > 0 { + format!("Refreshing providers ({}/{})", completed.min(total), total) + } else if from_cache { + "Showing cached usage while refreshing providers".to_string() + } else { + "Fetching usage limits from connected providers".to_string() + }; + items.push(UsageOverlayItem::new( + "refreshing", + "Refreshing usage", + subtitle, + UsageOverlayStatus::Loading, + vec![ + "## Live refresh".to_string(), + if from_cache { + "• Cached results are visible immediately.".to_string() + } else { + "• Waiting for provider responses.".to_string() + }, + if total > 0 { + format!( + "• Completed {}/{} provider checks.", + completed.min(total), + total + ) + } else { + "• Discovering connected providers.".to_string() + }, + "• This panel updates as each provider returns.".to_string(), + ], + )); + } else if items.is_empty() { + items.push(UsageOverlayItem::new( + "no-providers", + "No connected providers", + "Connect Claude or OpenAI OAuth to show usage limits", + UsageOverlayStatus::Info, + vec![ + "## No usage sources found".to_string(), + "• No providers with OAuth credentials were found.".to_string(), + "• Use `/login claude` or `/login openai` to connect a provider.".to_string(), + "• Then run `/usage` again.".to_string(), + ], + )); + } + + let mut summary = UsageOverlaySummary { + provider_count: reports.len(), + session_visible: false, + ..UsageOverlaySummary::default() + }; + for report in reports { + match provider_status(report) { + UsageOverlayStatus::Warning => summary.warning_count += 1, + UsageOverlayStatus::Critical => summary.critical_count += 1, + UsageOverlayStatus::Error => summary.error_count += 1, + _ => {} + } + } + + let title = if refreshing { + " Usage · refreshing " + } else { + " Usage " + }; + Self::new(title, items, summary) + } + + pub fn debug_memory_profile(&self) -> serde_json::Value { + let items_estimate_bytes: usize = self.items.iter().map(estimate_item_bytes).sum(); + let filtered_estimate_bytes = self.filtered.capacity() * std::mem::size_of::(); + let filter_bytes = self.filter.capacity(); + let title_bytes = self.title.capacity(); + let total_estimate_bytes = + items_estimate_bytes + filtered_estimate_bytes + filter_bytes + title_bytes; + + serde_json::json!({ + "items_count": self.items.len(), + "filtered_count": self.filtered.len(), + "selected": self.selected, + "title_bytes": title_bytes, + "filter_bytes": filter_bytes, + "items_estimate_bytes": items_estimate_bytes, + "filtered_estimate_bytes": filtered_estimate_bytes, + "total_estimate_bytes": total_estimate_bytes, + }) + } + + pub fn new( + title: impl Into, + items: Vec, + summary: UsageOverlaySummary, + ) -> Self { + let mut overlay = Self { + title: title.into(), + items, + filtered: Vec::new(), + selected: 0, + filter: String::new(), + summary, + }; + overlay.apply_filter(); + overlay + } + + pub fn selected_item_title(&self) -> Option<&str> { + self.selected_item().map(|item| item.title.as_str()) + } + + pub fn replace_preserving_view(&mut self, mut next: Self) { + let selected_id = self.selected_item().map(|item| item.id.clone()); + next.filter = self.filter.clone(); + next.apply_filter(); + if let Some(selected_id) = selected_id + && let Some(selected) = next + .filtered + .iter() + .position(|item_idx| next.items[*item_idx].id == selected_id) + { + next.selected = selected; + } + *self = next; + } + + pub fn selected_item_detail_text(&self) -> String { + self.selected_item() + .map(|item| item.detail_lines.join("\n")) + .unwrap_or_default() + } + + fn selected_item(&self) -> Option<&UsageOverlayItem> { + self.filtered + .get(self.selected) + .and_then(|idx| self.items.get(*idx)) + } + + fn apply_filter(&mut self) { + self.filtered = self + .items + .iter() + .enumerate() + .filter_map(|(idx, item)| item_matches_filter(item, &self.filter).then_some(idx)) + .collect(); + if self.selected >= self.filtered.len() { + self.selected = self.filtered.len().saturating_sub(1); + } + } + + pub fn handle_overlay_key( + &mut self, + code: KeyCode, + modifiers: KeyModifiers, + ) -> Result { + match code { + KeyCode::Esc => { + if !self.filter.is_empty() { + self.filter.clear(); + self.apply_filter(); + return Ok(OverlayAction::Continue); + } + return Ok(OverlayAction::Close); + } + KeyCode::Char('q') if !modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(OverlayAction::Close); + } + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(OverlayAction::Close); + } + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + let max = self.filtered.len().saturating_sub(1); + self.selected = (self.selected + 1).min(max); + } + KeyCode::PageUp | KeyCode::Char('K') => { + self.selected = self.selected.saturating_sub(6); + } + KeyCode::PageDown | KeyCode::Char('J') => { + let max = self.filtered.len().saturating_sub(1); + self.selected = (self.selected + 6).min(max); + } + KeyCode::Home | KeyCode::Char('g') => { + self.selected = 0; + } + KeyCode::End | KeyCode::Char('G') => { + self.selected = self.filtered.len().saturating_sub(1); + } + KeyCode::Backspace => { + if self.filter.pop().is_some() { + self.apply_filter(); + } + } + KeyCode::Char(c) + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.filter.push(c); + self.apply_filter(); + } + _ => {} + } + Ok(OverlayAction::Continue) + } + + pub fn render(&self, frame: &mut Frame) { + let area = centered_rect(OVERLAY_PERCENT_X, OVERLAY_PERCENT_Y, frame.area()); + + let block = Block::default() + .title(format!(" {} ", self.title)) + .title_bottom(Line::from(vec![ + hotkey(" Up/Down "), + Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), + hotkey(" type "), + Span::styled(" filter ", Style::default().fg(MUTED_DARK)), + hotkey(" /usage "), + Span::styled(" refresh ", Style::default().fg(MUTED_DARK)), + hotkey(" Esc "), + Span::styled(" clear / close ", Style::default().fg(MUTED_DARK)), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(PANEL_BORDER)); + frame.render_widget(block, area); + + let inner = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(10), + Constraint::Length(2), + ]) + .split(inner); + + self.render_header(frame, rows[0]); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(39), Constraint::Percentage(61)]) + .split(rows[1]); + + self.render_item_list(frame, body[0]); + self.render_detail_pane(frame, body[1]); + + let footer = Paragraph::new(Line::from(vec![ + Span::styled("Focus ", Style::default().fg(MUTED_DARK)), + Span::styled( + "Use this panel to compare provider headroom and reset times without cluttering the chat transcript.", + Style::default().fg(MUTED), + ), + ])); + frame.render_widget(footer, rows[2]); + } + + fn render_header(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .title(Span::styled( + " Usage overview ", + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(SECTION_BORDER)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lines = vec![ + Line::from(vec![ + Span::styled("Filter ", Style::default().fg(MUTED_DARK)), + Span::styled( + if self.filter.is_empty() { + "type provider or plan name".to_string() + } else { + self.filter.clone() + }, + if self.filter.is_empty() { + Style::default().fg(Color::Gray).italic() + } else { + Style::default().fg(Color::White) + }, + ), + Span::styled( + format!(" · {} results", self.filtered.len()), + Style::default().fg(MUTED_DARK), + ), + ]), + Line::from(vec![ + metric_span( + "providers", + self.summary.provider_count, + Color::Rgb(111, 214, 181), + ), + Span::raw(" "), + metric_span( + "watch", + self.summary.warning_count, + Color::Rgb(255, 196, 112), + ), + Span::raw(" "), + metric_span( + "high", + self.summary.critical_count, + Color::Rgb(255, 146, 110), + ), + Span::raw(" "), + metric_span( + "errors", + self.summary.error_count, + Color::Rgb(232, 134, 134), + ), + if self.summary.session_visible { + Span::styled(" · session included", Style::default().fg(MUTED_DARK)) + } else { + Span::raw("") + }, + ]), + ]; + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } + + fn render_item_list(&self, frame: &mut Frame, area: Rect) { + let title = if self.filtered.is_empty() { + " Sources ".to_string() + } else { + format!(" Sources ({}/{}) ", self.selected + 1, self.filtered.len()) + }; + let block = Block::default() + .title(Span::styled( + title, + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(PANEL_BORDER_ACTIVE)); + let inner = block.inner(area); + frame.render_widget(block, area); + + if self.filtered.is_empty() { + frame.render_widget( + Paragraph::new("No usage items match the current filter.") + .style(Style::default().fg(MUTED)) + .wrap(Wrap { trim: false }), + inner, + ); + return; + } + + let mut lines: Vec> = Vec::new(); + let mut selected_line = 0usize; + for (visible_idx, item_idx) in self.filtered.iter().enumerate() { + let item = &self.items[*item_idx]; + let selected = visible_idx == self.selected; + if selected { + selected_line = lines.len(); + } + let title_style = if selected { + Style::default().fg(Color::White).bg(SELECTED_BG).bold() + } else { + Style::default().fg(Color::White) + }; + let subtitle_style = if selected { + Style::default().fg(MUTED).bg(SELECTED_BG) + } else { + Style::default().fg(MUTED) + }; + let badge_style = Style::default() + .fg(item.status.color()) + .bg(if selected { SELECTED_BG } else { PANEL_BG }) + .bold(); + let marker = if selected { "›" } else { " " }; + lines.push(Line::from(vec![ + Span::styled( + format!("{} {} ", marker, item.status.icon()), + Style::default().fg(item.status.color()).bg(if selected { + SELECTED_BG + } else { + PANEL_BG + }), + ), + Span::styled( + truncate_with_ellipsis(&item.title, inner.width.saturating_sub(16) as usize), + title_style, + ), + Span::raw(" "), + Span::styled(format!("[{}]", item.status.label()), badge_style), + ])); + lines.push(Line::from(Span::styled( + format!(" {}", item.subtitle), + subtitle_style, + ))); + lines.push(Line::from("")); + } + + let visible_height = inner.height.max(1) as usize; + let scroll = selected_line.saturating_sub(visible_height.saturating_sub(3)); + frame.render_widget( + Paragraph::new(lines) + .scroll((scroll.min(u16::MAX as usize) as u16, 0)) + .wrap(Wrap { trim: false }), + inner, + ); + } + + fn render_detail_pane(&self, frame: &mut Frame, area: Rect) { + let selected = self.selected_item(); + let title = selected + .map(|item| format!(" {} · {} ", item.title, item.status.label())) + .unwrap_or_else(|| " Usage details ".to_string()); + let border_color = selected + .map(|item| item.status.color()) + .unwrap_or(PANEL_BORDER_ACTIVE); + let block = Block::default() + .title(Span::styled( + title, + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(border_color)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lines: Vec> = match selected { + Some(item) => item + .detail_lines + .iter() + .map(|line| { + if line.is_empty() { + Line::from("") + } else if let Some(rest) = line.strip_prefix("## ") { + Line::from(Span::styled( + format!(" {}", rest), + Style::default().fg(Color::White).bold(), + )) + } else if let Some(rest) = line.strip_prefix("• ") { + Line::from(vec![ + Span::styled(" • ", Style::default().fg(MUTED_DARK)), + Span::styled(rest.to_string(), Style::default().fg(MUTED)), + ]) + } else { + Line::from(Span::styled(line.clone(), Style::default().fg(MUTED))) + } + }) + .collect(), + None => vec![Line::from(Span::styled( + "No usage item selected.", + Style::default().fg(MUTED), + ))], + }; + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } +} + +fn estimate_item_bytes(item: &UsageOverlayItem) -> usize { + item.id.capacity() + + item.title.capacity() + + item.subtitle.capacity() + + item + .detail_lines + .iter() + .map(|value| value.capacity()) + .sum::() +} + +fn hotkey(text: &'static str) -> Span<'static> { + Span::styled(text, Style::default().fg(Color::White).bg(Color::DarkGray)) +} + +fn metric_span(label: &'static str, value: usize, color: Color) -> Span<'static> { + Span::styled( + format!("{} {}", label, value), + Style::default().fg(color).bold(), + ) +} + +fn provider_item(report: &jcode_usage_types::ProviderUsage) -> UsageOverlayItem { + let status = provider_status(report); + let subtitle = provider_subtitle(report); + UsageOverlayItem::new( + report.provider_name.clone(), + report.provider_name.clone(), + subtitle, + status, + provider_detail_lines(report), + ) +} + +fn provider_status(report: &jcode_usage_types::ProviderUsage) -> UsageOverlayStatus { + if report.error.is_some() { + return UsageOverlayStatus::Error; + } + if report.hard_limit_reached { + return UsageOverlayStatus::Critical; + } + let max_percent = report + .limits + .iter() + .map(|limit| limit.usage_percent) + .fold(0.0_f32, f32::max); + if max_percent >= 90.0 { + UsageOverlayStatus::Critical + } else if max_percent >= 70.0 { + UsageOverlayStatus::Warning + } else if report.limits.is_empty() && report.extra_info.is_empty() { + UsageOverlayStatus::Info + } else { + UsageOverlayStatus::Good + } +} + +fn provider_subtitle(report: &jcode_usage_types::ProviderUsage) -> String { + if let Some(error) = &report.error { + return truncate_with_ellipsis(error, 72); + } + if report.hard_limit_reached { + return "Hard limit reached".to_string(); + } + let mut parts = Vec::new(); + if let Some(limit) = report + .limits + .iter() + .max_by(|a, b| a.usage_percent.total_cmp(&b.usage_percent)) + { + let mut part = format!( + "{} {:.0}% used", + limit.name, + limit.usage_percent.clamp(0.0, 999.0) + ); + if let Some(reset) = limit.resets_at.as_deref() { + part.push_str(&format!(" · resets in {}", format_reset_time(reset))); + } + parts.push(part); + } + if let Some((key, value)) = report.extra_info.first() { + parts.push(format!("{}: {}", key, value)); + } + if parts.is_empty() { + "No usage data available".to_string() + } else { + truncate_with_ellipsis(&parts.join(" · "), 96) + } +} + +fn parse_reset_timestamp(timestamp: &str) -> Option> { + if let Ok(reset) = chrono::DateTime::parse_from_rfc3339(timestamp) { + Some(reset.with_timezone(&chrono::Utc)) + } else if let Ok(reset) = + chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%.fZ") + { + Some(reset.and_utc()) + } else { + None + } +} + +pub fn format_reset_time(timestamp: &str) -> String { + if let Some(reset) = parse_reset_timestamp(timestamp) { + let duration = reset.signed_duration_since(chrono::Utc::now()); + if duration.num_seconds() <= 0 { + return "now".to_string(); + } + if duration.num_seconds() < 60 { + return "1m".to_string(); + } + let days = duration.num_days(); + let hours = duration.num_hours() % 24; + let minutes = duration.num_minutes() % 60; + if days > 0 { + if hours > 0 { + format!("{}d {}h", days, hours) + } else if minutes > 0 { + format!("{}d {}m", days, minutes) + } else { + format!("{}d", days) + } + } else if hours > 0 { + format!("{}h {}m", hours, minutes) + } else { + format!("{}m", minutes) + } + } else { + timestamp.to_string() + } +} + +pub fn format_usage_bar(percent: f32, width: usize) -> String { + let filled = ((percent / 100.0) * width as f32).round() as usize; + let filled = filled.min(width); + let empty = width.saturating_sub(filled); + let bar: String = "█".repeat(filled) + &"░".repeat(empty); + format!("{} {:.0}%", bar, percent) +} + +fn provider_detail_lines(report: &jcode_usage_types::ProviderUsage) -> Vec { + let mut lines = Vec::new(); + lines.push("## Status".to_string()); + if let Some(error) = &report.error { + lines.push(format!("• Error: {}", error)); + lines.push("".to_string()); + lines.push("## Next steps".to_string()); + lines.push( + "• Re-run `/usage` to retry after credentials or network issues are fixed.".to_string(), + ); + if report.provider_name.to_lowercase().contains("openai") { + lines.push("• Use `/login openai` if the token needs refreshing.".to_string()); + } else if report.provider_name.to_lowercase().contains("anthropic") + || report.provider_name.to_lowercase().contains("claude") + { + lines.push("• Use `/login claude` if the token needs refreshing.".to_string()); + } + return lines; + } + + lines.push(format!("• {}", provider_status(report).label())); + if report.hard_limit_reached { + lines.push("• Hard limit reached.".to_string()); + } + + if !report.limits.is_empty() { + lines.push("".to_string()); + lines.push("## Limits".to_string()); + for limit in &report.limits { + let reset = limit + .resets_at + .as_deref() + .map(format_reset_time) + .map(|value| format!(" · resets in {}", value)) + .unwrap_or_default(); + lines.push(format!( + "• {} {}{}", + limit.name, + format_usage_bar(limit.usage_percent, 18), + reset + )); + } + } + + if !report.extra_info.is_empty() { + lines.push("".to_string()); + lines.push("## Details".to_string()); + for (key, value) in &report.extra_info { + lines.push(format!("• {}: {}", key, value)); + } + } + + if report.limits.is_empty() && report.extra_info.is_empty() { + lines.push("• No usage data available from this provider.".to_string()); + } + + lines +} + +fn truncate_with_ellipsis(input: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + let chars: Vec = input.chars().collect(); + if chars.len() <= width { + return input.to_string(); + } + if width <= 3 { + return ".".repeat(width); + } + let mut out: String = chars.into_iter().take(width - 3).collect(); + out.push_str("..."); + out +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let popup = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup[1])[1] +} + #[cfg(test)] mod tests { use super::*; @@ -131,4 +895,31 @@ mod tests { assert!(item_matches_filter(&item, "claude 85")); assert!(!item_matches_filter(&item, "openai")); } + + #[test] + fn provider_reports_build_searchable_overlay_items() { + let overlay = UsageOverlay::from_provider_reports( + &[jcode_usage_types::ProviderUsage { + provider_name: "Claude".to_string(), + limits: vec![jcode_usage_types::UsageLimit { + name: "5h".to_string(), + usage_percent: 92.0, + resets_at: Some("2020-01-01T00:00:00Z".to_string()), + }], + extra_info: vec![("plan".to_string(), "max".to_string())], + hard_limit_reached: false, + error: None, + }], + false, + 1, + 1, + false, + ); + + assert_eq!(overlay.selected_item_title(), Some("Claude")); + let details = overlay.selected_item_detail_text(); + assert!(details.contains("## Limits")); + assert!(details.contains("5h")); + assert!(details.contains("now")); + } } diff --git a/crates/jcode-tui-visual-debug/Cargo.toml b/crates/jcode-tui-visual-debug/Cargo.toml new file mode 100644 index 000000000..6290a9253 --- /dev/null +++ b/crates/jcode-tui-visual-debug/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "jcode-tui-visual-debug" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +name = "jcode_tui_visual_debug" +path = "src/lib.rs" + +[dependencies] +dirs = "5" +jcode-logging = { path = "../jcode-logging" } +ratatui = "0.30" +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["raw_value"] } diff --git a/crates/jcode-tui-visual-debug/src/lib.rs b/crates/jcode-tui-visual-debug/src/lib.rs new file mode 100644 index 000000000..17f7bec85 --- /dev/null +++ b/crates/jcode-tui-visual-debug/src/lib.rs @@ -0,0 +1,857 @@ +//! Visual Debug Infrastructure +//! +//! Captures TUI frame state for autonomous debugging by AI agents. +//! When enabled, writes detailed render information to a debug file +//! that can be read to understand visual bugs without seeing the terminal. + +use std::collections::VecDeque; +use std::fs::{self, File}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; + +use ratatui::layout::Rect; +use serde::Serialize; +use serde_json::Value; + +/// Global flag to enable visual debugging (set via /debug-visual command) +static VISUAL_DEBUG_ENABLED: AtomicBool = AtomicBool::new(false); +/// Global flag to enable overlay drawing +static VISUAL_DEBUG_OVERLAY: AtomicBool = AtomicBool::new(false); + +/// Maximum number of frames to keep in the ring buffer +const MAX_FRAMES: usize = 100; + +/// Global frame buffer +static FRAME_BUFFER: OnceLock> = OnceLock::new(); + +fn get_frame_buffer() -> &'static Mutex { + FRAME_BUFFER.get_or_init(|| Mutex::new(FrameBuffer::new())) +} + +/// A captured frame with all render context +#[derive(Debug, Clone, Serialize)] +pub struct FrameCapture { + /// Frame number (monotonically increasing) + pub frame_id: u64, + /// Timestamp when frame was rendered + pub timestamp: std::time::SystemTime, + /// Terminal dimensions + pub terminal_size: (u16, u16), + /// Layout areas computed for this frame + pub layout: LayoutCapture, + /// State snapshot at render time + pub state: StateSnapshot, + /// Any anomalies detected during rendering + pub anomalies: Vec, + /// The actual text content rendered to each area (stripped of ANSI) + pub rendered_text: RenderedText, + /// Mermaid image regions detected in wrapped content + pub image_regions: Vec, + /// Render timing information (milliseconds) + pub render_timing: Option, + /// Info widget placements and summary data + pub info_widgets: Option, + /// Render order for major phases + pub render_order: Vec, + /// Mermaid debug stats snapshot (if available) + pub mermaid: Option, + /// Side-panel debug snapshot, including live Mermaid utilization when available + pub side_panel: Option, + /// Markdown debug stats snapshot (if available) + pub markdown: Option, + /// Theme/palette snapshot (if available) + pub theme: Option, +} + +/// Captured layout computation +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct LayoutCapture { + /// Whether packed layout was used (vs scrolling) + pub use_packed: bool, + /// Estimated content height + pub estimated_content_height: usize, + /// Messages area + pub messages_area: Option, + /// Diagram area (pinned diagram pane) + pub diagram_area: Option, + /// Status line area + pub status_area: Option, + /// Queued messages area + pub queued_area: Option, + /// Input area + pub input_area: Option, + /// Input line count (before wrapping) + pub input_lines_raw: usize, + /// Input line count (after wrapping) + pub input_lines_wrapped: usize, + /// Margin widths for info widgets (per visible row) + pub margins: Option, + /// Info widget placements + pub widget_placements: Vec, +} + +/// Rect capture (serializable) +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)] +pub struct RectCapture { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +/// Margin widths captured for debug +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct MarginsCapture { + pub left_widths: Vec, + pub right_widths: Vec, + pub centered: bool, +} + +/// Info widget placement capture +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct WidgetPlacementCapture { + pub kind: String, + pub side: String, + pub rect: RectCapture, +} + +/// Render timing capture (milliseconds) +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct RenderTimingCapture { + pub prepare_ms: f32, + pub draw_ms: f32, + pub total_ms: f32, + pub messages_ms: Option, + pub widgets_ms: Option, +} + +/// Info widget summary capture +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct InfoWidgetSummary { + pub todos_total: usize, + pub todos_done: usize, + pub context_total_chars: Option, + pub context_limit: Option, + pub queue_mode: Option, + pub model: Option, + pub reasoning_effort: Option, + pub session_count: Option, + pub client_count: Option, + pub memory_total: Option, + pub memory_project: Option, + pub memory_global: Option, + pub memory_activity: Option, + pub swarm_session_count: Option, + pub swarm_member_count: Option, + pub swarm_subagent_status: Option, + pub background_running: Option, + pub background_tasks: Option, + pub usage_available: Option, + pub usage_provider: Option, + pub tokens_per_second: Option, + pub auth_method: Option, + pub upstream_provider: Option, +} + +/// Info widget capture (summary + placements) +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct InfoWidgetCapture { + pub summary: InfoWidgetSummary, + pub placements: Vec, +} + +impl From for RectCapture { + fn from(r: Rect) -> Self { + Self { + x: r.x, + y: r.y, + width: r.width, + height: r.height, + } + } +} + +/// State snapshot at render time +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct StateSnapshot { + pub is_processing: bool, + pub input_len: usize, + pub input_preview: String, + pub cursor_pos: usize, + pub scroll_offset: usize, + pub queued_count: usize, + pub message_count: usize, + pub streaming_text_len: usize, + pub has_suggestions: bool, + pub status: String, + pub diagram_mode: Option, + pub diagram_focus: bool, + pub diagram_index: usize, + pub diagram_count: usize, + pub diagram_scroll_x: i32, + pub diagram_scroll_y: i32, + pub diagram_pane_ratio: u8, + pub diagram_pane_enabled: bool, + pub diagram_pane_position: Option, + pub diagram_zoom: u8, +} + +/// Actual rendered text content +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct RenderedText { + /// Status line text (spinner, tokens, elapsed, etc.) + pub status_line: String, + /// Input area text (what the user is typing) + pub input_area: String, + /// Hint text shown above input (if any) + pub input_hint: Option, + /// Queued messages (messages waiting to be sent) + pub queued_messages: Vec, + /// Recent messages displayed (last few for context) + pub recent_messages: Vec, + /// Streaming text (if currently streaming) + pub streaming_text_preview: String, +} + +/// Mermaid image region capture +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct ImageRegionCapture { + pub hash: String, + pub abs_line_idx: usize, + pub height: u16, +} + +/// Captured message for debugging +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct MessageCapture { + pub role: String, + pub content_preview: String, + pub content_len: usize, +} + +/// Ring buffer of recent frames +struct FrameBuffer { + frames: VecDeque, + next_frame_id: u64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct VisualDebugMemoryProfile { + pub enabled: bool, + pub overlay_enabled: bool, + pub frames_in_buffer: usize, + pub max_frames: usize, + pub total_frames_captured: u64, + pub anomalous_frames_in_buffer: usize, + pub frame_json_estimate_bytes: usize, +} + +impl FrameBuffer { + fn new() -> Self { + Self { + frames: VecDeque::with_capacity(MAX_FRAMES), + next_frame_id: 0, + } + } + + fn push(&mut self, mut frame: FrameCapture) { + frame.frame_id = self.next_frame_id; + self.next_frame_id += 1; + + if self.frames.len() >= MAX_FRAMES { + self.frames.pop_front(); + } + self.frames.push_back(frame); + } + + fn recent(&self, count: usize) -> Vec<&FrameCapture> { + self.frames.iter().rev().take(count).collect() + } + + fn frames_with_anomalies(&self) -> Vec<&FrameCapture> { + self.frames + .iter() + .filter(|f| !f.anomalies.is_empty()) + .collect() + } +} + +/// Enable visual debugging +pub fn enable() { + VISUAL_DEBUG_ENABLED.store(true, Ordering::SeqCst); + jcode_logging::info("Visual debugging enabled"); +} + +/// Disable visual debugging +pub fn disable() { + VISUAL_DEBUG_ENABLED.store(false, Ordering::SeqCst); +} + +/// Enable or disable overlay drawing +pub fn set_overlay(enabled: bool) { + VISUAL_DEBUG_OVERLAY.store(enabled, Ordering::SeqCst); +} + +/// Check if overlay drawing is enabled +pub fn overlay_enabled() -> bool { + VISUAL_DEBUG_OVERLAY.load(Ordering::SeqCst) +} + +/// Check if visual debugging is enabled +pub fn is_enabled() -> bool { + VISUAL_DEBUG_ENABLED.load(Ordering::SeqCst) +} + +/// Record a frame capture (skips if identical to previous frame) +pub fn record_frame(frame: FrameCapture) { + if !is_enabled() { + return; + } + + let mut buffer = get_frame_buffer() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + // Skip duplicate frames - only capture when something changes + // Always capture frames with anomalies + if let Some(last) = buffer.frames.back() { + let dominated = frame.state == last.state + && frame.rendered_text == last.rendered_text + && frame.layout == last.layout + && frame.info_widgets == last.info_widgets + && frame.side_panel == last.side_panel + && frame.anomalies.is_empty(); + if dominated { + return; + } + } + + buffer.push(frame); +} + +/// Get the debug output path +fn debug_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("jcode") + .join("visual-debug.txt") +} + +/// Dump recent frames to the debug file +pub fn dump_to_file() -> std::io::Result { + let path = debug_path(); + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let buffer = get_frame_buffer() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut file = File::create(&path)?; + + writeln!(file, "=== JCODE VISUAL DEBUG DUMP ===")?; + writeln!(file, "Generated: {:?}", std::time::SystemTime::now())?; + writeln!(file, "Total frames captured: {}", buffer.next_frame_id)?; + writeln!(file, "Frames in buffer: {}", buffer.frames.len())?; + writeln!(file)?; + + // First, show frames with anomalies + let anomaly_frames = buffer.frames_with_anomalies(); + if !anomaly_frames.is_empty() { + writeln!( + file, + "=== FRAMES WITH ANOMALIES ({}) ===", + anomaly_frames.len() + )?; + for frame in anomaly_frames { + write_frame(&mut file, frame)?; + } + writeln!(file)?; + } + + // Then show recent frames + writeln!(file, "=== RECENT FRAMES (last 20) ===")?; + for frame in buffer.recent(20) { + write_frame(&mut file, frame)?; + } + + Ok(path) +} + +/// Return the most recent frame capture. +pub fn latest_frame() -> Option { + let buffer = get_frame_buffer().lock().ok()?; + buffer.frames.back().cloned() +} + +/// Return the most recent frame as a JSON string. +pub fn latest_frame_json() -> Option { + let frame = latest_frame()?; + serde_json::to_string_pretty(&frame).ok() +} + +/// Return the most recent frame as a normalized JSON string (for stable diffs). +/// Strips timestamps, UUIDs, session IDs, and other non-deterministic values. +pub fn latest_frame_json_normalized() -> Option { + let frame = latest_frame()?; + let normalized = normalize_frame(&frame); + serde_json::to_string_pretty(&normalized).ok() +} + +fn estimate_json_bytes(value: &T) -> usize { + serde_json::to_vec(value) + .map(|bytes| bytes.len()) + .unwrap_or(0) +} + +pub fn debug_memory_profile() -> VisualDebugMemoryProfile { + let Ok(buffer) = get_frame_buffer().lock() else { + return VisualDebugMemoryProfile { + enabled: is_enabled(), + overlay_enabled: overlay_enabled(), + max_frames: MAX_FRAMES, + ..VisualDebugMemoryProfile::default() + }; + }; + + VisualDebugMemoryProfile { + enabled: is_enabled(), + overlay_enabled: overlay_enabled(), + frames_in_buffer: buffer.frames.len(), + max_frames: MAX_FRAMES, + total_frames_captured: buffer.next_frame_id, + anomalous_frames_in_buffer: buffer + .frames + .iter() + .filter(|f| !f.anomalies.is_empty()) + .count(), + frame_json_estimate_bytes: buffer.frames.iter().map(estimate_json_bytes).sum(), + } +} + +/// Normalize a frame capture for stable comparisons. +/// Replaces timestamps, UUIDs, session IDs, and other volatile values with placeholders. +pub fn normalize_frame(frame: &FrameCapture) -> serde_json::Value { + let json = serde_json::to_value(frame).unwrap_or(serde_json::Value::Null); + normalize_json_value(json) +} + +/// Recursively normalize JSON values, replacing volatile content. +fn normalize_json_value(value: serde_json::Value) -> serde_json::Value { + use serde_json::Value; + + match value { + Value::String(s) => Value::String(normalize_string(&s)), + Value::Array(arr) => Value::Array(arr.into_iter().map(normalize_json_value).collect()), + Value::Object(map) => { + let mut new_map = serde_json::Map::new(); + for (k, v) in map { + // Skip timestamp fields entirely or normalize them + if k == "timestamp" || k == "created_at" || k == "updated_at" { + new_map.insert(k, Value::String("".to_string())); + } else if k == "frame_id" { + // Keep frame_id but note it's sequential + new_map.insert(k, v); + } else { + new_map.insert(k, normalize_json_value(v)); + } + } + Value::Object(new_map) + } + other => other, + } +} + +/// Normalize a string by replacing volatile patterns with placeholders. +fn normalize_string(s: &str) -> String { + use regex::Regex; + use std::sync::OnceLock; + + fn compile_regex(pattern: &str) -> Option { + match Regex::new(pattern) { + Ok(regex) => Some(regex), + Err(err) => { + jcode_logging::warn(&format!( + "visual_debug: failed to compile normalization regex: {}", + err + )); + None + } + } + } + + // Cached regex patterns for performance + static UUID_RE: OnceLock> = OnceLock::new(); + static SESSION_ID_RE: OnceLock> = OnceLock::new(); + static TIMESTAMP_RE: OnceLock> = OnceLock::new(); + static ISO_DATE_RE: OnceLock> = OnceLock::new(); + static DURATION_RE: OnceLock> = OnceLock::new(); + static PATH_RE: OnceLock> = OnceLock::new(); + static ELAPSED_RE: OnceLock> = OnceLock::new(); + static TOKENS_RE: OnceLock> = OnceLock::new(); + + let Some(uuid_re) = UUID_RE + .get_or_init(|| { + compile_regex( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + ) + }) + .as_ref() + else { + return s.to_string(); + }; + let Some(session_id_re) = SESSION_ID_RE + .get_or_init(|| compile_regex(r"session_[0-9a-zA-Z_]+")) + .as_ref() + else { + return s.to_string(); + }; + let Some(timestamp_re) = TIMESTAMP_RE + .get_or_init(|| compile_regex(r"\d{10,13}")) + .as_ref() + else { + return s.to_string(); + }; + let Some(iso_date_re) = ISO_DATE_RE + .get_or_init(|| compile_regex(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}")) + .as_ref() + else { + return s.to_string(); + }; + let Some(duration_re) = DURATION_RE + .get_or_init(|| compile_regex(r"\d+(\.\d+)?s")) + .as_ref() + else { + return s.to_string(); + }; + let Some(path_re) = PATH_RE + .get_or_init(|| compile_regex(r"/(?:home|Users)/[^/\s]+")) + .as_ref() + else { + return s.to_string(); + }; + let Some(elapsed_re) = ELAPSED_RE + .get_or_init(|| compile_regex(r"\d+m?\d*s")) + .as_ref() + else { + return s.to_string(); + }; + let Some(tokens_re) = TOKENS_RE + .get_or_init(|| compile_regex(r"\d+[kK]? tokens?")) + .as_ref() + else { + return s.to_string(); + }; + + let mut result = s.to_string(); + + // Replace in order of specificity (most specific first) + result = uuid_re.replace_all(&result, "").to_string(); + result = session_id_re + .replace_all(&result, "") + .to_string(); + result = iso_date_re.replace_all(&result, "").to_string(); + result = elapsed_re.replace_all(&result, "").to_string(); + result = tokens_re.replace_all(&result, "").to_string(); + result = duration_re.replace_all(&result, "").to_string(); + result = path_re.replace_all(&result, "").to_string(); + + // Only replace long timestamps that aren't part of other patterns + if result.len() < 20 { + result = timestamp_re.replace_all(&result, "").to_string(); + } + + result +} + +/// Compare two frames for semantic equality (ignoring volatile fields). +pub fn frames_equal_normalized(a: &FrameCapture, b: &FrameCapture) -> bool { + let norm_a = normalize_frame(a); + let norm_b = normalize_frame(b); + norm_a == norm_b +} + +fn write_frame(file: &mut File, frame: &FrameCapture) -> std::io::Result<()> { + writeln!(file, "--- Frame {} ---", frame.frame_id)?; + writeln!(file, "Time: {:?}", frame.timestamp)?; + writeln!( + file, + "Terminal: {}x{}", + frame.terminal_size.0, frame.terminal_size.1 + )?; + + // State + writeln!(file, "State:")?; + writeln!(file, " is_processing: {}", frame.state.is_processing)?; + writeln!(file, " input_len: {}", frame.state.input_len)?; + writeln!(file, " input_preview: {:?}", frame.state.input_preview)?; + writeln!(file, " cursor_pos: {}", frame.state.cursor_pos)?; + writeln!(file, " scroll_offset: {}", frame.state.scroll_offset)?; + writeln!(file, " queued_count: {}", frame.state.queued_count)?; + writeln!(file, " message_count: {}", frame.state.message_count)?; + writeln!( + file, + " streaming_text_len: {}", + frame.state.streaming_text_len + )?; + writeln!(file, " has_suggestions: {}", frame.state.has_suggestions)?; + writeln!(file, " status: {}", frame.state.status)?; + + // Layout + writeln!(file, "Layout:")?; + writeln!(file, " use_packed: {}", frame.layout.use_packed)?; + writeln!( + file, + " estimated_content_height: {}", + frame.layout.estimated_content_height + )?; + if let Some(r) = frame.layout.messages_area { + writeln!( + file, + " messages_area: ({}, {}) {}x{}", + r.x, r.y, r.width, r.height + )?; + } + if let Some(r) = frame.layout.status_area { + writeln!( + file, + " status_area: ({}, {}) {}x{}", + r.x, r.y, r.width, r.height + )?; + } + if let Some(r) = frame.layout.queued_area { + writeln!( + file, + " queued_area: ({}, {}) {}x{}", + r.x, r.y, r.width, r.height + )?; + } + if let Some(r) = frame.layout.input_area { + writeln!( + file, + " input_area: ({}, {}) {}x{}", + r.x, r.y, r.width, r.height + )?; + } + writeln!( + file, + " input_lines: {} raw, {} wrapped", + frame.layout.input_lines_raw, frame.layout.input_lines_wrapped + )?; + if let Some(margins) = &frame.layout.margins { + writeln!( + file, + " margins: centered={} left_rows={} right_rows={}", + margins.centered, + margins.left_widths.len(), + margins.right_widths.len() + )?; + } + if !frame.layout.widget_placements.is_empty() { + writeln!(file, " widget_placements:")?; + for placement in &frame.layout.widget_placements { + let r = placement.rect; + writeln!( + file, + " {} ({}) at ({}, {}) {}x{}", + placement.kind, placement.side, r.x, r.y, r.width, r.height + )?; + } + } + + // Rendered text + writeln!(file, "Rendered:")?; + writeln!(file, " status_line: {:?}", frame.rendered_text.status_line)?; + if let Some(hint) = &frame.rendered_text.input_hint { + writeln!(file, " input_hint: {:?}", hint)?; + } + writeln!(file, " input_area: {:?}", frame.rendered_text.input_area)?; + if !frame.rendered_text.queued_messages.is_empty() { + writeln!(file, " queued_messages:")?; + for (i, msg) in frame.rendered_text.queued_messages.iter().enumerate() { + writeln!(file, " [{}]: {:?}", i, msg)?; + } + } + if !frame.rendered_text.recent_messages.is_empty() { + writeln!(file, " recent_messages:")?; + for msg in &frame.rendered_text.recent_messages { + writeln!( + file, + " [{}] ({} chars): {:?}", + msg.role, msg.content_len, msg.content_preview + )?; + } + } + if !frame.rendered_text.streaming_text_preview.is_empty() { + writeln!( + file, + " streaming_text: {:?}", + frame.rendered_text.streaming_text_preview + )?; + } + if !frame.image_regions.is_empty() { + writeln!(file, " image_regions:")?; + for region in &frame.image_regions { + writeln!( + file, + " {} @{} (h={})", + region.hash, region.abs_line_idx, region.height + )?; + } + } + + // Render timing + if let Some(timing) = &frame.render_timing { + writeln!( + file, + "Timing: prepare={:.2}ms draw={:.2}ms total={:.2}ms messages={:?} widgets={:?}", + timing.prepare_ms, + timing.draw_ms, + timing.total_ms, + timing.messages_ms, + timing.widgets_ms + )?; + } + + // Info widget summary + if let Some(info) = &frame.info_widgets { + writeln!(file, "InfoWidgets:")?; + writeln!( + file, + " todos: {}/{} done, context_chars: {:?}, model: {:?}", + info.summary.todos_done, + info.summary.todos_total, + info.summary.context_total_chars, + info.summary.model + )?; + writeln!( + file, + " session_count: {:?}, client_count: {:?}, swarm_members: {:?}", + info.summary.session_count, info.summary.client_count, info.summary.swarm_member_count + )?; + } + + if !frame.render_order.is_empty() { + writeln!(file, "Render order:")?; + for step in &frame.render_order { + writeln!(file, " - {}", step)?; + } + } + + if let Some(mermaid) = &frame.mermaid { + writeln!(file, "Mermaid: {}", mermaid)?; + } + if let Some(side_panel) = &frame.side_panel { + writeln!(file, "Side panel: {}", side_panel)?; + } + if let Some(markdown) = &frame.markdown { + writeln!(file, "Markdown: {}", markdown)?; + } + if let Some(theme) = &frame.theme { + writeln!(file, "Theme: {}", theme)?; + } + + // Anomalies + if !frame.anomalies.is_empty() { + writeln!(file, "ANOMALIES:")?; + for anomaly in &frame.anomalies { + writeln!(file, " ⚠ {}", anomaly)?; + } + } + + writeln!(file)?; + Ok(()) +} + +/// Builder for constructing frame captures during rendering +#[derive(Default)] +pub struct FrameCaptureBuilder { + pub layout: LayoutCapture, + pub state: StateSnapshot, + pub rendered_text: RenderedText, + pub image_regions: Vec, + pub anomalies: Vec, + pub render_timing: Option, + pub info_widgets: Option, + pub render_order: Vec, + pub mermaid: Option, + pub side_panel: Option, + pub markdown: Option, + pub theme: Option, + terminal_size: (u16, u16), +} + +impl FrameCaptureBuilder { + pub fn new(width: u16, height: u16) -> Self { + Self { + terminal_size: (width, height), + ..Default::default() + } + } + + /// Record an anomaly detected during rendering + pub fn anomaly(&mut self, msg: impl Into) { + self.anomalies.push(msg.into()); + } + + /// Check a condition and record anomaly if false + pub fn check(&mut self, condition: bool, msg: impl Into) { + if !condition { + self.anomalies.push(msg.into()); + } + } + + /// Build the final frame capture + pub fn build(self) -> FrameCapture { + FrameCapture { + frame_id: 0, // Will be set by buffer + timestamp: std::time::SystemTime::now(), + terminal_size: self.terminal_size, + layout: self.layout, + state: self.state, + anomalies: self.anomalies, + rendered_text: self.rendered_text, + image_regions: self.image_regions, + render_timing: self.render_timing, + info_widgets: self.info_widgets, + render_order: self.render_order, + mermaid: self.mermaid, + side_panel: self.side_panel, + markdown: self.markdown, + theme: self.theme, + } + } +} + +/// Check for the specific alternate-send hint anomaly. +pub fn check_shift_enter_anomaly( + builder: &mut FrameCaptureBuilder, + is_processing: bool, + input_text: &str, + hint_shown: bool, +) { + // The hint should ONLY show when processing AND input is non-empty + let should_show = is_processing && !input_text.is_empty(); + + if hint_shown != should_show { + builder.anomaly(format!( + "alternate-send hint mismatch: shown={}, should_show={} (is_processing={}, input_len={})", + hint_shown, + should_show, + is_processing, + input_text.len() + )); + } + + // Also check if the hint text appears in the input itself (the bug!) + if input_text.to_lowercase().contains("shift") && input_text.to_lowercase().contains("enter") { + builder.anomaly(format!( + "INPUT CONTAINS 'shift'+'enter' - possible hint leak: {:?}", + input_text + )); + } +} diff --git a/crates/jcode-tui/Cargo.toml b/crates/jcode-tui/Cargo.toml index 8c8a4fdb0..d0950767d 100644 --- a/crates/jcode-tui/Cargo.toml +++ b/crates/jcode-tui/Cargo.toml @@ -75,6 +75,7 @@ jcode-tui-core = { path = "../jcode-tui-core" } jcode-tui-mermaid = { path = "../jcode-tui-mermaid" } jcode-tui-account-picker = { path = "../jcode-tui-account-picker" } jcode-tui-render = { path = "../jcode-tui-render" } +jcode-tui-visual-debug = { path = "../jcode-tui-visual-debug" } jcode-tui-session-picker = { path = "../jcode-tui-session-picker", features = ["serde"] } jcode-tui-style = { path = "../jcode-tui-style" } jcode-tui-tool-display = { path = "../jcode-tui-tool-display" } diff --git a/crates/jcode-tui/src/tui/account_picker.rs b/crates/jcode-tui/src/tui/account_picker.rs index 3e9052473..380aafff2 100644 --- a/crates/jcode-tui/src/tui/account_picker.rs +++ b/crates/jcode-tui/src/tui/account_picker.rs @@ -1,861 +1,13 @@ -use anyhow::Result; -use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph, Wrap}, -}; -use std::collections::HashMap; - pub use jcode_tui_account_picker::{ - AccountPickerCommand, AccountPickerItem, AccountPickerSummary, AccountProviderKind, -}; - -#[path = "account_picker_render.rs"] -mod render_support; -use render_support::{ - ActionSection, account_count_summary, account_is_active, action_icon, action_kind_badge, - action_kind_help, action_section, centered_rect, command_preview, compact_item_title, hotkey, - metric_span, provider_header_line, provider_style, truncate_with_ellipsis, + AccountPicker, AccountPickerCommand, AccountPickerItem, AccountPickerSummary, + AccountProviderKind, OverlayAction, }; -const PANEL_BG: Color = Color::Rgb(24, 28, 40); -const PANEL_BORDER: Color = Color::Rgb(90, 95, 110); -const PANEL_BORDER_ACTIVE: Color = Color::Rgb(120, 140, 190); -const SECTION_BORDER: Color = Color::Rgb(70, 78, 94); -const SELECTED_BG: Color = Color::Rgb(38, 42, 56); -const MUTED: Color = Color::Rgb(140, 146, 163); -const MUTED_DARK: Color = Color::Rgb(100, 106, 122); -const OVERLAY_PERCENT_X: u16 = 88; -const OVERLAY_PERCENT_Y: u16 = 74; - -#[derive(Debug, Clone)] -pub struct AccountPicker { - title: String, - items: Vec, - filtered: Vec, - selected: usize, - filter: String, - summary: Option, - last_action_list_area: Option, -} - -pub enum OverlayAction { - Continue, - Close, - Execute(AccountPickerCommand), -} - -impl AccountPicker { - pub fn new(title: impl Into, items: Vec) -> Self { - Self::with_summary(title, items, AccountPickerSummary::default()) - } - - pub fn debug_memory_profile(&self) -> serde_json::Value { - let items_estimate_bytes: usize = self.items.iter().map(estimate_item_bytes).sum(); - let filtered_estimate_bytes = self.filtered.capacity() * std::mem::size_of::(); - let filter_bytes = self.filter.capacity(); - let title_bytes = self.title.capacity(); - let summary_estimate_bytes = self - .summary - .as_ref() - .map(estimate_summary_bytes) - .unwrap_or(0); - let total_estimate_bytes = items_estimate_bytes - + filtered_estimate_bytes - + filter_bytes - + title_bytes - + summary_estimate_bytes; - - serde_json::json!({ - "items_count": self.items.len(), - "filtered_count": self.filtered.len(), - "selected": self.selected, - "title_bytes": title_bytes, - "filter_bytes": filter_bytes, - "summary_estimate_bytes": summary_estimate_bytes, - "items_estimate_bytes": items_estimate_bytes, - "filtered_estimate_bytes": filtered_estimate_bytes, - "total_estimate_bytes": total_estimate_bytes, - }) - } - - pub fn with_summary( - title: impl Into, - items: Vec, - summary: AccountPickerSummary, - ) -> Self { - let mut picker = Self { - title: title.into(), - items, - filtered: Vec::new(), - selected: 0, - filter: String::new(), - summary: Some(summary), - last_action_list_area: None, - }; - picker.apply_filter(); - picker - } - - fn selected_item(&self) -> Option<&AccountPickerItem> { - self.filtered - .get(self.selected) - .and_then(|idx| self.items.get(*idx)) - } - - fn visible_window_start(&self, available_items: usize) -> usize { - self.selected - .saturating_sub(available_items.saturating_sub(1).min(available_items / 2)) - } - - fn visible_index_for_action_row(&self, row: u16, list_height: u16) -> Option { - if self.filtered.is_empty() { - return None; - } - - let available_items = (list_height as usize).max(1); - let start = self.visible_window_start(available_items); - let end = (start + available_items).min(self.filtered.len()); - let mut current_provider: Option<&str> = None; - let mut rendered_row = 0u16; - - for visible_idx in start..end { - let item = &self.items[self.filtered[visible_idx]]; - if current_provider != Some(item.provider_id.as_str()) { - current_provider = Some(item.provider_id.as_str()); - if rendered_row == row { - return None; - } - rendered_row = rendered_row.saturating_add(1); - if rendered_row >= list_height { - return None; - } - } - - if rendered_row == row { - return Some(visible_idx); - } - rendered_row = rendered_row.saturating_add(1); - if rendered_row > row && rendered_row >= list_height { - return None; - } - } - - None - } - - fn apply_filter(&mut self) { - self.filtered = self - .items - .iter() - .enumerate() - .filter_map(|(idx, item)| { - jcode_tui_account_picker::item_matches_filter(item, &self.filter).then_some(idx) - }) - .collect(); - let provider_order = self.provider_order(); - self.filtered.sort_by(|left, right| { - let left_item = &self.items[*left]; - let right_item = &self.items[*right]; - - provider_order - .get(&left_item.provider_id) - .cmp(&provider_order.get(&right_item.provider_id)) - .then_with(|| action_section(left_item).cmp(&action_section(right_item))) - .then_with(|| left_item.title.cmp(&right_item.title)) - .then_with(|| left.cmp(right)) - }); - if self.selected >= self.filtered.len() { - self.selected = self.filtered.len().saturating_sub(1); - } - } - - fn provider_order(&self) -> HashMap { - let mut order = HashMap::new(); - let mut next = 0usize; - for item in &self.items { - if order.contains_key(&item.provider_id) { - continue; - } - let rank = if item.provider_id == "defaults" { - usize::MAX / 2 - } else { - let current = next; - next += 1; - current - }; - order.insert(item.provider_id.clone(), rank); - } - order - } - - fn filtered_provider_switch_count(&self, provider_id: &str) -> usize { - self.filtered - .iter() - .filter(|idx| { - let item = &self.items[**idx]; - item.provider_id == provider_id - && matches!(action_section(item), ActionSection::Switch) - }) - .count() - } - - fn filtered_provider_secondary_count(&self, provider_id: &str) -> usize { - self.filtered - .iter() - .filter(|idx| { - let item = &self.items[**idx]; - item.provider_id == provider_id - && !matches!(action_section(item), ActionSection::Switch) - }) - .count() - } - - fn select_prev_provider_group(&mut self) { - let Some(current_idx) = self.filtered.get(self.selected).copied() else { - return; - }; - let current_provider = self.items[current_idx].provider_id.as_str(); - let mut target = None; - - for pos in (0..self.selected).rev() { - let provider_id = self.items[self.filtered[pos]].provider_id.as_str(); - if provider_id != current_provider { - target = Some(pos); - break; - } - } - - let Some(mut pos) = target else { - return; - }; - let provider_id = self.items[self.filtered[pos]].provider_id.clone(); - while pos > 0 && self.items[self.filtered[pos - 1]].provider_id == provider_id { - pos -= 1; - } - self.selected = pos; - } - - fn select_next_provider_group(&mut self) { - let Some(current_idx) = self.filtered.get(self.selected).copied() else { - return; - }; - let current_provider = self.items[current_idx].provider_id.as_str(); - - for pos in (self.selected + 1)..self.filtered.len() { - let provider_id = self.items[self.filtered[pos]].provider_id.as_str(); - if provider_id != current_provider { - self.selected = pos; - break; - } - } - } - - fn provider_overview_line(&self) -> Line<'static> { - let mut seen = Vec::new(); - let mut stats: HashMap = HashMap::new(); - - for item in &self.items { - if matches!(item.provider_id.as_str(), "defaults" | "account-flow") { - continue; - } - if !stats.contains_key(&item.provider_id) { - seen.push(item.provider_id.clone()); - stats.insert( - item.provider_id.clone(), - (item.provider_label.clone(), 0, 0), - ); - } - if let Some((_, accounts, actions)) = stats.get_mut(&item.provider_id) { - if matches!(action_section(item), ActionSection::Switch) { - *accounts += 1; - } else { - *actions += 1; - } - } - } - - let mut spans = vec![Span::styled("Providers ", Style::default().fg(MUTED_DARK))]; - let mut first = true; - for provider_id in seen { - let Some((label, accounts, actions)) = stats.get(&provider_id) else { - continue; - }; - if !first { - spans.push(Span::styled(" | ", Style::default().fg(MUTED_DARK))); - } - first = false; - let summary = if *accounts > 0 { - format!("{} {}", label, account_count_summary(*accounts)) - } else { - format!( - "{} {} control{}", - label, - actions, - if *actions == 1 { "" } else { "s" } - ) - }; - spans.push(Span::styled(summary, provider_style(&provider_id))); - } - if first { - spans.push(Span::styled( - "No providers available", - Style::default().fg(MUTED), - )); - } - Line::from(spans) - } - - pub fn handle_overlay_key( - &mut self, - code: KeyCode, - modifiers: KeyModifiers, - ) -> Result { - match code { - KeyCode::Esc => { - if !self.filter.is_empty() { - self.filter.clear(); - self.apply_filter(); - return Ok(OverlayAction::Continue); - } - return Ok(OverlayAction::Close); - } - KeyCode::Char('q') if !modifiers.contains(KeyModifiers::CONTROL) => { - return Ok(OverlayAction::Close); - } - KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { - return Ok(OverlayAction::Close); - } - KeyCode::Up | KeyCode::Char('k') => { - self.selected = self.selected.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - let max = self.filtered.len().saturating_sub(1); - self.selected = (self.selected + 1).min(max); - } - KeyCode::Left => { - self.select_prev_provider_group(); - } - KeyCode::Right => { - self.select_next_provider_group(); - } - KeyCode::PageUp | KeyCode::Char('K') => { - self.selected = self.selected.saturating_sub(6); - } - KeyCode::PageDown | KeyCode::Char('J') => { - let max = self.filtered.len().saturating_sub(1); - self.selected = (self.selected + 6).min(max); - } - KeyCode::Home | KeyCode::Char('g') => { - self.selected = 0; - } - KeyCode::End | KeyCode::Char('G') => { - self.selected = self.filtered.len().saturating_sub(1); - } - KeyCode::Backspace => { - if self.filter.pop().is_some() { - self.apply_filter(); - } - } - KeyCode::Enter => { - if let Some(item) = self.selected_item() { - return Ok(OverlayAction::Execute(item.command.clone())); - } - return Ok(OverlayAction::Close); - } - KeyCode::Char(c) - if !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) => - { - self.filter.push(c); - self.apply_filter(); - } - _ => {} - } - Ok(OverlayAction::Continue) - } - - pub fn handle_overlay_mouse(&mut self, mouse: MouseEvent) { - let Some(list_inner) = self.last_action_list_area else { - return; - }; - let inside_list = mouse.column >= list_inner.x - && mouse.column < list_inner.x.saturating_add(list_inner.width) - && mouse.row >= list_inner.y - && mouse.row < list_inner.y.saturating_add(list_inner.height); - - match mouse.kind { - MouseEventKind::ScrollUp if inside_list => { - self.selected = self.selected.saturating_sub(1); - } - MouseEventKind::ScrollDown if inside_list => { - let max = self.filtered.len().saturating_sub(1); - self.selected = (self.selected + 1).min(max); - } - MouseEventKind::Down(MouseButton::Left) if inside_list => { - let row = mouse.row.saturating_sub(list_inner.y); - if let Some(visible_idx) = self.visible_index_for_action_row(row, list_inner.height) - { - self.selected = visible_idx; - } - } - _ => {} - } - } - - pub fn render(&mut self, frame: &mut Frame) { - let area = centered_rect(OVERLAY_PERCENT_X, OVERLAY_PERCENT_Y, frame.area()); - - let block = Block::default() - .title(format!(" {} ", self.title)) - .title_bottom(Line::from(vec![ - hotkey(" Enter "), - Span::styled(" run ", Style::default().fg(MUTED_DARK)), - hotkey(" Up/Down "), - Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), - hotkey(" Click "), - Span::styled(" select ", Style::default().fg(MUTED_DARK)), - hotkey(" type "), - Span::styled(" filter ", Style::default().fg(MUTED_DARK)), - hotkey(" Esc "), - Span::styled(" clear / close ", Style::default().fg(MUTED_DARK)), - ])) - .borders(Borders::ALL) - .border_style(Style::default().fg(PANEL_BORDER)); - frame.render_widget(block, area); - - let inner = Rect { - x: area.x + 1, - y: area.y + 1, - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(2), - }; - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(7), - Constraint::Min(10), - Constraint::Length(2), - ]) - .split(inner); - - self.render_header(frame, rows[0]); - - let body = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) - .split(rows[1]); - - self.render_action_list(frame, body[0]); - self.render_detail_pane(frame, body[1]); - - let footer = Paragraph::new(Line::from(vec![ - Span::styled("Focus ", Style::default().fg(MUTED_DARK)), - Span::styled( - "saved accounts stay surfaced here; click actions to focus them, use Left/Right to jump provider groups, or use `/account settings` for the full text view.", - Style::default().fg(MUTED), - ), - ])); - frame.render_widget(footer, rows[2]); - } - - fn render_header(&self, frame: &mut Frame, area: Rect) { - let block = Block::default() - .title(Span::styled( - " Overview ", - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(SECTION_BORDER)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let lines = vec![ - Line::from(vec![ - Span::styled("Filter ", Style::default().fg(MUTED_DARK)), - Span::styled( - if self.filter.is_empty() { - "type provider or account name".to_string() - } else { - self.filter.clone() - }, - if self.filter.is_empty() { - Style::default().fg(Color::Gray).italic() - } else { - Style::default().fg(Color::White) - }, - ), - Span::styled( - format!(" - {} results", self.filtered.len()), - Style::default().fg(MUTED_DARK), - ), - ]), - self.provider_overview_line(), - self.summary_line(), - self.defaults_line(), - ]; - - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); - } - - fn render_action_list(&mut self, frame: &mut Frame, area: Rect) { - let title = if self.filtered.is_empty() { - " Providers & Quick Actions ".to_string() - } else { - format!( - " Providers & Quick Actions ({}/{}) ", - self.selected + 1, - self.filtered.len() - ) - }; - let block = Block::default() - .title(Span::styled( - title, - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(PANEL_BORDER_ACTIVE)); - let list_inner = block.inner(area); - frame.render_widget(block, area); - self.last_action_list_area = Some(list_inner); - - let available_items = (list_inner.height as usize).max(1); - let start = self.visible_window_start(available_items); - let end = (start + available_items).min(self.filtered.len()); - - let mut lines = Vec::new(); - if self.filtered.is_empty() { - lines.push(Line::from(Span::styled( - "No matching account or provider actions.", - Style::default().fg(Color::Gray).italic(), - ))); - lines.push(Line::from(Span::styled( - "Try `openai`, `claude`, an account label, `login`, or `default`.", - Style::default().fg(MUTED), - ))); - } else { - let mut current_provider: Option<&str> = None; - for visible_idx in start..end { - let idx = self.filtered[visible_idx]; - let item = &self.items[idx]; - let selected = visible_idx == self.selected; - - if current_provider != Some(item.provider_id.as_str()) { - current_provider = Some(item.provider_id.as_str()); - lines.push(provider_header_line( - &item.provider_label, - self.filtered_provider_switch_count(&item.provider_id), - self.filtered_provider_secondary_count(&item.provider_id), - &item.provider_id, - )); - } - - let row_style = if selected { - Style::default().bg(SELECTED_BG) - } else { - Style::default() - }; - let (icon, icon_color) = action_icon(item); - let title = compact_item_title(item); - let meta_width = list_inner.width.saturating_sub(16) as usize; - let meta = truncate_with_ellipsis(&item.subtitle, meta_width); - lines.push(Line::from(vec![ - Span::styled( - if selected { "> " } else { " " }, - row_style.fg(Color::White), - ), - Span::styled(format!("{} ", icon), row_style.fg(icon_color).bold()), - Span::styled( - truncate_with_ellipsis(&title, 22), - row_style.fg(Color::White), - ), - Span::styled(" - ", row_style.fg(MUTED_DARK)), - Span::styled(meta, row_style.fg(MUTED)), - ])); - } - } - - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), list_inner); - } - - fn render_detail_pane(&self, frame: &mut Frame, area: Rect) { - let title = self - .selected_item() - .map(|item| format!(" {} ", item.provider_label)) - .unwrap_or_else(|| " Details ".to_string()); - let block = Block::default() - .title(Span::styled( - title, - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(SECTION_BORDER)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let Some(item) = self.selected_item() else { - frame.render_widget( - Paragraph::new("No action selected").style(Style::default().fg(Color::DarkGray)), - inner, - ); - return; - }; - - let provider_items: Vec<&AccountPickerItem> = self - .items - .iter() - .filter(|candidate| candidate.provider_id == item.provider_id) - .collect(); - let mut account_items: Vec<&AccountPickerItem> = provider_items - .iter() - .copied() - .filter(|candidate| matches!(action_section(candidate), ActionSection::Switch)) - .collect(); - account_items.sort_by(|left, right| { - account_is_active(right) - .cmp(&account_is_active(left)) - .then_with(|| compact_item_title(left).cmp(&compact_item_title(right))) - }); - let mut secondary_items: Vec<&AccountPickerItem> = provider_items - .iter() - .copied() - .filter(|candidate| !matches!(action_section(candidate), ActionSection::Switch)) - .filter(|candidate| candidate.title != item.title) - .collect(); - secondary_items.sort_by(|left, right| { - action_section(left) - .cmp(&action_section(right)) - .then_with(|| compact_item_title(left).cmp(&compact_item_title(right))) - }); - secondary_items.truncate(6); - let (kind_label, kind_color) = action_kind_badge(&item.command); - - let mut lines = vec![ - Line::from(vec![ - Span::styled("Provider ", Style::default().fg(MUTED_DARK)), - Span::styled( - item.provider_label.clone(), - provider_style(&item.provider_id), - ), - ]), - Line::from(vec![ - Span::styled("Saved accounts ", Style::default().fg(MUTED_DARK)), - Span::styled( - account_count_summary(account_items.len()), - Style::default().fg(Color::White).bold(), - ), - ]), - Line::from(""), - Line::from(vec![Span::styled( - "Quick switch", - Style::default().fg(MUTED_DARK).bold(), - )]), - ]; - - if account_items.is_empty() { - lines.push(Line::from(vec![Span::styled( - "No saved accounts for this provider yet.", - Style::default().fg(MUTED), - )])); - } else { - for account in &account_items { - let is_selected = account.title == item.title; - let bullet = if account_is_active(account) { "*" } else { "o" }; - let note = if is_selected { " [selected]" } else { "" }; - lines.push(Line::from(vec![ - Span::styled( - format!("{} ", bullet), - Style::default().fg(if account_is_active(account) { - Color::Rgb(110, 214, 158) - } else { - MUTED_DARK - }), - ), - Span::styled( - compact_item_title(account), - Style::default().fg(Color::White).bold(), - ), - Span::styled( - note.to_string(), - Style::default().fg(Color::Rgb(170, 210, 255)), - ), - ])); - lines.push(Line::from(vec![Span::styled( - format!( - " {}", - truncate_with_ellipsis( - &account.subtitle, - inner.width.saturating_sub(3) as usize, - ) - ), - Style::default().fg(MUTED), - )])); - } - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Selected action", - Style::default().fg(MUTED_DARK).bold(), - )])); - lines.push(Line::from(vec![ - Span::styled(kind_label, Style::default().fg(kind_color).bold()), - Span::styled(" - ", Style::default().fg(MUTED_DARK)), - Span::styled(item.title.clone(), Style::default().fg(Color::White).bold()), - ])); - lines.push(Line::from(vec![Span::styled( - item.subtitle.clone(), - Style::default().fg(MUTED), - )])); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Runs", - Style::default().fg(MUTED_DARK).bold(), - )])); - lines.push(Line::from(vec![Span::styled( - command_preview(&item.command), - Style::default().fg(Color::White), - )])); - lines.push(Line::from(vec![Span::styled( - action_kind_help(&item.command), - Style::default().fg(MUTED), - )])); - - if !secondary_items.is_empty() { - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Other controls", - Style::default().fg(MUTED_DARK).bold(), - )])); - for related in secondary_items { - lines.push(Line::from(vec![ - Span::styled("- ", Style::default().fg(MUTED_DARK)), - Span::styled( - compact_item_title(related), - Style::default().fg(Color::White), - ), - ])); - } - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Press Enter to run this action.", - Style::default().fg(Color::Rgb(170, 210, 255)), - )])); - - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); - } - - fn summary_line(&self) -> Line<'static> { - if let Some(summary) = &self.summary { - let mut spans = vec![ - metric_span("ready", summary.ready_count, Color::Rgb(110, 214, 158)), - Span::raw(" "), - metric_span( - "attention", - summary.attention_count, - Color::Rgb(255, 192, 120), - ), - Span::raw(" "), - metric_span("setup", summary.setup_count, Color::Rgb(160, 168, 188)), - Span::raw(" "), - metric_span( - "providers", - summary.provider_count, - Color::Rgb(140, 176, 255), - ), - ]; - if summary.named_account_count > 0 { - spans.push(Span::raw(" ")); - spans.push(metric_span( - "accounts", - summary.named_account_count, - Color::Rgb(196, 170, 255), - )); - } - return Line::from(spans); - } - - Line::from(vec![Span::styled( - format!("{} actions available", self.filtered.len()), - Style::default().fg(MUTED), - )]) - } - - fn defaults_line(&self) -> Line<'static> { - let Some(summary) = &self.summary else { - return Line::from(vec![Span::styled( - "Type to narrow actions by provider, account label, or setting.", - Style::default().fg(MUTED), - )]); - }; - - let provider = summary.default_provider.as_deref().unwrap_or("auto"); - let model = summary - .default_model - .as_deref() - .unwrap_or("provider default"); - - Line::from(vec![ - Span::styled("Defaults ", Style::default().fg(MUTED_DARK)), - Span::styled("provider ", Style::default().fg(MUTED_DARK)), - Span::styled(provider.to_string(), Style::default().fg(Color::White)), - Span::styled(" - model ", Style::default().fg(MUTED_DARK)), - Span::styled(model.to_string(), Style::default().fg(Color::White)), - ]) - } -} - -fn estimate_optional_string_bytes(value: &Option) -> usize { - value.as_ref().map(|value| value.capacity()).unwrap_or(0) -} - -fn estimate_command_bytes(command: &AccountPickerCommand) -> usize { - match command { - AccountPickerCommand::SubmitInput(value) => value.capacity(), - AccountPickerCommand::OpenAccountCenter { provider_filter } - | AccountPickerCommand::OpenAddReplaceFlow { provider_filter } => { - estimate_optional_string_bytes(provider_filter) - } - AccountPickerCommand::PromptValue { - prompt, - command_prefix, - empty_value, - status_notice, - } => { - prompt.capacity() - + command_prefix.capacity() - + estimate_optional_string_bytes(empty_value) - + status_notice.capacity() - } - AccountPickerCommand::Switch { label, .. } - | AccountPickerCommand::Login { label, .. } - | AccountPickerCommand::Remove { label, .. } => label.capacity(), - AccountPickerCommand::PromptNew { .. } => 0, - } -} - -fn estimate_item_bytes(item: &AccountPickerItem) -> usize { - item.provider_id.capacity() - + item.provider_label.capacity() - + item.title.capacity() - + item.subtitle.capacity() - + estimate_command_bytes(&item.command) -} - -fn estimate_summary_bytes(summary: &AccountPickerSummary) -> usize { - estimate_optional_string_bytes(&summary.default_provider) - + estimate_optional_string_bytes(&summary.default_model) -} - #[cfg(test)] mod tests { use super::*; - use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph}; + use crossterm::event::{KeyCode, KeyModifiers}; + use ratatui::{Terminal, backend::TestBackend}; fn buffer_to_text(buffer: &ratatui::buffer::Buffer) -> String { let area = buffer.area; @@ -877,12 +29,6 @@ mod tests { if tokens.is_empty() { return true; } - - // The account picker renders a list and a detail panel side by side. Long - // action text can wrap in the left column while unrelated right-column - // text occupies the same terminal rows, so the expected prose is not - // always contiguous in the raw buffer. Verify the expected tokens still - // appear in order. let mut start = 0; for token in tokens { let Some(offset) = rendered[start..].find(token) else { @@ -893,201 +39,6 @@ mod tests { true } - #[test] - fn test_account_picker_preserves_underlying_background_outside_panels() { - let mut picker = AccountPicker::new( - " Accounts ", - vec![AccountPickerItem::action( - "openai", - "OpenAI", - "Add account", - "Start login flow", - AccountPickerCommand::SubmitInput("/account openai add default".to_string()), - )], - ); - - let backend = TestBackend::new(40, 12); - let mut terminal = Terminal::new(backend).expect("failed to create terminal"); - terminal - .draw(|frame| { - let area = frame.area(); - let fill = vec![Line::from("X".repeat(area.width as usize)); area.height as usize]; - frame.render_widget(Paragraph::new(fill), area); - picker.render(frame); - }) - .expect("draw failed"); - - let overlay = centered_rect( - OVERLAY_PERCENT_X, - OVERLAY_PERCENT_Y, - Rect::new(0, 0, 40, 12), - ); - let probe = &terminal.backend().buffer()[(overlay.x + overlay.width - 3, overlay.y + 2)]; - assert_eq!(probe.symbol(), "X"); - assert_ne!(probe.bg, Color::Rgb(18, 21, 30)); - } - - #[test] - fn test_account_picker_mouse_click_selects_visible_action_after_group_header() { - let mut picker = AccountPicker::new( - " Accounts ", - vec![ - AccountPickerItem::action( - "openai", - "OpenAI", - "Provider settings", - "configured", - AccountPickerCommand::SubmitInput("/account openai settings".to_string()), - ), - AccountPickerItem::action( - "openai", - "OpenAI", - "Login / refresh", - "OAuth", - AccountPickerCommand::SubmitInput("/account openai login".to_string()), - ), - ], - ); - - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).expect("failed to create terminal"); - terminal - .draw(|frame| picker.render(frame)) - .expect("draw failed"); - - let list_area = picker - .last_action_list_area - .expect("render should record action list area"); - - let initially_selected = picker.selected; - picker.handle_overlay_mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: list_area.x + 1, - row: list_area.y, - modifiers: KeyModifiers::empty(), - }); - assert_eq!( - picker.selected, initially_selected, - "provider group header rows should not be selectable" - ); - - let expected_first_action = picker.items[picker.filtered[0]].title.clone(); - // Row 0 is the provider group header; row 1 is the first sorted action. - picker.handle_overlay_mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: list_area.x + 1, - row: list_area.y + 1, - modifiers: KeyModifiers::empty(), - }); - - assert_eq!( - picker.selected_item().map(|item| item.title.as_str()), - Some(expected_first_action.as_str()) - ); - } - - #[test] - fn test_prompt_value_command_preview_shows_placeholder() { - let preview = command_preview(&AccountPickerCommand::PromptValue { - prompt: "Enter default model".to_string(), - command_prefix: "/account default-model".to_string(), - empty_value: Some("clear".to_string()), - status_notice: "editing".to_string(), - }); - - assert!(preview.contains("/account default-model ")); - assert!(preview.contains("clear")); - } - - #[test] - fn test_account_picker_sorts_switches_before_settings() { - let picker = AccountPicker::new( - " Accounts ", - vec![ - AccountPickerItem::action( - "openai", - "OpenAI", - "Provider settings", - "configured", - AccountPickerCommand::SubmitInput("/account openai settings".to_string()), - ), - AccountPickerItem::action( - "openai", - "OpenAI", - "Switch account `work`", - "user@example.com - valid - active", - AccountPickerCommand::SubmitInput("/account openai switch work".to_string()), - ), - AccountPickerItem::action( - "defaults", - "Global", - "Default provider", - "Current: auto", - AccountPickerCommand::PromptValue { - prompt: "provider".to_string(), - command_prefix: "/account default-provider".to_string(), - empty_value: Some("auto".to_string()), - status_notice: "editing".to_string(), - }, - ), - ], - ); - - let ordered_titles: Vec = picker - .filtered - .iter() - .map(|idx| picker.items[*idx].title.clone()) - .collect(); - - assert_eq!(ordered_titles[0], "Switch account `work`"); - assert_eq!(ordered_titles[1], "Provider settings"); - assert_eq!(ordered_titles[2], "Default provider"); - } - - #[test] - fn test_account_picker_left_right_jump_by_provider_group() { - let mut picker = AccountPicker::new( - " Accounts ", - vec![ - AccountPickerItem::action( - "claude", - "Claude", - "Switch account `work`", - "a@example.com - valid - active", - AccountPickerCommand::SubmitInput("/account claude switch work".to_string()), - ), - AccountPickerItem::action( - "claude", - "Claude", - "Provider settings", - "configured", - AccountPickerCommand::SubmitInput("/account claude settings".to_string()), - ), - AccountPickerItem::action( - "openai", - "OpenAI", - "Switch account `default`", - "b@example.com - valid - active", - AccountPickerCommand::SubmitInput("/account openai switch default".to_string()), - ), - ], - ); - - picker.selected = 1; - let _ = picker.handle_overlay_key(KeyCode::Right, KeyModifiers::empty()); - assert_eq!( - picker.items[picker.filtered[picker.selected]].provider_id, - "openai" - ); - - let _ = picker.handle_overlay_key(KeyCode::Left, KeyModifiers::empty()); - assert_eq!( - picker.items[picker.filtered[picker.selected]].provider_id, - "claude" - ); - assert_eq!(picker.selected, 0); - } - #[test] fn account_picker_catalog_state_space_renders_and_executes_every_provider_action() { let providers = crate::provider_catalog::login_providers(); diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 447d02ecf..5343f3bc0 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -340,28 +340,6 @@ pub enum ProcessingStatus { RunningTool(String), } -/// Live "collapse the current reasoning" animation state. -/// -/// In `current` reasoning-display mode the model's reasoning streams live as -/// dim+italic lines, then must disappear once the answer commits or a tool runs. -/// Instead of deleting every reasoning line in a single frame (a jarring upward -/// jump), the closed reasoning block is moved into a dedicated `"reasoning"` -/// display message that height-collapses toward a one-line summary over a short -/// ease-out, leaving a `▸ thought for Xs` trace behind. -#[derive(Clone, Debug)] -pub(crate) struct ReasoningCollapse { - /// Index into `display_messages` of the `"reasoning"` message being collapsed. - pub(crate) msg_index: usize, - /// One-line dim summary the block collapses down to (markup for - /// "▸ thought for Xs"), always shown at the top of the message. - pub(crate) summary_markup: String, - /// Per-line dim+italic markup for each reasoning line, in order. The block - /// shrinks by dropping leading lines until only `summary_markup` remains. - pub(crate) line_markups: Vec, - /// When the collapse animation started. - pub(crate) started_at: Instant, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum RemoteStartupPhase { StartingServer, @@ -566,6 +544,99 @@ struct CommandCandidatesCache { candidates: Vec<(String, &'static str)>, } +/// Session-wide token and cache accounting accumulated across all turns. +/// +/// Grouped out of [`App`] to keep the cohesive token/cache totals together. The +/// `total_*` fields accumulate over the whole session; the `last_*` fields hold +/// the most recently reported per-turn values used for cache TTL display. +#[derive(Clone, Debug, Default)] +struct TokenAccounting { + // Total session token usage (accumulated across all turns) + total_input_tokens: u64, + total_output_tokens: u64, + // Total session KV cache usage for turns where the provider reported cache telemetry. + total_cache_reported_input_tokens: u64, + total_cache_read_tokens: u64, + total_cache_creation_tokens: u64, + total_cache_optimal_input_tokens: u64, + last_cache_reported_input_tokens: Option, + last_cache_read_tokens: Option, + last_cache_creation_tokens: Option, + last_cache_optimal_input_tokens: Option, + cache_next_optimal_input_tokens: Option, +} + +/// KV cache baseline tracking and per-turn cache-miss attribution. +/// +/// Grouped out of [`App`]. The baseline and pending-request fields drive cache +/// telemetry recording; the turn/call indices and miss samples feed the cache +/// hit/miss attribution surfaced in the info widget. +#[derive(Clone, Debug, Default)] +struct KvCacheState { + kv_cache_baseline: Option, + pending_kv_cache_request: Option, + current_api_usage_recorded: bool, + kv_cache_turn_number: Option, + kv_cache_turn_call_index: u16, + kv_cache_miss_samples: Vec, +} + +/// Live streaming/turn progress: streamed text, per-turn token counts, and the +/// tokens-per-second tracking state. +/// +/// Grouped out of [`App`]. These fields are reset/updated as a unit each turn, +/// so keeping them together clarifies the streaming lifecycle. +#[derive(Clone, Debug, Default)] +struct StreamingProgress { + streaming_text: String, + // Live token usage (per turn) + streaming_input_tokens: u64, + streaming_output_tokens: u64, + streaming_cache_read_tokens: Option, + streaming_cache_creation_tokens: Option, + // Accurate TPS tracking: counts model output generation time, not tool execution. + /// Set while the provider is generating output tokens (text, reasoning, or tool-call JSON). + streaming_tps_start: Option, + /// Accumulated model-output generation time across agentic loop iterations. + streaming_tps_elapsed: Duration, + /// Whether incoming provider output-token deltas should contribute to TPS. + /// + /// This is enabled while an API call has generated model output, and can stay enabled + /// briefly after generation ends so late final usage snapshots still count. + streaming_tps_collect_output: bool, + /// Accumulated output tokens across all API calls in a turn. + /// + /// Providers may emit repeated cumulative usage snapshots for a single API call, + /// so we accumulate per-call deltas to avoid double counting. + streaming_total_output_tokens: u64, + /// Latest provider output-token snapshot used for TPS display. + /// + /// We update this only when newly generated output tokens are observed. That keeps the + /// displayed TPS anchored to the latest real token sample instead of decaying on every + /// redraw while no new usage data has arrived. + streaming_tps_observed_output_tokens: u64, + /// Streaming-only elapsed time corresponding to streaming_tps_observed_output_tokens. + streaming_tps_observed_elapsed: Duration, +} + +/// Accumulated session cost and cached per-model pricing. +/// +/// Grouped out of [`App`]. `total_cost` accrues across the session; the cached +/// price fields memoize the active model's pricing so they are re-resolved only +/// when `cached_price_model` no longer matches the current model. +#[derive(Clone, Debug, Default)] +struct CostState { + // Total cost in USD (for API-key providers) + total_cost: f32, + // Cached pricing (input $/1M tokens, output $/1M tokens) + cached_prompt_price: Option, + cached_completion_price: Option, + // Cached cache-read pricing ($/1M tokens), when known for the active model. + cached_cache_read_price: Option, + // Model the cached_*_price values were resolved for, so we re-resolve on switch. + cached_price_model: Option, +} + /// State for an in-progress OAuth/API-key login flow triggered by `/login`. /// TUI Application state pub struct App { @@ -588,51 +659,25 @@ pub struct App { auto_scroll_paused: bool, active_skill: Option, is_processing: bool, - streaming_text: String, + // Live streaming/turn progress (text, per-turn tokens, TPS tracking). + streaming: StreamingProgress, should_quit: bool, // Message queueing queued_messages: Vec, hidden_queued_system_messages: Vec, current_turn_system_reminder: Option, - // Live token usage (per turn) - streaming_input_tokens: u64, - streaming_output_tokens: u64, - streaming_cache_read_tokens: Option, - streaming_cache_creation_tokens: Option, // Upstream provider (e.g., which provider OpenRouter routed to) upstream_provider: Option, // Active stream connection type (websocket/https/etc.) connection_type: Option, // Provider-supplied human-readable transport detail for the current stream status_detail: Option, - // Total session token usage (accumulated across all turns) - total_input_tokens: u64, - total_output_tokens: u64, - // Total session KV cache usage for turns where the provider reported cache telemetry. - total_cache_reported_input_tokens: u64, - total_cache_read_tokens: u64, - total_cache_creation_tokens: u64, - total_cache_optimal_input_tokens: u64, - last_cache_reported_input_tokens: Option, - last_cache_read_tokens: Option, - last_cache_creation_tokens: Option, - last_cache_optimal_input_tokens: Option, - cache_next_optimal_input_tokens: Option, - kv_cache_baseline: Option, - pending_kv_cache_request: Option, - current_api_usage_recorded: bool, - kv_cache_turn_number: Option, - kv_cache_turn_call_index: u16, - kv_cache_miss_samples: Vec, - // Total cost in USD (for API-key providers) - total_cost: f32, - // Cached pricing (input $/1M tokens, output $/1M tokens) - cached_prompt_price: Option, - cached_completion_price: Option, - // Cached cache-read pricing ($/1M tokens), when known for the active model. - cached_cache_read_price: Option, - // Model the cached_*_price values were resolved for, so we re-resolve on switch. - cached_price_model: Option, + // Session-wide token + cache accounting (accumulated across all turns). + token_accounting: TokenAccounting, + // KV cache baseline tracking + per-turn miss attribution. + kv_cache: KvCacheState, + // Accumulated session cost + cached per-model pricing. + cost: CostState, // Context limit tracking (for compaction warning) context_limit: u64, context_warning_shown: bool, @@ -649,29 +694,6 @@ pub struct App { remote_resume_activity: Option, // Reload reconnect is waiting for server history before deciding whether to continue. pending_reload_reconnect_status: Option, - // Accurate TPS tracking: counts model output generation time, not tool execution. - /// Set while the provider is generating output tokens (text, reasoning, or tool-call JSON). - streaming_tps_start: Option, - /// Accumulated model-output generation time across agentic loop iterations. - streaming_tps_elapsed: Duration, - /// Whether incoming provider output-token deltas should contribute to TPS. - /// - /// This is enabled while an API call has generated model output, and can stay enabled - /// briefly after generation ends so late final usage snapshots still count. - streaming_tps_collect_output: bool, - /// Accumulated output tokens across all API calls in a turn. - /// - /// Providers may emit repeated cumulative usage snapshots for a single API call, - /// so we accumulate per-call deltas to avoid double counting. - streaming_total_output_tokens: u64, - /// Latest provider output-token snapshot used for TPS display. - /// - /// We update this only when newly generated output tokens are observed. That keeps the - /// displayed TPS anchored to the latest real token sample instead of decaying on every - /// redraw while no new usage data has arrived. - streaming_tps_observed_output_tokens: u64, - /// Streaming-only elapsed time corresponding to streaming_tps_observed_output_tokens. - streaming_tps_observed_elapsed: Duration, // Current status status: ProcessingStatus, // Subagent status (shown during Task tool execution) @@ -736,16 +758,9 @@ pub struct App { reasoning_partial_len: usize, // Byte offset in `streaming_text` where the current reasoning block began // (recorded by `open_reasoning_region`). Used in `current` mode to slice the - // closed reasoning block out of the stream and hand it to the collapse - // animation while keeping any answer text that preceded it in order. + // closed reasoning block back out of the stream in place, keeping any answer + // text that preceded it in order. reasoning_block_start: Option, - // Wall-clock instant the current reasoning region opened, used to label the - // collapsed summary ("▸ thought for Xs"). - reasoning_block_started_at: Option, - // Active "collapse the current reasoning" animation (current mode only). While - // set, a `"reasoning"` display message height-collapses toward its one-line - // summary; the redraw loop advances it each frame and finalizes on completion. - reasoning_collapse: Option, // Hot-reload: if set, exec into new binary with this session ID (no rebuild) reload_requested: Option, // Hot-rebuild: if set, do full git pull + cargo build + tests then exec @@ -1165,6 +1180,9 @@ pub struct App { productivity_refreshing: bool, /// Last time the passive overnight progress card polled its run files. last_overnight_card_refresh: Option, + /// Per-client Niri-style workspace navigation state. Previously a process + /// global; now owned per App instance. + workspace_client: super::workspace_client::WorkspaceClientState, } /// Inert provider used by runtime modes whose output is supplied by another source. @@ -1238,11 +1256,11 @@ impl App { .filter(|message| message.role == "user") .count() .max(1); - if self.kv_cache_turn_number == Some(turn_number) { - self.kv_cache_turn_call_index = self.kv_cache_turn_call_index.saturating_add(1).max(1); + if self.kv_cache.kv_cache_turn_number == Some(turn_number) { + self.kv_cache.kv_cache_turn_call_index = self.kv_cache.kv_cache_turn_call_index.saturating_add(1).max(1); } else { - self.kv_cache_turn_number = Some(turn_number); - self.kv_cache_turn_call_index = 1; + self.kv_cache.kv_cache_turn_number = Some(turn_number); + self.kv_cache.kv_cache_turn_call_index = 1; } let baseline = self.kv_cache_baseline_for_current_session(); @@ -1255,15 +1273,15 @@ impl App { self.maybe_push_cold_cache_warning( turn_number, - self.kv_cache_turn_call_index, + self.kv_cache.kv_cache_turn_call_index, baseline.as_ref(), ); self.pause_streaming_tps(false); - self.current_api_usage_recorded = false; + self.kv_cache.current_api_usage_recorded = false; - self.pending_kv_cache_request = Some(PendingKvCacheRequest { + self.kv_cache.pending_kv_cache_request = Some(PendingKvCacheRequest { turn_number, - call_index: self.kv_cache_turn_call_index, + call_index: self.kv_cache.kv_cache_turn_call_index, provider: self.kv_cache_provider_name(), model: self.kv_cache_provider_model(), upstream_provider: self.upstream_provider.clone(), @@ -1283,11 +1301,11 @@ impl App { .filter(|message| message.role == "user") .count() .max(1); - if self.kv_cache_turn_number == Some(turn_number) { - self.kv_cache_turn_call_index = self.kv_cache_turn_call_index.saturating_add(1).max(1); + if self.kv_cache.kv_cache_turn_number == Some(turn_number) { + self.kv_cache.kv_cache_turn_call_index = self.kv_cache.kv_cache_turn_call_index.saturating_add(1).max(1); } else { - self.kv_cache_turn_number = Some(turn_number); - self.kv_cache_turn_call_index = 1; + self.kv_cache.kv_cache_turn_number = Some(turn_number); + self.kv_cache.kv_cache_turn_call_index = 1; } let baseline = self.kv_cache_baseline_for_current_session(); @@ -1297,14 +1315,14 @@ impl App { .map(|previous| Self::kv_cache_signatures_prefix_match(&signature, previous)); self.maybe_push_cold_cache_warning( turn_number, - self.kv_cache_turn_call_index, + self.kv_cache.kv_cache_turn_call_index, baseline.as_ref(), ); self.pause_streaming_tps(false); - self.current_api_usage_recorded = false; - self.pending_kv_cache_request = Some(PendingKvCacheRequest { + self.kv_cache.current_api_usage_recorded = false; + self.kv_cache.pending_kv_cache_request = Some(PendingKvCacheRequest { turn_number, - call_index: self.kv_cache_turn_call_index, + call_index: self.kv_cache.kv_cache_turn_call_index, provider: self.kv_cache_provider_name(), model: self.kv_cache_provider_model(), upstream_provider: self.upstream_provider.clone(), @@ -1334,7 +1352,7 @@ impl App { /// emits a spurious `harness:_prefix_changed` miss. Treat a foreign baseline /// as absent (warmup) instead. fn kv_cache_baseline_for_current_session(&self) -> Option { - let baseline = self.kv_cache_baseline.clone()?; + let baseline = self.kv_cache.kv_cache_baseline.clone()?; let current = self.kv_cache_session_id(); if baseline.session_id == current { Some(baseline) @@ -1381,41 +1399,41 @@ impl App { } pub(super) fn record_completed_stream_cache_usage(&mut self) -> bool { - let has_cache_telemetry = self.streaming_cache_read_tokens.is_some() - || self.streaming_cache_creation_tokens.is_some(); - if self.current_api_usage_recorded { + let has_cache_telemetry = self.streaming.streaming_cache_read_tokens.is_some() + || self.streaming.streaming_cache_creation_tokens.is_some(); + if self.kv_cache.current_api_usage_recorded { return false; } - if self.streaming_input_tokens == 0 { + if self.streaming.streaming_input_tokens == 0 { return false; } - let optimal_input_tokens = self.cache_next_optimal_input_tokens; + let optimal_input_tokens = self.token_accounting.cache_next_optimal_input_tokens; // Stash the *effective* prompt size for this request so the next request's // cache-read can be compared against everything that just became cacheable. // For split-accounting providers (Anthropic) bare `input` is only the // uncached remainder, so the reusable prefix is input + read + creation. - self.cache_next_optimal_input_tokens = + self.token_accounting.cache_next_optimal_input_tokens = Some(crate::tui::info_widget::effective_prompt_tokens( - self.streaming_input_tokens, - self.streaming_cache_read_tokens.unwrap_or(0), - self.streaming_cache_creation_tokens.unwrap_or(0), + self.streaming.streaming_input_tokens, + self.streaming.streaming_cache_read_tokens.unwrap_or(0), + self.streaming.streaming_cache_creation_tokens.unwrap_or(0), )); let request = self - .pending_kv_cache_request + .kv_cache.pending_kv_cache_request .take() .unwrap_or_else(|| self.fallback_pending_kv_cache_request()); - self.current_api_usage_recorded = true; + self.kv_cache.current_api_usage_recorded = true; self.record_kv_cache_miss_sample(&request); let baseline_session_id = self.kv_cache_session_id(); if !has_cache_telemetry { - self.kv_cache_baseline = Some(KvCacheBaseline { + self.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id: baseline_session_id, - input_tokens: self.streaming_input_tokens, + input_tokens: self.streaming.streaming_input_tokens, completed_at: Instant::now(), provider: request.provider, model: request.model, @@ -1425,30 +1443,30 @@ impl App { return true; } - self.total_cache_reported_input_tokens = self - .total_cache_reported_input_tokens - .saturating_add(self.streaming_input_tokens); + self.token_accounting.total_cache_reported_input_tokens = self + .token_accounting.total_cache_reported_input_tokens + .saturating_add(self.streaming.streaming_input_tokens); if let Some(optimal) = optimal_input_tokens { - self.total_cache_optimal_input_tokens = self - .total_cache_optimal_input_tokens + self.token_accounting.total_cache_optimal_input_tokens = self + .token_accounting.total_cache_optimal_input_tokens .saturating_add(optimal); } - self.total_cache_read_tokens = self - .total_cache_read_tokens - .saturating_add(self.streaming_cache_read_tokens.unwrap_or(0)); - self.total_cache_creation_tokens = self - .total_cache_creation_tokens - .saturating_add(self.streaming_cache_creation_tokens.unwrap_or(0)); - self.last_cache_reported_input_tokens = Some(self.streaming_input_tokens); - self.last_cache_read_tokens = Some(self.streaming_cache_read_tokens.unwrap_or(0)); - self.last_cache_creation_tokens = Some(self.streaming_cache_creation_tokens.unwrap_or(0)); - self.last_cache_optimal_input_tokens = optimal_input_tokens; + self.token_accounting.total_cache_read_tokens = self + .token_accounting.total_cache_read_tokens + .saturating_add(self.streaming.streaming_cache_read_tokens.unwrap_or(0)); + self.token_accounting.total_cache_creation_tokens = self + .token_accounting.total_cache_creation_tokens + .saturating_add(self.streaming.streaming_cache_creation_tokens.unwrap_or(0)); + self.token_accounting.last_cache_reported_input_tokens = Some(self.streaming.streaming_input_tokens); + self.token_accounting.last_cache_read_tokens = Some(self.streaming.streaming_cache_read_tokens.unwrap_or(0)); + self.token_accounting.last_cache_creation_tokens = Some(self.streaming.streaming_cache_creation_tokens.unwrap_or(0)); + self.token_accounting.last_cache_optimal_input_tokens = optimal_input_tokens; self.log_kv_cache_usage_summary(&request, optimal_input_tokens); - self.kv_cache_baseline = Some(KvCacheBaseline { + self.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id: baseline_session_id, - input_tokens: self.streaming_input_tokens, + input_tokens: self.streaming.streaming_input_tokens, completed_at: Instant::now(), provider: request.provider, model: request.model, @@ -1463,26 +1481,26 @@ impl App { request: &PendingKvCacheRequest, optimal_input_tokens: Option, ) { - let input_tokens = self.streaming_input_tokens; - let read_tokens = self.streaming_cache_read_tokens.unwrap_or(0); - let creation_tokens = self.streaming_cache_creation_tokens.unwrap_or(0); + let input_tokens = self.streaming.streaming_input_tokens; + let read_tokens = self.streaming.streaming_cache_read_tokens.unwrap_or(0); + let creation_tokens = self.streaming.streaming_cache_creation_tokens.unwrap_or(0); let read_pct = ratio_pct(read_tokens, input_tokens); let creation_pct = ratio_pct(creation_tokens, input_tokens); let optimal_read_pct = optimal_input_tokens.map(|optimal| ratio_pct(read_tokens, optimal)); let session_read_pct = ratio_pct( - self.total_cache_read_tokens, - self.total_cache_reported_input_tokens, + self.token_accounting.total_cache_read_tokens, + self.token_accounting.total_cache_reported_input_tokens, ); - let session_optimal_read_pct = if self.total_cache_optimal_input_tokens > 0 { + let session_optimal_read_pct = if self.token_accounting.total_cache_optimal_input_tokens > 0 { Some(ratio_pct( - self.total_cache_read_tokens, - self.total_cache_optimal_input_tokens, + self.token_accounting.total_cache_read_tokens, + self.token_accounting.total_cache_optimal_input_tokens, )) } else { None }; let miss = self - .kv_cache_miss_samples + .kv_cache.kv_cache_miss_samples .last() .filter(|sample| { sample.turn_number == request.turn_number && sample.call_index == request.call_index @@ -1600,11 +1618,11 @@ impl App { optimal_read_pct, missed_tokens, miss, - self.total_cache_reported_input_tokens, - self.total_cache_read_tokens, - self.total_cache_creation_tokens, + self.token_accounting.total_cache_reported_input_tokens, + self.token_accounting.total_cache_read_tokens, + self.token_accounting.total_cache_creation_tokens, session_read_pct, - self.total_cache_optimal_input_tokens, + self.token_accounting.total_cache_optimal_input_tokens, session_optimal_read_pct, baseline_input_tokens, baseline_age_secs, @@ -1666,7 +1684,7 @@ impl App { return; } - let read_tokens = self.streaming_cache_read_tokens.unwrap_or(0); + let read_tokens = self.streaming.streaming_cache_read_tokens.unwrap_or(0); let missed_tokens = expected_tokens.saturating_sub(read_tokens); if missed_tokens < Self::KV_CACHE_MIN_MISSED_TOKENS { return; @@ -1690,15 +1708,15 @@ impl App { return; } - self.kv_cache_miss_samples.push(KvCacheMissSample { + self.kv_cache.kv_cache_miss_samples.push(KvCacheMissSample { turn_number: request.turn_number, call_index: request.call_index, missed_tokens, reason, }); - if self.kv_cache_miss_samples.len() > Self::KV_CACHE_MAX_MISS_SAMPLES { - let overflow = self.kv_cache_miss_samples.len() - Self::KV_CACHE_MAX_MISS_SAMPLES; - self.kv_cache_miss_samples.drain(0..overflow); + if self.kv_cache.kv_cache_miss_samples.len() > Self::KV_CACHE_MAX_MISS_SAMPLES { + let overflow = self.kv_cache.kv_cache_miss_samples.len() - Self::KV_CACHE_MAX_MISS_SAMPLES; + self.kv_cache.kv_cache_miss_samples.drain(0..overflow); } } @@ -1744,7 +1762,7 @@ impl App { return KvCacheMissReason::HarnessPrefixChanged; } - if self.streaming_cache_read_tokens.is_none() { + if self.streaming.streaming_cache_read_tokens.is_none() { return KvCacheMissReason::Unknown; } if read_tokens == 0 { diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 5b8f5a18b..c60813566 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -302,19 +302,19 @@ pub(super) fn activate_auto_poke_local(app: &mut App) { app.thinking_buffer.clear(); app.streaming_tool_calls.clear(); app.batch_progress = None; - app.streaming_input_tokens = 0; - app.streaming_output_tokens = 0; - app.streaming_cache_read_tokens = None; - app.streaming_cache_creation_tokens = None; - app.current_api_usage_recorded = false; + app.streaming.streaming_input_tokens = 0; + app.streaming.streaming_output_tokens = 0; + app.streaming.streaming_cache_read_tokens = None; + app.streaming.streaming_cache_creation_tokens = None; + app.kv_cache.current_api_usage_recorded = false; app.upstream_provider = None; app.status_detail = None; - app.streaming_tps_start = None; - app.streaming_tps_elapsed = std::time::Duration::ZERO; - app.streaming_tps_collect_output = false; - app.streaming_total_output_tokens = 0; - app.streaming_tps_observed_output_tokens = 0; - app.streaming_tps_observed_elapsed = std::time::Duration::ZERO; + app.streaming.streaming_tps_start = None; + app.streaming.streaming_tps_elapsed = std::time::Duration::ZERO; + app.streaming.streaming_tps_collect_output = false; + app.streaming.streaming_total_output_tokens = 0; + app.streaming.streaming_tps_observed_output_tokens = 0; + app.streaming.streaming_tps_observed_elapsed = std::time::Duration::ZERO; app.processing_started = Some(Instant::now()); app.visible_turn_started = Some(Instant::now()); app.pending_turn = true; diff --git a/crates/jcode-tui/src/tui/app/commands_improve.rs b/crates/jcode-tui/src/tui/app/commands_improve.rs index 26e1cc6b4..914c365ad 100644 --- a/crates/jcode-tui/src/tui/app/commands_improve.rs +++ b/crates/jcode-tui/src/tui/app/commands_improve.rs @@ -419,19 +419,19 @@ pub(super) fn start_synthetic_user_turn(app: &mut App, content: String) { app.thinking_buffer.clear(); app.streaming_tool_calls.clear(); app.batch_progress = None; - app.streaming_input_tokens = 0; - app.streaming_output_tokens = 0; - app.streaming_cache_read_tokens = None; - app.streaming_cache_creation_tokens = None; - app.current_api_usage_recorded = false; + app.streaming.streaming_input_tokens = 0; + app.streaming.streaming_output_tokens = 0; + app.streaming.streaming_cache_read_tokens = None; + app.streaming.streaming_cache_creation_tokens = None; + app.kv_cache.current_api_usage_recorded = false; app.upstream_provider = None; app.status_detail = None; - app.streaming_tps_start = None; - app.streaming_tps_elapsed = std::time::Duration::ZERO; - app.streaming_tps_collect_output = false; - app.streaming_total_output_tokens = 0; - app.streaming_tps_observed_output_tokens = 0; - app.streaming_tps_observed_elapsed = std::time::Duration::ZERO; + app.streaming.streaming_tps_start = None; + app.streaming.streaming_tps_elapsed = std::time::Duration::ZERO; + app.streaming.streaming_tps_collect_output = false; + app.streaming.streaming_total_output_tokens = 0; + app.streaming.streaming_tps_observed_output_tokens = 0; + app.streaming.streaming_tps_observed_elapsed = std::time::Duration::ZERO; app.processing_started = Some(Instant::now()); app.visible_turn_started = Some(Instant::now()); app.pending_turn = true; diff --git a/crates/jcode-tui/src/tui/app/commands_overnight.rs b/crates/jcode-tui/src/tui/app/commands_overnight.rs index 0a3f30990..6288095ba 100644 --- a/crates/jcode-tui/src/tui/app/commands_overnight.rs +++ b/crates/jcode-tui/src/tui/app/commands_overnight.rs @@ -97,19 +97,19 @@ fn start_visible_overnight_turn(app: &mut App, content: String) { app.thinking_buffer.clear(); app.streaming_tool_calls.clear(); app.batch_progress = None; - app.streaming_input_tokens = 0; - app.streaming_output_tokens = 0; - app.streaming_cache_read_tokens = None; - app.streaming_cache_creation_tokens = None; - app.current_api_usage_recorded = false; + app.streaming.streaming_input_tokens = 0; + app.streaming.streaming_output_tokens = 0; + app.streaming.streaming_cache_read_tokens = None; + app.streaming.streaming_cache_creation_tokens = None; + app.kv_cache.current_api_usage_recorded = false; app.upstream_provider = None; app.status_detail = None; - app.streaming_tps_start = None; - app.streaming_tps_elapsed = Duration::ZERO; - app.streaming_tps_collect_output = false; - app.streaming_total_output_tokens = 0; - app.streaming_tps_observed_output_tokens = 0; - app.streaming_tps_observed_elapsed = Duration::ZERO; + app.streaming.streaming_tps_start = None; + app.streaming.streaming_tps_elapsed = Duration::ZERO; + app.streaming.streaming_tps_collect_output = false; + app.streaming.streaming_total_output_tokens = 0; + app.streaming.streaming_tps_observed_output_tokens = 0; + app.streaming.streaming_tps_observed_elapsed = Duration::ZERO; app.processing_started = Some(Instant::now()); app.visible_turn_started = Some(Instant::now()); app.pending_turn = true; diff --git a/crates/jcode-tui/src/tui/app/debug.rs b/crates/jcode-tui/src/tui/app/debug.rs index 7bcea4983..040139e5e 100644 --- a/crates/jcode-tui/src/tui/app/debug.rs +++ b/crates/jcode-tui/src/tui/app/debug.rs @@ -490,7 +490,7 @@ impl ScrollTestState { diff_pane_focus: app.diff_pane_focus, diff_pane_auto_scroll: app.diff_pane_auto_scroll, is_processing: app.is_processing, - streaming_text: app.streaming_text.clone(), + streaming_text: app.streaming.streaming_text.clone(), queued_messages: app.queued_messages.clone(), interleave_message: app.interleave_message.clone(), pending_soft_interrupts: app.pending_soft_interrupts.clone(), diff --git a/crates/jcode-tui/src/tui/app/debug_profile.rs b/crates/jcode-tui/src/tui/app/debug_profile.rs index 7d38ebff6..9cb67b449 100644 --- a/crates/jcode-tui/src/tui/app/debug_profile.rs +++ b/crates/jcode-tui/src/tui/app/debug_profile.rs @@ -120,7 +120,7 @@ impl App { "cursor_pos": self.cursor_pos, }, "streaming": { - "streaming_text_bytes": self.streaming_text.len(), + "streaming_text_bytes": self.streaming.streaming_text.len(), "thinking_buffer_bytes": self.thinking_buffer.len(), "stream_buffer": self.stream_buffer.debug_memory_profile(), "streaming_tool_calls_count": self.streaming_tool_calls.len(), diff --git a/crates/jcode-tui/src/tui/app/helpers.rs b/crates/jcode-tui/src/tui/app/helpers.rs index 8c5764aac..4b3d5ccb3 100644 --- a/crates/jcode-tui/src/tui/app/helpers.rs +++ b/crates/jcode-tui/src/tui/app/helpers.rs @@ -248,7 +248,8 @@ pub(super) fn format_tokens(tokens: u64) -> String { } } -/// Copy text to clipboard, trying wl-copy first (Wayland), then arboard as fallback. +/// Copy text to clipboard, trying wl-copy first (Wayland), then OSC 52 (works +/// over SSH / Docker / tmux), then arboard as a final fallback. pub(super) fn copy_to_clipboard(text: &str) -> bool { if let Ok(mut child) = std::process::Command::new("wl-copy") .stdin(std::process::Stdio::piped()) @@ -261,14 +262,37 @@ pub(super) fn copy_to_clipboard(text: &str) -> bool { && stdin.write_all(text.as_bytes()).is_ok() { drop(child.stdin.take()); - return child.wait().map(|s| s.success()).unwrap_or(false); + if child.wait().map(|s| s.success()).unwrap_or(false) { + return true; + } } } + if copy_to_clipboard_osc52(text) { + return true; + } arboard::Clipboard::new() .and_then(|mut cb| cb.set_text(text.to_string())) .is_ok() } +/// Copy to clipboard using the OSC 52 terminal escape sequence. This asks the +/// terminal emulator to set the system clipboard without needing a local +/// display server, making it work over SSH, inside Docker, and under tmux +/// (with `set -g set-clipboard on`). Returns false if stdout is not a TTY. +fn copy_to_clipboard_osc52(text: &str) -> bool { + use base64::Engine as _; + use std::io::{IsTerminal, Write}; + + let mut out = std::io::stdout(); + if !out.is_terminal() { + return false; + } + let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + // OSC 52: ESC ] 52 ; c ; BEL + let seq = format!("\x1b]52;c;{}\x07", encoded); + out.write_all(seq.as_bytes()).is_ok() && out.flush().is_ok() +} + pub(super) fn effort_display_label(effort: &str) -> &str { match effort { "max" => "Max", diff --git a/crates/jcode-tui/src/tui/app/inline_interactive.rs b/crates/jcode-tui/src/tui/app/inline_interactive.rs index 94ff6f947..4024d5191 100644 --- a/crates/jcode-tui/src/tui/app/inline_interactive.rs +++ b/crates/jcode-tui/src/tui/app/inline_interactive.rs @@ -1955,7 +1955,7 @@ impl App { name ))); } - crate::tui::workspace_client::queue_resume_session(session_id); + self.workspace_client.queue_resume_session(session_id); self.session_picker_overlay = None; self.session_picker_mode = SessionPickerMode::Resume; self.set_status_notice(format!("Switching → {}", name)); diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 6fe5b2bd9..7af4089b8 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -50,47 +50,6 @@ pub(super) fn strip_reasoning_lines(content: &str) -> String { result.trim_end().to_string() } -/// Total duration of the "current reasoning collapses away" height animation. -pub(super) const REASONING_COLLAPSE_DURATION: Duration = Duration::from_millis(280); - -/// Split a just-closed reasoning block (sentinel-wrapped dim/italic line markup, -/// as produced by [`jcode_tui_markdown::reasoning_line_markup`]) into one markup -/// string per visible reasoning line. Blank separator lines are dropped so the -/// collapse animates over real thought lines only. -pub(super) fn reasoning_block_line_markups(block: &str) -> Vec { - block - .split_inclusive('\n') - .filter(|segment| segment.contains(jcode_tui_markdown::REASONING_SENTINEL)) - .map(|segment| segment.to_string()) - .collect() -} - -/// One-line dim summary the collapsed reasoning folds into. Includes a `▸` marker -/// and the thinking duration when known (e.g. `▸ thought for 12s`). -pub(super) fn reasoning_summary_markup(line_count: usize, elapsed: Option) -> String { - let label = match elapsed { - Some(d) if d.as_secs() >= 1 => format!("▸ thought for {}s", d.as_secs()), - Some(_) => "▸ thought".to_string(), - None if line_count == 1 => "▸ thought (1 line)".to_string(), - None => format!("▸ thought ({} lines)", line_count), - }; - jcode_tui_markdown::reasoning_line_markup(&label) -} - -/// Build the transcript content for a collapsing `"reasoning"` message: the last -/// `remaining` reasoning lines, or just the summary line once fully collapsed. -pub(super) fn reasoning_message_content( - summary_markup: &str, - line_markups: &[String], - remaining: usize, -) -> String { - if remaining == 0 || line_markups.is_empty() { - return summary_markup.to_string(); - } - let remaining = remaining.min(line_markups.len()); - let start = line_markups.len() - remaining; - line_markups[start..].concat() -} pub(super) fn edit_input_in_external_editor(app: &mut App) { match edit_text_in_external_editor(&app.input) { @@ -2416,10 +2375,10 @@ impl App { prefix.push('\n'); } prefix.push('\n'); - if self.streaming_text.is_empty() { + if self.streaming.streaming_text.is_empty() { self.replace_streaming_text(prefix); } else { - self.replace_streaming_text(format!("{}{}", prefix, self.streaming_text)); + self.replace_streaming_text(format!("{}{}", prefix, self.streaming.streaming_text)); } } @@ -2430,10 +2389,10 @@ impl App { return; } // Separate the reasoning block from any prior content with a blank line. - if !self.streaming_text.is_empty() { - if self.streaming_text.ends_with("\n\n") { + if !self.streaming.streaming_text.is_empty() { + if self.streaming.streaming_text.ends_with("\n\n") { // already separated - } else if self.streaming_text.ends_with('\n') { + } else if self.streaming.streaming_text.ends_with('\n') { self.append_streaming_text("\n"); } else { self.append_streaming_text("\n\n"); @@ -2443,10 +2402,9 @@ impl App { self.reasoning_pending_line.clear(); self.reasoning_partial_len = 0; // Remember where this reasoning block starts in the stream so `current` - // mode can later slice it out (without disturbing any preceding answer - // text) and hand it to the collapse animation. - self.reasoning_block_start = Some(self.streaming_text.len()); - self.reasoning_block_started_at = Some(Instant::now()); + // mode can later slice it back out in place (without disturbing any + // preceding answer text) once the model starts answering. + self.reasoning_block_start = Some(self.streaming.streaming_text.len()); } /// Remove the live partial-reasoning tail (the rendered, not-yet-committed @@ -2455,10 +2413,10 @@ impl App { fn strip_reasoning_partial_tail(&mut self) { if self.reasoning_partial_len > 0 { let new_len = self - .streaming_text + .streaming.streaming_text .len() .saturating_sub(self.reasoning_partial_len); - self.streaming_text.truncate(new_len); + self.streaming.streaming_text.truncate(new_len); self.reasoning_partial_len = 0; } } @@ -2488,12 +2446,12 @@ impl App { } } if !committed.is_empty() { - self.streaming_text.push_str(&committed); + self.streaming.streaming_text.push_str(&committed); } // Re-append the live tail for the in-progress (partial) line. let partial = jcode_tui_markdown::reasoning_partial_markup(&self.reasoning_pending_line); self.reasoning_partial_len = partial.len(); - self.streaming_text.push_str(&partial); + self.streaming.streaming_text.push_str(&partial); self.refresh_split_view_if_needed(); } @@ -2508,179 +2466,62 @@ impl App { self.strip_reasoning_partial_tail(); let pending = std::mem::take(&mut self.reasoning_pending_line); if !pending.is_empty() { - self.streaming_text + self.streaming.streaming_text .push_str(&jcode_tui_markdown::reasoning_line_markup(&pending)); } self.reasoning_streaming = false; - // In `current` mode, animate the block away instead of leaving it in the - // stream to be stripped wholesale at commit time. + // In `current` mode, reasoning is ephemeral: only the *current* (live) + // block is ever shown. Once it closes (the model starts answering or runs + // a tool), slice it straight back out of the stream in place. This keeps + // any answer text that preceded it in order and never accumulates a + // separate trace message for past reasoning. if matches!( crate::config::config().display.reasoning_display(), crate::config::ReasoningDisplayMode::Current ) { - self.begin_reasoning_collapse(); + self.discard_current_reasoning_block(); return; } // Terminate the reasoning block with a blank line so following output // renders as a normal paragraph. - if !self.streaming_text.ends_with("\n\n") { - if self.streaming_text.ends_with('\n') { - self.streaming_text.push('\n'); + if !self.streaming.streaming_text.ends_with("\n\n") { + if self.streaming.streaming_text.ends_with('\n') { + self.streaming.streaming_text.push('\n'); } else { - self.streaming_text.push_str("\n\n"); + self.streaming.streaming_text.push_str("\n\n"); } } self.refresh_split_view_if_needed(); } - /// Slice the just-closed reasoning block out of `streaming_text` and move it - /// into a dedicated `"reasoning"` display message, then start (or replace) the - /// height-collapse animation. Any answer text streamed *before* the reasoning - /// block is left untouched so ordering is preserved. With decorative - /// animations disabled (reduced motion / low-power tiers) the block is - /// finalized straight to its summary line. - pub(super) fn begin_reasoning_collapse(&mut self) { - let block_start = self.reasoning_block_start.take().unwrap_or(0); - let started_at = self.reasoning_block_started_at.take(); - // Finalize any previous collapse first so its message snaps to its summary - // instead of being orphaned mid-animation. - self.finalize_reasoning_collapse(); - - let block_start = block_start.min(self.streaming_text.len()); - + /// Slice the just-closed reasoning block out of `streaming_text` in place, + /// leaving any answer text that streamed *before* it untouched and in order. + /// Used in `current` mode so only the live reasoning block is ever visible and + /// no per-block trace is left behind. + pub(super) fn discard_current_reasoning_block(&mut self) { + let block_start = self + .reasoning_block_start + .take() + .unwrap_or(0) + .min(self.streaming.streaming_text.len()); // Everything from the block start onward is reasoning markup (plus the - // separators inserted by open/close). Take it out of the live stream. - let block: String = self.streaming_text.split_off(block_start); - // Drop a trailing separator the answer-side path would otherwise add. - while self.streaming_text.ends_with('\n') { - self.streaming_text.pop(); + // separators inserted by open/close). Drop it from the live stream. + self.streaming.streaming_text.truncate(block_start); + // Drop the separator the open path added before the reasoning block so the + // surrounding answer text rejoins cleanly. + while self.streaming.streaming_text.ends_with('\n') { + self.streaming.streaming_text.pop(); } self.refresh_split_view_if_needed(); - - let line_markups = reasoning_block_line_markups(&block); - if line_markups.is_empty() { - // Nothing to show (e.g. empty reasoning); just clear state. - self.reasoning_collapse = None; - return; - } - - let elapsed = started_at.map(|t| t.elapsed()); - let summary_markup = reasoning_summary_markup(line_markups.len(), elapsed); - - // Build the committed message content: every reasoning line, then the - // summary as the final line. The renderer reveals a shrinking suffix. - let content = - reasoning_message_content(&summary_markup, &line_markups, line_markups.len()); - - let msg_index = self.display_messages.len(); - self.push_display_message(DisplayMessage::reasoning(content)); - - let decorative = crate::perf::tui_policy().enable_decorative_animations; - if !decorative { - // Reduced motion: snap straight to the one-line summary. - self.replace_display_message_content( - msg_index, - reasoning_message_content(&summary_markup, &line_markups, 0), - ); - self.reasoning_collapse = None; - return; - } - - self.reasoning_collapse = Some(super::ReasoningCollapse { - msg_index, - summary_markup, - line_markups, - started_at: Instant::now(), - }); - } - - /// Advance the active reasoning-collapse animation. Returns `true` when the - /// transcript changed (so the caller should request a redraw). Finalizes to - /// the summary line once the animation completes. - pub(super) fn advance_reasoning_collapse(&mut self) -> bool { - let Some(collapse) = self.reasoning_collapse.as_ref() else { - return false; - }; - - // If the target message moved or was replaced (compaction/rewind), drop the - // animation rather than risk mutating an unrelated message. - if self - .display_messages - .get(collapse.msg_index) - .map(|m| m.role.as_str()) - != Some("reasoning") - { - self.reasoning_collapse = None; - return false; - } - - let total = collapse.line_markups.len(); - let elapsed = collapse.started_at.elapsed(); - let progress = - (elapsed.as_secs_f32() / REASONING_COLLAPSE_DURATION.as_secs_f32()).clamp(0.0, 1.0); - // Ease-out cubic so the block decelerates as it folds away. - let eased = 1.0 - (1.0 - progress).powi(3); - // Number of reasoning lines still visible above the summary. Counts down - // from `total` to 0 (only the summary remains). - let remaining = ((total as f32) * (1.0 - eased)).round() as usize; - let remaining = remaining.min(total); - - let msg_index = collapse.msg_index; - let content = - reasoning_message_content(&collapse.summary_markup, &collapse.line_markups, remaining); - let changed = self.replace_display_message_content(msg_index, content); - - if progress >= 1.0 { - self.reasoning_collapse = None; - } - changed - } - - /// Whether a reasoning-collapse animation is currently running. - pub(super) fn reasoning_collapse_active(&self) -> bool { - self.reasoning_collapse.is_some() - } - - /// Test hook: backdate the active collapse's start so `advance_*` observes a - /// specific elapsed fraction, and return the number of source reasoning lines. - #[cfg(test)] - pub(super) fn backdate_reasoning_collapse_for_test( - &mut self, - elapsed: std::time::Duration, - ) -> Option { - let collapse = self.reasoning_collapse.as_mut()?; - collapse.started_at = Instant::now() - .checked_sub(elapsed) - .unwrap_or_else(Instant::now); - Some(collapse.line_markups.len()) - } - - /// Finalize any in-flight reasoning collapse immediately (snap to summary). - /// Used when the turn ends or state is reset so no animation is left dangling. - pub(super) fn finalize_reasoning_collapse(&mut self) { - if let Some(collapse) = self.reasoning_collapse.take() { - if self - .display_messages - .get(collapse.msg_index) - .map(|m| m.role.as_str()) - == Some("reasoning") - { - let content = - reasoning_message_content(&collapse.summary_markup, &collapse.line_markups, 0); - self.replace_display_message_content(collapse.msg_index, content); - } - } - self.reasoning_block_start = None; - self.reasoning_block_started_at = None; } pub(super) fn append_streaming_text(&mut self, text: &str) { if text.is_empty() { return; } - self.streaming_text.push_str(text); + self.streaming.streaming_text.push_str(text); self.refresh_split_view_if_needed(); } @@ -2699,33 +2540,30 @@ impl App { } pub(super) fn replace_streaming_text(&mut self, text: String) { - self.streaming_text = text; + self.streaming.streaming_text = text; self.refresh_split_view_if_needed(); } pub(super) fn clear_streaming_render_state(&mut self) { - self.streaming_text.clear(); + self.streaming.streaming_text.clear(); self.stream_message_ended = false; self.reasoning_streaming = false; self.reasoning_pending_line.clear(); self.reasoning_partial_len = 0; - // The stream (and any block offset into it) is gone; a running collapse - // targets a separate display message and is left to finish on its own. + // The stream (and any block offset into it) is gone. self.reasoning_block_start = None; - self.reasoning_block_started_at = None; self.refresh_split_view_if_needed(); self.streaming_md_renderer.borrow_mut().reset(); crate::tui::mermaid::clear_streaming_preview_diagram(); } pub(super) fn take_streaming_text(&mut self) -> String { - let content = std::mem::take(&mut self.streaming_text); + let content = std::mem::take(&mut self.streaming.streaming_text); self.stream_message_ended = false; self.reasoning_streaming = false; self.reasoning_pending_line.clear(); self.reasoning_partial_len = 0; self.reasoning_block_start = None; - self.reasoning_block_started_at = None; self.refresh_split_view_if_needed(); self.streaming_md_renderer.borrow_mut().reset(); crate::tui::mermaid::clear_streaming_preview_diagram(); @@ -2737,7 +2575,7 @@ impl App { self.append_streaming_text(&chunk); } - if self.streaming_text.is_empty() { + if self.streaming.streaming_text.is_empty() { self.stream_buffer.clear(); return false; } @@ -2766,8 +2604,8 @@ impl App { // treat this as a reset and count the full value once. output_tokens }; - if self.streaming_tps_collect_output { - self.streaming_total_output_tokens += delta; + if self.streaming.streaming_tps_collect_output { + self.streaming.streaming_total_output_tokens += delta; if delta > 0 { self.snapshot_streaming_tps(); } @@ -2982,19 +2820,19 @@ impl App { self.thinking_prefix_emitted = false; self.thinking_buffer.clear(); self.streaming_tool_calls.clear(); - self.streaming_input_tokens = 0; - self.streaming_output_tokens = 0; - self.streaming_cache_read_tokens = None; - self.streaming_cache_creation_tokens = None; - self.current_api_usage_recorded = false; + self.streaming.streaming_input_tokens = 0; + self.streaming.streaming_output_tokens = 0; + self.streaming.streaming_cache_read_tokens = None; + self.streaming.streaming_cache_creation_tokens = None; + self.kv_cache.current_api_usage_recorded = false; self.upstream_provider = None; self.status_detail = None; - self.streaming_tps_start = None; - self.streaming_tps_elapsed = Duration::ZERO; - self.streaming_tps_collect_output = false; - self.streaming_total_output_tokens = 0; - self.streaming_tps_observed_output_tokens = 0; - self.streaming_tps_observed_elapsed = Duration::ZERO; + self.streaming.streaming_tps_start = None; + self.streaming.streaming_tps_elapsed = Duration::ZERO; + self.streaming.streaming_tps_collect_output = false; + self.streaming.streaming_total_output_tokens = 0; + self.streaming.streaming_tps_observed_output_tokens = 0; + self.streaming.streaming_tps_observed_elapsed = Duration::ZERO; self.processing_started = Some(Instant::now()); self.visible_turn_started = Some(Instant::now()); self.pending_turn = true; @@ -3050,19 +2888,19 @@ impl App { self.thinking_prefix_emitted = false; self.thinking_buffer.clear(); self.streaming_tool_calls.clear(); - self.streaming_input_tokens = 0; - self.streaming_output_tokens = 0; - self.streaming_cache_read_tokens = None; - self.streaming_cache_creation_tokens = None; - self.current_api_usage_recorded = false; + self.streaming.streaming_input_tokens = 0; + self.streaming.streaming_output_tokens = 0; + self.streaming.streaming_cache_read_tokens = None; + self.streaming.streaming_cache_creation_tokens = None; + self.kv_cache.current_api_usage_recorded = false; self.upstream_provider = None; self.status_detail = None; - self.streaming_tps_start = None; - self.streaming_tps_elapsed = Duration::ZERO; - self.streaming_tps_collect_output = false; - self.streaming_total_output_tokens = 0; - self.streaming_tps_observed_output_tokens = 0; - self.streaming_tps_observed_elapsed = Duration::ZERO; + self.streaming.streaming_tps_start = None; + self.streaming.streaming_tps_elapsed = Duration::ZERO; + self.streaming.streaming_tps_collect_output = false; + self.streaming.streaming_total_output_tokens = 0; + self.streaming.streaming_tps_observed_output_tokens = 0; + self.streaming.streaming_tps_observed_elapsed = Duration::ZERO; self.processing_started = Some(Instant::now()); if has_combined { if preserve_visible_turn { diff --git a/crates/jcode-tui/src/tui/app/local.rs b/crates/jcode-tui/src/tui/app/local.rs index 204730a75..ddab4bf2f 100644 --- a/crates/jcode-tui/src/tui/app/local.rs +++ b/crates/jcode-tui/src/tui/app/local.rs @@ -55,7 +55,6 @@ pub(super) async fn process_turn_with_input( pub(super) fn handle_tick(app: &mut App) -> bool { let mut needs_redraw = crate::tui::periodic_redraw_required(app); - needs_redraw |= app.advance_reasoning_collapse(); app.maybe_capture_runtime_memory_heartbeat(); needs_redraw |= app.progress_copy_selection_edge_autoscroll(); app.progress_mouse_scroll_animation(); @@ -460,8 +459,8 @@ fn handle_input_shell_completed(app: &mut App, shell: InputShellCompleted) { } pub(super) fn finish_turn(app: &mut App) { - app.total_input_tokens += app.streaming_input_tokens; - app.total_output_tokens += app.streaming_output_tokens; + app.token_accounting.total_input_tokens += app.streaming.streaming_input_tokens; + app.token_accounting.total_output_tokens += app.streaming.streaming_output_tokens; app.update_cost_impl(); app.is_processing = false; app.status = ProcessingStatus::Idle; @@ -473,9 +472,6 @@ pub(super) fn finish_turn(app: &mut App) { app.thought_line_inserted = false; app.thinking_prefix_emitted = false; app.thinking_buffer.clear(); - // Snap any in-flight reasoning collapse straight to its summary so no - // animation is left running once the turn is idle. - app.finalize_reasoning_collapse(); app.note_runtime_memory_event_force("turn_completed", "local_turn_finished"); if !app.schedule_auto_poke_followup_if_needed() && !app.schedule_overnight_poke_followup_if_needed() diff --git a/crates/jcode-tui/src/tui/app/misc_ui.rs b/crates/jcode-tui/src/tui/app/misc_ui.rs index fe8d33ae9..b4211756f 100644 --- a/crates/jcode-tui/src/tui/app/misc_ui.rs +++ b/crates/jcode-tui/src/tui/app/misc_ui.rs @@ -84,39 +84,39 @@ impl ResolvedTokenPricing { /// Update cost calculation based on token usage (for API-key providers) impl App { pub(super) fn current_streaming_tps_elapsed(&self) -> Duration { - let mut elapsed = self.streaming_tps_elapsed; - if let Some(start) = self.streaming_tps_start { + let mut elapsed = self.streaming.streaming_tps_elapsed; + if let Some(start) = self.streaming.streaming_tps_start { elapsed += start.elapsed(); } elapsed } pub(super) fn snapshot_streaming_tps(&mut self) { - self.streaming_tps_observed_output_tokens = self.streaming_total_output_tokens; - self.streaming_tps_observed_elapsed = self.current_streaming_tps_elapsed(); + self.streaming.streaming_tps_observed_output_tokens = self.streaming.streaming_total_output_tokens; + self.streaming.streaming_tps_observed_elapsed = self.current_streaming_tps_elapsed(); } pub(super) fn resume_streaming_tps(&mut self) { - self.streaming_tps_collect_output = true; - if self.streaming_tps_start.is_none() { - self.streaming_tps_start = Some(Instant::now()); + self.streaming.streaming_tps_collect_output = true; + if self.streaming.streaming_tps_start.is_none() { + self.streaming.streaming_tps_start = Some(Instant::now()); } } pub(super) fn pause_streaming_tps(&mut self, keep_collecting_output: bool) { - if let Some(start) = self.streaming_tps_start.take() { - self.streaming_tps_elapsed += start.elapsed(); + if let Some(start) = self.streaming.streaming_tps_start.take() { + self.streaming.streaming_tps_elapsed += start.elapsed(); } - self.streaming_tps_collect_output = keep_collecting_output; + self.streaming.streaming_tps_collect_output = keep_collecting_output; } pub(super) fn reset_streaming_tps(&mut self) { - self.streaming_tps_start = None; - self.streaming_tps_elapsed = Duration::ZERO; - self.streaming_tps_collect_output = false; - self.streaming_total_output_tokens = 0; - self.streaming_tps_observed_output_tokens = 0; - self.streaming_tps_observed_elapsed = Duration::ZERO; + self.streaming.streaming_tps_start = None; + self.streaming.streaming_tps_elapsed = Duration::ZERO; + self.streaming.streaming_tps_collect_output = false; + self.streaming.streaming_total_output_tokens = 0; + self.streaming.streaming_tps_observed_output_tokens = 0; + self.streaming.streaming_tps_observed_elapsed = Duration::ZERO; } pub(super) fn open_usage_inline_loading(&mut self) { @@ -160,14 +160,22 @@ impl App { let runtime_provider = active_runtime_provider_key(); let auth_status = crate::auth::AuthStatus::check_fast(); - let is_explicit_anthropic_api = matches!( + let pinned_anthropic = jcode_provider_core::pinned_mode_for( + jcode_provider_core::DualAuthProvider::Anthropic, runtime_provider.as_deref(), - Some("claude-api" | "anthropic-api") ); + let pinned_openai = jcode_provider_core::pinned_mode_for( + jcode_provider_core::DualAuthProvider::OpenAI, + runtime_provider.as_deref(), + ); + let is_explicit_anthropic_api = + matches!(pinned_anthropic, Some(jcode_provider_core::AuthMode::ApiKey)); let is_explicit_anthropic_oauth = - matches!(runtime_provider.as_deref(), Some("claude" | "anthropic")); - let is_explicit_openai_api = matches!(runtime_provider.as_deref(), Some("openai-api")); - let is_explicit_openai_oauth = matches!(runtime_provider.as_deref(), Some("openai")); + matches!(pinned_anthropic, Some(jcode_provider_core::AuthMode::Oauth)); + let is_explicit_openai_api = + matches!(pinned_openai, Some(jcode_provider_core::AuthMode::ApiKey)); + let is_explicit_openai_oauth = + matches!(pinned_openai, Some(jcode_provider_core::AuthMode::Oauth)); let is_anthropic = provider_name.contains("anthropic") || provider_name.contains("claude"); let is_openai = provider_name.contains("openai"); @@ -211,9 +219,9 @@ impl App { // Pricing in $/1M tokens. Anthropic resolves real per-model pricing in // refresh_cached_pricing; other providers fall back to the generic // defaults cached here. - let prompt_price = *self.cached_prompt_price.get_or_insert(15.0); - let completion_price = *self.cached_completion_price.get_or_insert(60.0); - let cache_read_price = self.cached_cache_read_price; + let prompt_price = *self.cost.cached_prompt_price.get_or_insert(15.0); + let completion_price = *self.cost.cached_completion_price.get_or_insert(60.0); + let cache_read_price = self.cost.cached_cache_read_price; let pricing = ResolvedTokenPricing { prompt_price, @@ -222,11 +230,11 @@ impl App { is_anthropic, }; - self.total_cost += pricing.cost_for_usage( - self.streaming_input_tokens, - self.streaming_output_tokens, - self.streaming_cache_read_tokens.unwrap_or(0), - self.streaming_cache_creation_tokens.unwrap_or(0), + self.cost.total_cost += pricing.cost_for_usage( + self.streaming.streaming_input_tokens, + self.streaming.streaming_output_tokens, + self.streaming.streaming_cache_read_tokens.unwrap_or(0), + self.streaming.streaming_cache_creation_tokens.unwrap_or(0), ); } @@ -258,7 +266,7 @@ impl App { let Some(pricing) = self.resolve_remote_cost_pricing() else { return; }; - self.total_cost += pricing.cost_for_usage( + self.cost.total_cost += pricing.cost_for_usage( input_delta, output_delta, cache_read_delta, @@ -308,9 +316,9 @@ impl App { self.refresh_cached_pricing(&model, is_anthropic, is_openai); Some(ResolvedTokenPricing { - prompt_price: *self.cached_prompt_price.get_or_insert(15.0), - completion_price: *self.cached_completion_price.get_or_insert(60.0), - cache_read_price: self.cached_cache_read_price, + prompt_price: *self.cost.cached_prompt_price.get_or_insert(15.0), + completion_price: *self.cost.cached_completion_price.get_or_insert(60.0), + cache_read_price: self.cost.cached_cache_read_price, is_anthropic, }) } @@ -320,7 +328,7 @@ impl App { /// (input, output and cache-read) so the API-key cost figure is accurate per /// model. Re-resolves when the active model changes. fn refresh_cached_pricing(&mut self, model: &str, is_anthropic: bool, is_openai: bool) { - if self.cached_price_model.as_deref() == Some(model) { + if self.cost.cached_price_model.as_deref() == Some(model) { return; } @@ -334,21 +342,21 @@ impl App { }; if let Some(estimate) = estimate { - self.cached_prompt_price = per_mtok(estimate.input_price_per_mtok_micros); - self.cached_completion_price = per_mtok(estimate.output_price_per_mtok_micros); - self.cached_cache_read_price = per_mtok(estimate.cache_read_price_per_mtok_micros); - self.cached_price_model = Some(model.to_string()); + self.cost.cached_prompt_price = per_mtok(estimate.input_price_per_mtok_micros); + self.cost.cached_completion_price = per_mtok(estimate.output_price_per_mtok_micros); + self.cost.cached_cache_read_price = per_mtok(estimate.cache_read_price_per_mtok_micros); + self.cost.cached_price_model = Some(model.to_string()); return; } // Unknown model: leave existing defaults in place but remember the model // so we do not repeatedly attempt resolution for it. - self.cached_price_model = Some(model.to_string()); + self.cost.cached_price_model = Some(model.to_string()); } pub(super) fn compute_streaming_tps(&self) -> Option { - let elapsed_secs = self.streaming_tps_observed_elapsed.as_secs_f32(); - let total_tokens = self.streaming_tps_observed_output_tokens; + let elapsed_secs = self.streaming.streaming_tps_observed_elapsed.as_secs_f32(); + let total_tokens = self.streaming.streaming_tps_observed_output_tokens; if elapsed_secs > 0.1 && total_tokens > 0 { Some(total_tokens as f32 / elapsed_secs) } else { diff --git a/crates/jcode-tui/src/tui/app/model_context.rs b/crates/jcode-tui/src/tui/app/model_context.rs index 6c242c18f..3433d1642 100644 --- a/crates/jcode-tui/src/tui/app/model_context.rs +++ b/crates/jcode-tui/src/tui/app/model_context.rs @@ -313,13 +313,13 @@ impl App { } pub(super) fn current_stream_context_tokens(&self) -> Option { - if self.streaming_input_tokens == 0 { + if self.streaming.streaming_input_tokens == 0 { return None; } Some(self.effective_context_tokens_from_usage( - self.streaming_input_tokens, - self.streaming_cache_read_tokens, - self.streaming_cache_creation_tokens, + self.streaming.streaming_input_tokens, + self.streaming.streaming_cache_read_tokens, + self.streaming.streaming_cache_creation_tokens, )) } @@ -465,11 +465,11 @@ impl App { self.clear_streaming_render_state(); self.stream_buffer.clear(); self.streaming_tool_calls.clear(); - self.streaming_input_tokens = 0; - self.streaming_output_tokens = 0; - self.streaming_cache_read_tokens = None; - self.streaming_cache_creation_tokens = None; - self.current_api_usage_recorded = false; + self.streaming.streaming_input_tokens = 0; + self.streaming.streaming_output_tokens = 0; + self.streaming.streaming_cache_read_tokens = None; + self.streaming.streaming_cache_creation_tokens = None; + self.kv_cache.current_api_usage_recorded = false; self.thought_line_inserted = false; self.thinking_prefix_emitted = false; self.thinking_buffer.clear(); diff --git a/crates/jcode-tui/src/tui/app/navigation.rs b/crates/jcode-tui/src/tui/app/navigation.rs index 0fd1919bc..70283bb0f 100644 --- a/crates/jcode-tui/src/tui/app/navigation.rs +++ b/crates/jcode-tui/src/tui/app/navigation.rs @@ -204,7 +204,7 @@ impl App { self.display_messages .len() .saturating_mul(100) - .saturating_add(self.streaming_text.len()), + .saturating_add(self.streaming.streaming_text.len()), ); }; @@ -213,7 +213,7 @@ impl App { // measuring every message on each scroll input, which is noticeable in // very long sessions. The estimate below is only needed while streaming // can make LAST_MAX_SCROLL stale between frames. - if renderer_max > 0 && !self.is_processing && self.streaming_text.is_empty() { + if renderer_max > 0 && !self.is_processing && self.streaming.streaming_text.is_empty() { return renderer_max; } @@ -268,7 +268,7 @@ impl App { lines }); - message_lines.saturating_add(wrapped_text_lines(&self.streaming_text, width)) + message_lines.saturating_add(wrapped_text_lines(&self.streaming.streaming_text, width)) } pub(super) fn diagram_available(&self) -> bool { @@ -1440,7 +1440,7 @@ impl App { // `rendered_max` stale at 0 even though there is content to scroll. let bottom_threshold = if rendered_max > 0 { rendered_max.min(max) - } else if self.is_processing || !self.streaming_text.is_empty() { + } else if self.is_processing || !self.streaming.streaming_text.is_empty() { max } else { // Not streaming and nothing to scroll: we are already at the bottom. diff --git a/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs b/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs index ec20f5439..15cba1a04 100644 --- a/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs +++ b/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs @@ -40,7 +40,9 @@ impl App { self.onboarding_after_login(); return; } - if !self.onboarding_preview_mode && !self.is_new_user_for_onboarding() { + if !self.onboarding_preview_mode + && (self.is_selfdev_canary_session() || !self.is_new_user_for_onboarding()) + { return; } self.begin_onboarding_flow(); @@ -83,6 +85,14 @@ impl App { self.onboarding_startup_checked = true; return; } + // Self-dev / canary sessions are explicitly not first-run users: they are + // spawned by developers (e.g. the niri `jcode self-dev` hotkey) and that + // launch path never increments `launch_count`, so the new-user heuristic + // would otherwise re-onboard on every spawn. Skip onboarding for them. + if self.is_selfdev_canary_session() { + self.onboarding_startup_checked = true; + return; + } if !self.is_new_user_for_onboarding() { self.onboarding_startup_checked = true; return; @@ -111,6 +121,21 @@ impl App { .unwrap_or(true) } + /// Whether this is a self-dev / canary session. + /// + /// These are launched by developers working on jcode itself (for example the + /// niri `jcode self-dev` hotkey). That launch path bypasses + /// `maybe_show_setup_hints`, so `launch_count` never advances and the + /// new-user heuristic above would otherwise treat every spawn as a first run. + /// Such sessions should never auto-start the guided onboarding flow. + fn is_selfdev_canary_session(&self) -> bool { + if self.is_remote { + self.remote_is_canary.unwrap_or(self.session.is_canary) + } else { + self.session.is_canary + } + } + /// Begin the guided post-login flow. Called once auth becomes available on a /// fresh install (login/import completes). New users are not forced through a /// model picker; the default route is used and `/model` remains available. diff --git a/crates/jcode-tui/src/tui/app/remote.rs b/crates/jcode-tui/src/tui/app/remote.rs index 4935da620..740e31f8b 100644 --- a/crates/jcode-tui/src/tui/app/remote.rs +++ b/crates/jcode-tui/src/tui/app/remote.rs @@ -75,7 +75,6 @@ pub(super) async fn handle_tick(app: &mut App, remote: &mut RemoteConnection) -> .is_some_and(|state| state.kind == crate::tui::PickerKind::Model), }); let mut needs_redraw = crate::tui::periodic_redraw_required(app); - needs_redraw |= app.advance_reasoning_collapse(); app.maybe_capture_runtime_memory_heartbeat(); needs_redraw |= app.progress_copy_selection_edge_autoscroll(); app.progress_mouse_scroll_animation(); @@ -124,7 +123,7 @@ pub(super) async fn handle_tick(app: &mut App, remote: &mut RemoteConnection) -> } } - if let Some(target_session) = crate::tui::workspace_client::take_pending_resume_session() { + if let Some(target_session) = app.workspace_client.take_pending_resume_session() { match remote.resume_session(&target_session).await { Ok(()) => { let label = crate::id::extract_session_name(&target_session) @@ -737,7 +736,7 @@ pub(super) fn handle_disconnect( if let Some(chunk) = app.stream_buffer.flush() { app.append_streaming_text(&chunk); } - if !app.streaming_text.is_empty() { + if !app.streaming.streaming_text.is_empty() { let content = app.take_streaming_text(); let content = app.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { @@ -1250,7 +1249,7 @@ async fn detect_and_cancel_stall(app: &mut App, remote: &mut RemoteConnection) { app.current_message_id = None; app.processing_started = None; app.last_stream_activity = None; - if !app.streaming_text.is_empty() { + if !app.streaming.streaming_text.is_empty() { let content = app.take_streaming_text(); let content = app.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { diff --git a/crates/jcode-tui/src/tui/app/remote/server_events.rs b/crates/jcode-tui/src/tui/app/remote/server_events.rs index 9d8b4d62a..3e9fc3b71 100644 --- a/crates/jcode-tui/src/tui/app/remote/server_events.rs +++ b/crates/jcode-tui/src/tui/app/remote/server_events.rs @@ -313,14 +313,16 @@ pub(in crate::tui::app) fn handle_server_event( if let Some(chunk) = app.stream_buffer.flush() { app.append_streaming_text(&chunk); } - if matches!( - app.status, - ProcessingStatus::Sending - | ProcessingStatus::Connecting(_) - | ProcessingStatus::Thinking(_) - ) || (app.is_processing && matches!(app.status, ProcessingStatus::Idle)) - { - app.status = ProcessingStatus::Streaming; + // Surface active reasoning in the status line. The server emits a + // `ConnectionPhase::Streaming` when reasoning starts (to kick off the + // client TPS timer), so the status arrives here as `Streaming`; flip it + // to `Thinking` while reasoning deltas flow. The next `TextDelta` moves + // it back to `Streaming`. + if !matches!(app.status, ProcessingStatus::RunningTool(_)) { + let thinking_start = *app.thinking_start.get_or_insert_with(Instant::now); + if !matches!(app.status, ProcessingStatus::Thinking(_)) { + app.status = ProcessingStatus::Thinking(thinking_start); + } } app.resume_streaming_tps(); app.append_reasoning_text(&text); @@ -328,6 +330,7 @@ pub(in crate::tui::app) fn handle_server_event( eager_stream_redraw } ServerEvent::ReasoningDone { .. } => { + app.thinking_start = None; app.close_reasoning_region(None); eager_stream_redraw } @@ -412,42 +415,44 @@ pub(in crate::tui::app) fn handle_server_event( cache_read_input, cache_creation_input, } => { - let previous_input = app.streaming_input_tokens; - let previous_output = app.streaming_output_tokens; - let previous_cache_read = app.streaming_cache_read_tokens; - let previous_cache_creation = app.streaming_cache_creation_tokens; - let was_recorded = app.current_api_usage_recorded; + let previous_input = app.streaming.streaming_input_tokens; + let previous_output = app.streaming.streaming_output_tokens; + let previous_cache_read = app.streaming.streaming_cache_read_tokens; + let previous_cache_creation = app.streaming.streaming_cache_creation_tokens; + let was_recorded = app.kv_cache.current_api_usage_recorded; app.accumulate_streaming_output_tokens(output, call_output_tokens_seen); - app.streaming_input_tokens = input; - app.streaming_output_tokens = output; + app.streaming.streaming_input_tokens = input; + app.streaming.streaming_output_tokens = output; if cache_read_input.is_some() { - app.streaming_cache_read_tokens = cache_read_input; + app.streaming.streaming_cache_read_tokens = cache_read_input; } if cache_creation_input.is_some() { - app.streaming_cache_creation_tokens = cache_creation_input; + app.streaming.streaming_cache_creation_tokens = cache_creation_input; } if app.record_completed_stream_cache_usage() { - app.total_input_tokens = app.total_input_tokens.saturating_add(input); - app.total_output_tokens = app.total_output_tokens.saturating_add(output); + app.token_accounting.total_input_tokens = + app.token_accounting.total_input_tokens.saturating_add(input); + app.token_accounting.total_output_tokens = + app.token_accounting.total_output_tokens.saturating_add(output); // The server only reports tokens, never a dollar cost, so the // remote client prices each completed call itself. This is the // first usage snapshot for this call, so bill the full counts. app.accrue_remote_call_cost( input, output, - app.streaming_cache_read_tokens.unwrap_or(0), - app.streaming_cache_creation_tokens.unwrap_or(0), + app.streaming.streaming_cache_read_tokens.unwrap_or(0), + app.streaming.streaming_cache_creation_tokens.unwrap_or(0), ); app.last_api_completed = Some(Instant::now()); app.last_api_completed_provider = Some(::provider_name(app)); app.last_api_completed_model = Some(::provider_model(app)); app.last_turn_input_tokens = (input > 0).then_some(input); - } else if was_recorded && app.current_api_usage_recorded { - app.total_input_tokens = app - .total_input_tokens + } else if was_recorded && app.kv_cache.current_api_usage_recorded { + app.token_accounting.total_input_tokens = app + .token_accounting.total_input_tokens .saturating_add(input.saturating_sub(previous_input)); - app.total_output_tokens = app - .total_output_tokens + app.token_accounting.total_output_tokens = app + .token_accounting.total_output_tokens .saturating_add(output.saturating_sub(previous_output)); // Bill only the new tokens since the previous snapshot for this // same call, so a call that reports usage multiple times while @@ -455,53 +460,53 @@ pub(in crate::tui::app) fn handle_server_event( app.accrue_remote_call_cost( input.saturating_sub(previous_input), output.saturating_sub(previous_output), - app.streaming_cache_read_tokens + app.streaming.streaming_cache_read_tokens .unwrap_or(0) .saturating_sub(previous_cache_read.unwrap_or(0)), - app.streaming_cache_creation_tokens + app.streaming.streaming_cache_creation_tokens .unwrap_or(0) .saturating_sub(previous_cache_creation.unwrap_or(0)), ); let had_cache_telemetry = previous_cache_read.is_some() || previous_cache_creation.is_some(); - let has_cache_telemetry = app.streaming_cache_read_tokens.is_some() - || app.streaming_cache_creation_tokens.is_some(); + let has_cache_telemetry = app.streaming.streaming_cache_read_tokens.is_some() + || app.streaming.streaming_cache_creation_tokens.is_some(); if has_cache_telemetry { let reported_delta = if had_cache_telemetry { input.saturating_sub(previous_input) } else { input }; - app.total_cache_reported_input_tokens = app - .total_cache_reported_input_tokens + app.token_accounting.total_cache_reported_input_tokens = app + .token_accounting.total_cache_reported_input_tokens .saturating_add(reported_delta); - app.total_cache_read_tokens = app.total_cache_read_tokens.saturating_add( - app.streaming_cache_read_tokens + app.token_accounting.total_cache_read_tokens = app.token_accounting.total_cache_read_tokens.saturating_add( + app.streaming.streaming_cache_read_tokens .unwrap_or(0) .saturating_sub(previous_cache_read.unwrap_or(0)), ); - app.total_cache_creation_tokens = - app.total_cache_creation_tokens.saturating_add( - app.streaming_cache_creation_tokens + app.token_accounting.total_cache_creation_tokens = + app.token_accounting.total_cache_creation_tokens.saturating_add( + app.streaming.streaming_cache_creation_tokens .unwrap_or(0) .saturating_sub(previous_cache_creation.unwrap_or(0)), ); - app.last_cache_reported_input_tokens = Some(input); - app.last_cache_read_tokens = Some(app.streaming_cache_read_tokens.unwrap_or(0)); - app.last_cache_creation_tokens = - Some(app.streaming_cache_creation_tokens.unwrap_or(0)); + app.token_accounting.last_cache_reported_input_tokens = Some(input); + app.token_accounting.last_cache_read_tokens = Some(app.streaming.streaming_cache_read_tokens.unwrap_or(0)); + app.token_accounting.last_cache_creation_tokens = + Some(app.streaming.streaming_cache_creation_tokens.unwrap_or(0)); } - if let Some(baseline) = app.kv_cache_baseline.as_mut() { + if let Some(baseline) = app.kv_cache.kv_cache_baseline.as_mut() { baseline.input_tokens = input; baseline.completed_at = Instant::now(); } - app.cache_next_optimal_input_tokens = + app.token_accounting.cache_next_optimal_input_tokens = Some(crate::tui::info_widget::effective_prompt_tokens( input, - app.streaming_cache_read_tokens.unwrap_or(0), - app.streaming_cache_creation_tokens.unwrap_or(0), + app.streaming.streaming_cache_read_tokens.unwrap_or(0), + app.streaming.streaming_cache_creation_tokens.unwrap_or(0), )); app.last_api_completed = Some(Instant::now()); app.last_api_completed_provider = Some(::provider_name(app)); @@ -595,7 +600,7 @@ pub(in crate::tui::app) fn handle_server_event( app.current_message_id, app.is_processing, app.status, - app.streaming_text.len(), + app.streaming.streaming_text.len(), app.pending_soft_interrupts.len(), app.queued_messages.len() )); @@ -610,7 +615,7 @@ pub(in crate::tui::app) fn handle_server_event( if let Some(chunk) = app.stream_buffer.flush() { app.append_streaming_text(&chunk); } - if !app.streaming_text.is_empty() { + if !app.streaming.streaming_text.is_empty() { let content = app.take_streaming_text(); let content = app.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { @@ -663,7 +668,7 @@ pub(in crate::tui::app) fn handle_server_event( let has_resumed_turn_evidence = had_remote_resume_activity || app.stream_message_ended || app.has_streaming_footer_stats() - || !app.streaming_text.is_empty() + || !app.streaming.streaming_text.is_empty() || !app.streaming_tool_calls.is_empty() || matches!( app.status, @@ -684,7 +689,7 @@ pub(in crate::tui::app) fn handle_server_event( app.append_streaming_text(&chunk); } app.pause_streaming_tps(false); - if !app.streaming_text.is_empty() { + if !app.streaming.streaming_text.is_empty() { let duration = app.display_turn_duration_secs(); let content = app.take_streaming_text(); let content = app.collapse_reasoning_for_commit(content); @@ -1049,25 +1054,25 @@ pub(in crate::tui::app) fn handle_server_event( app.thought_line_inserted = false; app.thinking_prefix_emitted = false; app.thinking_buffer.clear(); - app.streaming_input_tokens = 0; - app.streaming_output_tokens = 0; - app.streaming_cache_read_tokens = None; - app.streaming_cache_creation_tokens = None; - app.current_api_usage_recorded = false; - app.total_cache_reported_input_tokens = 0; - app.total_cache_read_tokens = 0; - app.total_cache_creation_tokens = 0; - app.total_cache_optimal_input_tokens = 0; - app.last_cache_reported_input_tokens = None; - app.last_cache_read_tokens = None; - app.last_cache_creation_tokens = None; - app.last_cache_optimal_input_tokens = None; - app.cache_next_optimal_input_tokens = None; - app.kv_cache_baseline = None; - app.pending_kv_cache_request = None; - app.kv_cache_turn_number = None; - app.kv_cache_turn_call_index = 0; - app.kv_cache_miss_samples.clear(); + app.streaming.streaming_input_tokens = 0; + app.streaming.streaming_output_tokens = 0; + app.streaming.streaming_cache_read_tokens = None; + app.streaming.streaming_cache_creation_tokens = None; + app.kv_cache.current_api_usage_recorded = false; + app.token_accounting.total_cache_reported_input_tokens = 0; + app.token_accounting.total_cache_read_tokens = 0; + app.token_accounting.total_cache_creation_tokens = 0; + app.token_accounting.total_cache_optimal_input_tokens = 0; + app.token_accounting.last_cache_reported_input_tokens = None; + app.token_accounting.last_cache_read_tokens = None; + app.token_accounting.last_cache_creation_tokens = None; + app.token_accounting.last_cache_optimal_input_tokens = None; + app.token_accounting.cache_next_optimal_input_tokens = None; + app.kv_cache.kv_cache_baseline = None; + app.kv_cache.pending_kv_cache_request = None; + app.kv_cache.kv_cache_turn_number = None; + app.kv_cache.kv_cache_turn_call_index = 0; + app.kv_cache.kv_cache_miss_samples.clear(); app.processing_started = None; app.clear_visible_turn_started(); app.replay_processing_started_ms = None; @@ -1145,12 +1150,12 @@ pub(in crate::tui::app) fn handle_server_event( app.remote_token_usage_totals = token_usage_totals; } if token_usage_totals.is_some() { - app.total_input_tokens = 0; - app.total_output_tokens = 0; - app.total_cache_reported_input_tokens = 0; - app.total_cache_read_tokens = 0; - app.total_cache_creation_tokens = 0; - app.total_cache_optimal_input_tokens = 0; + app.token_accounting.total_input_tokens = 0; + app.token_accounting.total_output_tokens = 0; + app.token_accounting.total_cache_reported_input_tokens = 0; + app.token_accounting.total_cache_read_tokens = 0; + app.token_accounting.total_cache_creation_tokens = 0; + app.token_accounting.total_cache_optimal_input_tokens = 0; } if let Some(totals) = token_usage_totals { crate::logging::info(&format!( @@ -1164,7 +1169,7 @@ pub(in crate::tui::app) fn handle_server_event( totals.cache_creation_input_tokens )); } - crate::tui::workspace_client::sync_after_history(&session_id, &app.remote_sessions); + app.workspace_client.sync_after_history(&session_id, &app.remote_sessions); if server_has_update == Some(true) && !app.pending_server_reload { app.pending_server_reload = true; @@ -1674,7 +1679,7 @@ pub(in crate::tui::app) fn handle_server_event( if let Some(chunk) = app.stream_buffer.flush() { app.append_streaming_text(&chunk); } - if !app.streaming_text.is_empty() { + if !app.streaming.streaming_text.is_empty() { let duration = app.display_turn_duration_secs(); let flushed = app.take_streaming_text(); let flushed = app.collapse_reasoning_for_commit(flushed); @@ -1868,7 +1873,7 @@ pub(in crate::tui::app) fn handle_server_event( new_session_name, .. } => { - if crate::tui::workspace_client::handle_split_response(&new_session_id) { + if app.workspace_client.handle_split_response(&new_session_id) { finish_remote_split_launch(app); app.pending_split_request = false; app.pending_split_startup_message = None; diff --git a/crates/jcode-tui/src/tui/app/remote/workspace.rs b/crates/jcode-tui/src/tui/app/remote/workspace.rs index 9305700fb..62c6eea5b 100644 --- a/crates/jcode-tui/src/tui/app/remote/workspace.rs +++ b/crates/jcode-tui/src/tui/app/remote/workspace.rs @@ -10,7 +10,7 @@ pub(super) async fn handle_workspace_navigation_key( modifiers: KeyModifiers, remote: &mut RemoteConnection, ) -> Result { - if !crate::tui::workspace_client::is_enabled() { + if !app.workspace_client.is_enabled() { return Ok(false); } @@ -19,10 +19,10 @@ pub(super) async fn handle_workspace_navigation_key( }; let target = match direction { - WorkspaceNavigationDirection::Left => crate::tui::workspace_client::navigate_left(), - WorkspaceNavigationDirection::Right => crate::tui::workspace_client::navigate_right(), - WorkspaceNavigationDirection::Up => crate::tui::workspace_client::navigate_up(), - WorkspaceNavigationDirection::Down => crate::tui::workspace_client::navigate_down(), + WorkspaceNavigationDirection::Left => app.workspace_client.navigate_left(), + WorkspaceNavigationDirection::Right => app.workspace_client.navigate_right(), + WorkspaceNavigationDirection::Up => app.workspace_client.navigate_up(), + WorkspaceNavigationDirection::Down => app.workspace_client.navigate_down(), }; if app.is_processing { @@ -60,20 +60,20 @@ pub(super) async fn handle_workspace_command( match trimmed { "/workspace" | "/workspace status" => { app.push_display_message(DisplayMessage::system( - crate::tui::workspace_client::status_summary(), + app.workspace_client.status_summary(), )); return Ok(true); } "/workspace on" | "/workspace import" => { - crate::tui::workspace_client::enable(current_session, &app.remote_sessions); + app.workspace_client.enable(current_session, &app.remote_sessions); app.set_status_notice("Workspace mode enabled"); app.push_display_message(DisplayMessage::system( - crate::tui::workspace_client::status_summary(), + app.workspace_client.status_summary(), )); return Ok(true); } "/workspace off" => { - crate::tui::workspace_client::disable(); + app.workspace_client.disable(); app.set_status_notice("Workspace mode disabled"); app.push_display_message(DisplayMessage::system("Workspace mode: off".to_string())); return Ok(true); @@ -91,8 +91,8 @@ pub(super) async fn handle_workspace_command( }; if let Some(target) = target { - crate::tui::workspace_client::enable(current_session, &app.remote_sessions); - crate::tui::workspace_client::queue_split_target(target); + app.workspace_client.enable(current_session, &app.remote_sessions); + app.workspace_client.queue_split_target(target); app.pending_split_label = Some("Workspace".to_string()); if app.is_processing { app.pending_split_request = true; diff --git a/crates/jcode-tui/src/tui/app/replay.rs b/crates/jcode-tui/src/tui/app/replay.rs index 4e619f921..900f397a2 100644 --- a/crates/jcode-tui/src/tui/app/replay.rs +++ b/crates/jcode-tui/src/tui/app/replay.rs @@ -410,12 +410,12 @@ pub(super) fn apply_replay_event( app.is_processing = true; app.processing_started = Some(Instant::now()); app.status = ProcessingStatus::Thinking(Instant::now()); - app.streaming_tps_start = None; - app.streaming_tps_elapsed = Duration::ZERO; - app.streaming_tps_collect_output = false; - app.streaming_total_output_tokens = 0; - app.streaming_tps_observed_output_tokens = 0; - app.streaming_tps_observed_elapsed = Duration::ZERO; + app.streaming.streaming_tps_start = None; + app.streaming.streaming_tps_elapsed = Duration::ZERO; + app.streaming.streaming_tps_collect_output = false; + app.streaming.streaming_total_output_tokens = 0; + app.streaming.streaming_tps_observed_output_tokens = 0; + app.streaming.streaming_tps_observed_elapsed = Duration::ZERO; app.replay_processing_started_ms = replay_processing_started_ms; } ReplayEvent::MemoryInjection { diff --git a/crates/jcode-tui/src/tui/app/run_shell.rs b/crates/jcode-tui/src/tui/app/run_shell.rs index 5bb954e75..4eedd0e71 100644 --- a/crates/jcode-tui/src/tui/app/run_shell.rs +++ b/crates/jcode-tui/src/tui/app/run_shell.rs @@ -64,7 +64,7 @@ pub(super) fn status_spinner_only_symbol(app: &App) -> Option<&'static str> { // When decorative animations are off it advances at the smooth liveness // rate; otherwise it uses the full-rate spinner clock. if !app.is_processing - || !app.streaming_text.is_empty() + || !app.streaming.streaming_text.is_empty() || app.centered_mode() || app.has_pending_mouse_scroll_animation() || app.remote_startup_phase_active() diff --git a/crates/jcode-tui/src/tui/app/split_view.rs b/crates/jcode-tui/src/tui/app/split_view.rs index cf127f127..36e64ea1d 100644 --- a/crates/jcode-tui/src/tui/app/split_view.rs +++ b/crates/jcode-tui/src/tui/app/split_view.rs @@ -99,7 +99,7 @@ impl App { } fn refresh_split_view_cache(&mut self, force: bool) -> bool { - let streaming_hash = hash_str(&self.streaming_text); + let streaming_hash = hash_str(&self.streaming.streaming_text); if !force && self.split_view_rendered_display_version == self.display_messages_version && self.split_view_rendered_streaming_hash == streaming_hash diff --git a/crates/jcode-tui/src/tui/app/state_ui.rs b/crates/jcode-tui/src/tui/app/state_ui.rs index e2edf6817..15780c624 100644 --- a/crates/jcode-tui/src/tui/app/state_ui.rs +++ b/crates/jcode-tui/src/tui/app/state_ui.rs @@ -677,7 +677,7 @@ impl App { tool_data: m.tool_data.clone(), }) .collect(), - streaming_text: self.streaming_text.clone(), + streaming_text: self.streaming.streaming_text.clone(), streaming_tool_calls: self.streaming_tool_calls.clone(), input: self.input.clone(), cursor_pos: self.cursor_pos, @@ -698,10 +698,10 @@ impl App { .map(|s| s.name.clone()) .collect(), session_id: self.provider_session_id.clone(), - input_tokens: self.streaming_input_tokens, - output_tokens: self.streaming_output_tokens, - cache_read_input_tokens: self.streaming_cache_read_tokens, - cache_creation_input_tokens: self.streaming_cache_creation_tokens, + input_tokens: self.streaming.streaming_input_tokens, + output_tokens: self.streaming.streaming_output_tokens, + cache_read_input_tokens: self.streaming.streaming_cache_read_tokens, + cache_creation_input_tokens: self.streaming.streaming_cache_creation_tokens, queued_messages: self.queued_messages.clone(), } } @@ -984,10 +984,10 @@ fn format_cache_stats(app: &App) -> String { let remote_cache_write = remote_usage .map(|usage| usage.cache_creation_input_tokens) .unwrap_or(0); - let reported = remote_cache_reported.saturating_add(app.total_cache_reported_input_tokens); - let read = remote_cache_read.saturating_add(app.total_cache_read_tokens); - let write = remote_cache_write.saturating_add(app.total_cache_creation_tokens); - let optimal = app.total_cache_optimal_input_tokens; + let reported = remote_cache_reported.saturating_add(app.token_accounting.total_cache_reported_input_tokens); + let read = remote_cache_read.saturating_add(app.token_accounting.total_cache_read_tokens); + let write = remote_cache_write.saturating_add(app.token_accounting.total_cache_creation_tokens); + let optimal = app.token_accounting.total_cache_optimal_input_tokens; // `reported` is the aggregate of provider-reported `input_tokens`, which for // split-accounting providers (Anthropic) excludes cached + cache-creation // tokens. Percentages must use the effective prompt size so they stay in @@ -999,30 +999,30 @@ fn format_cache_stats(app: &App) -> String { let optimal_pct = (optimal > 0).then(|| cache_ratio_pct(read, optimal)); let cache_totals_source = match ( remote_usage.is_some(), - app.total_cache_reported_input_tokens > 0, + app.token_accounting.total_cache_reported_input_tokens > 0, ) { (true, true) => "remote_history+client_observed_api_calls", (true, false) => "remote_history", (false, true) => "client_observed_api_calls", (false, false) => "none_yet", }; - let live_cache_telemetry = app.streaming_input_tokens > 0 - && !app.current_api_usage_recorded - && (app.streaming_cache_read_tokens.is_some() - || app.streaming_cache_creation_tokens.is_some()); + let live_cache_telemetry = app.streaming.streaming_input_tokens > 0 + && !app.kv_cache.current_api_usage_recorded + && (app.streaming.streaming_cache_read_tokens.is_some() + || app.streaming.streaming_cache_creation_tokens.is_some()); let live_reported = if live_cache_telemetry { - app.streaming_input_tokens + app.streaming.streaming_input_tokens } else { 0 }; let reported_including_live = reported.saturating_add(live_reported); let read_including_live = read.saturating_add(if live_cache_telemetry { - app.streaming_cache_read_tokens.unwrap_or(0) + app.streaming.streaming_cache_read_tokens.unwrap_or(0) } else { 0 }); let write_including_live = write.saturating_add(if live_cache_telemetry { - app.streaming_cache_creation_tokens.unwrap_or(0) + app.streaming.streaming_cache_creation_tokens.unwrap_or(0) } else { 0 }); @@ -1130,9 +1130,9 @@ fn format_cache_stats(app: &App) -> String { let (history_input_tokens, history_output_tokens, totals_source) = if app.is_remote { if let Some((input, output)) = remote_history_tokens { ( - input.saturating_add(app.total_input_tokens), - output.saturating_add(app.total_output_tokens), - if app.total_input_tokens > 0 || app.total_output_tokens > 0 { + input.saturating_add(app.token_accounting.total_input_tokens), + output.saturating_add(app.token_accounting.total_output_tokens), + if app.token_accounting.total_input_tokens > 0 || app.token_accounting.total_output_tokens > 0 { "remote_history+client_observed_api_calls" } else { "remote_history" @@ -1140,27 +1140,27 @@ fn format_cache_stats(app: &App) -> String { ) } else { ( - app.total_input_tokens, - app.total_output_tokens, + app.token_accounting.total_input_tokens, + app.token_accounting.total_output_tokens, "client_observed_api_calls", ) } } else { ( - app.total_input_tokens, - app.total_output_tokens, + app.token_accounting.total_input_tokens, + app.token_accounting.total_output_tokens, "local_completed_turns", ) }; let live_unrecorded_input_tokens = - if app.streaming_input_tokens > 0 && !app.current_api_usage_recorded { - app.streaming_input_tokens + if app.streaming.streaming_input_tokens > 0 && !app.kv_cache.current_api_usage_recorded { + app.streaming.streaming_input_tokens } else { 0 }; let live_unrecorded_output_tokens = - if app.streaming_output_tokens > 0 && !app.current_api_usage_recorded { - app.streaming_output_tokens + if app.streaming.streaming_output_tokens > 0 && !app.kv_cache.current_api_usage_recorded { + app.streaming.streaming_output_tokens } else { 0 }; @@ -1211,22 +1211,22 @@ fn format_cache_stats(app: &App) -> String { )); lines.push(format!( "- client_observed_completed_input_tokens: {}", - bold_count(app.total_input_tokens) + bold_count(app.token_accounting.total_input_tokens) )); lines.push(format!( "- client_observed_completed_output_tokens: {}", - bold_count(app.total_output_tokens) + bold_count(app.token_accounting.total_output_tokens) )); - lines.push(format!("- total_cost_usd: {:.6}", app.total_cost)); + lines.push(format!("- total_cost_usd: {:.6}", app.cost.total_cost)); lines.push(format!( "- cached_prompt_price_per_1m: {}", - app.cached_prompt_price + app.cost.cached_prompt_price .map(|price| format!("{:.6}", price)) .unwrap_or_else(|| "None".to_string()) )); lines.push(format!( "- cached_completion_price_per_1m: {}", - app.cached_completion_price + app.cost.cached_completion_price .map(|price| format!("{:.6}", price)) .unwrap_or_else(|| "None".to_string()) )); @@ -1309,50 +1309,50 @@ fn format_cache_stats(app: &App) -> String { )); lines.push(format!( "- last_cache_reported_input_tokens: {}", - opt_u64(app.last_cache_reported_input_tokens) + opt_u64(app.token_accounting.last_cache_reported_input_tokens) )); lines.push(format!( "- last_cache_read_tokens: {}", - opt_u64(app.last_cache_read_tokens) + opt_u64(app.token_accounting.last_cache_read_tokens) )); lines.push(format!( "- last_cache_creation_tokens: {}", - opt_u64(app.last_cache_creation_tokens) + opt_u64(app.token_accounting.last_cache_creation_tokens) )); lines.push(format!( "- last_cache_optimal_input_tokens: {}", - opt_u64(app.last_cache_optimal_input_tokens) + opt_u64(app.token_accounting.last_cache_optimal_input_tokens) )); lines.push(format!( "- cache_next_optimal_input_tokens: {}", - opt_u64(app.cache_next_optimal_input_tokens) + opt_u64(app.token_accounting.cache_next_optimal_input_tokens) )); lines.push(String::new()); lines.push("Current / live stream counters".to_string()); lines.push(format!( "- streaming_input_tokens: {}", - bold_count(app.streaming_input_tokens) + bold_count(app.streaming.streaming_input_tokens) )); lines.push(format!( "- streaming_output_tokens: {}", - bold_count(app.streaming_output_tokens) + bold_count(app.streaming.streaming_output_tokens) )); lines.push(format!( "- streaming_total_output_tokens: {}", - bold_count(app.streaming_total_output_tokens) + bold_count(app.streaming.streaming_total_output_tokens) )); lines.push(format!( "- streaming_cache_read_tokens: {}", - opt_u64(app.streaming_cache_read_tokens) + opt_u64(app.streaming.streaming_cache_read_tokens) )); lines.push(format!( "- streaming_cache_creation_tokens: {}", - opt_u64(app.streaming_cache_creation_tokens) + opt_u64(app.streaming.streaming_cache_creation_tokens) )); lines.push(format!( "- current_api_usage_recorded: {}", - app.current_api_usage_recorded + app.kv_cache.current_api_usage_recorded )); lines.push(format!("- status: {:?}", app.status)); lines.push(format!("- is_processing: {}", app.is_processing)); @@ -1381,18 +1381,18 @@ fn format_cache_stats(app: &App) -> String { lines.push("KV cache tracker state".to_string()); lines.push(format!( "- kv_cache_turn_number: {}", - opt_usize(app.kv_cache_turn_number) + opt_usize(app.kv_cache.kv_cache_turn_number) )); lines.push(format!( "- kv_cache_turn_call_index: {}", - app.kv_cache_turn_call_index + app.kv_cache.kv_cache_turn_call_index )); lines.push(format!( "- kv_cache_miss_samples_len: {}", - app.kv_cache_miss_samples.len() + app.kv_cache.kv_cache_miss_samples.len() )); - push_cache_baseline(&mut lines, "baseline", app.kv_cache_baseline.as_ref()); - if let Some(request) = app.pending_kv_cache_request.as_ref() { + push_cache_baseline(&mut lines, "baseline", app.kv_cache.kv_cache_baseline.as_ref()); + if let Some(request) = app.kv_cache.pending_kv_cache_request.as_ref() { lines.push("- pending_request: present".to_string()); lines.push(format!( "- pending_request.turn_number: {}", @@ -1469,10 +1469,10 @@ fn format_cache_stats(app: &App) -> String { lines.push(String::new()); lines.push("Recent miss attributions".to_string()); - if app.kv_cache_miss_samples.is_empty() { + if app.kv_cache.kv_cache_miss_samples.is_empty() { lines.push("- none attributed".to_string()); } else { - for sample in app.kv_cache_miss_samples.iter().rev() { + for sample in app.kv_cache.kv_cache_miss_samples.iter().rev() { lines.push(format!( "- turn={} call={} missed_tokens={} reason={}", sample.turn_number, @@ -1738,7 +1738,7 @@ pub(super) fn handle_info_command(app: &mut App, trimmed: &str) -> bool { )); info.push_str(&format!( "Tokens: ↑{} ↓{}\n", - app.total_input_tokens, app.total_output_tokens + app.token_accounting.total_input_tokens, app.token_accounting.total_output_tokens )); info.push_str(&format!("Terminal: {}\n", terminal_size)); info.push_str(&format!("CWD: {}\n", cwd)); @@ -1818,7 +1818,7 @@ pub(super) fn handle_info_command(app: &mut App, trimmed: &str) -> bool { app.provider.reasoning_effort(), app.provider.service_tier(), app.provider.transport(), - Some((app.total_input_tokens, app.total_output_tokens)), + Some((app.token_accounting.total_input_tokens, app.token_accounting.total_output_tokens)), ) }; diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index e019b9add..d45ea60d2 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -1425,7 +1425,45 @@ struct ExternalCliSuggestionCandidate { context: Option, } +/// How long a scan of the external-CLI session directories is reused before we +/// re-scan. The onboarding welcome screen animates a donut, so it redraws at +/// animation FPS and calls [`latest_external_cli_continuation_prompt`] multiple +/// times per frame. Scanning `~/.codex/sessions` / `~/.claude/projects` (reading +/// and JSON-parsing the newest transcripts) can cost hundreds of milliseconds +/// for users with large histories, which would otherwise make first-run +/// onboarding extremely laggy. A short TTL keeps the suggestion fresh while +/// reducing the cost to a single scan per window. +const EXTERNAL_CLI_PROMPT_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30); + +/// Cached result of the external-CLI continuation-prompt scan, with the time it +/// was computed. `None` value means "scanned, but nothing found". +static EXTERNAL_CLI_PROMPT_CACHE: std::sync::LazyLock< + std::sync::RwLock, std::time::Instant)>>, +> = std::sync::LazyLock::new(|| std::sync::RwLock::new(None)); + +/// Cached front-end for [`latest_external_cli_continuation_prompt_uncached`]. +/// +/// See [`EXTERNAL_CLI_PROMPT_CACHE_TTL`] for why this is cached: the uncached +/// scan reads and parses the newest external transcripts, which is expensive for +/// large histories and would otherwise run several times per onboarding frame. fn latest_external_cli_continuation_prompt() -> Option { + if let Ok(cache) = EXTERNAL_CLI_PROMPT_CACHE.read() + && let Some((ref value, ref when)) = *cache + && when.elapsed() < EXTERNAL_CLI_PROMPT_CACHE_TTL + { + return value.clone(); + } + + let value = latest_external_cli_continuation_prompt_uncached(); + + if let Ok(mut cache) = EXTERNAL_CLI_PROMPT_CACHE.write() { + *cache = Some((value.clone(), std::time::Instant::now())); + } + + value +} + +fn latest_external_cli_continuation_prompt_uncached() -> Option { let home = std::env::var_os("HOME").map(PathBuf::from)?; let mut candidates = Vec::new(); candidates.extend(latest_jsonl_suggestion_candidates( @@ -1645,6 +1683,40 @@ mod external_cli_suggestion_tests { use super::*; use std::io::Write; + /// Faithful, real-home measurement of the per-frame onboarding cost. + /// Ignored by default (depends on local ~/.codex and ~/.claude contents). + /// Run with: + /// cargo test -p jcode-tui --lib onboarding_suggestion_scan_cost -- --ignored --nocapture + #[test] + #[ignore] + fn onboarding_suggestion_scan_cost() { + use std::time::Instant; + + // Cold: the uncached scan that reads + JSON-parses the newest external + // transcripts. This is the work that used to run several times per frame. + let cold_start = Instant::now(); + let cold = latest_external_cli_continuation_prompt_uncached(); + let cold_ms = cold_start.elapsed().as_secs_f64() * 1000.0; + + // Warm: the cached front-end the onboarding screen actually calls. Prime + // the cache once, then measure repeated calls (as a redrawing frame does). + let _ = latest_external_cli_continuation_prompt(); + let runs = 1000; + let warm_start = Instant::now(); + let mut warm = None; + for _ in 0..runs { + warm = latest_external_cli_continuation_prompt(); + } + let warm_ms = warm_start.elapsed().as_secs_f64() * 1000.0 / runs as f64; + + eprintln!( + "external-cli continuation prompt: cold(uncached)={cold_ms:.1} ms, \ + warm(cached, avg of {runs})={warm_ms:.4} ms; cold_some={}, warm_some={}", + cold.is_some(), + warm.is_some() + ); + } + #[test] fn parses_claude_code_jsonl_with_session_path_and_context() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/jcode-tui/src/tui/app/state_ui_messages.rs b/crates/jcode-tui/src/tui/app/state_ui_messages.rs index 906e4cc68..5e2a58dfd 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_messages.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_messages.rs @@ -79,8 +79,6 @@ impl App { pub(super) fn replace_display_messages(&mut self, mut messages: Vec) { compact_display_messages_for_storage(&mut messages); - // Indices the collapse animation targets no longer apply to the new list. - self.reasoning_collapse = None; self.display_messages = messages; self.sync_compacted_history_lazy_from_display_messages(); self.bump_display_messages_version(); @@ -343,12 +341,9 @@ impl App { pub(super) fn clear_display_messages(&mut self) { self.compacted_history_lazy = CompactedHistoryLazyState::default(); - // The transcript (and the index the collapse animation targets) is about - // to be discarded; drop any in-flight collapse so it can't mutate a stale - // or unrelated message. - self.reasoning_collapse = None; + // The transcript is about to be discarded; forget where the live reasoning + // block started so a stale offset can't slice the new stream. self.reasoning_block_start = None; - self.reasoning_block_started_at = None; if !self.display_messages.is_empty() { self.display_messages.clear(); self.bump_display_messages_version(); diff --git a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs index 9ae17a20a..ea7d8ef46 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs @@ -23,7 +23,7 @@ impl App { } pub fn streaming_text(&self) -> &str { - &self.streaming_text + &self.streaming.streaming_text } pub fn active_skill(&self) -> Option<&str> { @@ -44,7 +44,7 @@ impl App { } pub fn streaming_tokens(&self) -> (u64, u64) { - (self.streaming_input_tokens, self.streaming_output_tokens) + (self.streaming.streaming_input_tokens, self.streaming.streaming_output_tokens) } pub(super) fn build_turn_footer(&self, duration: Option) -> Option { @@ -56,16 +56,16 @@ impl App { if let Some(tps) = self.compute_streaming_tps() { parts.push(format!("{:.1} tps", tps)); } - if self.streaming_input_tokens > 0 || self.streaming_output_tokens > 0 { + if self.streaming.streaming_input_tokens > 0 || self.streaming.streaming_output_tokens > 0 { parts.push(format!( "↑{} ↓{}", - format_tokens(self.streaming_input_tokens), - format_tokens(self.streaming_output_tokens) + format_tokens(self.streaming.streaming_input_tokens), + format_tokens(self.streaming.streaming_output_tokens) )); } if let Some(cache) = format_cache_footer( - self.streaming_cache_read_tokens, - self.streaming_cache_creation_tokens, + self.streaming.streaming_cache_read_tokens, + self.streaming.streaming_cache_creation_tokens, ) { parts.push(cache); } @@ -78,10 +78,10 @@ impl App { } pub(super) fn has_streaming_footer_stats(&self) -> bool { - self.streaming_input_tokens > 0 - || self.streaming_output_tokens > 0 - || self.streaming_cache_read_tokens.is_some() - || self.streaming_cache_creation_tokens.is_some() + self.streaming.streaming_input_tokens > 0 + || self.streaming.streaming_output_tokens > 0 + || self.streaming.streaming_cache_read_tokens.is_some() + || self.streaming.streaming_cache_creation_tokens.is_some() || self.compute_streaming_tps().is_some() } @@ -93,7 +93,7 @@ impl App { self.last_api_completed_provider = Some(::provider_name(self)); self.last_api_completed_model = Some(::provider_model(self)); self.last_turn_input_tokens = { - let input = self.streaming_input_tokens; + let input = self.streaming.streaming_input_tokens; if input > 0 { Some(input) } else { None } }; @@ -124,9 +124,9 @@ impl App { &provider, upstream_provider, user_turn_count, - self.streaming_input_tokens, - self.streaming_cache_read_tokens, - self.streaming_cache_creation_tokens, + self.streaming.streaming_input_tokens, + self.streaming.streaming_cache_read_tokens, + self.streaming.streaming_cache_creation_tokens, cache_ttl.as_ref(), ); @@ -134,12 +134,12 @@ impl App { // Collect context for debugging let session_id = self.session_id().to_string(); let model = ::provider_model(self); - let input_tokens = self.streaming_input_tokens; - let output_tokens = self.streaming_output_tokens; + let input_tokens = self.streaming.streaming_input_tokens; + let output_tokens = self.streaming.streaming_output_tokens; // Format as Option to distinguish None vs Some(0) - let cache_creation_dbg = format!("{:?}", self.streaming_cache_creation_tokens); - let cache_read_dbg = format!("{:?}", self.streaming_cache_read_tokens); + let cache_creation_dbg = format!("{:?}", self.streaming.streaming_cache_creation_tokens); + let cache_read_dbg = format!("{:?}", self.streaming.streaming_cache_read_tokens); // Count message types in conversation let mut user_msgs = 0; diff --git a/crates/jcode-tui/src/tui/app/tests.rs b/crates/jcode-tui/src/tui/app/tests.rs index b07dc8523..aebc2ceca 100644 --- a/crates/jcode-tui/src/tui/app/tests.rs +++ b/crates/jcode-tui/src/tui/app/tests.rs @@ -141,7 +141,7 @@ fn cold_cache_warning_is_persisted_when_starting_next_request() { crate::provider::anthropic::set_cache_ttl_1h(true); app.display_messages.push(DisplayMessage::user("first")); let session_id = app.kv_cache_session_id(); - app.kv_cache_baseline = Some(KvCacheBaseline { + app.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id, input_tokens: 911_873, completed_at: Instant::now() - Duration::from_secs(3723), @@ -190,7 +190,7 @@ fn kv_cache_baseline_from_other_session_is_ignored() { .map(|i| Message::user(format!("big session message {i}").as_str())) .collect(); let big_signature = App::kv_cache_request_signature(&big_history, &[], "system", ""); - app.kv_cache_baseline = Some(KvCacheBaseline { + app.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id: Some("session_big".to_string()), input_tokens: 200_000, completed_at: Instant::now(), @@ -211,7 +211,7 @@ fn kv_cache_baseline_from_other_session_is_ignored() { app.begin_remote_kv_cache_request(small_signature); let request = app - .pending_kv_cache_request + .kv_cache.pending_kv_cache_request .as_ref() .expect("request should be pending"); assert!( @@ -236,7 +236,7 @@ fn kv_cache_baseline_same_session_still_compares() { Message::assistant_text("first answer"), ]; let baseline_signature = App::kv_cache_request_signature(&history, &[], "system", ""); - app.kv_cache_baseline = Some(KvCacheBaseline { + app.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id: Some("session_same".to_string()), input_tokens: 1_000, completed_at: Instant::now(), @@ -253,7 +253,7 @@ fn kv_cache_baseline_same_session_still_compares() { app.begin_remote_kv_cache_request(grown_signature); let request = app - .pending_kv_cache_request + .kv_cache.pending_kv_cache_request .as_ref() .expect("request should be pending"); assert!( @@ -307,12 +307,12 @@ fn remote_token_usage_records_cache_stats_before_done_and_dedupes_snapshots() { &mut remote, ); - assert_eq!(app.total_cache_reported_input_tokens, 63_762); - assert_eq!(app.total_cache_read_tokens, 0); - assert_eq!(app.last_cache_reported_input_tokens, Some(63_762)); - assert_eq!(app.total_input_tokens, 63_762); + assert_eq!(app.token_accounting.total_cache_reported_input_tokens, 63_762); + assert_eq!(app.token_accounting.total_cache_read_tokens, 0); + assert_eq!(app.token_accounting.last_cache_reported_input_tokens, Some(63_762)); + assert_eq!(app.token_accounting.total_input_tokens, 63_762); assert!(app.last_api_completed.is_some()); - assert!(app.pending_kv_cache_request.is_none()); + assert!(app.kv_cache.pending_kv_cache_request.is_none()); app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -324,8 +324,8 @@ fn remote_token_usage_records_cache_stats_before_done_and_dedupes_snapshots() { &mut remote, ); - assert_eq!(app.total_cache_reported_input_tokens, 63_762); - assert_eq!(app.total_input_tokens, 63_762); + assert_eq!(app.token_accounting.total_cache_reported_input_tokens, 63_762); + assert_eq!(app.token_accounting.total_input_tokens, 63_762); assert!(super::state_ui::handle_info_command( &mut app, @@ -943,9 +943,9 @@ fn remote_done_finalizes_resumed_activity_without_current_message_id() { observed_at: Instant::now(), current_tool_name: Some("bg".to_string()), }); - app.streaming_input_tokens = 63_762; - app.streaming_output_tokens = 153; - app.streaming_cache_read_tokens = Some(0); + app.streaming.streaming_input_tokens = 63_762; + app.streaming.streaming_output_tokens = 153; + app.streaming.streaming_cache_read_tokens = Some(0); app.stream_message_ended = true; app.handle_server_event(crate::protocol::ServerEvent::Done { id: 99 }, &mut remote); diff --git a/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs index b79b6aec5..4d4378a4e 100644 --- a/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs @@ -99,7 +99,7 @@ fn session_picker_enter_queues_current_terminal_resume_and_closes_overlay() { assert!(app.session_picker_overlay.is_none()); assert_eq!( - crate::tui::workspace_client::take_pending_resume_session().as_deref(), + app.workspace_client.take_pending_resume_session().as_deref(), Some("session_here_123") ); } @@ -974,7 +974,7 @@ fn test_splitview_mirrors_chat_and_streaming_text() { DisplayMessage::assistant("We decided to ship it.".to_string()), ]; app.bump_display_messages_version(); - app.streaming_text = "Working on the follow-up now...".to_string(); + app.streaming.streaming_text = "Working on the follow-up now...".to_string(); app.set_split_view_enabled(true, true); let page = app diff --git a/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs b/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs index f93ecfc9c..9bba6b32b 100644 --- a/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs +++ b/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs @@ -574,6 +574,27 @@ fn startup_check_is_noop_once_committed() { }); } +#[test] +fn startup_check_skips_selfdev_canary_session() { + with_temp_jcode_home(|| { + let mut app = create_test_app(); + app.onboarding_flow = None; + app.onboarding_startup_checked = false; + // Self-dev / canary sessions (e.g. the niri `jcode self-dev` hotkey) take + // a launch path that never bumps `launch_count`, so without this guard the + // new-user heuristic would re-onboard on every spawn. + app.session.is_canary = true; + + app.maybe_begin_onboarding_flow_on_startup(); + + assert!(app.onboarding_startup_checked); + assert!( + app.onboarding_flow.is_none(), + "self-dev/canary sessions must never auto-start onboarding" + ); + }); +} + #[test] fn model_validation_success_appends_single_ready_line() { let mut app = create_test_app(); diff --git a/crates/jcode-tui/src/tui/app/tests/reasoning_region.rs b/crates/jcode-tui/src/tui/app/tests/reasoning_region.rs index b4d1ecf76..5d10f697b 100644 --- a/crates/jcode-tui/src/tui/app/tests/reasoning_region.rs +++ b/crates/jcode-tui/src/tui/app/tests/reasoning_region.rs @@ -10,6 +10,11 @@ // The in-progress (not yet newline-terminated) line renders live as a partial // `*…*` tail so reasoning trickles in token-by-token; that tail is rebuilt in // place on each delta and promoted to a committed line when its newline arrives. +// +// In `current` mode (the default) reasoning is *ephemeral*: only the live block is +// ever shown. Once it closes (the model answers or runs a tool) the whole block is +// sliced back out of the stream in place, so no per-block trace accumulates and +// answer text keeps its order. #[test] fn reasoning_region_emits_dim_italic_lines_no_gutter_header_or_footer() { @@ -41,23 +46,17 @@ fn reasoning_region_emits_dim_italic_lines_no_gutter_header_or_footer() { "second line not dim+italic: {streaming:?}" ); - // In `current` mode (the default), closing moves the block into a dedicated - // collapsing `"reasoning"` display message and clears it from the stream. + // In `current` mode (the default), closing discards the block in place: it + // leaves the live stream entirely and never becomes a persistent message. app.close_reasoning_region(None); assert!( app.streaming_text().is_empty(), - "reasoning should leave the live stream once collapsed: {:?}", + "reasoning should leave the live stream once discarded: {:?}", app.streaming_text() ); - let reasoning_msg = app - .display_messages - .iter() - .find(|m| m.role == "reasoning") - .expect("reasoning message present"); assert!( - reasoning_msg.content.contains(sentinel), - "reasoning message keeps dim+italic markup: {:?}", - reasoning_msg.content + !app.display_messages.iter().any(|m| m.role == "reasoning"), + "ephemeral reasoning must not create a persistent message" ); } @@ -85,14 +84,15 @@ fn reasoning_region_closes_before_normal_output() { !answer_line.contains(jcode_tui_markdown::REASONING_SENTINEL), "final answer must not be styled as reasoning: {answer_line:?}" ); - // The reasoning collapsed into its own message; it is no longer in the stream. + // The reasoning was discarded; it is no longer in the stream and no persistent + // reasoning message was created. assert!( !text.contains(jcode_tui_markdown::REASONING_SENTINEL), "reasoning must not remain in the answer stream: {text:?}" ); assert!( - app.display_messages.iter().any(|m| m.role == "reasoning"), - "a collapsing reasoning message should exist" + !app.display_messages.iter().any(|m| m.role == "reasoning"), + "ephemeral reasoning must not create a persistent message" ); } @@ -129,16 +129,9 @@ fn reasoning_line_split_across_deltas_stays_one_run() { app.open_reasoning_region(); app.append_reasoning_text("one "); app.append_reasoning_text("two\n"); - app.close_reasoning_region(None); - // The split-across-deltas line is committed as a single emphasis run in the - // collapsed reasoning message. - let content = app - .display_messages - .iter() - .find(|m| m.role == "reasoning") - .map(|m| m.content.clone()) - .expect("reasoning message present"); + // While streaming live, the split-across-deltas line is a single emphasis run. + let content = app.streaming_text(); let sentinel = jcode_tui_markdown::REASONING_SENTINEL; assert!( content.contains(&format!("*{sentinel}one two{sentinel}*")), @@ -154,15 +147,9 @@ fn reasoning_region_renders_dim_italic_text_without_gutter() { app.open_reasoning_region(); app.append_reasoning_text("considering options\n"); - app.close_reasoning_region(None); - // In `current` mode the reasoning now lives in a dedicated collapsing message. - let reasoning_content = app - .display_messages - .iter() - .find(|m| m.role == "reasoning") - .map(|m| m.content.clone()) - .expect("reasoning message present"); + // The live reasoning renders dim+italic from the streaming buffer. + let reasoning_content = app.streaming_text().to_string(); let lines = crate::tui::markdown::render_markdown_with_width(&reasoning_content, Some(80)); let body = lines @@ -308,152 +295,81 @@ fn reasoning_close_promotes_pending_partial_line() { app.append_reasoning_text("final thought"); app.close_reasoning_region(None); - // The live stream no longer carries the reasoning; it moved into its message. + // The reasoning is discarded in place on close: it leaves the live stream and + // never becomes a persistent message. + let _ = sentinel; assert!( app.streaming_text().is_empty(), - "reasoning should leave the live stream once collapsed: {:?}", + "reasoning should leave the live stream once discarded: {:?}", app.streaming_text() ); - let content = app - .display_messages - .iter() - .find(|m| m.role == "reasoning") - .map(|m| m.content.clone()) - .expect("reasoning message present"); - assert_eq!( - content - .matches(&format!("*{sentinel}final thought{sentinel}*")) - .count(), - 1, - "pending partial promoted exactly once on close: {content:?}" + assert!( + !app.display_messages.iter().any(|m| m.role == "reasoning"), + "ephemeral reasoning must not create a persistent message" ); } #[test] -fn reasoning_block_line_markups_keeps_only_sentinel_lines() { - use crate::tui::app::input::{reasoning_block_line_markups, reasoning_message_content}; - - let mut block = String::new(); - block.push_str(&jcode_tui_markdown::reasoning_line_markup("alpha")); - block.push('\n'); // a blank separator line (no sentinel) - block.push_str(&jcode_tui_markdown::reasoning_line_markup("beta")); - - let lines = reasoning_block_line_markups(&block); - assert_eq!(lines.len(), 2, "blank separators are dropped: {lines:?}"); - let sentinel = jcode_tui_markdown::REASONING_SENTINEL; - assert!(lines[0].contains(&format!("{sentinel}alpha{sentinel}"))); - assert!(lines[1].contains(&format!("{sentinel}beta{sentinel}"))); - - // Full content shows every line; remaining==0 shows only the summary. - let summary = jcode_tui_markdown::reasoning_line_markup("▸ thought"); - let full = reasoning_message_content(&summary, &lines, lines.len()); - assert!(full.contains("alpha") && full.contains("beta")); - let collapsed = reasoning_message_content(&summary, &lines, 0); - assert!(collapsed.contains("▸ thought")); - assert!(!collapsed.contains("alpha") && !collapsed.contains("beta")); - - // A partial reveal keeps the *trailing* lines (oldest fold away first). - let partial = reasoning_message_content(&summary, &lines, 1); - assert!(partial.contains("beta"), "trailing line kept: {partial:?}"); - assert!(!partial.contains("alpha"), "leading line folded: {partial:?}"); -} - -#[test] -fn reasoning_summary_markup_uses_duration_when_known() { - use crate::tui::app::input::reasoning_summary_markup; - use std::time::Duration; - - let with_secs = reasoning_summary_markup(3, Some(Duration::from_secs(12))); - assert!(with_secs.contains("▸ thought for 12s"), "{with_secs:?}"); - - let no_time = reasoning_summary_markup(4, None); - assert!(no_time.contains("▸ thought (4 lines)"), "{no_time:?}"); -} - -#[test] -fn reasoning_collapse_finalizes_to_single_summary_line() { +fn reasoning_preceded_by_answer_keeps_order_and_drops_reasoning() { + // Answer text streamed *before* a reasoning block must stay in place and in + // order; closing the reasoning removes only the reasoning, leaving the answer. let mut app = create_test_app(); + let sentinel = jcode_tui_markdown::REASONING_SENTINEL; + app.append_streaming_text("Intro before thinking."); app.open_reasoning_region(); - app.append_reasoning_text("first\nsecond\nthird\n"); + app.append_reasoning_text("let me think\nstep two\n"); app.close_reasoning_region(None); + app.append_streaming_text("Conclusion after thinking."); - assert!(app.reasoning_collapse_active(), "collapse should start"); - - // Snapping finalizes the message to just the summary line. - app.finalize_reasoning_collapse(); - assert!(!app.reasoning_collapse_active(), "collapse cleared on finalize"); - - let content = app - .display_messages - .iter() - .find(|m| m.role == "reasoning") - .map(|m| m.content.clone()) - .expect("reasoning message present"); - assert!(content.contains("▸ thought"), "summary present: {content:?}"); - assert!(!content.contains("first"), "lines folded away: {content:?}"); - assert!(!content.contains("third"), "lines folded away: {content:?}"); + let text = app.streaming_text(); + assert!( + !text.contains(sentinel), + "reasoning must be fully removed: {text:?}" + ); + let intro = text.find("Intro before thinking.").expect("intro present"); + let concl = text + .find("Conclusion after thinking.") + .expect("conclusion present"); + assert!( + intro < concl, + "answer text must keep its original order: {text:?}" + ); + assert!( + !app.display_messages.iter().any(|m| m.role == "reasoning"), + "ephemeral reasoning must not create a persistent message" + ); } #[test] -fn reasoning_collapse_drops_when_target_message_replaced() { +fn multiple_reasoning_blocks_do_not_accumulate() { + // Each reasoning block is ephemeral: closing a second block (after a commit) + // must not leave any reasoning message behind from the first or second block. let mut app = create_test_app(); app.open_reasoning_region(); - app.append_reasoning_text("thinking\n"); + app.append_reasoning_text("first block thinking\n"); app.close_reasoning_region(None); - assert!(app.reasoning_collapse_active()); + app.append_streaming_text("Answer one."); + app.commit_pending_streaming_assistant_message(); - // A transcript reset must invalidate the animation target safely. - app.clear_display_messages(); - assert!(!app.reasoning_collapse_active()); - // Advancing now is a no-op and must not panic. - assert!(!app.advance_reasoning_collapse()); -} - -#[test] -fn reasoning_collapse_visible_lines_shrink_monotonically_over_time() { - use crate::tui::app::input::REASONING_COLLAPSE_DURATION; - use std::time::Duration; - - let mut app = create_test_app(); app.open_reasoning_region(); - app.append_reasoning_text("l1\nl2\nl3\nl4\nl5\nl6\n"); + app.append_reasoning_text("second block thinking\n"); app.close_reasoning_region(None); - let sentinel = jcode_tui_markdown::REASONING_SENTINEL; - let count_visible = |app: &App| -> usize { - app.display_messages - .iter() - .find(|m| m.role == "reasoning") - .map(|m| { - m.content - .split_inclusive('\n') - .filter(|seg| seg.contains(sentinel)) - .filter(|seg| !seg.contains('▸')) - .count() - }) - .unwrap_or(0) - }; - - // Sample the eased timeline; visible reasoning lines must never increase and - // must reach a single summary line (0 source lines) at/after the duration. - let dur = REASONING_COLLAPSE_DURATION; - let mut prev = usize::MAX; - for frac in [0.0_f32, 0.25, 0.5, 0.75, 1.0] { - let elapsed = Duration::from_secs_f32(dur.as_secs_f32() * frac); - app.backdate_reasoning_collapse_for_test(elapsed) - .expect("collapse active"); - app.advance_reasoning_collapse(); - let visible = count_visible(&app); - assert!( - visible <= prev, - "visible lines must not increase: frac={frac} visible={visible} prev={prev}" - ); - prev = visible; - } - - // Past the duration the animation is finalized to the summary only. - assert!(!app.reasoning_collapse_active(), "collapse should finish"); - assert_eq!(count_visible(&app), 0, "only the summary line remains"); + let reasoning_msgs = app + .display_messages + .iter() + .filter(|m| m.role == "reasoning") + .count(); + assert_eq!( + reasoning_msgs, 0, + "reasoning must never accumulate as persistent messages" + ); + assert!( + !app.streaming_text() + .contains(jcode_tui_markdown::REASONING_SENTINEL), + "no reasoning markup should linger in the stream: {:?}", + app.streaming_text() + ); } diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs index a398b3962..c0c4d80e7 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs @@ -540,7 +540,7 @@ fn test_handle_server_event_token_usage_uses_per_call_deltas() { let _guard = rt.enter(); let mut remote = crate::tui::backend::RemoteConnection::dummy(); - app.streaming_tps_collect_output = true; + app.streaming.streaming_tps_collect_output = true; app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -570,10 +570,10 @@ fn test_handle_server_event_token_usage_uses_per_call_deltas() { &mut remote, ); - assert_eq!(app.streaming_output_tokens, 30); - assert_eq!(app.streaming_total_output_tokens, 30); - assert_eq!(app.total_input_tokens, 100); - assert_eq!(app.total_output_tokens, 30); + assert_eq!(app.streaming.streaming_output_tokens, 30); + assert_eq!(app.streaming.streaming_total_output_tokens, 30); + assert_eq!(app.token_accounting.total_input_tokens, 100); + assert_eq!(app.token_accounting.total_output_tokens, 30); } #[test] @@ -583,7 +583,7 @@ fn test_handle_server_event_tool_exec_pauses_tps_but_collects_final_tool_usage() let _guard = rt.enter(); let mut remote = crate::tui::backend::RemoteConnection::dummy(); - app.streaming_tps_elapsed = Duration::from_secs(2); + app.streaming.streaming_tps_elapsed = Duration::from_secs(2); app.handle_server_event( crate::protocol::ServerEvent::ToolStart { @@ -593,10 +593,10 @@ fn test_handle_server_event_tool_exec_pauses_tps_but_collects_final_tool_usage() &mut remote, ); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_some()); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_some()); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); app.handle_server_event( crate::protocol::ServerEvent::ToolExec { @@ -606,9 +606,9 @@ fn test_handle_server_event_tool_exec_pauses_tps_but_collects_final_tool_usage() &mut remote, ); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); - assert!(app.streaming_tps_elapsed >= Duration::from_secs(5)); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); + assert!(app.streaming.streaming_tps_elapsed >= Duration::from_secs(5)); app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -620,8 +620,8 @@ fn test_handle_server_event_tool_exec_pauses_tps_but_collects_final_tool_usage() &mut remote, ); - assert_eq!(app.streaming_total_output_tokens, 25); - assert_eq!(app.streaming_tps_observed_output_tokens, 25); + assert_eq!(app.streaming.streaming_total_output_tokens, 25); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 25); app.handle_server_event( crate::protocol::ServerEvent::TextDelta { @@ -630,8 +630,8 @@ fn test_handle_server_event_tool_exec_pauses_tps_but_collects_final_tool_usage() &mut remote, ); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_some()); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_some()); } #[test] @@ -641,7 +641,7 @@ fn test_handle_server_event_kv_cache_request_resets_tps_output_watermark_for_nex let _guard = rt.enter(); let mut remote = crate::tui::backend::RemoteConnection::dummy(); - app.streaming_tps_collect_output = true; + app.streaming.streaming_tps_collect_output = true; app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -671,7 +671,7 @@ fn test_handle_server_event_kv_cache_request_resets_tps_output_watermark_for_nex &mut remote, ); - assert!(!app.streaming_tps_collect_output); + assert!(!app.streaming.streaming_tps_collect_output); app.handle_server_event( crate::protocol::ServerEvent::ConnectionPhase { @@ -680,7 +680,7 @@ fn test_handle_server_event_kv_cache_request_resets_tps_output_watermark_for_nex &mut remote, ); - assert!(app.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_collect_output); app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -692,8 +692,8 @@ fn test_handle_server_event_kv_cache_request_resets_tps_output_watermark_for_nex &mut remote, ); - assert_eq!(app.streaming_total_output_tokens, 55); - assert_eq!(app.streaming_tps_observed_output_tokens, 55); + assert_eq!(app.streaming.streaming_total_output_tokens, 55); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 55); } #[test] @@ -705,7 +705,7 @@ fn test_handle_server_event_message_end_marks_stream_as_finalizing_without_stall app.is_processing = true; app.status = ProcessingStatus::Streaming; - app.streaming_tps_collect_output = true; + app.streaming.streaming_tps_collect_output = true; let needs_redraw = app.handle_server_event(crate::protocol::ServerEvent::MessageEnd, &mut remote); @@ -713,7 +713,7 @@ fn test_handle_server_event_message_end_marks_stream_as_finalizing_without_stall assert!(needs_redraw); assert!(app.stream_message_ended); assert!(matches!(app.status, ProcessingStatus::Streaming)); - assert!(app.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_collect_output); } #[test] @@ -730,8 +730,8 @@ fn test_handle_server_event_tps_connection_phase_streaming_starts_collection_onl &mut remote, ); - assert!(!app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); + assert!(!app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); app.handle_server_event( crate::protocol::ServerEvent::ConnectionPhase { @@ -740,8 +740,8 @@ fn test_handle_server_event_tps_connection_phase_streaming_starts_collection_onl &mut remote, ); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_some()); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_some()); assert!(matches!(app.status, ProcessingStatus::Streaming)); } @@ -758,13 +758,13 @@ fn test_handle_server_event_tps_message_end_counts_late_usage_without_timer_runn }, &mut remote, ); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(4)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(4)); app.handle_server_event(crate::protocol::ServerEvent::MessageEnd, &mut remote); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); - assert!(app.streaming_tps_elapsed >= Duration::from_secs(4)); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); + assert!(app.streaming.streaming_tps_elapsed >= Duration::from_secs(4)); app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -776,10 +776,10 @@ fn test_handle_server_event_tps_message_end_counts_late_usage_without_timer_runn &mut remote, ); - assert_eq!(app.streaming_total_output_tokens, 20); - assert_eq!(app.streaming_tps_observed_output_tokens, 20); - assert!(app.streaming_tps_observed_elapsed >= Duration::from_secs(4)); - assert!(app.streaming_tps_start.is_none()); + assert_eq!(app.streaming.streaming_total_output_tokens, 20); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 20); + assert!(app.streaming.streaming_tps_observed_elapsed >= Duration::from_secs(4)); + assert!(app.streaming.streaming_tps_start.is_none()); } #[test] @@ -795,7 +795,7 @@ fn test_handle_server_event_tps_redundant_late_usage_after_message_end_does_not_ }, &mut remote, ); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(5)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(5)); app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -826,8 +826,8 @@ fn test_handle_server_event_tps_redundant_late_usage_after_message_end_does_not_ &mut remote, ); - assert_eq!(app.streaming_total_output_tokens, 30); - assert_eq!(app.streaming_tps_observed_output_tokens, 30); + assert_eq!(app.streaming.streaming_total_output_tokens, 30); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 30); assert_eq!(*remote.call_output_tokens_seen(), 30); } @@ -842,7 +842,7 @@ fn test_handle_server_event_interrupted_clears_stream_state_and_sets_idle() { app.status = ProcessingStatus::Streaming; app.processing_started = Some(Instant::now()); app.current_message_id = Some(42); - app.streaming_text = "partial".to_string(); + app.streaming.streaming_text = "partial".to_string(); app.streaming_tool_calls.push(crate::message::ToolCall { id: "tool_1".to_string(), name: "bash".to_string(), @@ -864,7 +864,7 @@ fn test_handle_server_event_interrupted_clears_stream_state_and_sets_idle() { assert!(matches!(app.status, ProcessingStatus::Idle)); assert!(app.processing_started.is_none()); assert!(app.current_message_id.is_none()); - assert!(app.streaming_text.is_empty()); + assert!(app.streaming.streaming_text.is_empty()); assert!(app.streaming_tool_calls.is_empty()); assert!(app.interleave_message.is_none()); assert_eq!(app.queued_messages(), &["queued interrupt"]); @@ -1132,3 +1132,56 @@ fn test_handle_server_event_mcp_status_updates_tools_without_status_notice() { assert_eq!(app.mcp_server_names, vec![("agentcard".to_string(), 8)]); assert_eq!(app.status_notice(), None); } + +#[test] +fn test_handle_server_event_reasoning_delta_shows_thinking_status() { + let mut app = create_test_app(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + let mut remote = crate::tui::backend::RemoteConnection::dummy(); + + app.is_processing = true; + // Server emits ConnectionPhase::Streaming when reasoning starts (to kick the + // TPS timer), so the status arrives as Streaming. + app.status = ProcessingStatus::Streaming; + + app.handle_server_event( + crate::protocol::ServerEvent::ReasoningDelta { + text: "weighing options".to_string(), + }, + &mut remote, + ); + + // Live reasoning should read as "thinking", not "streaming". + assert!(matches!(app.status, ProcessingStatus::Thinking(_))); + + // Real output text flips the status back to streaming. + app.handle_server_event( + crate::protocol::ServerEvent::TextDelta { + text: "Here is the answer".to_string(), + }, + &mut remote, + ); + assert!(matches!(app.status, ProcessingStatus::Streaming)); +} + +#[test] +fn test_handle_server_event_reasoning_delta_keeps_tool_status() { + let mut app = create_test_app(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + let mut remote = crate::tui::backend::RemoteConnection::dummy(); + + app.is_processing = true; + app.status = ProcessingStatus::RunningTool("bash".to_string()); + + app.handle_server_event( + crate::protocol::ServerEvent::ReasoningDelta { + text: "post-tool reflection".to_string(), + }, + &mut remote, + ); + + // A running tool must not be masked by reasoning text. + assert!(matches!(app.status, ProcessingStatus::RunningTool(_))); +} diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs index b0741706f..3a2d5626d 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs @@ -190,7 +190,7 @@ fn test_handle_server_event_tool_start_flushes_streaming_text_before_tool_messag app.is_processing = true; app.status = ProcessingStatus::Streaming; - app.streaming_text = "Let me inspect those files first.".to_string(); + app.streaming.streaming_text = "Let me inspect those files first.".to_string(); app.handle_server_event( crate::protocol::ServerEvent::ToolStart { @@ -200,7 +200,7 @@ fn test_handle_server_event_tool_start_flushes_streaming_text_before_tool_messag &mut remote, ); - assert!(app.streaming_text.is_empty()); + assert!(app.streaming.streaming_text.is_empty()); assert_eq!(app.display_messages().len(), 1); assert_eq!(app.display_messages()[0].role, "assistant"); assert_eq!( @@ -811,7 +811,7 @@ fn test_handle_remote_disconnect_flushes_streaming_text_and_sets_reconnect_state retry_attempts: 0, retry_at: None, }); - app.streaming_text = "partial response being streamed".to_string(); + app.streaming.streaming_text = "partial response being streamed".to_string(); let mut state = remote::RemoteRunState::default(); remote::handle_disconnect(&mut app, &mut state, None); @@ -820,7 +820,7 @@ fn test_handle_remote_disconnect_flushes_streaming_text_and_sets_reconnect_state assert!(matches!(app.status, ProcessingStatus::Idle)); assert!(app.current_message_id.is_none()); assert!(app.rate_limit_pending_message.is_none()); - assert!(app.streaming_text.is_empty()); + assert!(app.streaming.streaming_text.is_empty()); assert_eq!(state.disconnect_msg_idx, Some(1)); assert_eq!(state.reconnect_attempts, 1); assert!(state.disconnect_start.is_some()); diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs index 8b8b8fab0..53346b915 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs @@ -645,8 +645,8 @@ fn test_info_widget_remote_opencode_shows_cost_based_usage() { app.is_remote = true; app.remote_provider_name = Some("opencode".to_string()); app.remote_provider_model = Some("qwen3-coder".to_string()); - app.total_input_tokens = 12_000; - app.total_output_tokens = 3_400; + app.token_accounting.total_input_tokens = 12_000; + app.token_accounting.total_output_tokens = 3_400; let data = crate::tui::TuiState::info_widget_data(&app); @@ -673,8 +673,8 @@ fn test_info_widget_remote_anthropic_api_key_shows_cost_based_usage() { app.remote_provider_name = Some("Claude".to_string()); app.remote_provider_model = Some("claude-sonnet-4-20250514".to_string()); app.remote_resolved_credential = Some(jcode_provider_core::ResolvedCredential::ApiKey); - app.total_input_tokens = 12_000; - app.total_output_tokens = 3_400; + app.token_accounting.total_input_tokens = 12_000; + app.token_accounting.total_output_tokens = 3_400; let data = crate::tui::TuiState::info_widget_data(&app); assert_eq!( @@ -773,14 +773,14 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { crate::auth::AuthStatus::invalidate_cache(); let mut app = create_named_provider_test_app(provider_name, model); - app.streaming_input_tokens = 1_000; - app.streaming_output_tokens = 1_000; - app.total_input_tokens = 12_000; - app.total_output_tokens = 3_400; + app.streaming.streaming_input_tokens = 1_000; + app.streaming.streaming_output_tokens = 1_000; + app.token_accounting.total_input_tokens = 12_000; + app.token_accounting.total_output_tokens = 3_400; app.update_cost_impl(); assert!( - app.total_cost > 0.0, + app.cost.total_cost > 0.0, "{runtime_provider} should accrue token cost" ); @@ -802,12 +802,12 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { crate::env::set_var("JCODE_RUNTIME_PROVIDER", "jcode"); crate::env::remove_var("JCODE_OPENROUTER_ALLOW_NO_AUTH"); let mut app = create_named_provider_test_app("openrouter", "subscription-model"); - app.streaming_input_tokens = 1_000; - app.streaming_output_tokens = 1_000; - app.total_input_tokens = 12_000; - app.total_output_tokens = 3_400; + app.streaming.streaming_input_tokens = 1_000; + app.streaming.streaming_output_tokens = 1_000; + app.token_accounting.total_input_tokens = 12_000; + app.token_accounting.total_output_tokens = 3_400; app.update_cost_impl(); - assert_eq!(app.total_cost, 0.0); + assert_eq!(app.cost.total_cost, 0.0); let data = crate::tui::TuiState::info_widget_data(&app); assert_eq!( @@ -819,12 +819,12 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { crate::env::set_var("JCODE_RUNTIME_PROVIDER", "openai-compatible"); crate::env::set_var("JCODE_OPENROUTER_ALLOW_NO_AUTH", "1"); let mut app = create_named_provider_test_app("openrouter", "local-model"); - app.streaming_input_tokens = 1_000; - app.streaming_output_tokens = 1_000; - app.total_input_tokens = 12_000; - app.total_output_tokens = 3_400; + app.streaming.streaming_input_tokens = 1_000; + app.streaming.streaming_output_tokens = 1_000; + app.token_accounting.total_input_tokens = 12_000; + app.token_accounting.total_output_tokens = 3_400; app.update_cost_impl(); - assert_eq!(app.total_cost, 0.0); + assert_eq!(app.cost.total_cost, 0.0); let data = crate::tui::TuiState::info_widget_data(&app); assert_eq!( @@ -864,10 +864,10 @@ fn test_anthropic_api_cost_accounts_for_split_cache_tokens() { // A representative cold turn: most of the prompt is freshly written to cache, // a little is read back, and only a small uncached remainder is fresh input. - app.streaming_input_tokens = 1_000; // uncached fresh input - app.streaming_cache_read_tokens = Some(40_000); // served from cache - app.streaming_cache_creation_tokens = Some(100_000); // written to cache (premium) - app.streaming_output_tokens = 2_000; + app.streaming.streaming_input_tokens = 1_000; // uncached fresh input + app.streaming.streaming_cache_read_tokens = Some(40_000); // served from cache + app.streaming.streaming_cache_creation_tokens = Some(100_000); // written to cache (premium) + app.streaming.streaming_output_tokens = 2_000; app.update_cost_impl(); // Expected: @@ -878,9 +878,9 @@ fn test_anthropic_api_cost_accounts_for_split_cache_tokens() { // total = $0.645 let expected = 0.003 + 0.030 + 0.012 + 0.600; assert!( - (app.total_cost - expected).abs() < 1e-4, + (app.cost.total_cost - expected).abs() < 1e-4, "anthropic split-accounting cost should be ~${expected:.4}, got ${:.4}", - app.total_cost + app.cost.total_cost ); if let Some(value) = saved_runtime { @@ -925,12 +925,12 @@ fn test_remote_anthropic_api_key_accrues_cost_from_token_usage() { // + write 100_000 * ($3 * 2x) = $0.645 let expected = 0.003 + 0.030 + 0.012 + 0.600; assert!( - (app.total_cost - expected).abs() < 1e-4, + (app.cost.total_cost - expected).abs() < 1e-4, "remote anthropic api-key cost should be ~${expected:.4}, got ${:.4}", - app.total_cost + app.cost.total_cost ); - assert_eq!(app.total_input_tokens, 1_000); - assert_eq!(app.total_output_tokens, 2_000); + assert_eq!(app.token_accounting.total_input_tokens, 1_000); + assert_eq!(app.token_accounting.total_output_tokens, 2_000); // OAuth subscription sessions are not metered per token; cost stays $0. let mut oauth_app = create_test_app(); @@ -948,8 +948,8 @@ fn test_remote_anthropic_api_key_accrues_cost_from_token_usage() { }, &mut remote, ); - assert_eq!(oauth_app.total_cost, 0.0); - assert_eq!(oauth_app.total_input_tokens, 1_000); + assert_eq!(oauth_app.cost.total_cost, 0.0); + assert_eq!(oauth_app.token_accounting.total_input_tokens, 1_000); } #[test] diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs index d47bd6b78..c780dbc78 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -590,7 +590,7 @@ fn test_submit_input_commits_pending_streaming_assistant_text_before_user_messag intent: None, thought_signature: None, }, )); app.bump_display_messages_version(); - app.streaming_text = "Here is the final paragraph".to_string(); + app.streaming.streaming_text = "Here is the final paragraph".to_string(); // Mirror the real streaming caller: append any paced chunk the buffer reveals. // The paced StreamBuffer may reveal part of the text immediately, so commit // (below) must still flush the remainder. diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs index fc7be4c16..5f5d0ffe2 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs @@ -239,8 +239,8 @@ fn test_streaming_tokens() { assert_eq!(app.streaming_tokens(), (0, 0)); - app.streaming_input_tokens = 100; - app.streaming_output_tokens = 50; + app.streaming.streaming_input_tokens = 100; + app.streaming.streaming_output_tokens = 50; assert_eq!(app.streaming_tokens(), (100, 50)); } diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs index 3ac987020..a2426ae47 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs @@ -1,12 +1,12 @@ #[test] fn test_build_turn_footer_combines_compact_duration_with_streaming_stats() { let mut app = create_test_app(); - app.streaming_input_tokens = 210_000; - app.streaming_output_tokens = 440; - app.streaming_tps_collect_output = true; - app.streaming_total_output_tokens = 440; - app.streaming_tps_observed_output_tokens = 440; - app.streaming_tps_observed_elapsed = Duration::from_secs(220); + app.streaming.streaming_input_tokens = 210_000; + app.streaming.streaming_output_tokens = 440; + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_total_output_tokens = 440; + app.streaming.streaming_tps_observed_output_tokens = 440; + app.streaming.streaming_tps_observed_elapsed = Duration::from_secs(220); let footer = app .build_turn_footer(Some(316.1)) diff --git a/crates/jcode-tui/src/tui/app/tests/scroll_copy_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/scroll_copy_01/part_01.rs index 56052ce04..b6f1a5eca 100644 --- a/crates/jcode-tui/src/tui/app/tests/scroll_copy_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/scroll_copy_01/part_01.rs @@ -56,7 +56,7 @@ fn create_scroll_test_app( app.scroll_offset = 0; app.auto_scroll_paused = false; app.is_processing = false; - app.streaming_text.clear(); + app.streaming.streaming_text.clear(); app.status = ProcessingStatus::Idle; // Set deterministic session name for snapshot stability app.session.short_name = Some("test".to_string()); @@ -90,7 +90,7 @@ fn create_copy_test_app() -> (App, ratatui::Terminal (App, ratatui::Terminal (App, ratatui::Terminal= Duration::from_secs(9)); + assert_eq!(app.streaming.streaming_total_output_tokens, 30); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 30); + assert!(app.streaming.streaming_tps_observed_elapsed >= Duration::from_secs(9)); assert_eq!(seen, 30); } @@ -196,25 +196,25 @@ fn test_accumulate_streaming_output_tokens_ignores_hidden_output_phase() { let mut seen = 0; app.accumulate_streaming_output_tokens(20, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 0); - assert_eq!(app.streaming_tps_observed_output_tokens, 0); + assert_eq!(app.streaming.streaming_total_output_tokens, 0); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 0); assert_eq!(seen, 20); - app.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); app.accumulate_streaming_output_tokens(60, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 40); - assert_eq!(app.streaming_tps_observed_output_tokens, 40); + assert_eq!(app.streaming.streaming_total_output_tokens, 40); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 40); assert_eq!(seen, 60); } #[test] fn test_compute_streaming_tps_uses_latest_observed_snapshot_instead_of_current_repaint_time() { let mut app = create_test_app(); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(20)); - app.streaming_tps_observed_output_tokens = 40; - app.streaming_tps_observed_elapsed = Duration::from_secs(10); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(20)); + app.streaming.streaming_tps_observed_output_tokens = 40; + app.streaming.streaming_tps_observed_elapsed = Duration::from_secs(10); let tps = app.compute_streaming_tps().expect("tps"); assert!(tps > 3.9 && tps < 4.1, "unexpected tps: {tps}"); @@ -225,12 +225,12 @@ fn test_compute_streaming_tps_does_not_decay_on_redundant_usage_snapshots() { let mut app = create_test_app(); let mut seen = 0; - app.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); app.accumulate_streaming_output_tokens(40, &mut seen); let initial_tps = app.compute_streaming_tps().expect("initial tps"); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(30)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(30)); app.accumulate_streaming_output_tokens(40, &mut seen); let tps = app.compute_streaming_tps().expect("tps"); @@ -249,21 +249,21 @@ fn test_compute_streaming_tps_bursty_stream_simulation_stays_constant_between_re let mut app = create_test_app(); let mut seen = 0; - app.streaming_tps_collect_output = true; + app.streaming.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); app.accumulate_streaming_output_tokens(10, &mut seen); let tps_after_first_burst = app.compute_streaming_tps().expect("tps after first burst"); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(5)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(5)); app.accumulate_streaming_output_tokens(10, &mut seen); let tps_after_idle_gap = app.compute_streaming_tps().expect("tps after idle gap"); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(6)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(6)); app.accumulate_streaming_output_tokens(30, &mut seen); let tps_after_second_burst = app.compute_streaming_tps().expect("tps after second burst"); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(9)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(9)); app.accumulate_streaming_output_tokens(30, &mut seen); let tps_after_second_idle_gap = app .compute_streaming_tps() @@ -292,48 +292,48 @@ fn test_streaming_tps_timer_resume_pause_reset_lifecycle() { let mut app = create_test_app(); assert_eq!(app.current_streaming_tps_elapsed(), Duration::ZERO); - assert!(!app.streaming_tps_collect_output); + assert!(!app.streaming.streaming_tps_collect_output); app.resume_streaming_tps(); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_some()); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_some()); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); app.pause_streaming_tps(true); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); - assert!(app.streaming_tps_elapsed >= Duration::from_secs(2)); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); + assert!(app.streaming.streaming_tps_elapsed >= Duration::from_secs(2)); - let elapsed_after_pause = app.streaming_tps_elapsed; + let elapsed_after_pause = app.streaming.streaming_tps_elapsed; app.pause_streaming_tps(false); - assert!(!app.streaming_tps_collect_output); - assert_eq!(app.streaming_tps_elapsed, elapsed_after_pause); + assert!(!app.streaming.streaming_tps_collect_output); + assert_eq!(app.streaming.streaming_tps_elapsed, elapsed_after_pause); - app.streaming_total_output_tokens = 42; - app.streaming_tps_observed_output_tokens = 42; - app.streaming_tps_observed_elapsed = elapsed_after_pause; + app.streaming.streaming_total_output_tokens = 42; + app.streaming.streaming_tps_observed_output_tokens = 42; + app.streaming.streaming_tps_observed_elapsed = elapsed_after_pause; app.reset_streaming_tps(); - assert_eq!(app.streaming_tps_elapsed, Duration::ZERO); - assert_eq!(app.streaming_total_output_tokens, 0); - assert_eq!(app.streaming_tps_observed_output_tokens, 0); - assert_eq!(app.streaming_tps_observed_elapsed, Duration::ZERO); - assert!(!app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); + assert_eq!(app.streaming.streaming_tps_elapsed, Duration::ZERO); + assert_eq!(app.streaming.streaming_total_output_tokens, 0); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 0); + assert_eq!(app.streaming.streaming_tps_observed_elapsed, Duration::ZERO); + assert!(!app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); } #[test] fn test_compute_streaming_tps_requires_tokens_and_minimum_elapsed() { let mut app = create_test_app(); - app.streaming_tps_observed_elapsed = Duration::from_secs(10); + app.streaming.streaming_tps_observed_elapsed = Duration::from_secs(10); assert!(app.compute_streaming_tps().is_none()); - app.streaming_tps_observed_output_tokens = 10; - app.streaming_tps_observed_elapsed = Duration::from_millis(100); + app.streaming.streaming_tps_observed_output_tokens = 10; + app.streaming.streaming_tps_observed_elapsed = Duration::from_millis(100); assert!(app.compute_streaming_tps().is_none()); - app.streaming_tps_observed_elapsed = Duration::from_millis(250); + app.streaming.streaming_tps_observed_elapsed = Duration::from_millis(250); let tps = app.compute_streaming_tps().expect("tps above threshold"); assert!(tps > 35.0 && tps <= 40.0, "unexpected tps: {tps}"); } @@ -343,16 +343,16 @@ fn test_accumulate_streaming_output_tokens_counts_provider_usage_reset_once() { let mut app = create_test_app(); let mut seen = 80; - app.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); app.accumulate_streaming_output_tokens(20, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 20); + assert_eq!(app.streaming.streaming_total_output_tokens, 20); assert_eq!(seen, 20); app.accumulate_streaming_output_tokens(25, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 25); - assert_eq!(app.streaming_tps_observed_output_tokens, 25); + assert_eq!(app.streaming.streaming_total_output_tokens, 25); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 25); assert_eq!(seen, 25); } @@ -361,18 +361,18 @@ fn test_streaming_tps_late_final_usage_after_pause_uses_paused_elapsed() { let mut app = create_test_app(); let mut seen = 0; - app.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); app.pause_streaming_tps(true); - assert!(app.streaming_tps_start.is_none()); - assert!(app.streaming_tps_elapsed >= Duration::from_secs(10)); + assert!(app.streaming.streaming_tps_start.is_none()); + assert!(app.streaming.streaming_tps_elapsed >= Duration::from_secs(10)); app.accumulate_streaming_output_tokens(40, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 40); - assert_eq!(app.streaming_tps_observed_output_tokens, 40); - assert!(app.streaming_tps_observed_elapsed >= Duration::from_secs(10)); + assert_eq!(app.streaming.streaming_total_output_tokens, 40); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 40); + assert!(app.streaming.streaming_tps_observed_elapsed >= Duration::from_secs(10)); let tps = app.compute_streaming_tps().expect("late tps"); assert!(tps > 3.0 && tps <= 4.0, "unexpected late tps: {tps}"); } @@ -382,26 +382,26 @@ fn test_begin_kv_cache_request_stops_tps_collection_until_output_resumes() { let mut app = create_test_app(); let mut seen = 0; - app.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); app.begin_kv_cache_request(&[Message::user("next")], &[], "system", "dynamic"); - assert!(!app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); - assert!(app.streaming_tps_elapsed >= Duration::from_secs(3)); + assert!(!app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); + assert!(app.streaming.streaming_tps_elapsed >= Duration::from_secs(3)); app.accumulate_streaming_output_tokens(20, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 0); + assert_eq!(app.streaming.streaming_total_output_tokens, 0); assert_eq!(seen, 20); app.resume_streaming_tps(); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); app.accumulate_streaming_output_tokens(50, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 30); - assert_eq!(app.streaming_tps_observed_output_tokens, 30); - assert!(app.streaming_tps_observed_elapsed >= Duration::from_secs(5)); + assert_eq!(app.streaming.streaming_total_output_tokens, 30); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 30); + assert!(app.streaming.streaming_tps_observed_elapsed >= Duration::from_secs(5)); } #[test] @@ -410,20 +410,20 @@ fn test_streaming_tps_accumulates_multiple_generation_segments_excluding_paused_ let mut seen = 0; app.resume_streaming_tps(); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); app.accumulate_streaming_output_tokens(10, &mut seen); app.pause_streaming_tps(true); - let elapsed_after_first_segment = app.streaming_tps_elapsed; + let elapsed_after_first_segment = app.streaming.streaming_tps_elapsed; assert!(elapsed_after_first_segment >= Duration::from_secs(2)); app.resume_streaming_tps(); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); app.accumulate_streaming_output_tokens(30, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 30); - assert_eq!(app.streaming_tps_observed_output_tokens, 30); - assert!(app.streaming_tps_observed_elapsed >= Duration::from_secs(5)); + assert_eq!(app.streaming.streaming_total_output_tokens, 30); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 30); + assert!(app.streaming.streaming_tps_observed_elapsed >= Duration::from_secs(5)); let tps = app.compute_streaming_tps().expect("segmented tps"); assert!(tps > 5.0 && tps <= 6.0, "unexpected segmented tps: {tps}"); } @@ -911,9 +911,9 @@ fn test_pinned_diagram_not_shown_when_terminal_too_narrow() { #[test] fn test_workspace_info_widget_appears_in_visual_debug_frame_when_enabled() { let _render_lock = scroll_render_test_lock(); - crate::tui::workspace_client::reset_for_tests(); let mut app = create_test_app(); + app.workspace_client.reset_for_tests(); app.centered = true; app.display_messages = vec![ DisplayMessage::system("Workspace widget render test".to_string()), @@ -922,7 +922,7 @@ fn test_workspace_info_widget_appears_in_visual_debug_frame_when_enabled() { app.bump_display_messages_version(); let current_session = app.session.id.clone(); - crate::tui::workspace_client::enable( + app.workspace_client.enable( Some(current_session.as_str()), &[current_session.clone(), "workspace_peer".to_string()], ); @@ -963,7 +963,7 @@ fn test_workspace_info_widget_appears_in_visual_debug_frame_when_enabled() { ); crate::tui::visual_debug::disable(); - crate::tui::workspace_client::reset_for_tests(); + app.workspace_client.reset_for_tests(); } #[test] diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index d5238330f..a9cefc932 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -294,40 +294,17 @@ impl App { auto_scroll_paused: false, active_skill: None, is_processing: false, - streaming_text: String::new(), + streaming: StreamingProgress::default(), should_quit: false, queued_messages: Vec::new(), hidden_queued_system_messages: Vec::new(), current_turn_system_reminder: None, - streaming_input_tokens: 0, - streaming_output_tokens: 0, - streaming_cache_read_tokens: None, - streaming_cache_creation_tokens: None, upstream_provider: None, connection_type: None, status_detail: None, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_reported_input_tokens: 0, - total_cache_read_tokens: 0, - total_cache_creation_tokens: 0, - total_cache_optimal_input_tokens: 0, - last_cache_reported_input_tokens: None, - last_cache_read_tokens: None, - last_cache_creation_tokens: None, - last_cache_optimal_input_tokens: None, - cache_next_optimal_input_tokens: None, - kv_cache_baseline: None, - pending_kv_cache_request: None, - current_api_usage_recorded: false, - kv_cache_turn_number: None, - kv_cache_turn_call_index: 0, - kv_cache_miss_samples: Vec::new(), - total_cost: 0.0, - cached_prompt_price: None, - cached_completion_price: None, - cached_cache_read_price: None, - cached_price_model: None, + token_accounting: TokenAccounting::default(), + kv_cache: KvCacheState::default(), + cost: CostState::default(), context_limit, context_warning_shown: false, context_info: crate::prompt::ContextInfo::default(), @@ -336,12 +313,6 @@ impl App { stream_message_ended: false, remote_resume_activity: None, pending_reload_reconnect_status: None, - streaming_tps_start: None, - streaming_tps_elapsed: Duration::ZERO, - streaming_tps_collect_output: false, - streaming_total_output_tokens: 0, - streaming_tps_observed_output_tokens: 0, - streaming_tps_observed_elapsed: Duration::ZERO, status: ProcessingStatus::default(), subagent_status: None, batch_progress: None, @@ -372,8 +343,6 @@ impl App { reasoning_pending_line: String::new(), reasoning_partial_len: 0, reasoning_block_start: None, - reasoning_block_started_at: None, - reasoning_collapse: None, reload_requested: None, rebuild_requested: None, update_requested: None, @@ -591,6 +560,7 @@ impl App { usage_report_refreshing: false, productivity_refreshing: false, last_overnight_card_refresh: None, + workspace_client: crate::tui::workspace_client::WorkspaceClientState::default(), }; for notice in app.provider.drain_startup_notices() { @@ -697,40 +667,17 @@ impl App { auto_scroll_paused: false, active_skill: None, is_processing: false, - streaming_text: String::new(), + streaming: StreamingProgress::default(), should_quit: false, queued_messages: Vec::new(), hidden_queued_system_messages: Vec::new(), current_turn_system_reminder: None, - streaming_input_tokens: 0, - streaming_output_tokens: 0, - streaming_cache_read_tokens: None, - streaming_cache_creation_tokens: None, upstream_provider: None, connection_type: None, status_detail: None, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_reported_input_tokens: 0, - total_cache_read_tokens: 0, - total_cache_creation_tokens: 0, - total_cache_optimal_input_tokens: 0, - last_cache_reported_input_tokens: None, - last_cache_read_tokens: None, - last_cache_creation_tokens: None, - last_cache_optimal_input_tokens: None, - cache_next_optimal_input_tokens: None, - kv_cache_baseline: None, - pending_kv_cache_request: None, - current_api_usage_recorded: false, - kv_cache_turn_number: None, - kv_cache_turn_call_index: 0, - kv_cache_miss_samples: Vec::new(), - total_cost: 0.0, - cached_prompt_price: None, - cached_completion_price: None, - cached_cache_read_price: None, - cached_price_model: None, + token_accounting: TokenAccounting::default(), + kv_cache: KvCacheState::default(), + cost: CostState::default(), context_limit, context_warning_shown: false, context_info, @@ -739,12 +686,6 @@ impl App { stream_message_ended: false, remote_resume_activity: None, pending_reload_reconnect_status: None, - streaming_tps_start: None, - streaming_tps_elapsed: Duration::ZERO, - streaming_tps_collect_output: false, - streaming_total_output_tokens: 0, - streaming_tps_observed_output_tokens: 0, - streaming_tps_observed_elapsed: Duration::ZERO, status: ProcessingStatus::default(), subagent_status: None, batch_progress: None, @@ -775,8 +716,6 @@ impl App { reasoning_pending_line: String::new(), reasoning_partial_len: 0, reasoning_block_start: None, - reasoning_block_started_at: None, - reasoning_collapse: None, reload_requested: None, rebuild_requested: None, update_requested: None, @@ -994,6 +933,7 @@ impl App { usage_report_refreshing: false, productivity_refreshing: false, last_overnight_card_refresh: None, + workspace_client: crate::tui::workspace_client::WorkspaceClientState::default(), }; for notice in app.provider.drain_startup_notices() { diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 5f0427843..e436a1fec 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -262,11 +262,11 @@ impl App { seven_day_resets_at: None, spark: None, spark_resets_at: None, - total_cost: self.total_cost, - input_tokens: self.total_input_tokens, - output_tokens: self.total_output_tokens, - cache_read_tokens: self.streaming_cache_read_tokens, - cache_write_tokens: self.streaming_cache_creation_tokens, + total_cost: self.cost.total_cost, + input_tokens: self.token_accounting.total_input_tokens, + output_tokens: self.token_accounting.total_output_tokens, + cache_read_tokens: self.streaming.streaming_cache_read_tokens, + cache_write_tokens: self.streaming.streaming_cache_creation_tokens, output_tps, available: true, }; @@ -281,12 +281,12 @@ impl App { spark: None, spark_resets_at: None, total_cost: 0.0, - input_tokens: self.total_input_tokens, - output_tokens: self.total_output_tokens, + input_tokens: self.token_accounting.total_input_tokens, + output_tokens: self.token_accounting.total_output_tokens, cache_read_tokens: None, cache_write_tokens: None, output_tps, - available: self.total_input_tokens > 0 || self.total_output_tokens > 0, + available: self.token_accounting.total_input_tokens > 0 || self.token_accounting.total_output_tokens > 0, }), WidgetProviderKind::Anthropic => { if matches!( @@ -412,7 +412,7 @@ impl crate::tui::TuiState for App { } fn streaming_text(&self) -> &str { - &self.streaming_text + &self.streaming.streaming_text } fn input(&self) -> &str { @@ -505,13 +505,13 @@ impl crate::tui::TuiState for App { } fn streaming_tokens(&self) -> (u64, u64) { - (self.streaming_input_tokens, self.streaming_output_tokens) + (self.streaming.streaming_input_tokens, self.streaming.streaming_output_tokens) } fn streaming_cache_tokens(&self) -> (Option, Option) { ( - self.streaming_cache_read_tokens, - self.streaming_cache_creation_tokens, + self.streaming.streaming_cache_read_tokens, + self.streaming.streaming_cache_creation_tokens, ) } @@ -596,10 +596,6 @@ impl crate::tui::TuiState for App { self.mouse_scroll_queue != 0 } - fn reasoning_collapse_animating(&self) -> bool { - self.reasoning_collapse_active() - } - fn total_session_tokens(&self) -> Option<(u64, u64)> { // In remote mode, use tokens from server // Independent mode doesn't currently track total tokens @@ -1203,18 +1199,18 @@ impl crate::tui::TuiState for App { None }; - let cache_hit_info = (self.total_cache_reported_input_tokens > 0).then(|| { + let cache_hit_info = (self.token_accounting.total_cache_reported_input_tokens > 0).then(|| { crate::tui::info_widget::CacheHitInfo { - reported_input_tokens: self.total_cache_reported_input_tokens, - read_tokens: self.total_cache_read_tokens, - creation_tokens: self.total_cache_creation_tokens, - optimal_input_tokens: self.total_cache_optimal_input_tokens, - last_reported_input_tokens: self.last_cache_reported_input_tokens, - last_read_tokens: self.last_cache_read_tokens, - last_creation_tokens: self.last_cache_creation_tokens, - last_optimal_input_tokens: self.last_cache_optimal_input_tokens, + reported_input_tokens: self.token_accounting.total_cache_reported_input_tokens, + read_tokens: self.token_accounting.total_cache_read_tokens, + creation_tokens: self.token_accounting.total_cache_creation_tokens, + optimal_input_tokens: self.token_accounting.total_cache_optimal_input_tokens, + last_reported_input_tokens: self.token_accounting.last_cache_reported_input_tokens, + last_read_tokens: self.token_accounting.last_cache_read_tokens, + last_creation_tokens: self.token_accounting.last_cache_creation_tokens, + last_optimal_input_tokens: self.token_accounting.last_cache_optimal_input_tokens, miss_attributions: self - .kv_cache_miss_samples + .kv_cache.kv_cache_miss_samples .iter() .rev() .map(|sample| crate::tui::info_widget::CacheMissAttribution { @@ -1234,13 +1230,13 @@ impl crate::tui::TuiState for App { Vec::new() }; - let workspace_rows = if crate::tui::workspace_client::is_enabled() { + let workspace_rows = if self.workspace_client.is_enabled() { let session_id = if self.is_remote { self.remote_session_id.as_deref() } else { Some(self.session.id.as_str()) }; - crate::tui::workspace_client::visible_rows(5, session_id, self.is_processing) + self.workspace_client.visible_rows(5, session_id, self.is_processing) } else { Vec::new() }; @@ -1318,7 +1314,7 @@ impl crate::tui::TuiState for App { } fn workspace_mode_enabled(&self) -> bool { - crate::tui::workspace_client::is_enabled() + self.workspace_client.is_enabled() } fn workspace_map_rows(&self) -> Vec { @@ -1327,7 +1323,7 @@ impl crate::tui::TuiState for App { } else { Some(self.session.id.as_str()) }; - crate::tui::workspace_client::visible_rows(5, session_id, self.is_processing) + self.workspace_client.visible_rows(5, session_id, self.is_processing) } fn workspace_animation_tick(&self) -> u64 { @@ -1337,7 +1333,7 @@ impl crate::tui::TuiState for App { fn render_streaming_markdown(&self, width: usize) -> Vec> { let mut renderer = self.streaming_md_renderer.borrow_mut(); renderer.set_width(Some(width)); - renderer.update(&self.streaming_text) + renderer.update(&self.streaming.streaming_text) } fn centered_mode(&self) -> bool { diff --git a/crates/jcode-tui/src/tui/app/turn.rs b/crates/jcode-tui/src/tui/app/turn.rs index 6e63434d5..37d176af6 100644 --- a/crates/jcode-tui/src/tui/app/turn.rs +++ b/crates/jcode-tui/src/tui/app/turn.rs @@ -268,8 +268,6 @@ impl App { if let Some(chunk) = self.stream_buffer.flush_smooth_frame() { self.append_streaming_text(&chunk); } - // Advance the "current reasoning collapses away" animation. - self.advance_reasoning_collapse(); // Poll for background compaction completion during streaming self.poll_compaction_completion(); status_spinner_renderer.draw_full(self, terminal)?; @@ -342,7 +340,7 @@ impl App { if let Some(chunk) = self.stream_buffer.flush() { self.append_streaming_text(&chunk); } - if !self.streaming_text.is_empty() { + if !self.streaming.streaming_text.is_empty() { let content = self.take_streaming_text(); let content = self.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { @@ -406,7 +404,7 @@ impl App { }); } // Add display message for partial response - if !self.streaming_text.is_empty() { + if !self.streaming.streaming_text.is_empty() { let content = self.take_streaming_text(); let content = self.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { @@ -606,22 +604,22 @@ impl App { } => { let mut usage_changed = false; if let Some(input) = input_tokens { - self.streaming_input_tokens = input; + self.streaming.streaming_input_tokens = input; usage_changed = true; } if let Some(output) = output_tokens { - self.streaming_output_tokens = output; + self.streaming.streaming_output_tokens = output; self.accumulate_streaming_output_tokens( output, &mut call_output_tokens_seen, ); } if cache_read_input_tokens.is_some() { - self.streaming_cache_read_tokens = cache_read_input_tokens; + self.streaming.streaming_cache_read_tokens = cache_read_input_tokens; usage_changed = true; } if cache_creation_input_tokens.is_some() { - self.streaming_cache_creation_tokens = + self.streaming.streaming_cache_creation_tokens = cache_creation_input_tokens; usage_changed = true; } @@ -632,11 +630,11 @@ impl App { } } self.broadcast_debug(crate::tui::backend::DebugEvent::TokenUsage { - input_tokens: self.streaming_input_tokens, - output_tokens: self.streaming_output_tokens, - cache_read_input_tokens: self.streaming_cache_read_tokens, + input_tokens: self.streaming.streaming_input_tokens, + output_tokens: self.streaming.streaming_output_tokens, + cache_read_input_tokens: self.streaming.streaming_cache_read_tokens, cache_creation_input_tokens: self - .streaming_cache_creation_tokens, + .streaming.streaming_cache_creation_tokens, }); } StreamEvent::ConnectionType { connection } => { @@ -677,7 +675,7 @@ impl App { let no_partial_output = text_content.is_empty() && tool_calls.is_empty() && current_tool.is_none() - && self.streaming_text.is_empty() + && self.streaming.streaming_text.is_empty() && !saw_message_end; if no_partial_output && let Some(reason) = crate::network_retry::classify_message(&message) @@ -719,6 +717,17 @@ impl App { } StreamEvent::ThinkingDelta(thinking_text) => { self.resume_streaming_tps(); + // Reflect active reasoning in the status line even when the + // provider streams reasoning deltas without an explicit + // ThinkingStart (e.g. OpenRouter, Bedrock) or when the + // reasoning text itself is hidden by config. + let thinking_start = + *self.thinking_start.get_or_insert_with(Instant::now); + let entered_thinking = + !matches!(self.status, ProcessingStatus::Thinking(_)); + if entered_thinking { + self.status = ProcessingStatus::Thinking(thinking_start); + } // Buffer thinking content for status/debug accounting. self.thinking_buffer.push_str(&thinking_text); // Flush any pending real output before reasoning text. @@ -734,6 +743,12 @@ impl App { // persisted as a history-only trace, regardless // of provider replay support. reasoning_content.push_str(&thinking_text); + // When reasoning text is hidden, the status flip to + // "thinking…" is the only visible signal, so repaint + // promptly on the first delta. + if entered_thinking && eager_stream_redraw { + status_spinner_renderer.draw_full(self, terminal)?; + } } StreamEvent::ThinkingEnd => { self.pause_streaming_tps(true); @@ -938,7 +953,7 @@ impl App { let no_partial_output = text_content.is_empty() && tool_calls.is_empty() && current_tool.is_none() - && self.streaming_text.is_empty() + && self.streaming.streaming_text.is_empty() && !saw_message_end; if no_partial_output && let Some(reason) = crate::network_retry::classify_network_interruption(e.as_ref()) @@ -964,7 +979,7 @@ impl App { let no_partial_output = text_content.is_empty() && tool_calls.is_empty() && current_tool.is_none() - && self.streaming_text.is_empty() + && self.streaming.streaming_text.is_empty() && !saw_message_end; if no_partial_output { let plan = crate::network_retry::wait_plan(); @@ -1066,8 +1081,8 @@ impl App { } else { // Had tool calls - only display text that came AFTER the last tool // (text before each tool was already committed in ToolUseEnd handler) - if !self.streaming_text.is_empty() { - let content = self.collapse_reasoning_for_commit(self.streaming_text.clone()); + if !self.streaming.streaming_text.is_empty() { + let content = self.collapse_reasoning_for_commit(self.streaming.streaming_text.clone()); if !content.trim().is_empty() { self.push_display_message(DisplayMessage { role: "assistant".to_string(), @@ -1229,7 +1244,7 @@ impl App { if let Some(chunk) = self.stream_buffer.flush() { self.append_streaming_text(&chunk); } - if !self.streaming_text.is_empty() { + if !self.streaming.streaming_text.is_empty() { let content = self.take_streaming_text(); let content = self.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { diff --git a/crates/jcode-tui/src/tui/mermaid.rs b/crates/jcode-tui/src/tui/mermaid.rs index b4222b38c..76fc06aca 100644 --- a/crates/jcode-tui/src/tui/mermaid.rs +++ b/crates/jcode-tui/src/tui/mermaid.rs @@ -1,4 +1,31 @@ -pub use jcode_tui_mermaid::*; +pub use jcode_tui_mermaid::{ + DiagramBlock, DiagramCacheKey, DiagramId, DiagramInfo, DiagramOrigin, DiagramRenderProfile, + DiagramRenderRequest, ImageStateInfo, MermaidCacheEntry, MermaidContent, MermaidDebugStats, + MermaidDebugStatsDelta, MermaidFlickerBenchmark, MermaidMemoryBenchmark, MermaidMemoryProfile, + MermaidTheme, MermaidTimingSummary, ProcessMemorySnapshot, RenderArtifact, RenderError, + RenderMode, RenderPriority, RenderResult, RenderStatus, RenderTarget, ScrollFrameInfo, + ScrollTestResult, TestRenderResult, active_diagram_count, clear_active_diagrams, clear_cache, + clear_image_state, clear_streaming_preview_diagram, current_preferred_aspect_ratio_bucket, + debug_cache, debug_flicker_benchmark, debug_image_state, debug_memory_benchmark, + debug_memory_profile, debug_render, debug_stats, debug_stats_json, debug_test_render, + debug_test_resize_stability, debug_test_scroll, deferred_render_epoch, + diagram_placeholder_lines, error_lines_for, error_to_lines, estimate_image_height, + evict_old_cache, get_active_diagrams, get_cached_path, get_cached_png, get_font_size, + image_protocol_available, image_widget_placeholder_markdown, init_picker, + invalidate_render_state, is_mermaid_lang, is_video_export_mode, normalize_aspect_ratio, + parse_image_placeholder, preferred_aspect_ratio_bucket, protocol_type, register_active_diagram, + register_external_image, register_inline_image, render_image_widget, render_image_widget_fit, + render_image_widget_scale, render_image_widget_viewport, render_image_widget_viewport_precise, + render_mermaid, render_mermaid_deferred, render_mermaid_deferred_with_registration, + render_mermaid_deferred_with_stream_scope, render_mermaid_sized, render_mermaid_untracked, + reset_debug_stats, restore_active_diagrams, result_to_content, result_to_lines, set_log_hooks, + set_memory_snapshot_hook, set_render_completed_hook, set_streaming_preview_diagram, + set_video_export_mode, snapshot_active_diagrams, with_preferred_aspect_ratio, + write_video_export_marker, +}; + +#[cfg(feature = "mmdr-size-api")] +pub use jcode_tui_mermaid::terminal_theme; pub fn install_jcode_mermaid_hooks() { jcode_tui_mermaid::set_log_hooks(crate::logging::info, crate::logging::warn); diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index 9556c23eb..90a8a6bec 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -112,7 +112,15 @@ pub fn disable_keyboard_enhancement() { } /// Trait for TUI state consumed by the shared renderer. +/// +/// This is a wide (114-method) presentation interface: the read-only surface the +/// renderer needs from `App`. The methods are grouped into the domain sections +/// below (transcript, input, scroll, stream/status, provider, session/server, +/// workspace, diagram pane, diff pane, side panel, inline, overlay, copy +/// selection, onboarding, misc). See `docs/TUISTATE_TRAIT_DECOMPOSITION.md` for +/// the incremental plan to split these into composable sub-traits. pub trait TuiState { + // ---- Transcript ---- fn display_messages(&self) -> &[DisplayMessage]; fn display_user_message_count(&self) -> usize; /// Number of user prompts hidden before the first visible message because of @@ -125,6 +133,8 @@ pub trait TuiState { /// Version counter for display_messages (monotonic, increments on mutation) fn display_messages_version(&self) -> u64; fn streaming_text(&self) -> &str; + + // ---- Input ---- fn input(&self) -> &str; fn cursor_pos(&self) -> usize; fn is_processing(&self) -> bool; @@ -132,6 +142,8 @@ pub trait TuiState { fn interleave_message(&self) -> Option<&str>; /// Messages sent as soft interrupt but not yet injected (shown in queue preview) fn pending_soft_interrupts(&self) -> &[String]; + + // ---- Scroll ---- fn scroll_offset(&self) -> usize; /// Whether auto-scroll to bottom is paused (user scrolled up during streaming) fn auto_scroll_paused(&self) -> bool; @@ -148,6 +160,8 @@ pub trait TuiState { fn copy_selection_edge_autoscroll_active(&self) -> bool { false } + + // ---- Provider ---- fn provider_name(&self) -> String; fn provider_model(&self) -> String; /// Upstream provider (e.g., which provider OpenRouter routed to) @@ -158,6 +172,8 @@ pub trait TuiState { fn status_detail(&self) -> Option; fn mcp_servers(&self) -> Vec<(String, usize)>; fn available_skills(&self) -> Vec; + + // ---- Stream / status ---- fn streaming_tokens(&self) -> (u64, u64); fn streaming_cache_tokens(&self) -> (Option, Option); /// Output tokens per second during streaming (for status bar) @@ -186,6 +202,8 @@ pub trait TuiState { 0 } /// Whether running in remote (client-server) mode + + // ---- Session / server ---- fn is_remote_mode(&self) -> bool; /// Whether running in canary/self-dev mode fn is_canary(&self) -> bool; @@ -217,11 +235,6 @@ pub trait TuiState { fn has_pending_mouse_scroll_animation(&self) -> bool { false } - /// Whether a "current reasoning collapses away" animation is in progress and - /// the redraw loop must keep ticking to advance it. - fn reasoning_collapse_animating(&self) -> bool { - false - } /// Optional configured keybinding label for external dictation. fn dictation_key_label(&self) -> Option; /// Time since app started (for startup animations) @@ -256,6 +269,8 @@ pub trait TuiState { /// Get info widget data (todos, client count, etc.) fn info_widget_data(&self) -> info_widget::InfoWidgetData; /// Whether workspace mode is enabled for this client. + + // ---- Workspace ---- fn workspace_mode_enabled(&self) -> bool { false } @@ -277,6 +292,7 @@ pub trait TuiState { /// Update cost calculation based on token usage (for API-key providers) fn update_cost(&mut self); /// Diagram display mode (none/margin/pinned) + // ---- Diagram pane ---- fn diagram_mode(&self) -> crate::config::DiagramDisplayMode; /// Whether the diagram pane is focused (pinned mode) fn diagram_focus(&self) -> bool; @@ -297,6 +313,7 @@ pub trait TuiState { /// Diagram zoom percentage (100 = normal) fn diagram_zoom(&self) -> u8; /// Scroll offset for pinned diff pane (line index) + // ---- Diff pane ---- fn diff_pane_scroll(&self) -> usize; /// Horizontal pan offset for the shared right pane (side-panel diagrams) fn diff_pane_scroll_x(&self) -> i32; @@ -305,6 +322,7 @@ pub trait TuiState { /// Whether the pinned diff pane is focused fn diff_pane_focus(&self) -> bool; /// Session-scoped side panel state managed by the side_panel tool + // ---- Side panel ---- fn side_panel(&self) -> &crate::side_panel::SidePanelSnapshot; /// Whether to pin read images to a side pane fn pin_images(&self) -> bool; @@ -319,6 +337,7 @@ pub trait TuiState { /// Whether to wrap lines in the pinned diff pane fn diff_line_wrap(&self) -> bool; /// Interactive inline UI state (picker-like flows shown above input) + // ---- Inline ---- fn inline_interactive_state(&self) -> Option<&InlineInteractiveState>; /// Passive inline UI state (informational views shown above input) fn inline_view_state(&self) -> Option<&InlineViewState> { @@ -331,6 +350,7 @@ pub trait TuiState { .or_else(|| self.inline_view_state().map(InlineUiStateRef::View)) } /// Changelog overlay scroll offset (None = not showing) + // ---- Overlay ---- fn changelog_scroll(&self) -> Option; /// Help overlay scroll offset (None = not showing) fn help_scroll(&self) -> Option; @@ -347,10 +367,12 @@ pub trait TuiState { /// Usage overlay for /usage command fn usage_overlay(&self) -> Option<&std::cell::RefCell>; /// Working directory for this session + // ---- Misc ---- fn working_dir(&self) -> Option; /// Monotonic clock for viewport animations fn now_millis(&self) -> u64; /// UI state for live copy badge highlighting / feedback + // ---- Copy selection ---- fn copy_badge_ui(&self) -> crate::tui::CopyBadgeUiState; /// Whether modal in-app copy selection mode is active. fn copy_selection_mode(&self) -> bool; @@ -359,6 +381,7 @@ pub trait TuiState { /// Persistent status for in-app copy selection mode. fn copy_selection_status(&self) -> Option; /// Whether the first-run onboarding empty state is being previewed in this session. + // ---- Onboarding ---- fn onboarding_preview_mode(&self) -> bool { false } @@ -1287,7 +1310,6 @@ pub(crate) fn redraw_interval_with_policy( || !state.streaming_text().is_empty() || state.status_notice().is_some() || state.has_pending_mouse_scroll_animation() - || state.reasoning_collapse_animating() || state.copy_selection_edge_autoscroll_active() || state.has_notification() || rate_limit_countdown_redraw_active(state) @@ -1347,7 +1369,6 @@ pub(crate) fn periodic_redraw_required(state: &dyn TuiState) -> bool { || !state.streaming_text().is_empty() || state.status_notice().is_some() || state.has_pending_mouse_scroll_animation() - || state.reasoning_collapse_animating() || state.copy_selection_edge_autoscroll_active() || state.chat_overscroll_active() || state.has_notification() diff --git a/crates/jcode-tui/src/tui/ui_memory.rs b/crates/jcode-tui/src/tui/ui_memory.rs index c66ef292d..9b03d7aa2 100644 --- a/crates/jcode-tui/src/tui/ui_memory.rs +++ b/crates/jcode-tui/src/tui/ui_memory.rs @@ -1,587 +1 @@ -use chrono::{DateTime, Utc}; -use ratatui::prelude::*; - -#[derive(Clone)] -pub(super) struct MemoryTilePlan { - pub(super) lines: Vec>, - pub(super) width: usize, - pub(super) height: usize, - pub(super) score: usize, -} - -pub(super) struct MemoryTile { - category: String, - items: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct MemoryTileItem { - pub(super) content: String, - pub(super) updated_at: Option>, -} - -impl From for MemoryTileItem { - fn from(content: String) -> Self { - Self { - content, - updated_at: None, - } - } -} - -impl From<&str> for MemoryTileItem { - fn from(content: &str) -> Self { - Self::from(content.to_string()) - } -} - -pub(super) fn parse_memory_display_entries(content: &str) -> Vec<(String, MemoryTileItem)> { - let mut entries: Vec<(String, MemoryTileItem)> = Vec::new(); - let mut current_category = String::new(); - let mut last_entry_idx: Option = None; - - for raw_line in content.lines() { - let line = raw_line.trim(); - if line.starts_with("# ") || line.is_empty() { - continue; - } - if let Some(category) = line.strip_prefix("## ") { - current_category = category.trim().to_string(); - continue; - } - if let Some(updated_at_raw) = line - .strip_prefix("")) - { - if let (Some(idx), Ok(updated_at)) = ( - last_entry_idx, - DateTime::parse_from_rfc3339(updated_at_raw.trim()), - ) { - entries[idx].1.updated_at = Some(updated_at.with_timezone(&Utc)); - } - continue; - } - - let content = if let Some(dot_pos) = line.find(". ") { - let prefix = &line[..dot_pos]; - if prefix.trim().chars().all(|c| c.is_ascii_digit()) { - line[dot_pos + 2..].trim() - } else { - line - } - } else { - line - }; - if content.is_empty() { - continue; - } - - let category = if current_category.is_empty() { - "memory".to_string() - } else { - current_category.clone() - }; - entries.push(( - category, - MemoryTileItem { - content: content.to_string(), - updated_at: None, - }, - )); - last_entry_idx = Some(entries.len() - 1); - } - - entries -} - -pub(super) fn group_into_tiles(entries: Vec<(String, T)>) -> Vec -where - T: Into, -{ - let mut order: Vec = Vec::new(); - let mut map: std::collections::HashMap> = - std::collections::HashMap::new(); - for (cat, content) in entries { - if !map.contains_key(&cat) { - order.push(cat.clone()); - } - map.entry(cat).or_default().push(content.into()); - } - order - .into_iter() - .filter_map(|cat| { - map.remove(&cat).map(|items| MemoryTile { - category: cat, - items, - }) - }) - .collect() -} - -/// Split a string into chunks that each fit within `max_width` display columns, -/// respecting multi-column characters (CJK characters take 2 columns, etc.). -pub(super) fn split_by_display_width(s: &str, max_width: usize) -> Vec { - use unicode_width::UnicodeWidthChar; - let mut chunks = Vec::new(); - let mut current = String::new(); - let mut current_width = 0usize; - - for ch in s.chars() { - let cw = UnicodeWidthChar::width(ch).unwrap_or(0); - if current_width + cw > max_width && !current.is_empty() { - chunks.push(std::mem::take(&mut current)); - current_width = 0; - } - current.push(ch); - current_width += cw; - } - if !current.is_empty() { - chunks.push(current); - } - if chunks.is_empty() { - chunks.push(String::new()); - } - chunks -} - -fn truncate_to_display_width(s: &str, max_width: usize) -> String { - use unicode_width::UnicodeWidthChar; - - if max_width == 0 { - return String::new(); - } - - let full_width = unicode_width::UnicodeWidthStr::width(s); - if full_width <= max_width { - return s.to_string(); - } - - let ellipsis = "…"; - let ellipsis_width = unicode_width::UnicodeWidthStr::width(ellipsis); - if ellipsis_width >= max_width { - return ellipsis.to_string(); - } - - let target_width = max_width - ellipsis_width; - let mut truncated = String::new(); - let mut width = 0usize; - for ch in s.chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if width + ch_width > target_width { - break; - } - truncated.push(ch); - width += ch_width; - } - truncated.push('…'); - truncated -} - -fn format_memory_updated_age(updated_at: DateTime) -> String { - let age = Utc::now().signed_duration_since(updated_at); - if age.num_seconds() < 2 { - "updated now".to_string() - } else if age.num_minutes() < 1 { - format!("updated {}s ago", age.num_seconds().max(1)) - } else if age.num_hours() < 1 { - format!("updated {}m ago", age.num_minutes()) - } else if age.num_days() < 1 { - format!("updated {}h ago", age.num_hours()) - } else if age.num_days() < 7 { - format!("updated {}d ago", age.num_days()) - } else if age.num_days() < 30 { - format!("updated {}w ago", (age.num_days() / 7).max(1)) - } else { - format!("updated {}mo ago", (age.num_days() / 30).max(1)) - } -} - -fn memory_age_text_tint(updated_at: Option>) -> Color { - let Some(updated_at) = updated_at else { - return Color::Rgb(140, 144, 152); - }; - let age = Utc::now().signed_duration_since(updated_at); - if age.num_hours() < 1 { - Color::Rgb(146, 156, 149) - } else if age.num_days() < 1 { - Color::Rgb(142, 148, 156) - } else if age.num_days() < 7 { - Color::Rgb(145, 144, 154) - } else if age.num_days() < 30 { - Color::Rgb(150, 143, 147) - } else { - Color::Rgb(154, 144, 144) - } -} - -fn memory_tile_content_lines( - items: &[MemoryTileItem], - inner_width: usize, - border_style: Style, - text_style: Style, -) -> Vec> { - let bullet = "· "; - let bullet_width = unicode_width::UnicodeWidthStr::width(bullet); - let item_width = inner_width.saturating_sub(bullet_width); - - let mut content_lines: Vec> = Vec::new(); - for item in items { - let text_fill_style = text_style.fg(memory_age_text_tint(item.updated_at)); - let meta_fill_style = Style::default().fg(Color::Rgb(160, 165, 172)); - let text_display_width = unicode_width::UnicodeWidthStr::width(item.content.as_str()); - if text_display_width <= item_width { - let text = item.content.to_string(); - let padding = inner_width.saturating_sub(bullet_width + text_display_width); - let mut spans = vec![ - Span::styled("│ ", border_style), - Span::styled(bullet.to_string(), text_fill_style), - Span::styled(text, text_fill_style), - ]; - if padding > 0 { - spans.push(Span::raw(" ".repeat(padding))); - } - spans.push(Span::styled(" │", border_style)); - content_lines.push(Line::from(spans)); - } else { - let indent = bullet_width; - let cont_width = inner_width.saturating_sub(indent); - let first_chunk_width = item_width; - let mut all_chunks: Vec = Vec::new(); - let first_chunks = split_by_display_width(&item.content, first_chunk_width); - if let Some(first) = first_chunks.first() { - all_chunks.push(first.clone()); - let remainder: String = item.content.chars().skip(first.chars().count()).collect(); - if !remainder.is_empty() { - all_chunks.extend(split_by_display_width(&remainder, cont_width)); - } - } - for (ci, chunk) in all_chunks.iter().enumerate() { - let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str()); - if ci == 0 { - let padding = inner_width.saturating_sub(bullet_width + chunk_width); - let mut spans = vec![ - Span::styled("│ ", border_style), - Span::styled(bullet.to_string(), text_fill_style), - Span::styled(chunk.clone(), text_fill_style), - ]; - if padding > 0 { - spans.push(Span::raw(" ".repeat(padding))); - } - spans.push(Span::styled(" │", border_style)); - content_lines.push(Line::from(spans)); - } else { - let padding = inner_width.saturating_sub(indent + chunk_width); - let mut spans = vec![ - Span::styled("│ ", border_style), - Span::raw(" ".repeat(indent)), - Span::styled(chunk.clone(), text_fill_style), - ]; - if padding > 0 { - spans.push(Span::raw(" ".repeat(padding))); - } - spans.push(Span::styled(" │", border_style)); - content_lines.push(Line::from(spans)); - } - } - } - - if let Some(updated_at) = item.updated_at { - let meta = format_memory_updated_age(updated_at); - let indent = bullet_width; - let meta_width = inner_width.saturating_sub(indent).max(1); - for chunk in split_by_display_width(&meta, meta_width) { - let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str()); - let padding = inner_width.saturating_sub(indent + chunk_width); - content_lines.push(Line::from(vec![ - Span::styled("│ ", border_style), - Span::raw(" ".repeat(indent)), - Span::styled(chunk, meta_fill_style), - Span::raw(" ".repeat(padding)), - Span::styled(" │", border_style), - ])); - } - } - } - - if content_lines.is_empty() { - content_lines.push(Line::from(vec![ - Span::styled("│ ", border_style), - Span::raw(" ".repeat(inner_width)), - Span::styled(" │", border_style), - ])); - } - - content_lines -} - -fn render_memory_tile_box( - tile: &MemoryTile, - box_width: usize, - border_style: Style, - text_style: Style, -) -> Vec> { - let inner_width = box_width.saturating_sub(4); - if inner_width < 4 { - return Vec::new(); - } - - let title_max_width = box_width.saturating_sub(4); - let title_label = truncate_to_display_width(&tile.category.to_lowercase(), title_max_width); - let title_text = format!(" {} ", title_label); - let title_len = unicode_width::UnicodeWidthStr::width(title_text.as_str()); - let border_chars = box_width.saturating_sub(title_len + 2); - let left_border = "─".repeat(border_chars / 2); - let right_border = "─".repeat(border_chars - border_chars / 2); - - let top = Line::from(Span::styled( - format!("╭{}{}{}╮", left_border, title_text, right_border), - border_style, - )); - let content_lines = - memory_tile_content_lines(&tile.items, inner_width, border_style, text_style); - let bottom = Line::from(Span::styled( - format!("╰{}╯", "─".repeat(box_width.saturating_sub(2))), - border_style, - )); - - let mut lines = Vec::with_capacity(content_lines.len() + 2); - lines.push(top); - lines.extend(content_lines); - lines.push(bottom); - lines -} - -pub(super) fn plan_memory_tile( - tile: &MemoryTile, - box_width: usize, - border_style: Style, - text_style: Style, -) -> Option { - let lines = render_memory_tile_box(tile, box_width, border_style, text_style); - if lines.is_empty() { - return None; - } - let width = lines.first().map(Line::width).unwrap_or(box_width); - let height = lines.len(); - let score = tile.items.len() * 10 - + tile - .items - .iter() - .map(|item| unicode_width::UnicodeWidthStr::width(item.content.as_str()).min(80)) - .sum::(); - Some(MemoryTilePlan { - lines, - width, - height, - score, - }) -} - -pub(super) fn choose_memory_tile_span( - tile: &MemoryTile, - column_width: usize, - gap: usize, - max_span: usize, - border_style: Style, - text_style: Style, -) -> Option<(MemoryTilePlan, usize)> { - let single = plan_memory_tile(tile, column_width, border_style, text_style)?; - let mut best_plan = single.clone(); - let mut best_span = 1usize; - - for span in 2..=max_span.max(1) { - let width = column_width * span + gap * span.saturating_sub(1); - let Some(plan) = plan_memory_tile(tile, width, border_style, text_style) else { - continue; - }; - - let single_area = single.width * single.height; - let span_area = plan.width * plan.height; - let height_gain = single.height.saturating_sub(plan.height); - let area_gain = single_area.saturating_sub(span_area); - - if height_gain >= 2 || (height_gain >= 1 && area_gain > column_width) { - best_plan = plan; - best_span = span; - break; - } - } - - Some((best_plan, best_span)) -} - -pub(super) fn render_memory_tiles( - tiles: &[MemoryTile], - total_width: usize, - border_style: Style, - text_style: Style, - header_line: Option>, -) -> Vec> { - if tiles.is_empty() { - return Vec::new(); - } - - let mut all_lines: Vec> = Vec::new(); - - if let Some(header) = header_line { - all_lines.push(header); - } - - let min_box_inner = 16usize; - let min_box_width = min_box_inner + 4; - let gap = 2usize; - let row_gap = 0usize; - let usable_width = total_width.max(min_box_width); - - #[derive(Clone)] - struct Placement { - x: usize, - y: usize, - plan: MemoryTilePlan, - } - - #[derive(Clone)] - struct PlannedTile { - span: usize, - plan: MemoryTilePlan, - } - - let max_cols = ((usable_width + gap) / (min_box_width + gap)).clamp(1, 4); - let mut best_layout: Option<(Vec, usize, usize)> = None; - - for column_count in 1..=max_cols { - let column_width = (usable_width.saturating_sub((column_count - 1) * gap)) / column_count; - if column_width < min_box_width { - continue; - } - - let max_span = if column_count >= 2 { 2 } else { 1 }; - let mut planned: Vec = tiles - .iter() - .filter_map(|tile| { - let (plan, span) = choose_memory_tile_span( - tile, - column_width, - gap, - max_span, - border_style, - text_style, - )?; - Some(PlannedTile { span, plan }) - }) - .collect(); - - if planned.is_empty() { - continue; - } - - planned.sort_by(|a, b| { - b.plan - .score - .cmp(&a.plan.score) - .then_with(|| b.span.cmp(&a.span)) - .then_with(|| b.plan.height.cmp(&a.plan.height)) - .then_with(|| b.plan.width.cmp(&a.plan.width)) - }); - - let mut column_heights = vec![0usize; column_count]; - let mut placements: Vec = Vec::with_capacity(planned.len()); - - for planned_tile in planned { - let mut best_start = 0usize; - let mut best_y = usize::MAX; - - for start_col in 0..=column_count.saturating_sub(planned_tile.span) { - let y = column_heights[start_col..start_col + planned_tile.span] - .iter() - .copied() - .max() - .unwrap_or(0); - - if y < best_y || (y == best_y && start_col < best_start) { - best_start = start_col; - best_y = y; - } - } - - let x = best_start * (column_width + gap); - let next_height = best_y + planned_tile.plan.height + row_gap; - for height in &mut column_heights[best_start..best_start + planned_tile.span] { - *height = next_height; - } - - placements.push(Placement { - x, - y: best_y, - plan: planned_tile.plan, - }); - } - - let total_height = column_heights - .iter() - .copied() - .max() - .unwrap_or(0) - .saturating_sub(row_gap); - let imbalance = column_heights.iter().copied().max().unwrap_or(0) - - column_heights.iter().copied().min().unwrap_or(0); - let used_width = column_count * column_width + gap * column_count.saturating_sub(1); - let leftover_width = usable_width.saturating_sub(used_width); - - // Vertical centering: if this column arrangement has imbalanced columns, - // center shorter columns' tiles vertically within the available space. - let max_col_height = *column_heights.iter().max().unwrap_or(&0); - for (col_idx, col_height) in column_heights.iter().enumerate() { - if *col_height < max_col_height { - let extra = max_col_height - col_height; - let offset = extra / 2; - if offset > 0 { - for placed in placements.iter_mut() { - let start_col = placed.x / (column_width + gap); - if start_col == col_idx { - placed.y += offset; - } - } - } - } - } - - let layout_score = total_height * 100 + imbalance * 3 + leftover_width; - - match &best_layout { - Some((_, _, best_score)) if *best_score <= layout_score => {} - _ => best_layout = Some((placements, total_height, layout_score)), - } - } - - let Some((mut placements, total_height, _)) = best_layout else { - return all_lines; - }; - - placements.sort_by(|a, b| a.x.cmp(&b.x).then_with(|| a.y.cmp(&b.y))); - - for y in 0..total_height { - let mut spans: Vec> = Vec::new(); - let mut cursor = 0usize; - let mut row_has_content = false; - for placed in placements - .iter() - .filter(|placed| y >= placed.y && y < placed.y + placed.plan.height) - { - if placed.x > cursor { - spans.push(Span::raw(" ".repeat(placed.x - cursor))); - } - spans.extend(placed.plan.lines[y - placed.y].spans.clone()); - cursor = placed.x + placed.plan.width; - row_has_content = true; - } - if row_has_content { - if cursor < usable_width { - spans.push(Span::raw(" ".repeat(usable_width - cursor))); - } - all_lines.push(Line::from(spans)); - } - } - - all_lines -} +pub(crate) use jcode_tui_render::memory_tiles::*; diff --git a/crates/jcode-tui/src/tui/usage_overlay.rs b/crates/jcode-tui/src/tui/usage_overlay.rs index 3c48ed9a3..41eb5eec5 100644 --- a/crates/jcode-tui/src/tui/usage_overlay.rs +++ b/crates/jcode-tui/src/tui/usage_overlay.rs @@ -1,721 +1,3 @@ -use anyhow::Result; -use crossterm::event::{KeyCode, KeyModifiers}; -pub use jcode_tui_usage_overlay::{UsageOverlayItem, UsageOverlayStatus, UsageOverlaySummary}; -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph, Wrap}, +pub use jcode_tui_usage_overlay::{ + OverlayAction, UsageOverlay, UsageOverlayItem, UsageOverlayStatus, UsageOverlaySummary, }; - -const PANEL_BG: Color = Color::Rgb(24, 28, 40); -const PANEL_BORDER: Color = Color::Rgb(90, 95, 110); -const PANEL_BORDER_ACTIVE: Color = Color::Rgb(120, 140, 190); -const SECTION_BORDER: Color = Color::Rgb(70, 78, 94); -const SELECTED_BG: Color = Color::Rgb(38, 42, 56); -const MUTED: Color = Color::Rgb(140, 146, 163); -const MUTED_DARK: Color = Color::Rgb(100, 106, 122); -const OVERLAY_PERCENT_X: u16 = 88; -const OVERLAY_PERCENT_Y: u16 = 74; - -#[derive(Debug, Clone)] -pub struct UsageOverlay { - title: String, - items: Vec, - filtered: Vec, - selected: usize, - filter: String, - summary: UsageOverlaySummary, -} - -pub enum OverlayAction { - Continue, - Close, -} - -impl UsageOverlay { - pub fn loading() -> Self { - Self::new( - " Usage ", - vec![UsageOverlayItem::new( - "loading", - "Refreshing usage", - "Fetching limits from connected providers", - UsageOverlayStatus::Loading, - vec![ - "Fetching usage limits from all connected providers...".to_string(), - "".to_string(), - "This view will update automatically when the usage report returns." - .to_string(), - ], - )], - UsageOverlaySummary::default(), - ) - } - - pub fn from_progress(progress: &crate::usage::ProviderUsageProgress) -> Self { - Self::from_provider_reports( - &progress.results, - !progress.done, - progress.completed, - progress.total, - progress.from_cache, - ) - } - - pub fn from_provider_reports( - reports: &[crate::usage::ProviderUsage], - refreshing: bool, - completed: usize, - total: usize, - from_cache: bool, - ) -> Self { - let mut items: Vec = reports.iter().map(provider_item).collect(); - - if refreshing { - let subtitle = if total > 0 { - format!("Refreshing providers ({}/{})", completed.min(total), total) - } else if from_cache { - "Showing cached usage while refreshing providers".to_string() - } else { - "Fetching usage limits from connected providers".to_string() - }; - items.push(UsageOverlayItem::new( - "refreshing", - "Refreshing usage", - subtitle, - UsageOverlayStatus::Loading, - vec![ - "## Live refresh".to_string(), - if from_cache { - "• Cached results are visible immediately.".to_string() - } else { - "• Waiting for provider responses.".to_string() - }, - if total > 0 { - format!( - "• Completed {}/{} provider checks.", - completed.min(total), - total - ) - } else { - "• Discovering connected providers.".to_string() - }, - "• This panel updates as each provider returns.".to_string(), - ], - )); - } else if items.is_empty() { - items.push(UsageOverlayItem::new( - "no-providers", - "No connected providers", - "Connect Claude or OpenAI OAuth to show usage limits", - UsageOverlayStatus::Info, - vec![ - "## No usage sources found".to_string(), - "• No providers with OAuth credentials were found.".to_string(), - "• Use `/login claude` or `/login openai` to connect a provider.".to_string(), - "• Then run `/usage` again.".to_string(), - ], - )); - } - - let mut summary = UsageOverlaySummary { - provider_count: reports.len(), - session_visible: false, - ..UsageOverlaySummary::default() - }; - for report in reports { - match provider_status(report) { - UsageOverlayStatus::Warning => summary.warning_count += 1, - UsageOverlayStatus::Critical => summary.critical_count += 1, - UsageOverlayStatus::Error => summary.error_count += 1, - _ => {} - } - } - - let title = if refreshing { - " Usage · refreshing " - } else { - " Usage " - }; - Self::new(title, items, summary) - } - - pub fn debug_memory_profile(&self) -> serde_json::Value { - let items_estimate_bytes: usize = self.items.iter().map(estimate_item_bytes).sum(); - let filtered_estimate_bytes = self.filtered.capacity() * std::mem::size_of::(); - let filter_bytes = self.filter.capacity(); - let title_bytes = self.title.capacity(); - let total_estimate_bytes = - items_estimate_bytes + filtered_estimate_bytes + filter_bytes + title_bytes; - - serde_json::json!({ - "items_count": self.items.len(), - "filtered_count": self.filtered.len(), - "selected": self.selected, - "title_bytes": title_bytes, - "filter_bytes": filter_bytes, - "items_estimate_bytes": items_estimate_bytes, - "filtered_estimate_bytes": filtered_estimate_bytes, - "total_estimate_bytes": total_estimate_bytes, - }) - } - - pub fn new( - title: impl Into, - items: Vec, - summary: UsageOverlaySummary, - ) -> Self { - let mut overlay = Self { - title: title.into(), - items, - filtered: Vec::new(), - selected: 0, - filter: String::new(), - summary, - }; - overlay.apply_filter(); - overlay - } - - pub fn selected_item_title(&self) -> Option<&str> { - self.selected_item().map(|item| item.title.as_str()) - } - - pub fn replace_preserving_view(&mut self, mut next: Self) { - let selected_id = self.selected_item().map(|item| item.id.clone()); - next.filter = self.filter.clone(); - next.apply_filter(); - if let Some(selected_id) = selected_id - && let Some(selected) = next - .filtered - .iter() - .position(|item_idx| next.items[*item_idx].id == selected_id) - { - next.selected = selected; - } - *self = next; - } - - pub fn selected_item_detail_text(&self) -> String { - self.selected_item() - .map(|item| item.detail_lines.join("\n")) - .unwrap_or_default() - } - - fn selected_item(&self) -> Option<&UsageOverlayItem> { - self.filtered - .get(self.selected) - .and_then(|idx| self.items.get(*idx)) - } - - fn apply_filter(&mut self) { - self.filtered = self - .items - .iter() - .enumerate() - .filter_map(|(idx, item)| { - jcode_tui_usage_overlay::item_matches_filter(item, &self.filter).then_some(idx) - }) - .collect(); - if self.selected >= self.filtered.len() { - self.selected = self.filtered.len().saturating_sub(1); - } - } - - pub fn handle_overlay_key( - &mut self, - code: KeyCode, - modifiers: KeyModifiers, - ) -> Result { - match code { - KeyCode::Esc => { - if !self.filter.is_empty() { - self.filter.clear(); - self.apply_filter(); - return Ok(OverlayAction::Continue); - } - return Ok(OverlayAction::Close); - } - KeyCode::Char('q') if !modifiers.contains(KeyModifiers::CONTROL) => { - return Ok(OverlayAction::Close); - } - KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { - return Ok(OverlayAction::Close); - } - KeyCode::Up | KeyCode::Char('k') => { - self.selected = self.selected.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - let max = self.filtered.len().saturating_sub(1); - self.selected = (self.selected + 1).min(max); - } - KeyCode::PageUp | KeyCode::Char('K') => { - self.selected = self.selected.saturating_sub(6); - } - KeyCode::PageDown | KeyCode::Char('J') => { - let max = self.filtered.len().saturating_sub(1); - self.selected = (self.selected + 6).min(max); - } - KeyCode::Home | KeyCode::Char('g') => { - self.selected = 0; - } - KeyCode::End | KeyCode::Char('G') => { - self.selected = self.filtered.len().saturating_sub(1); - } - KeyCode::Backspace => { - if self.filter.pop().is_some() { - self.apply_filter(); - } - } - KeyCode::Char(c) - if !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) => - { - self.filter.push(c); - self.apply_filter(); - } - _ => {} - } - Ok(OverlayAction::Continue) - } - - pub fn render(&self, frame: &mut Frame) { - let area = centered_rect(OVERLAY_PERCENT_X, OVERLAY_PERCENT_Y, frame.area()); - - let block = Block::default() - .title(format!(" {} ", self.title)) - .title_bottom(Line::from(vec![ - hotkey(" Up/Down "), - Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), - hotkey(" type "), - Span::styled(" filter ", Style::default().fg(MUTED_DARK)), - hotkey(" /usage "), - Span::styled(" refresh ", Style::default().fg(MUTED_DARK)), - hotkey(" Esc "), - Span::styled(" clear / close ", Style::default().fg(MUTED_DARK)), - ])) - .borders(Borders::ALL) - .border_style(Style::default().fg(PANEL_BORDER)); - frame.render_widget(block, area); - - let inner = Rect { - x: area.x + 1, - y: area.y + 1, - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(2), - }; - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), - Constraint::Min(10), - Constraint::Length(2), - ]) - .split(inner); - - self.render_header(frame, rows[0]); - - let body = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(39), Constraint::Percentage(61)]) - .split(rows[1]); - - self.render_item_list(frame, body[0]); - self.render_detail_pane(frame, body[1]); - - let footer = Paragraph::new(Line::from(vec![ - Span::styled("Focus ", Style::default().fg(MUTED_DARK)), - Span::styled( - "Use this panel to compare provider headroom and reset times without cluttering the chat transcript.", - Style::default().fg(MUTED), - ), - ])); - frame.render_widget(footer, rows[2]); - } - - fn render_header(&self, frame: &mut Frame, area: Rect) { - let block = Block::default() - .title(Span::styled( - " Usage overview ", - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(SECTION_BORDER)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let lines = vec![ - Line::from(vec![ - Span::styled("Filter ", Style::default().fg(MUTED_DARK)), - Span::styled( - if self.filter.is_empty() { - "type provider or plan name".to_string() - } else { - self.filter.clone() - }, - if self.filter.is_empty() { - Style::default().fg(Color::Gray).italic() - } else { - Style::default().fg(Color::White) - }, - ), - Span::styled( - format!(" · {} results", self.filtered.len()), - Style::default().fg(MUTED_DARK), - ), - ]), - Line::from(vec![ - metric_span( - "providers", - self.summary.provider_count, - Color::Rgb(111, 214, 181), - ), - Span::raw(" "), - metric_span( - "watch", - self.summary.warning_count, - Color::Rgb(255, 196, 112), - ), - Span::raw(" "), - metric_span( - "high", - self.summary.critical_count, - Color::Rgb(255, 146, 110), - ), - Span::raw(" "), - metric_span( - "errors", - self.summary.error_count, - Color::Rgb(232, 134, 134), - ), - if self.summary.session_visible { - Span::styled(" · session included", Style::default().fg(MUTED_DARK)) - } else { - Span::raw("") - }, - ]), - ]; - - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); - } - - fn render_item_list(&self, frame: &mut Frame, area: Rect) { - let title = if self.filtered.is_empty() { - " Sources ".to_string() - } else { - format!(" Sources ({}/{}) ", self.selected + 1, self.filtered.len()) - }; - let block = Block::default() - .title(Span::styled( - title, - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(PANEL_BORDER_ACTIVE)); - let inner = block.inner(area); - frame.render_widget(block, area); - - if self.filtered.is_empty() { - frame.render_widget( - Paragraph::new("No usage items match the current filter.") - .style(Style::default().fg(MUTED)) - .wrap(Wrap { trim: false }), - inner, - ); - return; - } - - let mut lines: Vec> = Vec::new(); - let mut selected_line = 0usize; - for (visible_idx, item_idx) in self.filtered.iter().enumerate() { - let item = &self.items[*item_idx]; - let selected = visible_idx == self.selected; - if selected { - selected_line = lines.len(); - } - let title_style = if selected { - Style::default().fg(Color::White).bg(SELECTED_BG).bold() - } else { - Style::default().fg(Color::White) - }; - let subtitle_style = if selected { - Style::default().fg(MUTED).bg(SELECTED_BG) - } else { - Style::default().fg(MUTED) - }; - let badge_style = Style::default() - .fg(item.status.color()) - .bg(if selected { SELECTED_BG } else { PANEL_BG }) - .bold(); - let marker = if selected { "›" } else { " " }; - lines.push(Line::from(vec![ - Span::styled( - format!("{} {} ", marker, item.status.icon()), - Style::default().fg(item.status.color()).bg(if selected { - SELECTED_BG - } else { - PANEL_BG - }), - ), - Span::styled( - truncate_with_ellipsis(&item.title, inner.width.saturating_sub(16) as usize), - title_style, - ), - Span::raw(" "), - Span::styled(format!("[{}]", item.status.label()), badge_style), - ])); - lines.push(Line::from(Span::styled( - format!(" {}", item.subtitle), - subtitle_style, - ))); - lines.push(Line::from("")); - } - - let visible_height = inner.height.max(1) as usize; - let scroll = selected_line.saturating_sub(visible_height.saturating_sub(3)); - frame.render_widget( - Paragraph::new(lines) - .scroll((scroll.min(u16::MAX as usize) as u16, 0)) - .wrap(Wrap { trim: false }), - inner, - ); - } - - fn render_detail_pane(&self, frame: &mut Frame, area: Rect) { - let selected = self.selected_item(); - let title = selected - .map(|item| format!(" {} · {} ", item.title, item.status.label())) - .unwrap_or_else(|| " Usage details ".to_string()); - let border_color = selected - .map(|item| item.status.color()) - .unwrap_or(PANEL_BORDER_ACTIVE); - let block = Block::default() - .title(Span::styled( - title, - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(border_color)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let lines: Vec> = match selected { - Some(item) => item - .detail_lines - .iter() - .map(|line| { - if line.is_empty() { - Line::from("") - } else if let Some(rest) = line.strip_prefix("## ") { - Line::from(Span::styled( - format!(" {}", rest), - Style::default().fg(Color::White).bold(), - )) - } else if let Some(rest) = line.strip_prefix("• ") { - Line::from(vec![ - Span::styled(" • ", Style::default().fg(MUTED_DARK)), - Span::styled(rest.to_string(), Style::default().fg(MUTED)), - ]) - } else { - Line::from(Span::styled(line.clone(), Style::default().fg(MUTED))) - } - }) - .collect(), - None => vec![Line::from(Span::styled( - "No usage item selected.", - Style::default().fg(MUTED), - ))], - }; - - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); - } -} - -fn estimate_item_bytes(item: &UsageOverlayItem) -> usize { - item.id.capacity() - + item.title.capacity() - + item.subtitle.capacity() - + item - .detail_lines - .iter() - .map(|value| value.capacity()) - .sum::() -} - -fn hotkey(text: &'static str) -> Span<'static> { - Span::styled(text, Style::default().fg(Color::White).bg(Color::DarkGray)) -} - -fn metric_span(label: &'static str, value: usize, color: Color) -> Span<'static> { - Span::styled( - format!("{} {}", label, value), - Style::default().fg(color).bold(), - ) -} - -fn provider_item(report: &crate::usage::ProviderUsage) -> UsageOverlayItem { - let status = provider_status(report); - let subtitle = provider_subtitle(report); - UsageOverlayItem::new( - report.provider_name.clone(), - report.provider_name.clone(), - subtitle, - status, - provider_detail_lines(report), - ) -} - -fn provider_status(report: &crate::usage::ProviderUsage) -> UsageOverlayStatus { - if report.error.is_some() { - return UsageOverlayStatus::Error; - } - if report.hard_limit_reached { - return UsageOverlayStatus::Critical; - } - let max_percent = report - .limits - .iter() - .map(|limit| limit.usage_percent) - .fold(0.0_f32, f32::max); - if max_percent >= 90.0 { - UsageOverlayStatus::Critical - } else if max_percent >= 70.0 { - UsageOverlayStatus::Warning - } else if report.limits.is_empty() && report.extra_info.is_empty() { - UsageOverlayStatus::Info - } else { - UsageOverlayStatus::Good - } -} - -fn provider_subtitle(report: &crate::usage::ProviderUsage) -> String { - if let Some(error) = &report.error { - return truncate_with_ellipsis(error, 72); - } - if report.hard_limit_reached { - return "Hard limit reached".to_string(); - } - let mut parts = Vec::new(); - if let Some(limit) = report - .limits - .iter() - .max_by(|a, b| a.usage_percent.total_cmp(&b.usage_percent)) - { - let mut part = format!( - "{} {:.0}% used", - limit.name, - limit.usage_percent.clamp(0.0, 999.0) - ); - if let Some(reset) = limit.resets_at.as_deref() { - part.push_str(&format!( - " · resets in {}", - crate::usage::format_reset_time(reset) - )); - } - parts.push(part); - } - if let Some((key, value)) = report.extra_info.first() { - parts.push(format!("{}: {}", key, value)); - } - if parts.is_empty() { - "No usage data available".to_string() - } else { - truncate_with_ellipsis(&parts.join(" · "), 96) - } -} - -fn provider_detail_lines(report: &crate::usage::ProviderUsage) -> Vec { - let mut lines = Vec::new(); - lines.push("## Status".to_string()); - if let Some(error) = &report.error { - lines.push(format!("• Error: {}", error)); - lines.push("".to_string()); - lines.push("## Next steps".to_string()); - lines.push( - "• Re-run `/usage` to retry after credentials or network issues are fixed.".to_string(), - ); - if report.provider_name.to_lowercase().contains("openai") { - lines.push("• Use `/login openai` if the token needs refreshing.".to_string()); - } else if report.provider_name.to_lowercase().contains("anthropic") - || report.provider_name.to_lowercase().contains("claude") - { - lines.push("• Use `/login claude` if the token needs refreshing.".to_string()); - } - return lines; - } - - lines.push(format!("• {}", provider_status(report).label())); - if report.hard_limit_reached { - lines.push("• Hard limit reached.".to_string()); - } - - if !report.limits.is_empty() { - lines.push("".to_string()); - lines.push("## Limits".to_string()); - for limit in &report.limits { - let reset = limit - .resets_at - .as_deref() - .map(crate::usage::format_reset_time) - .map(|value| format!(" · resets in {}", value)) - .unwrap_or_default(); - lines.push(format!( - "• {} {}{}", - limit.name, - crate::usage::format_usage_bar(limit.usage_percent, 18), - reset - )); - } - } - - if !report.extra_info.is_empty() { - lines.push("".to_string()); - lines.push("## Details".to_string()); - for (key, value) in &report.extra_info { - lines.push(format!("• {}: {}", key, value)); - } - } - - if report.limits.is_empty() && report.extra_info.is_empty() { - lines.push("• No usage data available from this provider.".to_string()); - } - - lines -} - -fn truncate_with_ellipsis(input: &str, width: usize) -> String { - if width == 0 { - return String::new(); - } - let chars: Vec = input.chars().collect(); - if chars.len() <= width { - return input.to_string(); - } - if width <= 3 { - return ".".repeat(width); - } - let mut out: String = chars.into_iter().take(width - 3).collect(); - out.push_str("..."); - out -} - -fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { - let popup = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(area); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup[1])[1] -} diff --git a/crates/jcode-tui/src/tui/visual_debug.rs b/crates/jcode-tui/src/tui/visual_debug.rs index 60c4ade0e..e602420cb 100644 --- a/crates/jcode-tui/src/tui/visual_debug.rs +++ b/crates/jcode-tui/src/tui/visual_debug.rs @@ -1,855 +1,8 @@ -//! Visual Debug Infrastructure -//! -//! Captures TUI frame state for autonomous debugging by AI agents. -//! When enabled, writes detailed render information to a debug file -//! that can be read to understand visual bugs without seeing the terminal. - -use std::collections::VecDeque; -use std::fs::{self, File}; -use std::io::Write; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, OnceLock}; - -use ratatui::layout::Rect; -use serde::Serialize; -use serde_json::Value; - -/// Global flag to enable visual debugging (set via /debug-visual command) -static VISUAL_DEBUG_ENABLED: AtomicBool = AtomicBool::new(false); -/// Global flag to enable overlay drawing -static VISUAL_DEBUG_OVERLAY: AtomicBool = AtomicBool::new(false); - -/// Maximum number of frames to keep in the ring buffer -const MAX_FRAMES: usize = 100; - -/// Global frame buffer -static FRAME_BUFFER: OnceLock> = OnceLock::new(); - -fn get_frame_buffer() -> &'static Mutex { - FRAME_BUFFER.get_or_init(|| Mutex::new(FrameBuffer::new())) -} - -/// A captured frame with all render context -#[derive(Debug, Clone, Serialize)] -pub struct FrameCapture { - /// Frame number (monotonically increasing) - pub frame_id: u64, - /// Timestamp when frame was rendered - pub timestamp: std::time::SystemTime, - /// Terminal dimensions - pub terminal_size: (u16, u16), - /// Layout areas computed for this frame - pub layout: LayoutCapture, - /// State snapshot at render time - pub state: StateSnapshot, - /// Any anomalies detected during rendering - pub anomalies: Vec, - /// The actual text content rendered to each area (stripped of ANSI) - pub rendered_text: RenderedText, - /// Mermaid image regions detected in wrapped content - pub image_regions: Vec, - /// Render timing information (milliseconds) - pub render_timing: Option, - /// Info widget placements and summary data - pub info_widgets: Option, - /// Render order for major phases - pub render_order: Vec, - /// Mermaid debug stats snapshot (if available) - pub mermaid: Option, - /// Side-panel debug snapshot, including live Mermaid utilization when available - pub side_panel: Option, - /// Markdown debug stats snapshot (if available) - pub markdown: Option, - /// Theme/palette snapshot (if available) - pub theme: Option, -} - -/// Captured layout computation -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct LayoutCapture { - /// Whether packed layout was used (vs scrolling) - pub use_packed: bool, - /// Estimated content height - pub estimated_content_height: usize, - /// Messages area - pub messages_area: Option, - /// Diagram area (pinned diagram pane) - pub diagram_area: Option, - /// Status line area - pub status_area: Option, - /// Queued messages area - pub queued_area: Option, - /// Input area - pub input_area: Option, - /// Input line count (before wrapping) - pub input_lines_raw: usize, - /// Input line count (after wrapping) - pub input_lines_wrapped: usize, - /// Margin widths for info widgets (per visible row) - pub margins: Option, - /// Info widget placements - pub widget_placements: Vec, -} - -/// Rect capture (serializable) -#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)] -pub struct RectCapture { - pub x: u16, - pub y: u16, - pub width: u16, - pub height: u16, -} - -/// Margin widths captured for debug -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct MarginsCapture { - pub left_widths: Vec, - pub right_widths: Vec, - pub centered: bool, -} - -/// Info widget placement capture -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct WidgetPlacementCapture { - pub kind: String, - pub side: String, - pub rect: RectCapture, -} - -/// Render timing capture (milliseconds) -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct RenderTimingCapture { - pub prepare_ms: f32, - pub draw_ms: f32, - pub total_ms: f32, - pub messages_ms: Option, - pub widgets_ms: Option, -} - -/// Info widget summary capture -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct InfoWidgetSummary { - pub todos_total: usize, - pub todos_done: usize, - pub context_total_chars: Option, - pub context_limit: Option, - pub queue_mode: Option, - pub model: Option, - pub reasoning_effort: Option, - pub session_count: Option, - pub client_count: Option, - pub memory_total: Option, - pub memory_project: Option, - pub memory_global: Option, - pub memory_activity: Option, - pub swarm_session_count: Option, - pub swarm_member_count: Option, - pub swarm_subagent_status: Option, - pub background_running: Option, - pub background_tasks: Option, - pub usage_available: Option, - pub usage_provider: Option, - pub tokens_per_second: Option, - pub auth_method: Option, - pub upstream_provider: Option, -} - -/// Info widget capture (summary + placements) -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct InfoWidgetCapture { - pub summary: InfoWidgetSummary, - pub placements: Vec, -} - -impl From for RectCapture { - fn from(r: Rect) -> Self { - Self { - x: r.x, - y: r.y, - width: r.width, - height: r.height, - } - } -} - -/// State snapshot at render time -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct StateSnapshot { - pub is_processing: bool, - pub input_len: usize, - pub input_preview: String, - pub cursor_pos: usize, - pub scroll_offset: usize, - pub queued_count: usize, - pub message_count: usize, - pub streaming_text_len: usize, - pub has_suggestions: bool, - pub status: String, - pub diagram_mode: Option, - pub diagram_focus: bool, - pub diagram_index: usize, - pub diagram_count: usize, - pub diagram_scroll_x: i32, - pub diagram_scroll_y: i32, - pub diagram_pane_ratio: u8, - pub diagram_pane_enabled: bool, - pub diagram_pane_position: Option, - pub diagram_zoom: u8, -} - -/// Actual rendered text content -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct RenderedText { - /// Status line text (spinner, tokens, elapsed, etc.) - pub status_line: String, - /// Input area text (what the user is typing) - pub input_area: String, - /// Hint text shown above input (if any) - pub input_hint: Option, - /// Queued messages (messages waiting to be sent) - pub queued_messages: Vec, - /// Recent messages displayed (last few for context) - pub recent_messages: Vec, - /// Streaming text (if currently streaming) - pub streaming_text_preview: String, -} - -/// Mermaid image region capture -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct ImageRegionCapture { - pub hash: String, - pub abs_line_idx: usize, - pub height: u16, -} - -/// Captured message for debugging -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct MessageCapture { - pub role: String, - pub content_preview: String, - pub content_len: usize, -} - -/// Ring buffer of recent frames -struct FrameBuffer { - frames: VecDeque, - next_frame_id: u64, -} - -#[derive(Debug, Clone, Default, Serialize)] -pub struct VisualDebugMemoryProfile { - pub enabled: bool, - pub overlay_enabled: bool, - pub frames_in_buffer: usize, - pub max_frames: usize, - pub total_frames_captured: u64, - pub anomalous_frames_in_buffer: usize, - pub frame_json_estimate_bytes: usize, -} - -impl FrameBuffer { - fn new() -> Self { - Self { - frames: VecDeque::with_capacity(MAX_FRAMES), - next_frame_id: 0, - } - } - - fn push(&mut self, mut frame: FrameCapture) { - frame.frame_id = self.next_frame_id; - self.next_frame_id += 1; - - if self.frames.len() >= MAX_FRAMES { - self.frames.pop_front(); - } - self.frames.push_back(frame); - } - - fn recent(&self, count: usize) -> Vec<&FrameCapture> { - self.frames.iter().rev().take(count).collect() - } - - fn frames_with_anomalies(&self) -> Vec<&FrameCapture> { - self.frames - .iter() - .filter(|f| !f.anomalies.is_empty()) - .collect() - } -} - -/// Enable visual debugging -pub fn enable() { - VISUAL_DEBUG_ENABLED.store(true, Ordering::SeqCst); - crate::logging::info("Visual debugging enabled"); -} - -/// Disable visual debugging -pub fn disable() { - VISUAL_DEBUG_ENABLED.store(false, Ordering::SeqCst); -} - -/// Enable or disable overlay drawing -pub fn set_overlay(enabled: bool) { - VISUAL_DEBUG_OVERLAY.store(enabled, Ordering::SeqCst); -} - -/// Check if overlay drawing is enabled -pub fn overlay_enabled() -> bool { - VISUAL_DEBUG_OVERLAY.load(Ordering::SeqCst) -} - -/// Check if visual debugging is enabled -pub fn is_enabled() -> bool { - VISUAL_DEBUG_ENABLED.load(Ordering::SeqCst) -} - -/// Record a frame capture (skips if identical to previous frame) -pub fn record_frame(frame: FrameCapture) { - if !is_enabled() { - return; - } - - let mut buffer = get_frame_buffer() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - - // Skip duplicate frames - only capture when something changes - // Always capture frames with anomalies - if let Some(last) = buffer.frames.back() { - let dominated = frame.state == last.state - && frame.rendered_text == last.rendered_text - && frame.layout == last.layout - && frame.info_widgets == last.info_widgets - && frame.side_panel == last.side_panel - && frame.anomalies.is_empty(); - if dominated { - return; - } - } - - buffer.push(frame); -} - -/// Get the debug output path -fn debug_path() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("jcode") - .join("visual-debug.txt") -} - -/// Dump recent frames to the debug file -pub fn dump_to_file() -> std::io::Result { - let path = debug_path(); - - // Ensure parent directory exists - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let buffer = get_frame_buffer() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let mut file = File::create(&path)?; - - writeln!(file, "=== JCODE VISUAL DEBUG DUMP ===")?; - writeln!(file, "Generated: {:?}", std::time::SystemTime::now())?; - writeln!(file, "Total frames captured: {}", buffer.next_frame_id)?; - writeln!(file, "Frames in buffer: {}", buffer.frames.len())?; - writeln!(file)?; - - // First, show frames with anomalies - let anomaly_frames = buffer.frames_with_anomalies(); - if !anomaly_frames.is_empty() { - writeln!( - file, - "=== FRAMES WITH ANOMALIES ({}) ===", - anomaly_frames.len() - )?; - for frame in anomaly_frames { - write_frame(&mut file, frame)?; - } - writeln!(file)?; - } - - // Then show recent frames - writeln!(file, "=== RECENT FRAMES (last 20) ===")?; - for frame in buffer.recent(20) { - write_frame(&mut file, frame)?; - } - - Ok(path) -} - -/// Return the most recent frame capture. -pub fn latest_frame() -> Option { - let buffer = get_frame_buffer().lock().ok()?; - buffer.frames.back().cloned() -} - -/// Return the most recent frame as a JSON string. -pub fn latest_frame_json() -> Option { - let frame = latest_frame()?; - serde_json::to_string_pretty(&frame).ok() -} - -/// Return the most recent frame as a normalized JSON string (for stable diffs). -/// Strips timestamps, UUIDs, session IDs, and other non-deterministic values. -pub fn latest_frame_json_normalized() -> Option { - let frame = latest_frame()?; - let normalized = normalize_frame(&frame); - serde_json::to_string_pretty(&normalized).ok() -} - -pub fn debug_memory_profile() -> VisualDebugMemoryProfile { - let Ok(buffer) = get_frame_buffer().lock() else { - return VisualDebugMemoryProfile { - enabled: is_enabled(), - overlay_enabled: overlay_enabled(), - max_frames: MAX_FRAMES, - ..VisualDebugMemoryProfile::default() - }; - }; - - VisualDebugMemoryProfile { - enabled: is_enabled(), - overlay_enabled: overlay_enabled(), - frames_in_buffer: buffer.frames.len(), - max_frames: MAX_FRAMES, - total_frames_captured: buffer.next_frame_id, - anomalous_frames_in_buffer: buffer - .frames - .iter() - .filter(|f| !f.anomalies.is_empty()) - .count(), - frame_json_estimate_bytes: buffer - .frames - .iter() - .map(crate::process_memory::estimate_json_bytes) - .sum(), - } -} - -/// Normalize a frame capture for stable comparisons. -/// Replaces timestamps, UUIDs, session IDs, and other volatile values with placeholders. -pub fn normalize_frame(frame: &FrameCapture) -> serde_json::Value { - let json = serde_json::to_value(frame).unwrap_or(serde_json::Value::Null); - normalize_json_value(json) -} - -/// Recursively normalize JSON values, replacing volatile content. -fn normalize_json_value(value: serde_json::Value) -> serde_json::Value { - use serde_json::Value; - - match value { - Value::String(s) => Value::String(normalize_string(&s)), - Value::Array(arr) => Value::Array(arr.into_iter().map(normalize_json_value).collect()), - Value::Object(map) => { - let mut new_map = serde_json::Map::new(); - for (k, v) in map { - // Skip timestamp fields entirely or normalize them - if k == "timestamp" || k == "created_at" || k == "updated_at" { - new_map.insert(k, Value::String("".to_string())); - } else if k == "frame_id" { - // Keep frame_id but note it's sequential - new_map.insert(k, v); - } else { - new_map.insert(k, normalize_json_value(v)); - } - } - Value::Object(new_map) - } - other => other, - } -} - -/// Normalize a string by replacing volatile patterns with placeholders. -fn normalize_string(s: &str) -> String { - use regex::Regex; - use std::sync::OnceLock; - - fn compile_regex(pattern: &str) -> Option { - match Regex::new(pattern) { - Ok(regex) => Some(regex), - Err(err) => { - crate::logging::warn(&format!( - "visual_debug: failed to compile normalization regex: {}", - err - )); - None - } - } - } - - // Cached regex patterns for performance - static UUID_RE: OnceLock> = OnceLock::new(); - static SESSION_ID_RE: OnceLock> = OnceLock::new(); - static TIMESTAMP_RE: OnceLock> = OnceLock::new(); - static ISO_DATE_RE: OnceLock> = OnceLock::new(); - static DURATION_RE: OnceLock> = OnceLock::new(); - static PATH_RE: OnceLock> = OnceLock::new(); - static ELAPSED_RE: OnceLock> = OnceLock::new(); - static TOKENS_RE: OnceLock> = OnceLock::new(); - - let Some(uuid_re) = UUID_RE - .get_or_init(|| { - compile_regex( - r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", - ) - }) - .as_ref() - else { - return s.to_string(); - }; - let Some(session_id_re) = SESSION_ID_RE - .get_or_init(|| compile_regex(r"session_[0-9a-zA-Z_]+")) - .as_ref() - else { - return s.to_string(); - }; - let Some(timestamp_re) = TIMESTAMP_RE - .get_or_init(|| compile_regex(r"\d{10,13}")) - .as_ref() - else { - return s.to_string(); - }; - let Some(iso_date_re) = ISO_DATE_RE - .get_or_init(|| compile_regex(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}")) - .as_ref() - else { - return s.to_string(); - }; - let Some(duration_re) = DURATION_RE - .get_or_init(|| compile_regex(r"\d+(\.\d+)?s")) - .as_ref() - else { - return s.to_string(); - }; - let Some(path_re) = PATH_RE - .get_or_init(|| compile_regex(r"/(?:home|Users)/[^/\s]+")) - .as_ref() - else { - return s.to_string(); - }; - let Some(elapsed_re) = ELAPSED_RE - .get_or_init(|| compile_regex(r"\d+m?\d*s")) - .as_ref() - else { - return s.to_string(); - }; - let Some(tokens_re) = TOKENS_RE - .get_or_init(|| compile_regex(r"\d+[kK]? tokens?")) - .as_ref() - else { - return s.to_string(); - }; - - let mut result = s.to_string(); - - // Replace in order of specificity (most specific first) - result = uuid_re.replace_all(&result, "").to_string(); - result = session_id_re - .replace_all(&result, "") - .to_string(); - result = iso_date_re.replace_all(&result, "").to_string(); - result = elapsed_re.replace_all(&result, "").to_string(); - result = tokens_re.replace_all(&result, "").to_string(); - result = duration_re.replace_all(&result, "").to_string(); - result = path_re.replace_all(&result, "").to_string(); - - // Only replace long timestamps that aren't part of other patterns - if result.len() < 20 { - result = timestamp_re.replace_all(&result, "").to_string(); - } - - result -} - -/// Compare two frames for semantic equality (ignoring volatile fields). -pub fn frames_equal_normalized(a: &FrameCapture, b: &FrameCapture) -> bool { - let norm_a = normalize_frame(a); - let norm_b = normalize_frame(b); - norm_a == norm_b -} - -fn write_frame(file: &mut File, frame: &FrameCapture) -> std::io::Result<()> { - writeln!(file, "--- Frame {} ---", frame.frame_id)?; - writeln!(file, "Time: {:?}", frame.timestamp)?; - writeln!( - file, - "Terminal: {}x{}", - frame.terminal_size.0, frame.terminal_size.1 - )?; - - // State - writeln!(file, "State:")?; - writeln!(file, " is_processing: {}", frame.state.is_processing)?; - writeln!(file, " input_len: {}", frame.state.input_len)?; - writeln!(file, " input_preview: {:?}", frame.state.input_preview)?; - writeln!(file, " cursor_pos: {}", frame.state.cursor_pos)?; - writeln!(file, " scroll_offset: {}", frame.state.scroll_offset)?; - writeln!(file, " queued_count: {}", frame.state.queued_count)?; - writeln!(file, " message_count: {}", frame.state.message_count)?; - writeln!( - file, - " streaming_text_len: {}", - frame.state.streaming_text_len - )?; - writeln!(file, " has_suggestions: {}", frame.state.has_suggestions)?; - writeln!(file, " status: {}", frame.state.status)?; - - // Layout - writeln!(file, "Layout:")?; - writeln!(file, " use_packed: {}", frame.layout.use_packed)?; - writeln!( - file, - " estimated_content_height: {}", - frame.layout.estimated_content_height - )?; - if let Some(r) = frame.layout.messages_area { - writeln!( - file, - " messages_area: ({}, {}) {}x{}", - r.x, r.y, r.width, r.height - )?; - } - if let Some(r) = frame.layout.status_area { - writeln!( - file, - " status_area: ({}, {}) {}x{}", - r.x, r.y, r.width, r.height - )?; - } - if let Some(r) = frame.layout.queued_area { - writeln!( - file, - " queued_area: ({}, {}) {}x{}", - r.x, r.y, r.width, r.height - )?; - } - if let Some(r) = frame.layout.input_area { - writeln!( - file, - " input_area: ({}, {}) {}x{}", - r.x, r.y, r.width, r.height - )?; - } - writeln!( - file, - " input_lines: {} raw, {} wrapped", - frame.layout.input_lines_raw, frame.layout.input_lines_wrapped - )?; - if let Some(margins) = &frame.layout.margins { - writeln!( - file, - " margins: centered={} left_rows={} right_rows={}", - margins.centered, - margins.left_widths.len(), - margins.right_widths.len() - )?; - } - if !frame.layout.widget_placements.is_empty() { - writeln!(file, " widget_placements:")?; - for placement in &frame.layout.widget_placements { - let r = placement.rect; - writeln!( - file, - " {} ({}) at ({}, {}) {}x{}", - placement.kind, placement.side, r.x, r.y, r.width, r.height - )?; - } - } - - // Rendered text - writeln!(file, "Rendered:")?; - writeln!(file, " status_line: {:?}", frame.rendered_text.status_line)?; - if let Some(hint) = &frame.rendered_text.input_hint { - writeln!(file, " input_hint: {:?}", hint)?; - } - writeln!(file, " input_area: {:?}", frame.rendered_text.input_area)?; - if !frame.rendered_text.queued_messages.is_empty() { - writeln!(file, " queued_messages:")?; - for (i, msg) in frame.rendered_text.queued_messages.iter().enumerate() { - writeln!(file, " [{}]: {:?}", i, msg)?; - } - } - if !frame.rendered_text.recent_messages.is_empty() { - writeln!(file, " recent_messages:")?; - for msg in &frame.rendered_text.recent_messages { - writeln!( - file, - " [{}] ({} chars): {:?}", - msg.role, msg.content_len, msg.content_preview - )?; - } - } - if !frame.rendered_text.streaming_text_preview.is_empty() { - writeln!( - file, - " streaming_text: {:?}", - frame.rendered_text.streaming_text_preview - )?; - } - if !frame.image_regions.is_empty() { - writeln!(file, " image_regions:")?; - for region in &frame.image_regions { - writeln!( - file, - " {} @{} (h={})", - region.hash, region.abs_line_idx, region.height - )?; - } - } - - // Render timing - if let Some(timing) = &frame.render_timing { - writeln!( - file, - "Timing: prepare={:.2}ms draw={:.2}ms total={:.2}ms messages={:?} widgets={:?}", - timing.prepare_ms, - timing.draw_ms, - timing.total_ms, - timing.messages_ms, - timing.widgets_ms - )?; - } - - // Info widget summary - if let Some(info) = &frame.info_widgets { - writeln!(file, "InfoWidgets:")?; - writeln!( - file, - " todos: {}/{} done, context_chars: {:?}, model: {:?}", - info.summary.todos_done, - info.summary.todos_total, - info.summary.context_total_chars, - info.summary.model - )?; - writeln!( - file, - " session_count: {:?}, client_count: {:?}, swarm_members: {:?}", - info.summary.session_count, info.summary.client_count, info.summary.swarm_member_count - )?; - } - - if !frame.render_order.is_empty() { - writeln!(file, "Render order:")?; - for step in &frame.render_order { - writeln!(file, " - {}", step)?; - } - } - - if let Some(mermaid) = &frame.mermaid { - writeln!(file, "Mermaid: {}", mermaid)?; - } - if let Some(side_panel) = &frame.side_panel { - writeln!(file, "Side panel: {}", side_panel)?; - } - if let Some(markdown) = &frame.markdown { - writeln!(file, "Markdown: {}", markdown)?; - } - if let Some(theme) = &frame.theme { - writeln!(file, "Theme: {}", theme)?; - } - - // Anomalies - if !frame.anomalies.is_empty() { - writeln!(file, "ANOMALIES:")?; - for anomaly in &frame.anomalies { - writeln!(file, " ⚠ {}", anomaly)?; - } - } - - writeln!(file)?; - Ok(()) -} - -/// Builder for constructing frame captures during rendering -#[derive(Default)] -pub struct FrameCaptureBuilder { - pub layout: LayoutCapture, - pub state: StateSnapshot, - pub rendered_text: RenderedText, - pub image_regions: Vec, - pub anomalies: Vec, - pub render_timing: Option, - pub info_widgets: Option, - pub render_order: Vec, - pub mermaid: Option, - pub side_panel: Option, - pub markdown: Option, - pub theme: Option, - terminal_size: (u16, u16), -} - -impl FrameCaptureBuilder { - pub fn new(width: u16, height: u16) -> Self { - Self { - terminal_size: (width, height), - ..Default::default() - } - } - - /// Record an anomaly detected during rendering - pub fn anomaly(&mut self, msg: impl Into) { - self.anomalies.push(msg.into()); - } - - /// Check a condition and record anomaly if false - pub fn check(&mut self, condition: bool, msg: impl Into) { - if !condition { - self.anomalies.push(msg.into()); - } - } - - /// Build the final frame capture - pub fn build(self) -> FrameCapture { - FrameCapture { - frame_id: 0, // Will be set by buffer - timestamp: std::time::SystemTime::now(), - terminal_size: self.terminal_size, - layout: self.layout, - state: self.state, - anomalies: self.anomalies, - rendered_text: self.rendered_text, - image_regions: self.image_regions, - render_timing: self.render_timing, - info_widgets: self.info_widgets, - render_order: self.render_order, - mermaid: self.mermaid, - side_panel: self.side_panel, - markdown: self.markdown, - theme: self.theme, - } - } -} - -/// Check for the specific alternate-send hint anomaly. -pub fn check_shift_enter_anomaly( - builder: &mut FrameCaptureBuilder, - is_processing: bool, - input_text: &str, - hint_shown: bool, -) { - // The hint should ONLY show when processing AND input is non-empty - let should_show = is_processing && !input_text.is_empty(); - - if hint_shown != should_show { - builder.anomaly(format!( - "alternate-send hint mismatch: shown={}, should_show={} (is_processing={}, input_len={})", - hint_shown, - should_show, - is_processing, - input_text.len() - )); - } - - // Also check if the hint text appears in the input itself (the bug!) - if input_text.to_lowercase().contains("shift") && input_text.to_lowercase().contains("enter") { - builder.anomaly(format!( - "INPUT CONTAINS 'shift'+'enter' - possible hint leak: {:?}", - input_text - )); - } -} +pub use jcode_tui_visual_debug::{ + FrameCapture, FrameCaptureBuilder, ImageRegionCapture, InfoWidgetCapture, InfoWidgetSummary, + LayoutCapture, MarginsCapture, MessageCapture, RectCapture, RenderTimingCapture, RenderedText, + StateSnapshot, VisualDebugMemoryProfile, WidgetPlacementCapture, check_shift_enter_anomaly, + debug_memory_profile, disable, dump_to_file, enable, frames_equal_normalized, is_enabled, + latest_frame, latest_frame_json, latest_frame_json_normalized, normalize_frame, + overlay_enabled, record_frame, set_overlay, +}; diff --git a/crates/jcode-tui/src/tui/workspace_client.rs b/crates/jcode-tui/src/tui/workspace_client.rs index 21fb31511..5eb3f638c 100644 --- a/crates/jcode-tui/src/tui/workspace_client.rs +++ b/crates/jcode-tui/src/tui/workspace_client.rs @@ -2,7 +2,6 @@ use crate::session::{Session, SessionStatus}; use crate::tui::workspace_map::{ VisibleWorkspaceRow, WorkspaceMapModel, WorkspaceSessionTile, WorkspaceSessionVisualState, }; -use std::sync::Mutex; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WorkspaceSplitTarget { @@ -11,8 +10,13 @@ pub enum WorkspaceSplitTarget { Down, } +/// Per-client workspace navigation state. +/// +/// Previously stored in a process-global `Mutex>`; now owned by +/// [`crate::tui::app::App`] so each client instance carries its own workspace +/// map instead of sharing one across the process. #[derive(Debug, Clone, Default)] -struct WorkspaceClientState { +pub(crate) struct WorkspaceClientState { enabled: bool, map: WorkspaceMapModel, imported_server_sessions: bool, @@ -20,180 +24,142 @@ struct WorkspaceClientState { pending_resume_session: Option, } -static WORKSPACE_STATE: Mutex> = Mutex::new(None); - -fn with_state(f: impl FnOnce(&mut WorkspaceClientState) -> R) -> R { - let mut guard = WORKSPACE_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let state = guard.get_or_insert_with(WorkspaceClientState::default); - f(state) -} - -pub fn is_enabled() -> bool { - with_state(|state| state.enabled) -} +impl WorkspaceClientState { + pub(crate) fn is_enabled(&self) -> bool { + self.enabled + } -pub fn enable(current_session_id: Option<&str>, all_sessions: &[String]) { - with_state(|state| { - state.enabled = true; - if state.map.is_empty() { - import_initial_row(state, current_session_id, all_sessions); + pub(crate) fn enable(&mut self, current_session_id: Option<&str>, all_sessions: &[String]) { + self.enabled = true; + if self.map.is_empty() { + self.import_initial_row(current_session_id, all_sessions); } else if let Some(session_id) = current_session_id { - let _ = state.map.focus_session_by_id(session_id); + let _ = self.map.focus_session_by_id(session_id); } - }); -} + } -pub fn disable() { - with_state(|state| { - state.enabled = false; - state.pending_split_target = None; - state.pending_resume_session = None; - }); -} + pub(crate) fn disable(&mut self) { + self.enabled = false; + self.pending_split_target = None; + self.pending_resume_session = None; + } -#[cfg(test)] -pub(crate) fn reset_for_tests() { - let mut guard = WORKSPACE_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *guard = None; -} + #[cfg(test)] + pub(crate) fn reset_for_tests(&mut self) { + *self = Self::default(); + } -pub fn status_summary() -> String { - with_state(|state| { - if !state.enabled { + pub(crate) fn status_summary(&self) -> String { + if !self.enabled { return "Workspace mode: off".to_string(); } - let rows = state.map.visible_rows(5); - let populated = state.map.populated_workspaces().len(); + let rows = self.map.visible_rows(5); + let populated = self.map.populated_workspaces().len(); let total_sessions: usize = rows.iter().map(|row| row.sessions.len()).sum(); format!( "Workspace mode: on\nCurrent workspace: {}\nVisible rows: {}\nPopulated workspaces: {}\nMapped sessions: {}", - state.map.current_workspace(), + self.map.current_workspace(), rows.len(), populated, total_sessions ) - }) -} + } -pub fn sync_after_history(current_session_id: &str, all_sessions: &[String]) { - with_state(|state| { - if !state.enabled { + pub(crate) fn sync_after_history(&mut self, current_session_id: &str, all_sessions: &[String]) { + if !self.enabled { return; } - if state.map.is_empty() { - import_initial_row(state, Some(current_session_id), all_sessions); + if self.map.is_empty() { + self.import_initial_row(Some(current_session_id), all_sessions); return; } - if state.map.focus_session_by_id(current_session_id) { + if self.map.focus_session_by_id(current_session_id) { return; } let tile = WorkspaceSessionTile::new(current_session_id.to_string()); - let _ = state.map.add_session_to_current_workspace(tile); - }); -} + let _ = self.map.add_session_to_current_workspace(tile); + } -pub fn queue_split_target(target: WorkspaceSplitTarget) { - with_state(|state| { - state.enabled = true; - state.pending_split_target = Some(target); - }); -} + pub(crate) fn queue_split_target(&mut self, target: WorkspaceSplitTarget) { + self.enabled = true; + self.pending_split_target = Some(target); + } -pub fn take_pending_resume_session() -> Option { - with_state(|state| state.pending_resume_session.take()) -} + pub(crate) fn take_pending_resume_session(&mut self) -> Option { + self.pending_resume_session.take() + } -pub fn queue_resume_session(session_id: String) { - with_state(|state| { - state.pending_resume_session = Some(session_id); - }); -} + pub(crate) fn queue_resume_session(&mut self, session_id: String) { + self.pending_resume_session = Some(session_id); + } -pub fn handle_split_response(new_session_id: &str) -> bool { - with_state(|state| { - if !state.enabled || state.pending_split_target.is_none() { - state.pending_split_target = None; + pub(crate) fn handle_split_response(&mut self, new_session_id: &str) -> bool { + if !self.enabled || self.pending_split_target.is_none() { + self.pending_split_target = None; return false; } - let target = state + let target = self .pending_split_target .take() .unwrap_or(WorkspaceSplitTarget::Right); let target_workspace = match target { - WorkspaceSplitTarget::Right => state.map.current_workspace(), - WorkspaceSplitTarget::Up => state.map.current_workspace() + 1, - WorkspaceSplitTarget::Down => state.map.current_workspace() - 1, + WorkspaceSplitTarget::Right => self.map.current_workspace(), + WorkspaceSplitTarget::Up => self.map.current_workspace() + 1, + WorkspaceSplitTarget::Down => self.map.current_workspace() - 1, }; - let _ = state.map.insert_session_in_workspace( + let _ = self.map.insert_session_in_workspace( target_workspace, WorkspaceSessionTile::new(new_session_id.to_string()), ); - let _ = state.map.focus_session_by_id(new_session_id); - state.pending_resume_session = Some(new_session_id.to_string()); + let _ = self.map.focus_session_by_id(new_session_id); + self.pending_resume_session = Some(new_session_id.to_string()); true - }) -} + } -pub fn navigate_left() -> Option { - with_state(|state| { - if !state.enabled || !state.map.move_left() { + pub(crate) fn navigate_left(&mut self) -> Option { + if !self.enabled || !self.map.move_left() { return None; } - state - .map - .current_focused_session_id() - .map(ToString::to_string) - }) -} + self.map.current_focused_session_id().map(ToString::to_string) + } -pub fn navigate_right() -> Option { - with_state(|state| { - if !state.enabled || !state.map.move_right() { + pub(crate) fn navigate_right(&mut self) -> Option { + if !self.enabled || !self.map.move_right() { return None; } - state - .map - .current_focused_session_id() - .map(ToString::to_string) - }) -} + self.map.current_focused_session_id().map(ToString::to_string) + } -pub fn navigate_up() -> Option { - with_state(|state| { - if !state.enabled { + pub(crate) fn navigate_up(&mut self) -> Option { + if !self.enabled { return None; } - let target_workspace = state.map.nearest_populated_workspace_above()?; - state.map.set_current_workspace(target_workspace); - state - .map + let target_workspace = self.map.nearest_populated_workspace_above()?; + self.map.set_current_workspace(target_workspace); + self.map .focused_session_in_workspace(target_workspace) .map(ToString::to_string) - }) -} + } -pub fn navigate_down() -> Option { - with_state(|state| { - if !state.enabled { + pub(crate) fn navigate_down(&mut self) -> Option { + if !self.enabled { return None; } - let target_workspace = state.map.nearest_populated_workspace_below()?; - state.map.set_current_workspace(target_workspace); - state - .map + let target_workspace = self.map.nearest_populated_workspace_below()?; + self.map.set_current_workspace(target_workspace); + self.map .focused_session_in_workspace(target_workspace) .map(ToString::to_string) - }) -} + } -pub fn visible_rows( - max_rows: usize, - current_session_id: Option<&str>, - current_session_running: bool, -) -> Vec { - with_state(|state| { - let mut rows = if state.enabled { - state.map.visible_rows(max_rows) + pub(crate) fn visible_rows( + &self, + max_rows: usize, + current_session_id: Option<&str>, + current_session_running: bool, + ) -> Vec { + let mut rows = if self.enabled { + self.map.visible_rows(max_rows) } else { Vec::new() }; @@ -207,41 +173,37 @@ pub fn visible_rows( } } rows - }) -} + } -fn import_initial_row( - state: &mut WorkspaceClientState, - current_session_id: Option<&str>, - all_sessions: &[String], -) { - let sessions: Vec = if all_sessions.is_empty() { - current_session_id - .map(|id| vec![id.to_string()]) - .unwrap_or_default() - } else { - all_sessions.to_vec() - }; + fn import_initial_row(&mut self, current_session_id: Option<&str>, all_sessions: &[String]) { + let sessions: Vec = if all_sessions.is_empty() { + current_session_id + .map(|id| vec![id.to_string()]) + .unwrap_or_default() + } else { + all_sessions.to_vec() + }; - if let Some(current) = current_session_id - && !state.map.is_empty() - && state.map.locate_session(current).is_some() - { - let _ = state.map.focus_session_by_id(current); - return; - } + if let Some(current) = current_session_id + && !self.map.is_empty() + && self.map.locate_session(current).is_some() + { + let _ = self.map.focus_session_by_id(current); + return; + } - let focused_index = current_session_id - .and_then(|current| sessions.iter().position(|session_id| session_id == current)) - .or_else(|| (!sessions.is_empty()).then_some(0)); + let focused_index = current_session_id + .and_then(|current| sessions.iter().position(|session_id| session_id == current)) + .or_else(|| (!sessions.is_empty()).then_some(0)); - let tiles = sessions - .into_iter() - .map(WorkspaceSessionTile::new) - .collect::>(); - state.map.set_row_sessions(0, tiles, focused_index); - state.map.set_current_workspace(0); - state.imported_server_sessions = true; + let tiles = sessions + .into_iter() + .map(WorkspaceSessionTile::new) + .collect::>(); + self.map.set_row_sessions(0, tiles, focused_index); + self.map.set_current_workspace(0); + self.imported_server_sessions = true; + } } fn derive_visual_state( @@ -271,33 +233,17 @@ fn derive_visual_state( #[cfg(test)] mod tests { - use super::{ - WorkspaceSplitTarget, enable, handle_split_response, is_enabled, navigate_right, - queue_split_target, reset_for_tests, status_summary, sync_after_history, visible_rows, - }; - use std::sync::{Mutex, OnceLock}; - - fn test_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .expect("workspace test lock") - } - - fn reset() { - reset_for_tests(); - } + use super::{WorkspaceClientState, WorkspaceSplitTarget}; #[test] fn enabling_imports_initial_sessions() { - let _guard = test_lock(); - reset(); - enable( + let mut state = WorkspaceClientState::default(); + state.enable( Some("session_a"), &["session_a".to_string(), "session_b".to_string()], ); - assert!(is_enabled()); - let rows = visible_rows(3, Some("session_a"), false); + assert!(state.is_enabled()); + let rows = state.visible_rows(3, Some("session_a"), false); assert_eq!(rows.len(), 1); assert_eq!(rows[0].sessions.len(), 2); assert_eq!(rows[0].focused_index, Some(0)); @@ -305,28 +251,26 @@ mod tests { #[test] fn horizontal_navigation_returns_new_target() { - let _guard = test_lock(); - reset(); - enable( + let mut state = WorkspaceClientState::default(); + state.enable( Some("session_a"), &["session_a".to_string(), "session_b".to_string()], ); - let next = navigate_right(); + let next = state.navigate_right(); assert_eq!(next.as_deref(), Some("session_b")); } #[test] fn split_response_in_workspace_targets_new_session() { - let _guard = test_lock(); - reset(); - enable(Some("session_a"), &["session_a".to_string()]); - queue_split_target(WorkspaceSplitTarget::Right); - assert!(handle_split_response("session_child")); - sync_after_history( + let mut state = WorkspaceClientState::default(); + state.enable(Some("session_a"), &["session_a".to_string()]); + state.queue_split_target(WorkspaceSplitTarget::Right); + assert!(state.handle_split_response("session_child")); + state.sync_after_history( "session_child", &["session_a".to_string(), "session_child".to_string()], ); - let rows = visible_rows(3, Some("session_child"), false); + let rows = state.visible_rows(3, Some("session_child"), false); assert!( rows[0] .sessions @@ -338,10 +282,9 @@ mod tests { #[test] fn status_summary_reports_enabled_state() { - let _guard = test_lock(); - reset(); - enable(Some("session_a"), &["session_a".to_string()]); - let summary = status_summary(); + let mut state = WorkspaceClientState::default(); + state.enable(Some("session_a"), &["session_a".to_string()]); + let summary = state.status_summary(); assert!(summary.contains("Workspace mode: on")); } } diff --git a/docs/COMPILE_PERFORMANCE_PLAN.md b/docs/COMPILE_PERFORMANCE_PLAN.md index 0d8b9ebdd..5198a63a3 100644 --- a/docs/COMPILE_PERFORMANCE_PLAN.md +++ b/docs/COMPILE_PERFORMANCE_PLAN.md @@ -5,6 +5,7 @@ without sacrificing full-feature builds. See also: +- [`COMPILE_TIME_ISOLATION_REFACTOR.md`](./COMPILE_TIME_ISOLATION_REFACTOR.md) - [`REFACTORING.md`](./REFACTORING.md) - [`MODULAR_ARCHITECTURE_RFC.md`](./MODULAR_ARCHITECTURE_RFC.md) diff --git a/docs/COMPILE_TIME_ISOLATION_REFACTOR.md b/docs/COMPILE_TIME_ISOLATION_REFACTOR.md new file mode 100644 index 000000000..1d29abeea --- /dev/null +++ b/docs/COMPILE_TIME_ISOLATION_REFACTOR.md @@ -0,0 +1,220 @@ +# Compile-Time Isolation Refactor + +This is the active migration plan for making full-feature debug/selfdev builds faster without removing features from the developer binary. + +## Goal + +Keep the normal debug/selfdev binary production-like, including PDF, embeddings, providers, update/selfdev tooling, and other integrations, while reducing the amount of Rust code that must be recompiled after common edits. + +The target is not just "more crates". The target is a wider dependency DAG with smaller serial front-end units and cleaner invalidation boundaries. + +## Current diagnosis + +The workspace already has many crates, but the critical path is dominated by a small number of large crates stacked linearly: + +```mermaid +graph LR + base["jcode-base\n~100k+ LOC"] --> appcore["jcode-app-core\n~100k LOC"] + appcore --> tui["jcode-tui\n~100k+ LOC"] + tui --> rootlib["jcode lib"] + rootlib --> bin["jcode bin"] + small["50+ smaller crates"] -. mostly parallel .-> base +``` + +From the last available Cargo timing report parsed with `scripts/compile_time_probe.sh --skip-build`: + +- Cargo timing wall: **16.00s** +- Known jcode serial stack span: **14.72s** +- Known jcode serial stack summed unit time: **17.36s** +- Known jcode serial stack frontend time: **11.99s** + +Slowest units from that timing report: + +| Unit | Total | Frontend | Codegen | +|---|---:|---:|---:| +| `jcode-app-core` | 4.73s | 3.82s | 0.91s | +| `jcode-base` | 4.34s | 3.63s | 0.71s | +| `jcode-tui` | 4.18s | 3.14s | 1.04s | +| `jcode` bin | 2.34s | n/a | n/a | +| `jcode` lib | 1.77s | 1.40s | 0.37s | + +This means the main bottleneck is rustc front-end serialization in a few mega-crates, not linker choice or third-party cold compile. + +## Measurement + +Use the focused timing probe for each phase: + +```bash +scripts/compile_time_probe.sh --json target/compile-time-probe.json +scripts/compile_time_probe.sh --touch crates/jcode-tui/src/tui/app/input.rs +scripts/compile_time_probe.sh --touch crates/jcode-app-core/src/server.rs +scripts/compile_time_probe.sh --touch crates/jcode-base/src/provider/mod.rs +``` + +For broader repeated measurements, continue using: + +```bash +scripts/bench_compile.sh selfdev-jcode --runs 3 --touch --json +scripts/bench_selfdev_checkpoints.sh --skip-cold --touch --runs 1 +``` + +Track at least: + +1. Full-feature selfdev build wall time. +2. Cargo timing wall time. +3. `jcode-base -> jcode-app-core -> jcode-tui -> jcode lib -> jcode bin` stack span. +4. Sum of frontend time in the serial stack. +5. Incremental rebuild after touching representative high-churn files. +6. Static report drift from `scripts/compile_isolation_report.py`: LOC, inline tests, `async_trait`, and target-state dependency advisories. + +## Target architecture + +```mermaid +graph TD + bin["jcode binary\ntiny composition root"] --> cli["jcode-cli"] + bin --> tui["jcode-tui"] + bin --> server["jcode-server"] + bin --> providers["provider leaf crates"] + bin --> tools["tool leaf crates"] + + cli --> api["jcode-client-api / app-api"] + tui --> api + server --> api + + api --> protocol["protocol + view models"] + protocol --> types["small stable type crates"] + + server --> agent["jcode-agent"] + server --> registry["jcode-tool-registry"] + server --> auth["jcode-auth-core"] + server --> session["jcode-session-core"] + server --> memory["jcode-memory-core"] + + providers --> provider_core["jcode-provider-core"] + tools --> tool_core["jcode-tool-core"] +``` + +Rules: + +- TUI and CLI depend on client API, protocol, view models, and small type crates, not full server/provider/tool implementations. +- Provider implementations are leaf crates. AWS/Bedrock dependencies live only in the Bedrock provider crate. +- Tool implementations are leaf crates. Heavy tools like PDF/browser/Gmail/search are isolated behind tool-core interfaces. +- Shared bottom crates are small and stable. Avoid putting high-churn behavior in protocol/type crates. +- Avoid broad `pub use whole_crate::*` compatibility ladders in final architecture. + +## Migration sequence + +### Phase 0: measurement and guardrails + +Status: started. + +Deliverables: + +- `scripts/compile_time_probe.sh` +- `scripts/compile_isolation_report.py` +- this document +- dependency boundary checks/advisory reports + +Success criteria: + +- Every structural phase has before/after timing. +- The timing report makes the serial stack visible. + +### Phase 1: widen the god-crate critical path + +Split the three long-pole crates into sibling domain crates. Priority is widening the graph, not extracting more tiny type crates. + +Likely first splits: + +- From `jcode-base`: + - `jcode-auth-core` + - `jcode-session-core` + - `jcode-memory-core` + - provider implementation crates, especially Bedrock/AWS as a leaf +- From `jcode-app-core`: + - `jcode-server` + - `jcode-agent` + - `jcode-tool-registry` + - service crates for background/swarm/update/selfdev as needed +- From `jcode-tui`: + - `jcode-client-api` / view-model boundary first + - then move reusable client-side state logic out of the terminal rendering crate only when it creates a real parallel unit + +Success criteria: + +- Touching common TUI code no longer recompiles app-core/provider/server implementation crates. +- Touching a provider implementation no longer recompiles TUI or broad base code. +- Cargo timing shows multiple medium-sized Jcode crates running in parallel instead of one 4-deep mega-crate ladder. + +### Phase 2: kill glob re-export ladders + +Current compatibility layering preserves the old monolith shape: + +```rust +pub use jcode_base::*; +pub use jcode_app_core::*; +pub use jcode_tui::*; +``` + +Migration approach: + +1. Keep compatibility re-exports temporarily while moving code. +2. Convert high-churn modules to explicit imports from leaf crates. +3. Remove glob re-exports once downstream imports are explicit. + +Success criteria: + +- New code does not rely on whole-layer prelude-style re-exports. +- Dependency direction is visible in imports and Cargo manifests. + +### Phase 3: move inline tests out of hot crates + +Problem: + +- Inline `#[cfg(test)]` modules make `cargo test` compile large production crates plus large test bodies as one rustc unit. + +Target: + +- Integration tests or dedicated `*-test-support` crates for broad behavior tests. +- Keep tiny unit tests inline only when they are genuinely local and cheap. + +Success criteria: + +- Targeted tests no longer require monolithic test cfg builds for unrelated domains. + +### Phase 4: reduce front-end macro tax + +Targets: + +- Replace `async_trait` with native `async fn` in traits where the trait is not used as `dyn`. +- Keep `async_trait` only at object-safe plugin/interface boundaries where boxed futures are intentional. +- Avoid adding derive-heavy types to broad shared crates unless the type is stable and necessary. + +Success criteria: + +- Fewer proc-macro expansions in the hot crates. +- No object-safety regressions. + +## Anti-goals + +- Do not make fast debug builds incomplete by default. +- Do not split code into tiny crates unless the split creates a real invalidation or parallelism boundary. +- Do not move high-churn behavior into low-level type/protocol crates. +- Do not do a single giant rewrite. Each phase should build and be measurable. + +## Validation checklist per phase + +Before committing a phase: + +```bash +scripts/compile_time_probe.sh --skip-build +scripts/compile_isolation_report.py +scripts/check_dependency_boundaries.py +cargo check --profile selfdev -p jcode --bin jcode +``` + +For code-moving phases, also run the relevant targeted tests for the moved domain, plus one full selfdev build through the coordinated selfdev path when practical: + +```bash +selfdev build target=tui +``` diff --git a/docs/TUISTATE_TRAIT_DECOMPOSITION.md b/docs/TUISTATE_TRAIT_DECOMPOSITION.md new file mode 100644 index 000000000..89a41c14d --- /dev/null +++ b/docs/TUISTATE_TRAIT_DECOMPOSITION.md @@ -0,0 +1,156 @@ +# TuiState Trait Decomposition Plan + +Status: Analysis + proposed plan + +This document audits the `TuiState` trait (`crates/jcode-tui/src/tui/mod.rs`) and +proposes a safe, incremental decomposition. It is the Phase 1.5 follow-on to the +`App` god-object decomposition (see `CLIENT_CORE_PRESENTATION_SPLIT_PLAN.md`). + +## Current state + +- `pub trait TuiState` exposes **114 methods**. +- Implementors: 2 (`App` in `tui/app/tui_state.rs`, and `TestState` in + `tui/ui_tests/mod.rs`). +- Consumers: ~95 usages across 29 files, almost all as `&dyn TuiState` (50 + render-function signatures take `app: &dyn TuiState`). + +It is the presentation-layer counterpart to the `App` god-object: a single wide +interface that couples every render module to the entire client surface. + +## Why a naive sub-trait split has limited value + +Two structural facts constrain the refactor: + +1. **`App` implements the whole surface regardless.** Splitting `TuiState` into + `TuiTranscriptState + TuiInputState + ...` does not reduce what `App` must + implement, and (because the trait is presentation-only data access) it does + not change crate-level compile coupling. The win is intent/navigability, not + decoupling of `App`. + +2. **`&dyn TuiState` does not compose.** Render functions take trait objects. + Rust has no stable `&dyn (A + B)`, so any consumer that needs methods from + more than one domain must take a supertrait that re-aggregates them. The two + central renderers (`ui.rs`, `ui_viewport.rs`) use methods from nearly every + domain, so they would keep the full supertrait bound. + +Measured: of the ~28 `&dyn TuiState` render modules, only **2** are +multi-category (`ui.rs`, `ui_viewport.rs`); the other ~26 each use a single +domain. So a sub-trait split *does* narrow the declared surface for the majority +of render modules, but the headline god-interface (driven by the 2 central +renderers) stays wide via the supertrait. + +Conclusion: the split is worthwhile for readability and for narrowing leaf +render-module bounds, but it is **not** a compile-coupling win and should be done +incrementally to avoid a high-conflict big-bang across 29 files. + +## Proposed target shape + +``` +trait TuiState: + TuiTranscriptState + TuiInputState + TuiScrollState + TuiStreamStatusState + + TuiProviderState + TuiSessionServerState + TuiWorkspaceState + + TuiDiagramPaneState + TuiDiffPaneState + TuiSidePanelState + + TuiInlineState + TuiOverlayState + TuiCopySelectionState + + TuiOnboardingState + TuiMiscState +{} +``` + +`App` and `TestState` keep a single `impl` per sub-trait (mechanical move). The 2 +central renderers take `&dyn TuiState` (the supertrait). Each leaf render module +narrows to the one sub-trait it needs. + +## Method categorization (all 114) + +### TuiTranscriptState +display_messages, display_user_message_count, compacted_hidden_user_prompts, +has_display_edit_tool_messages, side_pane_images, display_messages_version, +render_streaming_markdown + +### TuiInputState +input, cursor_pos, queued_messages, interleave_message, +pending_soft_interrupts, has_stashed_input, command_suggestions, +command_suggestion_selected, suggestion_prompts, queue_mode, +next_prompt_new_session_armed, dictation_key_label + +### TuiScrollState +scroll_offset, auto_scroll_paused, chat_overscroll_active, +copy_selection_edge_autoscroll_active, chat_native_scrollbar, +has_pending_mouse_scroll_animation + +### TuiStreamStatusState +streaming_text, is_processing, streaming_tokens, streaming_cache_tokens, +output_tps, streaming_tool_calls, elapsed, status, active_skill, +subagent_status, batch_progress, time_since_activity, stream_message_ended, +status_notice, status_detail, rate_limit_remaining, animation_elapsed + +### TuiProviderState +provider_name, provider_model, upstream_provider, connection_type, +mcp_servers, available_skills, auth_status, update_cost, +total_session_tokens, session_compaction_count, context_info, +context_snapshot, context_limit, cache_ttl_status + +### TuiSessionServerState +is_remote_mode, is_canary, is_replay, current_session_id, +session_display_name, server_display_name, server_display_icon, +server_sessions, connected_clients, remote_startup_phase_active, +client_update_available, server_update_available, info_widget_data, +active_experimental_feature_notice + +### TuiWorkspaceState +workspace_mode_enabled, workspace_map_rows, workspace_animation_tick + +### TuiDiagramPaneState +diagram_mode, diagram_focus, diagram_index, diagram_scroll, +diagram_pane_ratio, diagram_pane_ratio_user_adjusted, diagram_pane_animating, +diagram_pane_enabled, diagram_pane_position, diagram_zoom + +### TuiDiffPaneState +diff_mode, diff_pane_scroll, diff_pane_scroll_x, diff_pane_focus, +diff_line_wrap + +### TuiSidePanelState +side_panel, side_panel_image_zoom_percent, side_panel_native_scrollbar, +pin_images, pinned_images_auto_hide_remaining_secs + +### TuiInlineState +inline_interactive_state, inline_view_state, inline_ui_state + +### TuiOverlayState +changelog_scroll, help_scroll, model_status_overlay, session_picker_overlay, +login_picker_overlay, account_picker_overlay, usage_overlay + +### TuiCopySelectionState +copy_badge_ui, copy_selection_mode, copy_selection_range, +copy_selection_status + +### TuiOnboardingState +onboarding_preview_mode, onboarding_welcome_active, onboarding_welcome_kind + +### TuiMiscState +working_dir, now_millis, has_notification, centered_mode + +## Incremental, low-conflict migration + +Do **not** split all 15 sub-traits at once across 29 files. Recommended order: + +1. Land the documented section headers in the trait definition (done; pure + comments, single file). Gives the categorization a canonical home. +2. Extract one leaf sub-trait with a single-file consumer as a proof of pattern + (e.g. `TuiCopySelectionState` or `TuiDiagramPaneState`). Verify with + `cargo check -p jcode-tui`. +3. Extract remaining leaf sub-traits one per commit, narrowing the corresponding + leaf render module's bound in the same commit. +4. Keep `ui.rs` and `ui_viewport.rs` on the `TuiState` supertrait throughout. + +Each step is behavior-preserving (data accessors only) and compiles +independently, so it can be merged between other agents' work without a +big-bang conflict. + +## Verification + +- `cargo check -p jcode-tui` after each sub-trait extraction (TMPDIR must point + at real disk, not the RAM-backed tmpfs, or ring/aws-lc-sys build scripts fail + with "Disk quota exceeded"). +- `cargo test -p jcode-tui --lib` once at the end. Note: the lib test suite has + pre-existing flaky parallel-order failures unrelated to this trait (verify any + failing test in isolation with `--test-threads=1`). diff --git a/examples/bench_anthropic_essay_tps.rs b/examples/bench_anthropic_essay_tps.rs index 78c8aee23..c10d476e5 100644 --- a/examples/bench_anthropic_essay_tps.rs +++ b/examples/bench_anthropic_essay_tps.rs @@ -5,6 +5,34 @@ use jcode::provider::Provider; use jcode::provider::anthropic::AnthropicProvider; use std::time::Instant; +async fn run_one_with_retry( + provider: &AnthropicProvider, + label: &str, + words: usize, + retries: usize, +) -> Result<()> { + let mut attempt = 0; + loop { + match run_one(provider, label, words).await { + Ok(()) => return Ok(()), + Err(e) => { + let msg = e.to_string(); + let is_rate_limit = msg.contains("429") || msg.contains("rate_limit"); + if is_rate_limit && attempt < retries { + attempt += 1; + let backoff = 30u64 * attempt as u64; + eprintln!( + "[{label}] rate limited (attempt {attempt}/{retries}); waiting {backoff}s..." + ); + tokio::time::sleep(std::time::Duration::from_secs(backoff)).await; + continue; + } + return Err(e); + } + } + } +} + async fn run_one(provider: &AnthropicProvider, label: &str, words: usize) -> Result<()> { let prompt = format!( "Write a very long essay of at least {words} words about the architecture, maintainability, reliability, performance, testing strategy, provider abstraction, TUI complexity, security model, and long-term engineering risks of a Rust terminal AI coding agent codebase like jcode. Be specific and detailed. Do not use tools. Do not stop early." @@ -81,6 +109,15 @@ async fn main() -> Result<()> { .nth(1) .and_then(|s| s.parse::().ok()) .unwrap_or(3000); + + // Force the direct Anthropic API-key path when requested (or when an API + // key is present and OAuth is not), so fast mode is exercised on the + // Console API rather than the subscription OAuth route. Fast mode / priority + // tier is gated by usage credits on the API account. + let force_api_key = std::env::var("BENCH_ANTHROPIC_API_KEY") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + println!( "tier,first_ms,last_text_ms,total_ms,generation_ms,chars,input_tokens,output_tokens,cache_read,cache_write,gen_output_tok_s,total_output_tok_s" ); @@ -90,7 +127,17 @@ async fn main() -> Result<()> { let fast = AnthropicProvider::new(); fast.set_model("claude-opus-4-8")?; fast.set_service_tier("priority")?; - run_one(&standard, "standard_only", words).await?; - run_one(&fast, "auto", words).await?; + + if force_api_key { + // false = API key (not OAuth) + standard.pin_credential_mode_for_doctor(false)?; + fast.pin_credential_mode_for_doctor(false)?; + eprintln!("[bench] forcing direct Anthropic API-key credential mode"); + } + + run_one_with_retry(&standard, "standard_only", words, 4).await?; + // Cool-down gap to avoid back-to-back rate limiting between the two runs. + tokio::time::sleep(std::time::Duration::from_secs(20)).await; + run_one_with_retry(&fast, "auto", words, 4).await?; Ok(()) } diff --git a/scripts/build_linux_compat.sh b/scripts/build_linux_compat.sh index b68ad8f9c..9d1d08bf5 100755 --- a/scripts/build_linux_compat.sh +++ b/scripts/build_linux_compat.sh @@ -31,18 +31,58 @@ mkdir -p "$out_dir" \ host_uid="$(id -u)" host_gid="$(id -g)" +# Compute git build metadata on the HOST and hand it to the container via a +# metadata file (read by jcode-build-meta/build.rs through +# JCODE_BUILD_METADATA_FILE). The repo is bind-mounted into the container and +# owned by the host UID while git inside the container runs as root, so any +# in-container `git` call trips git's "dubious ownership" guard +# (CVE-2022-24765) and fails. That previously zeroed out the embedded git hash, +# date, AND changelog, shipping release binaries that report +# "vX.Y.Z (unknown) (unknown)" with an empty /changelog overlay. Computing the +# values here makes the embedded metadata independent of container-git. This +# mirrors scripts/remote_build.sh. +git_hash="" +git_date="" +git_tag="" +git_dirty="0" +changelog_raw="" +if command -v git >/dev/null 2>&1 && git -C "$repo_root" rev-parse --git-dir >/dev/null 2>&1; then + git_hash="$(git -C "$repo_root" rev-parse --short HEAD 2>/dev/null || true)" + git_date="$(git -C "$repo_root" log -1 --format=%ci 2>/dev/null || true)" + git_tag="$(git -C "$repo_root" describe --tags --always 2>/dev/null || true)" + changelog_raw="$(git -C "$repo_root" log -700 --format='%h|%ct|%D|%s' 2>/dev/null || true)" + if [[ -n "$(git -C "$repo_root" status --porcelain 2>/dev/null || true)" ]]; then + git_dirty="1" + fi +else + echo "warning: git metadata unavailable on host; embedded changelog/version may be empty" >&2 +fi + +metadata_file="$(mktemp)" +trap 'rm -f "$metadata_file"' EXIT +{ + printf 'git_hash=%s\n' "$git_hash" + printf 'git_date=%s\n' "$git_date" + printf 'git_tag=%s\n' "$git_tag" + printf 'git_dirty=%s\n' "$git_dirty" + printf 'changelog_raw< "$metadata_file" + echo "Building portable Linux release in Docker image: $image" echo "Output dir: $out_dir" +echo "Embedding git metadata: hash=${git_hash:-} tag=${git_tag:-} dirty=$git_dirty changelog_lines=$(printf '%s' "$changelog_raw" | grep -c '' || true)" docker run --rm \ -e CARGO_TERM_COLOR=always \ -e JCODE_RELEASE_BUILD="${JCODE_RELEASE_BUILD:-1}" \ -e JCODE_BUILD_SEMVER="${JCODE_BUILD_SEMVER:-}" \ + -e JCODE_BUILD_METADATA_FILE=/jcode-build-meta \ -e JCODE_COMPAT_PROFILE="$profile" \ -e JCODE_COMPAT_TARGET="$target" \ -e HOST_UID="$host_uid" \ -e HOST_GID="$host_gid" \ -v "$repo_root:/work" \ + -v "$metadata_file:/jcode-build-meta:ro" \ -v "$out_dir:/out" \ -v "$cache_root/cargo-registry:/root/.cargo/registry" \ -v "$cache_root/cargo-git:/root/.cargo/git" \ @@ -84,6 +124,13 @@ docker run --rm \ fi source /root/.cargo/env + # Belt-and-suspenders: the host-computed metadata file + # (JCODE_BUILD_METADATA_FILE=/jcode-build-meta) is the primary source of + # git hash/date/changelog, but mark the bind-mounted repo as a safe + # directory so any in-container git fallback still works despite the + # host-UID/root-git ownership mismatch (CVE-2022-24765 guard). + git config --global --add safe.directory /work 2>/dev/null || true + export CARGO_TARGET_DIR=/work/target/linux-compat export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-1}" export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="${CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS:--C link-arg=-static-libgcc}" diff --git a/scripts/compile_isolation_report.py b/scripts/compile_isolation_report.py new file mode 100755 index 000000000..1136cda5d --- /dev/null +++ b/scripts/compile_isolation_report.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Report compile-time isolation risks in the Jcode crate graph. + +This is advisory by default. Use --strict-target-state only when a migration phase +has removed the listed temporary violations and we want to prevent regressions. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +WATCHED_CRATES = ["jcode-base", "jcode-app-core", "jcode-tui", "jcode"] + + +@dataclass +class CrateStats: + name: str + manifest_path: str + src_path: str | None + rust_files: int + loc: int + cfg_test_count: int + test_attr_count: int + async_trait_count: int + derive_count: int + glob_reexports: list[str] + normal_workspace_deps: list[str] + normal_external_deps: list[str] + dev_workspace_deps: list[str] + dev_external_deps: list[str] + build_workspace_deps: list[str] + build_external_deps: list[str] + + +def run_metadata() -> dict[str, Any]: + result = subprocess.run( + ["cargo", "metadata", "--no-deps", "--format-version", "1"], + cwd=ROOT, + check=True, + text=True, + stdout=subprocess.PIPE, + ) + return json.loads(result.stdout) + + +def lib_or_root_src(package: dict[str, Any]) -> Path | None: + manifest = Path(package["manifest_path"]) + for target in package.get("targets", []): + if "lib" in target.get("kind", []): + return Path(target["src_path"]) + if package["name"] == "jcode": + return ROOT / "src" + src = manifest.parent / "src" + return src if src.exists() else None + + +def iter_rust_files(src_path: Path | None) -> list[Path]: + if src_path is None: + return [] + if src_path.is_file(): + root = src_path.parent + else: + root = src_path + if not root.exists(): + return [] + return sorted(path for path in root.rglob("*.rs") if path.is_file()) + + +def count_file(path: Path) -> tuple[int, str]: + text = path.read_text(errors="replace") + return text.count("\n") + (0 if text.endswith("\n") or not text else 1), text + + +def collect_stats(package: dict[str, Any], workspace_names: set[str]) -> CrateStats: + src_path = lib_or_root_src(package) + rust_files = iter_rust_files(src_path) + loc = 0 + cfg_test_count = 0 + test_attr_count = 0 + async_trait_count = 0 + derive_count = 0 + glob_reexports: list[str] = [] + + for path in rust_files: + file_loc, text = count_file(path) + loc += file_loc + cfg_test_count += len(re.findall(r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]", text)) + test_attr_count += len(re.findall(r"#\s*\[\s*(?:tokio::)?test(?:\s*\([^\]]*\))?\s*\]", text)) + async_trait_count += len(re.findall(r"#\s*\[\s*(?:async_trait::)?async_trait\s*\]", text)) + derive_count += len(re.findall(r"#\s*\[\s*derive\s*\(", text)) + for line_number, line in enumerate(text.splitlines(), 1): + stripped = line.strip() + if stripped.startswith("//"): + continue + if re.fullmatch(r"pub\s+use\s+[^;\n]+::\s*\*\s*;", stripped): + rel = path.relative_to(ROOT) + glob_reexports.append(f"{rel}:{line_number}: {stripped}") + + workspace_deps_by_kind: dict[str, list[str]] = {"normal": [], "dev": [], "build": []} + external_deps_by_kind: dict[str, list[str]] = {"normal": [], "dev": [], "build": []} + for dep in package.get("dependencies", []): + dep_name = dep["name"] + dep_kind = dep.get("kind") or "normal" + if dep_kind not in workspace_deps_by_kind: + dep_kind = "normal" + buckets = workspace_deps_by_kind if dep_name in workspace_names else external_deps_by_kind + buckets[dep_kind].append(dep_name) + + return CrateStats( + name=package["name"], + manifest_path=str(Path(package["manifest_path"]).relative_to(ROOT)), + src_path=str(src_path.relative_to(ROOT)) if src_path and src_path.exists() else None, + rust_files=len(rust_files), + loc=loc, + cfg_test_count=cfg_test_count, + test_attr_count=test_attr_count, + async_trait_count=async_trait_count, + derive_count=derive_count, + glob_reexports=glob_reexports, + normal_workspace_deps=sorted(workspace_deps_by_kind["normal"]), + normal_external_deps=sorted(external_deps_by_kind["normal"]), + dev_workspace_deps=sorted(workspace_deps_by_kind["dev"]), + dev_external_deps=sorted(external_deps_by_kind["dev"]), + build_workspace_deps=sorted(workspace_deps_by_kind["build"]), + build_external_deps=sorted(external_deps_by_kind["build"]), + ) + + +def target_state_violations(stats_by_name: dict[str, CrateStats]) -> list[str]: + violations: list[str] = [] + + tui = stats_by_name.get("jcode-tui") + if tui and "jcode-app-core" in tui.normal_workspace_deps: + violations.append("target-state: jcode-tui still directly depends on jcode-app-core") + + app_core = stats_by_name.get("jcode-app-core") + if app_core and "jcode-base" in app_core.normal_workspace_deps: + violations.append("target-state: jcode-app-core still directly depends on jcode-base") + + base = stats_by_name.get("jcode-base") + if base: + for dep in base.normal_workspace_deps: + if dep in { + "jcode-azure-auth", + "jcode-provider-gemini", + "jcode-provider-openai", + "jcode-provider-openrouter", + "jcode-notify-email", + "jcode-build-support", + }: + violations.append(f"target-state: jcode-base still depends on leaf/runtime crate {dep}") + for dep in base.normal_external_deps: + if dep.startswith("aws-") or dep in {"aws-types"}: + violations.append(f"target-state: jcode-base still depends directly on AWS crate {dep}") + + for crate in stats_by_name.values(): + for glob in crate.glob_reexports: + if crate.name in {"jcode", "jcode-app-core", "jcode-tui"}: + violations.append(f"target-state: broad glob re-export remains in {glob}") + + return violations + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="print machine-readable JSON") + parser.add_argument( + "--strict-target-state", + action="store_true", + help="exit non-zero for target-state violations (advisory by default)", + ) + parser.add_argument( + "--top", + type=int, + default=12, + help="number of largest workspace crates to print in text mode", + ) + args = parser.parse_args() + + metadata = run_metadata() + package_by_id = {package["id"]: package for package in metadata["packages"]} + workspace_packages = [package_by_id[package_id] for package_id in metadata["workspace_members"]] + workspace_names = {package["name"] for package in workspace_packages} + + stats = [collect_stats(package, workspace_names) for package in workspace_packages] + stats_by_name = {crate.name: crate for crate in stats} + violations = target_state_violations(stats_by_name) + largest = sorted(stats, key=lambda crate: crate.loc, reverse=True) + + payload = { + "watched_crates": {name: asdict(stats_by_name[name]) for name in WATCHED_CRATES if name in stats_by_name}, + "largest_crates": [asdict(crate) for crate in largest[: args.top]], + "target_state_violations": violations, + } + + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print("compile isolation static report") + print("largest workspace crates by Rust LOC:") + for crate in largest[: args.top]: + print( + f" - {crate.name}: {crate.loc} LOC, {crate.rust_files} files, " + f"#[test] {crate.test_attr_count}, cfg(test) {crate.cfg_test_count}, " + f"async_trait {crate.async_trait_count}, derive {crate.derive_count}" + ) + print("watched crates:") + for name in WATCHED_CRATES: + crate = stats_by_name.get(name) + if not crate: + continue + print( + f" - {name}: {crate.loc} LOC, " + f"normal workspace deps={len(crate.normal_workspace_deps)}, " + f"normal external deps={len(crate.normal_external_deps)}" + ) + if crate.dev_workspace_deps or crate.dev_external_deps or crate.build_workspace_deps or crate.build_external_deps: + print( + f" non-normal deps: dev workspace={len(crate.dev_workspace_deps)}, " + f"dev external={len(crate.dev_external_deps)}, " + f"build workspace={len(crate.build_workspace_deps)}, " + f"build external={len(crate.build_external_deps)}" + ) + if crate.glob_reexports: + print(" glob re-exports:") + for glob in crate.glob_reexports[:8]: + print(f" {glob}") + if len(crate.glob_reexports) > 8: + print(f" ... {len(crate.glob_reexports) - 8} more") + if violations: + print("target-state violations/advisories:") + for violation in violations: + print(f" - {violation}") + else: + print("target-state violations/advisories: none") + + if args.strict_target_state and violations: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/compile_time_probe.sh b/scripts/compile_time_probe.sh new file mode 100755 index 000000000..4ecdf9539 --- /dev/null +++ b/scripts/compile_time_probe.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$repo_root" + +usage() { + cat <<'USAGE' +Usage: + scripts/compile_time_probe.sh [options] + +Runs a full-feature selfdev jcode build with Cargo timings enabled and summarizes +critical-path-ish rustc units from target/cargo-timings/cargo-timing.html. + +Options: + --skip-build Parse the latest timing HTML without running cargo + --timing-html Parse a specific cargo timing HTML file + --touch Touch a file before building to simulate an edit + --profile Cargo profile to build (default: selfdev) + --package Cargo package to build (default: jcode) + --bin Cargo binary to build (default: jcode) + --feature-profile JCODE_DEV_FEATURE_PROFILE for dev_cargo.sh (default: default) + --json Write the parsed summary JSON to this path + --top Number of slowest units to print (default: 12) + -h, --help Show this help + +Examples: + scripts/compile_time_probe.sh --skip-build + scripts/compile_time_probe.sh --touch crates/jcode-tui/src/tui/app/input.rs + scripts/compile_time_probe.sh --json target/compile-time-probe.json + +Notes: + - This intentionally defaults to the full/default feature set. It is for + compile-time isolation work that keeps debug/selfdev behavior production-like. + - The "jcode serial stack" summary is not a formal Cargo critical path. It is a + focused view of the known long-pole crates: jcode-base, jcode-app-core, + jcode-tui, root jcode lib, and jcode bin. +USAGE +} + +skip_build=0 +timing_html="" +touch_path="" +profile="selfdev" +package="jcode" +bin="jcode" +feature_profile="default" +json_path="" +top_n=12 + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-build) + skip_build=1 + ;; + --timing-html) + [[ $# -ge 2 ]] || { echo 'error: --timing-html requires a path' >&2; exit 1; } + timing_html="$2" + shift + ;; + --touch) + [[ $# -ge 2 ]] || { echo 'error: --touch requires a path' >&2; exit 1; } + touch_path="$2" + shift + ;; + --profile) + [[ $# -ge 2 ]] || { echo 'error: --profile requires a value' >&2; exit 1; } + profile="$2" + shift + ;; + --package|-p) + [[ $# -ge 2 ]] || { echo 'error: --package requires a value' >&2; exit 1; } + package="$2" + shift + ;; + --bin) + [[ $# -ge 2 ]] || { echo 'error: --bin requires a value' >&2; exit 1; } + bin="$2" + shift + ;; + --feature-profile) + [[ $# -ge 2 ]] || { echo 'error: --feature-profile requires a value' >&2; exit 1; } + feature_profile="$2" + shift + ;; + --json) + [[ $# -ge 2 ]] || { echo 'error: --json requires a path' >&2; exit 1; } + json_path="$2" + shift + ;; + --top) + [[ $# -ge 2 ]] || { echo 'error: --top requires a positive integer' >&2; exit 1; } + top_n="$2" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'error: unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +if ! [[ "$top_n" =~ ^[1-9][0-9]*$ ]]; then + printf 'error: --top must be a positive integer (got %s)\n' "$top_n" >&2 + exit 1 +fi + +if [[ -n "$touch_path" && ! -e "$touch_path" ]]; then + printf 'error: touch path does not exist: %s\n' "$touch_path" >&2 + exit 1 +fi + +if [[ $skip_build -eq 0 ]]; then + if [[ -n "$touch_path" ]]; then + printf 'compile_time_probe: touching %s\n' "$touch_path" >&2 + touch "$touch_path" + fi + + printf 'compile_time_probe: building %s/%s profile=%s feature_profile=%s with --timings\n' \ + "$package" "$bin" "$profile" "$feature_profile" >&2 + + start_ns=$(python3 - <<'PY' +import time +print(time.perf_counter_ns()) +PY +) + + JCODE_DEV_FEATURE_PROFILE="$feature_profile" \ + scripts/dev_cargo.sh build --profile "$profile" -p "$package" --bin "$bin" --timings + + end_ns=$(python3 - <<'PY' +import time +print(time.perf_counter_ns()) +PY +) + elapsed_seconds=$(python3 - "$start_ns" "$end_ns" <<'PY' +import sys +start = int(sys.argv[1]) +end = int(sys.argv[2]) +print(f"{(end - start) / 1_000_000_000:.3f}") +PY +) +else + elapsed_seconds="" +fi + +if [[ -z "$timing_html" ]]; then + timing_html=$(find target/cargo-timings -maxdepth 1 -type f -name 'cargo-timing*.html' -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- || true) +fi + +if [[ -z "$timing_html" || ! -f "$timing_html" ]]; then + printf 'error: no cargo timing HTML found; run without --skip-build or pass --timing-html\n' >&2 + exit 1 +fi + +python3 - "$timing_html" "$elapsed_seconds" "$json_path" "$top_n" "$profile" "$package" "$bin" "$feature_profile" <<'PY' +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +html_path = Path(sys.argv[1]) +elapsed_arg = sys.argv[2] +json_path = Path(sys.argv[3]) if sys.argv[3] else None +top_n = int(sys.argv[4]) +profile = sys.argv[5] +package = sys.argv[6] +bin_name = sys.argv[7] +feature_profile = sys.argv[8] + +text = html_path.read_text(errors="replace") + +def extract_duration() -> float | None: + match = re.search(r"(?:const\s+)?DURATION\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*;", text) + return float(match.group(1)) if match else None + + +def extract_unit_data() -> list[dict[str, Any]]: + marker = "const UNIT_DATA = " + start = text.find(marker) + if start < 0: + raise SystemExit(f"error: {html_path} does not contain Cargo UNIT_DATA") + idx = start + len(marker) + while idx < len(text) and text[idx].isspace(): + idx += 1 + if idx >= len(text) or text[idx] != "[": + raise SystemExit("error: Cargo UNIT_DATA did not start with '['") + + depth = 0 + in_string = False + escape = False + end = idx + while end < len(text): + ch = text[end] + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + end += 1 + break + end += 1 + return json.loads(text[idx:end]) + + +def section_duration(unit: dict[str, Any], section_name: str) -> float | None: + sections = unit.get("sections") + if not sections: + return None + for name, payload in sections: + if name == section_name: + return float(payload["end"]) - float(payload["start"]) + return None + + +def fmt_seconds(value: float | None) -> str: + if value is None: + return "n/a" + return f"{value:.2f}s" + +units = extract_unit_data() +for unit in units: + unit["end"] = float(unit.get("start", 0.0)) + float(unit.get("duration", 0.0)) + unit["frontend_duration"] = section_duration(unit, "frontend") + unit["codegen_duration"] = section_duration(unit, "codegen") + +# Slowest rustc-ish units by duration. Keep build-script run units visible but low-noise. +top_units = sorted(units, key=lambda unit: float(unit.get("duration", 0.0)), reverse=True)[:top_n] + +def is_jcode_stack_unit(unit: dict[str, Any]) -> bool: + name = unit.get("name") + target = unit.get("target") or "" + if name in {"jcode-base", "jcode-app-core", "jcode-tui"} and "build script" not in target: + return True + if name == "jcode" and (target == "" or f'bin "{bin_name}"' in target): + return True + return False + +jcode_stack = sorted([unit for unit in units if is_jcode_stack_unit(unit)], key=lambda unit: float(unit.get("start", 0.0))) +stack_span = None +if jcode_stack: + stack_span = max(float(unit["end"]) for unit in jcode_stack) - min(float(unit.get("start", 0.0)) for unit in jcode_stack) +stack_sum = sum(float(unit.get("duration", 0.0)) for unit in jcode_stack) +stack_frontend_sum = sum(float(unit.get("frontend_duration") or 0.0) for unit in jcode_stack) +stack_codegen_sum = sum(float(unit.get("codegen_duration") or 0.0) for unit in jcode_stack) + +summary = { + "timing_html": str(html_path), + "profile": profile, + "package": package, + "bin": bin_name, + "feature_profile": feature_profile, + "wall_seconds_from_cargo_timing": extract_duration(), + "wall_seconds_measured_by_probe": float(elapsed_arg) if elapsed_arg else None, + "unit_count": len(units), + "top_units": [ + { + "name": unit.get("name"), + "version": unit.get("version"), + "target": unit.get("target") or "", + "features": unit.get("features") or [], + "start_seconds": round(float(unit.get("start", 0.0)), 3), + "duration_seconds": round(float(unit.get("duration", 0.0)), 3), + "frontend_seconds": round(unit["frontend_duration"], 3) if unit.get("frontend_duration") is not None else None, + "codegen_seconds": round(unit["codegen_duration"], 3) if unit.get("codegen_duration") is not None else None, + } + for unit in top_units + ], + "jcode_serial_stack": { + "span_seconds": round(stack_span, 3) if stack_span is not None else None, + "sum_unit_seconds": round(stack_sum, 3), + "sum_frontend_seconds": round(stack_frontend_sum, 3), + "sum_codegen_seconds": round(stack_codegen_sum, 3), + "units": [ + { + "name": unit.get("name"), + "target": unit.get("target") or "", + "start_seconds": round(float(unit.get("start", 0.0)), 3), + "end_seconds": round(float(unit.get("end", 0.0)), 3), + "duration_seconds": round(float(unit.get("duration", 0.0)), 3), + "frontend_seconds": round(unit["frontend_duration"], 3) if unit.get("frontend_duration") is not None else None, + "codegen_seconds": round(unit["codegen_duration"], 3) if unit.get("codegen_duration") is not None else None, + "features": unit.get("features") or [], + } + for unit in jcode_stack + ], + }, +} + +if json_path: + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + +print("compile_time_probe summary") +print(f" timing html: {html_path}") +print(f" cargo timing wall: {fmt_seconds(summary['wall_seconds_from_cargo_timing'])}") +if summary["wall_seconds_measured_by_probe"] is not None: + print(f" measured wall: {fmt_seconds(summary['wall_seconds_measured_by_probe'])}") +print(f" units: {len(units)}") +print(" jcode serial stack:") +print(f" span: {fmt_seconds(stack_span)}") +print(f" sum: {stack_sum:.2f}s (frontend {stack_frontend_sum:.2f}s, codegen {stack_codegen_sum:.2f}s)") +for unit in jcode_stack: + target = unit.get("target") or "lib" + frontend = fmt_seconds(unit.get("frontend_duration")) + codegen = fmt_seconds(unit.get("codegen_duration")) + print( + f" - {unit.get('name')} {target}: " + f"start {float(unit.get('start', 0.0)):.2f}s, " + f"dur {float(unit.get('duration', 0.0)):.2f}s, " + f"frontend {frontend}, codegen {codegen}" + ) +print(f" top {top_n} units:") +for unit in top_units: + target = unit.get("target") or "lib" + frontend = fmt_seconds(unit.get("frontend_duration")) + codegen = fmt_seconds(unit.get("codegen_duration")) + print( + f" - {unit.get('name')} {target}: " + f"{float(unit.get('duration', 0.0)):.2f}s " + f"(frontend {frontend}, codegen {codegen})" + ) +if json_path: + print(f" wrote json: {json_path}") +PY diff --git a/src/cli/hot_exec.rs b/src/cli/hot_exec.rs index 604587382..59fe44547 100644 --- a/src/cli/hot_exec.rs +++ b/src/cli/hot_exec.rs @@ -152,6 +152,7 @@ pub fn hot_update(session_id: &str) -> Result<()> { }) { Ok(path) => { update::print_centered(&format!("✓ Installed {}", release.tag_name)); + reload_server_after_update("installed update"); let is_selfdev = crate::cli::selfdev::client_selfdev_requested(); let exe = build::client_update_candidate(is_selfdev) @@ -180,6 +181,9 @@ pub fn hot_update(session_id: &str) -> Result<()> { } } Ok(None) => { + if repair_stale_shared_server_after_update_check() { + reload_server_after_update("repaired stale server target"); + } update::print_centered(&format!( "Already up to date ({})", jcode_build_meta::VERSION @@ -317,9 +321,13 @@ pub fn run_update() -> Result<()> { )); })?; update::print_centered(&format!("✅ Updated to {}", release.tag_name)); + reload_server_after_update("installed update"); update::print_centered("Restart jcode to use the new version."); } Ok(None) => { + if repair_stale_shared_server_after_update_check() { + reload_server_after_update("repaired stale server target"); + } update::print_centered(&format!( "Already up to date ({})", jcode_build_meta::VERSION @@ -364,3 +372,66 @@ pub fn run_update() -> Result<()> { Ok(()) } + +fn repair_stale_shared_server_after_update_check() -> bool { + match build::repair_stale_shared_server_channel() { + Ok(build::SharedServerRepair::Repaired { + previous, + repaired_to, + }) => { + crate::logging::info(&format!( + "update: repaired stale shared-server channel {:?} -> {}", + previous, repaired_to + )); + update::print_centered(&format!( + "Repaired stale server reload target: {}", + repaired_to + )); + true + } + Ok(build::SharedServerRepair::AlreadyCurrent) => false, + Err(error) => { + crate::logging::warn(&format!( + "update: failed to repair stale shared-server channel: {}", + error + )); + false + } + } +} + +fn reload_server_after_update(reason: &str) { + let exe = build::client_update_candidate(false) + .map(|(path, _)| path) + .or_else(|| std::env::current_exe().ok()); + let Some(exe) = exe else { + crate::logging::warn("update: could not find jcode binary to reload stale server"); + return; + }; + + let output = ProcessCommand::new(&exe) + .args(["--no-update", "server", "reload", "--force"]) + .output(); + match output { + Ok(output) if output.status.success() => { + crate::logging::info(&format!( + "update: requested server reload after {} via {:?}", + reason, exe + )); + } + Ok(output) => { + crate::logging::warn(&format!( + "update: server reload after {} failed with status {:?}: {}", + reason, + output.status.code(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Err(error) => { + crate::logging::warn(&format!( + "update: failed to request server reload after {} via {:?}: {}", + reason, exe, error + )); + } + } +}