From 76575a7fd82ed26d99d5a8846c272dce693735c1 Mon Sep 17 00:00:00 2001 From: ANAS Date: Sun, 7 Jun 2026 01:21:45 +0100 Subject: [PATCH] Add retry-policy flags and tool integrations Introduce runtime retry-policy overrides and integrate new tools and UI for expanded capabilities. - CLI: add --qir and --retry-policy flags with apply_retry_policy_override (including unit tests) and apply override to loaded config. Spawn background model fetch task and wire timeout handling. - Tools: register LSP, ApplyPatch, WebFetch, WebSearch, SubAgent tools and load dynamic MCP tools via a new McpManager at startup. - UI: add comprehensive terminal UI modules (app, events, render, streaming, types) with settings toggles, mouse hover/hover-to-message logic, API key flows, and command handling. - README: document persistent config options and explain retry_policy strategies (including QIR warning and migration notes). - SDK: add LSP/MCP/web tool scaffolding, retry/subagent/supporting utilities, and various agent/tool updates to support the new features. This change enables per-run retry policy control, dynamic tool loading via MCP, and richer CLI UI interactions. --- Cargo.lock | 46 +- README.md | 37 + apps/cli/src/main.rs | 145 +- apps/cli/src/ui/app.rs | 430 ++++ apps/cli/src/ui/events.rs | 922 ++++++++ apps/cli/src/ui/menus.rs | 1 - apps/cli/src/ui/mod.rs | 1993 +---------------- apps/cli/src/ui/render.rs | 407 ++++ apps/cli/src/ui/session.rs | 14 +- apps/cli/src/ui/streaming.rs | 154 ++ apps/cli/src/ui/types.rs | 352 +++ apps/desktop-t/src-tauri/Cargo.toml | 1 + apps/desktop-t/src-tauri/src/lib.rs | 237 +- apps/desktop-t/src/App.tsx | 744 +++++- apps/desktop-t/src/components/AgentStatus.tsx | 50 + apps/desktop-t/src/components/ChatArea.tsx | 348 ++- apps/desktop-t/src/components/ChatInput.tsx | 359 ++- .../src/components/CommandPalette.tsx | 232 ++ .../src/components/ConfirmationModal.tsx | 83 +- apps/desktop-t/src/components/EmptyState.tsx | 53 + .../src/components/ErrorBoundary.tsx | 106 + .../src/components/ModeIndicator.tsx | 36 + apps/desktop-t/src/components/PromptModal.tsx | 93 + .../src/components/SettingsModal.tsx | 57 +- apps/desktop-t/src/components/Sidebar.tsx | 142 -- apps/desktop-t/src/components/TabBar.tsx | 171 ++ apps/desktop-t/src/components/Toaster.tsx | 47 + .../desktop-t/src/components/ToolCallCard.tsx | 286 +++ apps/desktop-t/src/index.css | 214 +- apps/desktop-t/src/lib/commands.ts | 182 ++ apps/desktop-t/src/lib/models.ts | 78 + apps/desktop-t/src/lib/textHighlight.tsx | 192 ++ apps/desktop-t/src/lib/toast.tsx | 99 + libs/sdk/Cargo.toml | 4 + libs/sdk/src/agents/anthropic.rs | 41 +- libs/sdk/src/agents/cloudflare.rs | 27 +- libs/sdk/src/agents/gemini.rs | 42 +- libs/sdk/src/agents/mod.rs | 66 +- libs/sdk/src/agents/openai.rs | 23 +- libs/sdk/src/agents/opencode.rs | 31 +- libs/sdk/src/agents/openrouter.rs | 19 +- libs/sdk/src/agents/retry.rs | 376 ++++ libs/sdk/src/agents/traits.rs | 11 +- libs/sdk/src/agents/types.rs | 8 + libs/sdk/src/agents/vertex.rs | 28 +- libs/sdk/src/core/config.rs | 338 ++- libs/sdk/src/core/orchestrator.rs | 253 ++- libs/sdk/src/tools/file_ops.rs | 62 + libs/sdk/src/tools/lsp/client.rs | 127 ++ libs/sdk/src/tools/lsp/manager.rs | 80 + libs/sdk/src/tools/lsp/mod.rs | 2 + libs/sdk/src/tools/lsp_tool.rs | 123 + libs/sdk/src/tools/mcp/client.rs | 122 + libs/sdk/src/tools/mcp/manager.rs | 104 + libs/sdk/src/tools/mcp/mod.rs | 2 + libs/sdk/src/tools/mcp_tool.rs | 81 + libs/sdk/src/tools/mod.rs | 6 + libs/sdk/src/tools/registry.rs | 8 + libs/sdk/src/tools/subagent.rs | 95 + libs/sdk/src/tools/web/fetch.rs | 97 + libs/sdk/src/tools/web/mod.rs | 2 + libs/sdk/src/tools/web/search.rs | 96 + libs/sdk/src/utils/costs.rs | 10 + libs/sdk/src/utils/error.rs | 171 ++ libs/sdk/src/utils/mod.rs | 2 + libs/sdk/src/utils/models.rs | 111 + libs/sdk/src/utils/storage.rs | 3 +- libs/sdk/tests/integration_test.rs | 80 +- 68 files changed, 8270 insertions(+), 2692 deletions(-) create mode 100644 apps/cli/src/ui/app.rs create mode 100644 apps/cli/src/ui/events.rs create mode 100644 apps/cli/src/ui/render.rs create mode 100644 apps/cli/src/ui/streaming.rs create mode 100644 apps/cli/src/ui/types.rs create mode 100644 apps/desktop-t/src/components/AgentStatus.tsx create mode 100644 apps/desktop-t/src/components/CommandPalette.tsx create mode 100644 apps/desktop-t/src/components/EmptyState.tsx create mode 100644 apps/desktop-t/src/components/ErrorBoundary.tsx create mode 100644 apps/desktop-t/src/components/ModeIndicator.tsx create mode 100644 apps/desktop-t/src/components/PromptModal.tsx delete mode 100644 apps/desktop-t/src/components/Sidebar.tsx create mode 100644 apps/desktop-t/src/components/TabBar.tsx create mode 100644 apps/desktop-t/src/components/Toaster.tsx create mode 100644 apps/desktop-t/src/components/ToolCallCard.tsx create mode 100644 apps/desktop-t/src/lib/commands.ts create mode 100644 apps/desktop-t/src/lib/models.ts create mode 100644 apps/desktop-t/src/lib/textHighlight.tsx create mode 100644 apps/desktop-t/src/lib/toast.tsx create mode 100644 libs/sdk/src/agents/retry.rs create mode 100644 libs/sdk/src/tools/lsp/client.rs create mode 100644 libs/sdk/src/tools/lsp/manager.rs create mode 100644 libs/sdk/src/tools/lsp/mod.rs create mode 100644 libs/sdk/src/tools/lsp_tool.rs create mode 100644 libs/sdk/src/tools/mcp/client.rs create mode 100644 libs/sdk/src/tools/mcp/manager.rs create mode 100644 libs/sdk/src/tools/mcp/mod.rs create mode 100644 libs/sdk/src/tools/mcp_tool.rs create mode 100644 libs/sdk/src/tools/subagent.rs create mode 100644 libs/sdk/src/tools/web/fetch.rs create mode 100644 libs/sdk/src/tools/web/mod.rs create mode 100644 libs/sdk/src/tools/web/search.rs create mode 100644 libs/sdk/src/utils/error.rs create mode 100644 libs/sdk/src/utils/models.rs diff --git a/Cargo.lock b/Cargo.lock index 7d45306..a264e3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,6 +939,16 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-updater", "tokio", + "tokio-util", +] + +[[package]] +name = "diffy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05264ab2aab4fb952fc4b0f3f6eff1ddfb4563064053a4ea174d91537584a769" +dependencies = [ + "hashbrown 0.17.1", ] [[package]] @@ -1803,6 +1813,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -2462,6 +2475,19 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lsp-types" +version = "0.95.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e34d33a8e9b006cd3fc4fe69a921affa097bae4bb65f76271f4644f9a334365" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -3495,7 +3521,7 @@ dependencies = [ [[package]] name = "routecode-cli" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "async-trait", @@ -3517,17 +3543,19 @@ dependencies = [ [[package]] name = "routecode-sdk" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "async-stream", "async-trait", "chrono", + "diffy", "dirs 5.0.1", "env_logger", "futures", "glob", "log", + "lsp-types", "once_cell", "regex", "reqwest 0.12.28", @@ -3540,6 +3568,8 @@ dependencies = [ "thiserror 1.0.69", "tiktoken-rs", "tokio", + "tokio-stream", + "tokio-util", "toml 0.8.2", "uuid", ] @@ -4847,6 +4877,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4856,6 +4897,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] diff --git a/README.md b/README.md index e82a958..e334807 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,43 @@ On first run, RouteCode will ask for your API key and save it to `~/.routecode/c --- +## Configuration + +All persistent settings live in `~/.routecode/config.json` (created on first run). +You can edit the file by hand — unknown fields are ignored, and missing fields +fall back to their defaults. The most useful fields: + +| Field | Type | Default | Description | +|-------------------------|-----------|----------------|-------------| +| `model` | `string` | `gpt-4o` | Default model used at startup. | +| `provider` | `string` | `openai` | Default provider (see [Supported providers](#supported-providers)). | +| `api_keys` | `object` | `{}` | Map of provider id → API key. | +| `thinking_level` | `string` | `default` | One of `default` / `low` / `medium` / `high` (provider-dependent). | +| `sub_agents_enabled` | `bool` | `true` | Allow the orchestrator to spawn sub-agents for subtasks. | +| `retry_policy` | `object` | `{"strategy": "disabled"}` | Retry strategy for failed provider requests. Tagged shape: `{"strategy": "disabled"}`, `{"strategy": "qir"}`, or `{"strategy": "exponential_backoff", "max_attempts": 5, "base_secs": 1.0, "jitter": true}`. Currently only `qir` and `disabled` are honored by the orchestrator; `exponential_backoff` is reserved. See below. | +| `vertex_project` | `string` | `""` | GCP project id, required when `provider = "vertex"`. | +| `vertex_location` | `string` | `us-central1` | GCP region for Vertex. | +| `allowlist` | `string[]`| `[]` | Extra filesystem paths the bash / file tools are allowed to touch. | + +#### `retry_policy` strategies + +| `strategy` | Behavior | +|-------------------------|----------| +| `disabled` | No retry on failure. Default. | +| `qir` | **Experimental.** Quick Infinite Retry — re-send failed requests immediately with no delay and no attempt limit. **Use at your own risk** — aggressive retrying can rate-limit, suspend, or permanently ban your account with Anthropic, OpenAI, Google Gemini, OpenRouter, Cloudflare, Vertex, OpenCode Zen, etc. The RouteCode project and its authors accept no responsibility for bans, lost credits, or other consequences. Toggle this in **Settings → Engine & Models → Quick Infinite Retry**. | +| `exponential_backoff` | Reserved for future use. The fields `max_attempts`, `base_secs`, and `jitter` are accepted but not yet honored at the call site. | + +> **Note:** `retry_policy` is snapshotted at the start of each request and is +> NOT re-read mid-run. If you want to stop a runaway retry loop, use the Stop +> button (sends a cancel signal) — don't just toggle QIR off. + +> **Migration:** Old configs with `quick_infinite_retry: true` (or +> `retry_policy: true`) are auto-migrated to `retry_policy: {"strategy": "qir"}` +> on load. A deprecation warning is logged. The legacy field is never written +> back out — save once to clean up. + +--- + ## Building from source ```sh diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 57e776e..4e365d1 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -31,6 +31,17 @@ pub struct Cli { #[arg(long, help = "Import a session from a JSON file")] pub import: Option, + /// Enable Quick Infinite Retry for this run (overrides the saved + /// config; does not persist). Equivalent to `--retry-policy qir`. + #[arg(long, help = "Enable Quick Infinite Retry (QIR) for this run")] + pub qir: bool, + + /// Override the retry policy for this run. Accepted values: + /// `disabled`, `qir`, `exponential_backoff`. Does not persist to + /// config.json. Takes precedence over `--qir` if both are set. + #[arg(long, value_name = "STRATEGY", help = "Retry policy for this run: disabled | qir | exponential_backoff")] + pub retry_policy: Option, + #[command(subcommand)] pub command: Option, } @@ -51,8 +62,12 @@ use crossterm::{ use ratatui::{backend::CrosstermBackend, Terminal}; use routecode_sdk::core::AgentOrchestrator; use routecode_sdk::tools::bash::BashTool; -use routecode_sdk::tools::file_ops::{FileEditTool, FileReadTool, FileWriteTool}; +use routecode_sdk::tools::file_ops::{FileEditTool, FileReadTool, FileWriteTool, ApplyPatchTool}; +use routecode_sdk::tools::lsp_tool::LspTool; +use routecode_sdk::tools::mcp::manager::McpManager; use routecode_sdk::tools::navigation::{GrepTool, LsTool, TreeTool}; +use routecode_sdk::tools::subagent::SubAgentTool; +use routecode_sdk::tools::web::{fetch::WebFetchTool, search::WebSearchTool}; use routecode_sdk::tools::ToolRegistry; use std::io; use std::process::Command; @@ -73,6 +88,39 @@ fn restore_terminal() { ); } +/// Resolve the retry-policy override from CLI flags. `--retry-policy` +/// wins over `--qir` if both are set. Returns `None` if neither flag is +/// set or both yield no change. Bad values for `--retry-policy` are +/// logged and ignored (we don't bail out — a typo shouldn't prevent the +/// user from running the app at all). +fn apply_retry_policy_override(cli: &Cli) -> Option { + use routecode_sdk::core::config::RetryPolicy; + if let Some(s) = &cli.retry_policy { + match s.as_str() { + "disabled" => return Some(RetryPolicy::Disabled), + "qir" => return Some(RetryPolicy::Qir), + "exponential_backoff" => { + return Some(RetryPolicy::ExponentialBackoff { + max_attempts: 10, + base_secs: 1.0, + jitter: true, + }); + } + other => { + log::warn!( + "Unknown --retry-policy value '{}'. Accepted: disabled, qir, exponential_backoff. Ignoring.", + other + ); + return None; + } + } + } + if cli.qir { + return Some(RetryPolicy::Qir); + } + None +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -178,6 +226,9 @@ async fn main() -> anyhow::Result<()> { if let Some(p) = &cli.provider { config.provider = p.clone(); } + if let Some(policy) = apply_retry_policy_override(&cli) { + config.retry_policy = policy; + } // API Key Discovery let provider_name = config.provider.clone(); @@ -209,6 +260,26 @@ async fn main() -> anyhow::Result<()> { tool_registry.register(Arc::new(LsTool)); tool_registry.register(Arc::new(TreeTool)); tool_registry.register(Arc::new(GrepTool)); + tool_registry.register(Arc::new(LspTool::new())); + tool_registry.register(Arc::new(ApplyPatchTool)); + tool_registry.register(Arc::new(WebFetchTool)); + tool_registry.register(Arc::new(WebSearchTool)); + + // Initialize MCP Manager and load dynamic tools + let mcp_manager = McpManager::new(); + if let Err(e) = mcp_manager.load_and_register_tools(&mut tool_registry).await { + eprintln!("Warning: Failed to load MCP tools: {}", e); + } + + if config.sub_agents_enabled { + let registry_clone = Arc::new(tool_registry.clone()); + tool_registry.register(Arc::new(SubAgentTool::new( + provider.clone(), + registry_clone, + Arc::new(Mutex::new(config.clone())), + ))); + } + let tool_registry = Arc::new(tool_registry); let config_mutex = Arc::new(Mutex::new(config.clone())); @@ -251,6 +322,13 @@ async fn main() -> anyhow::Result<()> { } } }); + + let models_handle = tokio::spawn(async move { + if let Err(e) = routecode_sdk::utils::models::fetch_and_cache_models().await { + log::warn!("Failed to fetch models in background: {}", e); + } + }); + app.current_model = config.model; if let Some(resume_name) = cli.resume { @@ -354,6 +432,71 @@ async fn main() -> anyhow::Result<()> { // Don't block shutdown on slow update checks — timeout after 1 second tokio::time::timeout(std::time::Duration::from_secs(1), update_handle).await.ok(); + tokio::time::timeout(std::time::Duration::from_secs(1), models_handle).await.ok(); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use routecode_sdk::core::config::RetryPolicy; + + fn cli_with(args: &[&str]) -> Cli { + let mut full = vec!["routecode"]; + full.extend_from_slice(args); + Cli::parse_from(full) + } + + #[test] + fn override_none_when_neither_flag_set() { + let cli = cli_with(&[]); + assert_eq!(apply_retry_policy_override(&cli), None); + } + + #[test] + fn override_qir_from_flag() { + let cli = cli_with(&["--qir"]); + assert_eq!(apply_retry_policy_override(&cli), Some(RetryPolicy::Qir)); + } + + #[test] + fn override_disabled_from_policy() { + let cli = cli_with(&["--retry-policy", "disabled"]); + assert_eq!(apply_retry_policy_override(&cli), Some(RetryPolicy::Disabled)); + } + + #[test] + fn override_qir_from_policy() { + let cli = cli_with(&["--retry-policy", "qir"]); + assert_eq!(apply_retry_policy_override(&cli), Some(RetryPolicy::Qir)); + } + + #[test] + fn override_exponential_backoff_uses_safe_defaults() { + let cli = cli_with(&["--retry-policy", "exponential_backoff"]); + let p = apply_retry_policy_override(&cli).unwrap(); + match p { + RetryPolicy::ExponentialBackoff { max_attempts, base_secs, jitter } => { + assert!(max_attempts > 0); + assert!(base_secs > 0.0); + assert!(jitter); + } + other => panic!("expected ExponentialBackoff, got {:?}", other), + } + } + + #[test] + fn override_policy_wins_over_qir_flag() { + let cli = cli_with(&["--qir", "--retry-policy", "disabled"]); + // --retry-policy is more specific, so it wins. + assert_eq!(apply_retry_policy_override(&cli), Some(RetryPolicy::Disabled)); + } + + #[test] + fn override_unknown_value_is_ignored() { + // Bad value must not panic and must not change the policy. + let cli = cli_with(&["--retry-policy", "totally-bogus"]); + assert_eq!(apply_retry_policy_override(&cli), None); + } +} diff --git a/apps/cli/src/ui/app.rs b/apps/cli/src/ui/app.rs new file mode 100644 index 0000000..1dd3ef0 --- /dev/null +++ b/apps/cli/src/ui/app.rs @@ -0,0 +1,430 @@ +use ratatui::{layout::Rect, style::Style, widgets::ListState}; +use routecode_sdk::agents::StreamChunk; +use routecode_sdk::core::{AgentOrchestrator, DynamicModelInfo, Message}; +use routecode_sdk::utils::costs::Usage; +use std::sync::Arc; +use tui_textarea::TextArea; +use tokio::task::JoinSet; + +use super::types::{ApprovalMode, Command, QirStatus, Screen, SettingsMenuItem, COMMANDS, ApiKeyInputStage}; + +pub struct App { + pub screen: Screen, + pub input: TextArea<'static>, + pub history: Vec, + pub orchestrator: Arc, + pub current_model: String, + pub current_provider_id: String, + pub provider_name: String, + pub show_menu: bool, + pub show_provider_menu: bool, + pub show_model_menu: bool, + pub show_settings_menu: bool, + pub menu_state: ListState, + pub filtered_commands: Vec<&'static Command>, + pub filtered_models: Vec, + pub all_available_models: Vec, + pub history_scroll: u16, + pub max_scroll: u16, + pub auto_scroll: bool, + pub is_generating: bool, + pub tick_count: u64, + pub active_tool: Option, + pub tasks: JoinSet<()>, + pub prompt_history: Vec, + pub prompt_history_index: Option, + pub api_key_input: TextArea<'static>, + pub model_search_input: TextArea<'static>, + pub is_inputting_api_key: bool, + pub pending_provider_id: Option, + pub api_key_input_stage: ApiKeyInputStage, + pub pending_account_id: Option, + pub pending_gateway_id: Option, + pub pending_clear: bool, + pub pending_exit: bool, + pub is_fetching_models: bool, + pub collapse_thinking: bool, + pub mouse_row: Option, + pub mouse_col: Option, + pub mouse_moved: bool, + pub mouse_events_count: u64, + pub logo_anim_frames: u16, + pub rx: tokio::sync::mpsc::UnboundedReceiver, + pub tx: tokio::sync::mpsc::UnboundedSender, + pub settings_items: Vec, + pub last_click_up: Option<(std::time::Instant, u16, u16)>, + pub mouse_down_start: Option<(std::time::Instant, u16, u16)>, + pub temp_expand_thinking: bool, + pub last_toggle_time: Option, + pub thinking_hover_rendered: bool, + pub usage: Usage, + pub cached_history_len: usize, + pub cached_width: u16, + pub cached_is_collapsed: bool, + pub cached_thinking_hovered: bool, + pub cached_total_height: usize, + pub cached_text: Option>, + pub cached_layout: Vec<(usize, bool)>, + pub pending_command_confirmation: Option<(String, String, super::types::ConfirmationSender)>, + pub inputting_command_feedback: bool, + pub show_user_msg_modal: Option, + pub user_msg_modal_selected: usize, + pub cached_hovered_msg_idx: Option, + pub session_id: String, + pub pending_update: Option, + pub pending_update_changelog: String, + pub pending_update_published_at: String, + pub update_modal_selected: usize, + pub pending_update_install: bool, + pub render_dirty: bool, + pub last_cache_update: std::time::Instant, + pub approval_mode: ApprovalMode, + pub startup_input_buffer: Vec, + pub startup_ready: bool, + pub hide_cwd: bool, + pub hide_model_info: bool, + pub hide_context_summary: bool, + pub qir_retry_status: Option, +} + +impl App { + pub fn new(orchestrator: Arc, provider_name: String, default_model: String) -> Self { + let mut input = TextArea::default(); + input.set_cursor_line_style(Style::default()); + input.set_placeholder_style(Style::default().fg(super::components::COLOR_SECONDARY)); + input.set_placeholder_text(" Ask anything... \"How do I use this?\""); + + let mut api_key_input = TextArea::default(); + api_key_input.set_cursor_line_style(Style::default()); + api_key_input.set_placeholder_text(" Paste your API key here..."); + + let mut model_search_input = TextArea::default(); + model_search_input.set_cursor_line_style(Style::default()); + model_search_input.set_placeholder_text(" Search models..."); + model_search_input.set_placeholder_style(Style::default().fg(super::components::COLOR_SECONDARY)); + + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + + Self { + screen: Screen::Welcome, + input, + history: Vec::new(), + orchestrator, + current_model: default_model, + current_provider_id: provider_name.clone(), + provider_name, + show_menu: false, + show_provider_menu: false, + show_model_menu: false, + show_settings_menu: false, + menu_state: ListState::default(), + filtered_commands: Vec::new(), + filtered_models: Vec::new(), + all_available_models: Vec::new(), + settings_items: Vec::new(), + history_scroll: 0, + max_scroll: 0, + auto_scroll: true, + is_generating: false, + tick_count: 0, + active_tool: None, + tasks: JoinSet::new(), + prompt_history: Vec::new(), + prompt_history_index: None, + api_key_input, + model_search_input, + is_inputting_api_key: false, + pending_provider_id: None, + api_key_input_stage: ApiKeyInputStage::None, + pending_account_id: None, + pending_gateway_id: None, + pending_clear: false, + pending_exit: false, + is_fetching_models: false, + collapse_thinking: false, + mouse_row: None, + mouse_col: None, + mouse_moved: false, + mouse_events_count: 0, + logo_anim_frames: 0, + rx, + tx, + usage: Usage::default(), + last_click_up: None, + mouse_down_start: None, + temp_expand_thinking: false, + last_toggle_time: None, + thinking_hover_rendered: false, + cached_history_len: 0, + cached_width: 0, + cached_is_collapsed: false, + cached_thinking_hovered: false, + cached_total_height: 0, + cached_text: None, + cached_layout: Vec::new(), + pending_command_confirmation: None, + inputting_command_feedback: false, + show_user_msg_modal: None, + user_msg_modal_selected: 0, + cached_hovered_msg_idx: None, + session_id: format!("session_{}", uuid::Uuid::new_v4()), + pending_update: None, + pending_update_changelog: String::new(), + pending_update_published_at: String::new(), + update_modal_selected: 1, + pending_update_install: false, + render_dirty: true, + last_cache_update: std::time::Instant::now(), + approval_mode: ApprovalMode::Normal, + startup_input_buffer: Vec::new(), + startup_ready: false, + hide_cwd: false, + hide_model_info: false, + hide_context_summary: false, + qir_retry_status: None, + } + } + + pub async fn populate_settings(&mut self) { + let config = self.orchestrator.config.lock().await; + self.settings_items = vec![ + SettingsMenuItem::Header("Appearance".to_string()), + SettingsMenuItem::Option { + name: "Logo Animation".to_string(), + val: config.logo_animation.clone(), + key: "logo_animation".to_string(), + }, + SettingsMenuItem::Option { + name: "Animation Theme".to_string(), + val: config.logo_animation_color.clone(), + key: "logo_animation_color".to_string(), + }, + SettingsMenuItem::Header("Footer".to_string()), + SettingsMenuItem::Option { + name: "Show Model Info".to_string(), + val: if self.hide_model_info { "hide" } else { "show" }.to_string(), + key: "hide_model_info".to_string(), + }, + SettingsMenuItem::Option { + name: "Show Context Summary".to_string(), + val: if self.hide_context_summary { "hide" } else { "show" }.to_string(), + key: "hide_context_summary".to_string(), + }, + SettingsMenuItem::Option { + name: "Show Directory".to_string(), + val: if self.hide_cwd { "hide" } else { "show" }.to_string(), + key: "hide_cwd".to_string(), + }, + SettingsMenuItem::Header("Advanced".to_string()), + SettingsMenuItem::Option { + name: "Enable Sub-Agents".to_string(), + val: if config.sub_agents_enabled { "on" } else { "off" }.to_string(), + key: "sub_agents_enabled".to_string(), + }, + ]; + } + + pub fn update_filtered_commands(&mut self) { + let input_line = self.input.lines().first().map(|l| l.to_lowercase()).unwrap_or_default(); + if input_line.starts_with('/') { + self.filtered_commands = COMMANDS + .iter() + .filter(|c| c.name.to_lowercase().starts_with(&input_line)) + .collect(); + self.show_menu = !self.filtered_commands.is_empty(); + if self.show_menu { + self.menu_state.select(Some(0)); + } + } else { + self.show_menu = false; + } + } +} + +/// Toggle a settings-menu item. Returns `true` if `key` matched a known +/// setting, `false` otherwise. Centralized so the keyboard and mouse +/// handlers can't drift apart (the original copy-paste had `sub_agents_enabled` +/// missing from the mouse path and `hide_context_summary` missing from the +/// keyboard path). +pub(crate) async fn apply_settings_toggle(app: &mut App, key: &str) -> bool { + match key { + "logo_animation" => { + let next_val = { + let config = app.orchestrator.config.lock().await; + match config.logo_animation.as_str() { + "always" => "hover", + "hover" => "click", + _ => "always", + } + }; + { + let mut config = app.orchestrator.config.lock().await; + config.logo_animation = next_val.to_string(); + if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { + log::error!("Failed to save config: {}", e); + } + } + } + "logo_animation_color" => { + let next_val = { + let config = app.orchestrator.config.lock().await; + match config.logo_animation_color.as_str() { + "rainbow" => "neon", + "neon" => "cyberpunk", + "cyberpunk" => "sunset", + "sunset" => "mono", + _ => "rainbow", + } + }; + { + let mut config = app.orchestrator.config.lock().await; + config.logo_animation_color = next_val.to_string(); + if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { + log::error!("Failed to save config: {}", e); + } + } + } + "hide_cwd" => app.hide_cwd = !app.hide_cwd, + "hide_model_info" => app.hide_model_info = !app.hide_model_info, + "hide_context_summary" => app.hide_context_summary = !app.hide_context_summary, + "sub_agents_enabled" => { + let mut config = app.orchestrator.config.lock().await; + config.sub_agents_enabled = !config.sub_agents_enabled; + if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { + log::error!("Failed to save config: {}", e); + } + } + _ => return false, + } + app.populate_settings().await; + true +} + +/// Compute whether the mouse is hovering over a thinking block, accounting for text wrapping. +/// Uses the same wrapping calculation as the auto-scroll logic in ui_session. +pub fn compute_thinking_hover(app: &App, size: Rect) -> bool { + let mouse_row = match app.mouse_row { + Some(r) => r, + None => return false, + }; + if app.screen != Screen::Session { + return false; + } + let has_thinking = app.history.iter().any(|m| m.thought.is_some()); + if !has_thinking { + return false; + } + + // Compute layout: header=1 row, then history area, then input, then status bar + let input_height = (app.input.lines().len() as u16 + 2).min(12); + // area starts at row 1 (after header). History is area minus input and status. + let area_height = size.height.saturating_sub(1); // main area below header + let history_height = area_height.saturating_sub(input_height).saturating_sub(1); + + // Check mouse is in history area (row 1 to 1+history_height exclusive) + if mouse_row < 1 || mouse_row > history_height { + return false; + } + + // The visual row within the history viewport (0-indexed from top of visible area) + let viewport_row = mouse_row - 1; + // The absolute visual row including scroll + let target_visual_row = viewport_row as usize + app.history_scroll as usize; + + if let Some(&(_, is_thinking)) = app.cached_layout.get(target_visual_row) { + return is_thinking; + } + false +} + +/// Compute which message is hovered by the mouse. +pub fn compute_message_hover(app: &App, size: Rect) -> Option { + let mouse_row = app.mouse_row?; + if app.screen != Screen::Session { + return None; + } + + let input_height = (app.input.lines().len() as u16 + 2).min(12); + let area_height = size.height.saturating_sub(1); + let history_height = area_height.saturating_sub(input_height).saturating_sub(1); + + if mouse_row < 1 || mouse_row > history_height { + return None; + } + + let viewport_row = mouse_row - 1; + let target_visual_row = viewport_row as usize + app.history_scroll as usize; + + if let Some(&(msg_idx, _)) = app.cached_layout.get(target_visual_row) { + return Some(msg_idx); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use routecode_sdk::agents::AIProvider; + use routecode_sdk::core::Config; + use routecode_sdk::tools::ToolRegistry; + use std::sync::Arc; + use tokio::sync::Mutex; + + struct MockProvider; + #[async_trait] + impl AIProvider for MockProvider { + fn name(&self) -> &str { "Mock" } + async fn list_models(&self) -> Result, anyhow::Error> { Ok(vec![]) } + async fn ask(&self, _: Arc>, _: &str, _: Arc>>, _: Option<&str>) -> Result { + Err(anyhow::anyhow!("Not implemented")) + } + } + + #[test] + fn test_app_initialization() { + let orchestrator = Arc::new(AgentOrchestrator::new( + Arc::new(MockProvider), + Arc::new(ToolRegistry::new()), + Arc::new(Mutex::new(Config::default())), + )); + let app = App::new(orchestrator, "Mock".to_string(), "gpt-4o".to_string()); + assert_eq!(app.screen, Screen::Welcome); + assert!(app.history.is_empty()); + assert_eq!(app.current_model, "gpt-4o"); + } + + #[test] + fn test_update_filtered_commands() { + let orchestrator = Arc::new(AgentOrchestrator::new( + Arc::new(MockProvider), + Arc::new(ToolRegistry::new()), + Arc::new(Mutex::new(Config::default())), + )); + let mut app = App::new(orchestrator, "Mock".to_string(), "gpt-4o".to_string()); + + app.input.insert_str("/hel"); + app.update_filtered_commands(); + + assert!(app.show_menu); + assert_eq!(app.filtered_commands.len(), 1); + assert_eq!(app.filtered_commands[0].name, "/help"); + } + + #[test] + fn test_update_filtered_commands_no_match() { + let orchestrator = Arc::new(AgentOrchestrator::new( + Arc::new(MockProvider), + Arc::new(ToolRegistry::new()), + Arc::new(Mutex::new(Config::default())), + )); + let mut app = App::new(orchestrator, "Mock".to_string(), "gpt-4o".to_string()); + + app.input.insert_str("/nonexistent"); + app.update_filtered_commands(); + + assert!(!app.show_menu); + assert!(app.filtered_commands.is_empty()); + } +} diff --git a/apps/cli/src/ui/events.rs b/apps/cli/src/ui/events.rs new file mode 100644 index 0000000..2a311d3 --- /dev/null +++ b/apps/cli/src/ui/events.rs @@ -0,0 +1,922 @@ +use crossterm::event::{self, Event, KeyCode, MouseButton, MouseEventKind}; +use ratatui::Terminal; +use routecode_sdk::agents::types::ConfirmationResponse; +use routecode_sdk::core::{Message, Role}; +use std::io; +use tui_textarea::TextArea; + +use super::app::{apply_settings_toggle, compute_message_hover, compute_thinking_hover, App}; +use super::logic::{handle_command, handle_model_search}; +use super::render::copy_to_clipboard; +use super::types::{ + ApiKeyInputStage, ApprovalMode, ModelMenuItem, PROVIDERS, Screen, SettingsMenuItem, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeyEventResult { + Continue, + Exit, +} + +pub(crate) async fn handle_key_event( + app: &mut App, + key: event::KeyEvent, + is_burst: bool, +) -> io::Result { + if app.pending_update.is_some() { + match key.code { + KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => { + app.update_modal_selected = if app.update_modal_selected == 0 { 1 } else { 0 }; + } + KeyCode::Left | KeyCode::Char('h') => { + app.update_modal_selected = if app.update_modal_selected == 1 { 0 } else { 1 }; + } + KeyCode::Enter => { + if app.update_modal_selected == 1 { + app.pending_update_install = true; + return Ok(KeyEventResult::Exit); + } else { + app.pending_update = None; + } + } + KeyCode::Esc => { + app.pending_update = None; + } + _ => {} + } + return Ok(KeyEventResult::Continue); + } + if let Some(msg_idx) = app.show_user_msg_modal { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + app.user_msg_modal_selected = if app.user_msg_modal_selected == 0 { 1 } else { 0 }; + } + KeyCode::Down | KeyCode::Char('j') => { + app.user_msg_modal_selected = if app.user_msg_modal_selected == 1 { 0 } else { 1 }; + } + KeyCode::Enter => { + let text = app.history[msg_idx].content.as_ref().map(|s| s.to_string()).unwrap_or_default(); + if app.user_msg_modal_selected == 0 { + let text_clone = text.clone(); + tokio::task::spawn_blocking(move || { + if let Err(e) = copy_to_clipboard(&text_clone) { + log::error!("Clipboard copy failed: {}", e); + } + }); + app.history.push(Message::system("Message copied to clipboard!".to_string())); + } else { + app.history.truncate(msg_idx); + app.input = tui_textarea::TextArea::from(text.lines().map(|s| s.to_string())); + app.input.move_cursor(tui_textarea::CursorMove::End); + } + app.show_user_msg_modal = None; + } + KeyCode::Esc => { + app.show_user_msg_modal = None; + } + _ => {} + } + return Ok(KeyEventResult::Continue); + } + if app.pending_command_confirmation.is_some() { + if app.inputting_command_feedback { + match key.code { + KeyCode::Esc => { + app.inputting_command_feedback = false; + app.input.delete_line_by_head(); + while app.input.cursor() != (0, 0) { + app.input.move_cursor(tui_textarea::CursorMove::Head); + app.input.delete_line_by_head(); + } + app.input.set_placeholder_text(" Ask anything... \"How do I use this?\""); + } + KeyCode::Enter => { + if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { + let lines = app.input.lines().to_vec(); + app.input.delete_line_by_head(); + while app.input.cursor() != (0, 0) { + app.input.move_cursor(tui_textarea::CursorMove::Head); + app.input.delete_line_by_head(); + } + app.input.set_placeholder_text(" Ask anything... \"How do I use this?\""); + + let msg = lines.join("\n").trim().to_string(); + let feedback = if msg.is_empty() { "Command cancelled.".to_string() } else { msg }; + + let mut tx_opt = tx_mutex.lock().await; + if let Some(tx) = tx_opt.take() { + let _ = tx.send(ConfirmationResponse::Feedback(feedback)); + } + } + app.inputting_command_feedback = false; + } + _ => { + app.input.input(key); + } + } + } else { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { + let mut tx_opt = tx_mutex.lock().await; + if let Some(tx) = tx_opt.take() { + let _ = tx.send(ConfirmationResponse::AllowOnce); + } + } + } + KeyCode::Char('s') | KeyCode::Char('S') => { + let mut config = routecode_sdk::utils::storage::load_session_config(&app.session_id).unwrap_or_default(); + config.allow_all_commands = true; + let _ = routecode_sdk::utils::storage::save_session_config(&app.session_id, &config); + + if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { + let mut tx_opt = tx_mutex.lock().await; + if let Some(tx) = tx_opt.take() { + let _ = tx.send(ConfirmationResponse::AllowSession); + } + } + } + KeyCode::Char('w') | KeyCode::Char('W') => { + let mut config = routecode_sdk::utils::storage::load_workspace_config().unwrap_or_default(); + config.allow_all_outside_access = true; + let _ = routecode_sdk::utils::storage::save_workspace_config(&config); + + if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { + let mut tx_opt = tx_mutex.lock().await; + if let Some(tx) = tx_opt.take() { + let _ = tx.send(ConfirmationResponse::AllowWorkspace); + } + } + } + KeyCode::Char('d') | KeyCode::Char('D') | KeyCode::Esc => { + if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { + let mut tx_opt = tx_mutex.lock().await; + if let Some(tx) = tx_opt.take() { + let _ = tx.send(ConfirmationResponse::Deny); + } + } + } + KeyCode::Char('f') | KeyCode::Char('F') => { + app.inputting_command_feedback = true; + app.input.set_placeholder_text(" Tell agent (e.g. 'don't run without backup')..."); + } + _ => {} + } + } + return Ok(KeyEventResult::Continue); + } + + if app.pending_clear { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + app.history.clear(); + app.screen = Screen::Welcome; + app.history_scroll = 0; + app.pending_clear = false; + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + app.pending_clear = false; + } + _ => {} + } + return Ok(KeyEventResult::Continue); + } + if app.pending_exit { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + app.tasks.abort_all(); + app.is_generating = false; + app.active_tool = None; + if !app.history.is_empty() { + let session = routecode_sdk::utils::storage::Session { + messages: app.history.clone(), + model: app.current_model.clone(), + usage: app.orchestrator.usage.lock().await.clone(), + timestamp: chrono::Utc::now().timestamp(), + }; + let _ = routecode_sdk::utils::storage::save_session(&app.session_id, &session); + } + return Ok(KeyEventResult::Exit); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + app.pending_exit = false; + } + _ => {} + } + return Ok(KeyEventResult::Continue); + } + match key.code { + KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + app.show_menu = true; + app.menu_state.select(Some(0)); + app.update_filtered_commands(); + } + KeyCode::Char('a') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + if app.show_model_menu { app.show_model_menu = false; } + app.show_provider_menu = true; + app.menu_state.select(Some(0)); + } + KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + if app.is_generating { + app.tasks.abort_all(); + app.is_generating = false; + app.active_tool = None; + } + } + KeyCode::Char('l') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + app.history.clear(); + app.screen = Screen::Welcome; + app.history_scroll = 0; + } + KeyCode::Enter if key.modifiers.contains(event::KeyModifiers::SHIFT) || key.modifiers.contains(event::KeyModifiers::ALT) => { + app.input.insert_newline(); + } + KeyCode::Enter => { + let mut should_send = !is_burst; + if should_send { + let lines = app.input.lines(); + if let Some(last_line) = lines.last() { + if last_line.ends_with('\\') { + app.input.delete_char(); + app.input.insert_newline(); + should_send = false; + } + } + } + + if !should_send { + app.input.insert_newline(); + } else if app.show_menu { + if let Some(selected) = app.menu_state.selected() { + if let Some(cmd) = app.filtered_commands.get(selected) { + let name = cmd.name.to_string(); + app.show_menu = false; + app.input = TextArea::default(); + handle_command(app, &name).await; + } + } + } else if app.show_provider_menu { + if let Some(selected) = app.menu_state.selected() { + if let Some(p) = PROVIDERS.get(selected) { + app.pending_provider_id = Some(p.id.to_string()); + app.is_inputting_api_key = true; + app.api_key_input = TextArea::default(); + app.show_provider_menu = false; + if p.id == "cloudflare-workers" || p.id == "cloudflare-gateway" { + app.api_key_input_stage = ApiKeyInputStage::CloudflareAccountId; + } else { + app.api_key_input_stage = ApiKeyInputStage::ApiKey; + } + } + } + } else if app.show_settings_menu { + if let Some(selected) = app.menu_state.selected() { + if let Some(SettingsMenuItem::Option { key, val: _, .. }) = app.settings_items.get(selected) { + let key = key.clone(); + apply_settings_toggle(app, &key).await; + } + } + } else if app.show_model_menu { + if let Some(selected) = app.menu_state.selected() { + if let Some(ModelMenuItem::Model(model_info)) = app.filtered_models.get(selected) { + let model_info = model_info.clone(); + let provider_id = &model_info.provider_id; + let model_name = &model_info.name; + let mut config = app.orchestrator.config.lock().await; + let env_key = format!("{}_API_KEY", provider_id.to_uppercase().replace("-", "_")); + let api_key = std::env::var(env_key).ok().or_else(|| config.api_keys.get(provider_id).cloned()); + if let Some(key) = api_key { + config.model = model_name.clone(); + config.provider = provider_id.clone(); + config.recent_models.retain(|m| m.name != *model_name || m.provider_id != *provider_id); + config.recent_models.insert(0, model_info.clone()); + config.recent_models.truncate(3); + if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { + log::error!("Failed to save config: {}", e); + } + if app.provider_name.to_lowercase() != *provider_id { + let vertex_project = config.vertex_project.clone(); + let vertex_location = config.vertex_location.clone(); + drop(config); + let provider = if provider_id == "vertex" { + routecode_sdk::agents::resolve_provider_with_config(provider_id, key, &vertex_project, &vertex_location) + } else { + routecode_sdk::agents::resolve_provider(provider_id, key) + }; + app.provider_name = provider.name().to_string(); + app.current_provider_id = provider_id.clone(); + app.orchestrator.change_provider(provider).await; + } else { drop(config); } + app.current_model = model_name.clone(); + app.history.push(Message::system(format!("Switched to {} on {}", model_name, app.provider_name))); + app.show_model_menu = false; + } else { + app.history.push(Message::system(format!("Error: No API key for {}", provider_id))); + } + } + } + } else if app.is_inputting_api_key { + let input_value = app.api_key_input.lines().join("\n").trim().to_string(); + if !input_value.is_empty() { + match app.api_key_input_stage { + ApiKeyInputStage::ApiKey => { + if let Some(provider_id) = app.pending_provider_id.clone() { + if provider_id == "vertex" { + app.api_key_input_stage = ApiKeyInputStage::VertexLocation; + app.api_key_input = TextArea::default(); + app.api_key_input.set_placeholder_text(" Location (e.g. us-central1)..."); + } else { + app.pending_provider_id.take(); + let mut config = app.orchestrator.config.lock().await; + config.api_keys.insert(provider_id, input_value); + if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { + log::error!("Failed to save config: {}", e); + } + app.history.push(Message::system("API Key saved")); + app.is_inputting_api_key = false; + app.api_key_input_stage = ApiKeyInputStage::None; + } + } else { + app.is_inputting_api_key = false; + app.api_key_input_stage = ApiKeyInputStage::None; + } + } + ApiKeyInputStage::VertexLocation => { + if let Some(provider_id) = app.pending_provider_id.take() { + let location = input_value; + let api_key = app.api_key_input.lines().join("\n").trim().to_string(); + let mut config = app.orchestrator.config.lock().await; + config.vertex_project = "".to_string(); + config.vertex_location = location; + config.api_keys.insert(provider_id, api_key); + if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { + log::error!("Failed to save config: {}", e); + } + app.history.push(Message::system("Vertex AI credentials saved")); + } + app.is_inputting_api_key = false; + app.api_key_input_stage = ApiKeyInputStage::None; + } + ApiKeyInputStage::CloudflareAccountId => { + app.pending_account_id = Some(input_value); + app.api_key_input = TextArea::default(); + if app.pending_provider_id.as_deref() == Some("cloudflare-gateway") { + app.api_key_input_stage = ApiKeyInputStage::CloudflareGatewayId; + } else { app.api_key_input_stage = ApiKeyInputStage::CloudflareApiKey; } + } + ApiKeyInputStage::CloudflareGatewayId => { + app.pending_gateway_id = Some(input_value); + app.api_key_input = TextArea::default(); + app.api_key_input_stage = ApiKeyInputStage::CloudflareApiKey; + } + ApiKeyInputStage::CloudflareApiKey => { + if let Some(provider_id) = app.pending_provider_id.take() { + let account_id = app.pending_account_id.take().unwrap_or_default(); + let final_key = if provider_id == "cloudflare-gateway" { + let gateway_id = app.pending_gateway_id.take().unwrap_or_default(); + format!("{}:{}:{}", account_id, gateway_id, input_value) + } else { format!("{}:{}", account_id, input_value) }; + let mut config = app.orchestrator.config.lock().await; + config.api_keys.insert(provider_id.clone(), final_key); + if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { + log::error!("Failed to save config: {}", e); + } + app.history.push(Message::system(format!("Credentials saved for {}", provider_id))); + } + app.is_inputting_api_key = false; + app.api_key_input_stage = ApiKeyInputStage::None; + } + _ => { app.is_inputting_api_key = false; } + } + } else { + app.is_inputting_api_key = false; + app.api_key_input_stage = ApiKeyInputStage::None; + } + } else { + let input_text = app.input.lines().join("\n"); + if !input_text.trim().is_empty() { + if input_text.starts_with('/') { + handle_command(app, &input_text).await; + } else if !app.startup_ready { + app.startup_input_buffer.push(input_text.clone()); + app.history.push(Message::system(format!("Queued: {}", input_text))); + app.input = TextArea::default(); + } else { + let provider_id = &app.current_provider_id; + let env_key = format!("{}_API_KEY", provider_id.to_uppercase().replace("-", "_")); + let mut api_key = std::env::var(&env_key).ok(); + if api_key.is_none() && provider_id.starts_with("cloudflare") { + api_key = std::env::var("CLOUDFLARE_API_KEY").ok(); + } + if api_key.is_none() { + let config = app.orchestrator.config.lock().await; + api_key = config.api_keys.get(provider_id).cloned(); + } + + let has_valid_key = api_key.is_some_and(|k| !k.trim().is_empty()); + + if !has_valid_key && super::types::provider_requires_api_key(provider_id) { + app.history.push(Message::system(format!("No API key found for {}. Please enter it to continue.", provider_id))); + app.show_provider_menu = true; + if let Some(pos) = PROVIDERS.iter().position(|p| p.id == *provider_id) { + app.menu_state.select(Some(pos)); + } else { + app.menu_state.select(Some(0)); + } + app.input = TextArea::default(); + return Ok(KeyEventResult::Continue); + } + + app.history.push(Message::user(input_text.clone())); + app.prompt_history.push(input_text.clone()); + app.prompt_history.truncate(100); + app.prompt_history_index = None; + app.input = TextArea::default(); + app.screen = Screen::Session; + app.is_generating = true; + app.auto_scroll = true; + let orchestrator = app.orchestrator.clone(); + let mut history = app.history.clone(); + let model = app.current_model.clone(); + let tx = app.tx.clone(); + app.tasks.spawn(async move { + if let Err(e) = orchestrator.run(&mut history, &model, Some(tx), None).await { + log::error!("Orchestrator run failed: {}", e); + } + }); + } + } + } + } + KeyCode::Esc => { + if app.show_menu { app.show_menu = false; } + else if app.show_provider_menu { app.show_provider_menu = false; } + else if app.show_model_menu { app.show_model_menu = false; } + else if app.show_settings_menu { app.show_settings_menu = false; } + else if app.is_inputting_api_key { + app.is_inputting_api_key = false; + app.api_key_input_stage = ApiKeyInputStage::None; + app.pending_account_id = None; + app.pending_gateway_id = None; + } else if app.is_generating { + app.tasks.abort_all(); + app.is_generating = false; + app.active_tool = None; + } else { + app.pending_exit = true; + } + } + KeyCode::Char('t') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + app.auto_scroll = !app.auto_scroll; + app.history.push(Message::system(format!("Auto-scroll {}", if app.auto_scroll { "enabled" } else { "disabled" }))); + } + KeyCode::Char('o') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + app.collapse_thinking = !app.collapse_thinking; + } + KeyCode::End => { + app.auto_scroll = true; + app.history_scroll = app.max_scroll; + } + KeyCode::Up if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + let (row, _) = app.input.cursor(); + if row == 0 && app.input.lines().len() == 1 && app.input.lines()[0].is_empty() && !app.prompt_history.is_empty() { + let idx = match app.prompt_history_index { + Some(i) => if i == 0 { 0 } else { i - 1 }, + None => app.prompt_history.len() - 1, + }; + app.prompt_history_index = Some(idx); + let prev = app.prompt_history[idx].clone(); + app.input = TextArea::from(prev.lines().map(|s| s.to_string())); + app.input.move_cursor(tui_textarea::CursorMove::End); + } + } + KeyCode::Down if key.modifiers.contains(event::KeyModifiers::CONTROL) => { + let (row, _) = app.input.cursor(); + let lines_len = app.input.lines().len(); + if row >= lines_len - 1 && app.prompt_history_index.is_some() { + let idx = app.prompt_history_index.unwrap(); + if idx >= app.prompt_history.len() - 1 { + app.prompt_history_index = None; + app.input = TextArea::default(); + } else { + let new_idx = idx + 1; + app.prompt_history_index = Some(new_idx); + let next = app.prompt_history[new_idx].clone(); + app.input = TextArea::from(next.lines().map(|s| s.to_string())); + app.input.move_cursor(tui_textarea::CursorMove::End); + } + } + } + KeyCode::Up => { + if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { + let items_len = if app.show_menu { app.filtered_commands.len() } + else if app.show_provider_menu { PROVIDERS.len() } + else if app.show_settings_menu { app.settings_items.len() } + else { app.filtered_models.len() }; + if items_len > 0 { + let selected = app.menu_state.selected().unwrap_or(0); + let mut new_selected = if selected == 0 { items_len - 1 } else { selected - 1 }; + if app.show_model_menu { + while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(new_selected) { + new_selected = if new_selected == 0 { items_len - 1 } else { new_selected - 1 }; + if new_selected == selected { break; } + } + } else if app.show_settings_menu { + while let Some(SettingsMenuItem::Header(_)) = app.settings_items.get(new_selected) { + new_selected = if new_selected == 0 { items_len - 1 } else { new_selected - 1 }; + if new_selected == selected { break; } + } + } + app.menu_state.select(Some(new_selected)); + } + } else if app.input.lines().len() == 1 && app.input.lines()[0].is_empty() || app.history_scroll > 0 || app.is_generating || key.modifiers.contains(event::KeyModifiers::SHIFT) { + app.history_scroll = app.history_scroll.saturating_sub(15); + app.auto_scroll = false; + } else { + app.input.input(Event::Key(key)); + } + } + KeyCode::Down => { + if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { + let items_len = if app.show_menu { app.filtered_commands.len() } + else if app.show_provider_menu { PROVIDERS.len() } + else if app.show_settings_menu { app.settings_items.len() } + else { app.filtered_models.len() }; + if items_len > 0 { + let selected = app.menu_state.selected().unwrap_or(0); + let mut new_selected = if selected >= items_len - 1 { 0 } else { selected + 1 }; + if app.show_model_menu { + while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(new_selected) { + new_selected = if new_selected >= items_len - 1 { 0 } else { new_selected + 1 }; + if new_selected == selected { break; } + } + } else if app.show_settings_menu { + while let Some(SettingsMenuItem::Header(_)) = app.settings_items.get(new_selected) { + new_selected = if new_selected >= items_len - 1 { 0 } else { new_selected + 1 }; + if new_selected == selected { break; } + } + } + app.menu_state.select(Some(new_selected)); + } + } else if app.input.lines().len() == 1 && app.input.lines()[0].is_empty() || app.history_scroll < app.max_scroll || app.is_generating || key.modifiers.contains(event::KeyModifiers::SHIFT) { + app.history_scroll = app.history_scroll.saturating_add(15); + if app.history_scroll >= app.max_scroll { app.auto_scroll = true; } + } else { + app.input.input(Event::Key(key)); + } + } + KeyCode::Right if app.show_model_menu => { + let len = app.filtered_models.len(); + if len > 0 { + let current = app.menu_state.selected().unwrap_or(0); + let mut next_header_idx = None; + for i in (current + 1)..len { if let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(i) { next_header_idx = Some(i); break; } } + if next_header_idx.is_none() { for i in 0..current { if let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(i) { next_header_idx = Some(i); break; } } } + if let Some(h_idx) = next_header_idx { + let mut target = (h_idx + 1) % len; + while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(target) { target = (target + 1) % len; if target == h_idx { break; } } + app.menu_state.select(Some(target)); + } + } + } + KeyCode::Left if app.show_model_menu => { + let len = app.filtered_models.len(); + if len > 0 { + let current = app.menu_state.selected().unwrap_or(0); + let mut headers = Vec::new(); + for (i, item) in app.filtered_models.iter().enumerate() { if let ModelMenuItem::Header(_) = item { headers.push(i); } } + if !headers.is_empty() { + let current_header_idx_in_headers = headers.iter().enumerate().rev().find(|(_, &h_idx)| h_idx < current).map(|(i, _)| i); + let target_header_idx = match current_header_idx_in_headers { Some(i) => if i == 0 { *headers.last().unwrap() } else { headers[i - 1] }, None => *headers.last().unwrap() }; + let mut target = (target_header_idx + 1) % len; + while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(target) { target = (target + 1) % len; if target == target_header_idx { break; } } + app.menu_state.select(Some(target)); + } + } + } + KeyCode::Char('f') if key.modifiers.contains(event::KeyModifiers::CONTROL) && app.show_model_menu => { + if let Some(selected) = app.menu_state.selected() { + if let Some(ModelMenuItem::Model(model_info)) = app.filtered_models.get(selected) { + let model_info = model_info.clone(); + let mut config = app.orchestrator.config.lock().await; + if config.favorites.iter().any(|m| m.name == model_info.name && m.provider_id == model_info.provider_id) { config.favorites.retain(|m| m.name != model_info.name || m.provider_id != model_info.provider_id); app.history.push(Message::system(format!("Removed {} from favorites", model_info.name))); } + else { config.favorites.push(model_info.clone()); app.history.push(Message::system(format!("Added {} to favorites", model_info.name))); } + if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { + log::error!("Failed to save config: {}", e); + } + } + } + } + KeyCode::BackTab => { + app.approval_mode = app.approval_mode.next(); + let info = match app.approval_mode { + ApprovalMode::YOLO => "YOLO -- commands will auto-approve", + ApprovalMode::Plan => "PLAN -- tool calls will be denied (read-only review)", + ApprovalMode::Shell => "SHELL -- shell commands shown first, auto-approved", + ApprovalMode::Normal => "Normal mode -- confirm each tool call", + }; + app.history.push(Message::system(format!("Mode: {}", info))); + } + _ => { + let event = Event::Key(key); + if app.is_inputting_api_key { app.api_key_input.input(event); } + else if app.show_model_menu { if app.model_search_input.input(event) { let search = app.model_search_input.lines().first().map(|l| l.trim().to_lowercase()).unwrap_or_default(); handle_model_search(app, &search, true).await; } } + else { app.input.input(event); app.update_filtered_commands(); } + } + } + Ok(KeyEventResult::Continue) +} + +pub(crate) async fn handle_mouse_event( + app: &mut App, + mouse: event::MouseEvent, + terminal: &mut Terminal, +) -> io::Result<()> { + app.mouse_events_count += 1; + // Always store current mouse position for render-time hover detection + app.mouse_row = Some(mouse.row); + app.mouse_col = Some(mouse.column); + match mouse.kind { + MouseEventKind::Moved => { + app.mouse_moved = true; + } + MouseEventKind::ScrollUp => { + if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { + let mut current = app.menu_state.selected().unwrap_or(0); + current = current.saturating_sub(3); + if app.show_model_menu { + while current > 0 && matches!(app.filtered_models.get(current), Some(ModelMenuItem::Header(_))) { + current -= 1; + } + } else if app.show_settings_menu { + while current > 0 && matches!(app.settings_items.get(current), Some(SettingsMenuItem::Header(_))) { + current -= 1; + } + } + app.menu_state.select(Some(current)); + } else { + app.history_scroll = app.history_scroll.saturating_sub(15); + app.auto_scroll = false; + } + } + MouseEventKind::ScrollDown => { + if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { + let current = app.menu_state.selected().unwrap_or(0); + let max = if app.show_menu { + app.filtered_commands.len() + } else if app.show_provider_menu { + PROVIDERS.len() + } else if app.show_settings_menu { + app.settings_items.len() + } else { + app.filtered_models.len() + }; + let mut next = current.saturating_add(3).min(max.saturating_sub(1)); + if app.show_model_menu { + while next < max - 1 && matches!(app.filtered_models.get(next), Some(ModelMenuItem::Header(_))) { + next += 1; + } + } else if app.show_settings_menu { + while next < max - 1 && matches!(app.settings_items.get(next), Some(SettingsMenuItem::Header(_))) { + next += 1; + } + } + app.menu_state.select(Some(next)); + } else { + app.history_scroll = app.history_scroll.saturating_add(15); + if app.history_scroll >= app.max_scroll { app.auto_scroll = true; } + } + } + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) => { + if let Some(msg_idx) = app.show_user_msg_modal { + if let Ok(size) = terminal.size() { + let width = (size.width as f32 * 0.40) as u16; + let height = 8; + let modal_x = (size.width.saturating_sub(width)) / 2; + let modal_y = (size.height.saturating_sub(height)) / 2; + + let is_outside = mouse.column < modal_x || mouse.column >= modal_x + width || mouse.row < modal_y || mouse.row >= modal_y + height; + + if is_outside { + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + app.show_user_msg_modal = None; + } + } else if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) { + let click_row = mouse.row; + if click_row == modal_y + 2 { + app.user_msg_modal_selected = 0; + let text = app.history[msg_idx].content.as_ref().map(|s| s.to_string()).unwrap_or_default(); + let text_clone = text.clone(); + tokio::task::spawn_blocking(move || { + if let Err(e) = copy_to_clipboard(&text_clone) { + log::error!("Clipboard copy failed: {}", e); + } + }); + app.history.push(Message::system("Message copied to clipboard!".to_string())); + app.show_user_msg_modal = None; + } else if click_row == modal_y + 3 { + app.user_msg_modal_selected = 1; + let text = app.history[msg_idx].content.as_ref().map(|s| s.to_string()).unwrap_or_default(); + app.history.truncate(msg_idx); + app.input = tui_textarea::TextArea::from(text.lines().map(|s| s.to_string())); + app.input.move_cursor(tui_textarea::CursorMove::End); + app.show_user_msg_modal = None; + } + } + } + } else if app.pending_update.is_some() { + if let Ok(size) = terminal.size() { + let width = (size.width as f32 * 0.50) as u16; + let height = 8; + let modal_x = (size.width.saturating_sub(width)) / 2; + let modal_y = (size.height.saturating_sub(height)) / 2; + + let is_outside = mouse.column < modal_x || mouse.column >= modal_x + width || mouse.row < modal_y || mouse.row >= modal_y + height; + + if is_outside { + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + app.pending_update = None; + } + } else if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + && mouse.row == modal_y + height.saturating_sub(2) { + if mouse.column >= modal_x + width.saturating_sub(25) && mouse.column < modal_x + width.saturating_sub(15) { + app.pending_update = None; + } else if mouse.column >= modal_x + width.saturating_sub(15) && mouse.column < modal_x + width { + app.pending_update_install = true; + } + } + } + } else if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { + if let Ok(size) = terminal.size() { + let (width, height) = if app.show_menu { + (60, (app.filtered_commands.len() + 6).min(15) as u16) + } else if app.show_provider_menu { + (60, (PROVIDERS.len() + 6).min(15) as u16) + } else if app.show_settings_menu { + (60, (app.settings_items.len() + 6).min(15) as u16) + } else { + (70, (app.filtered_models.len() + 7).min(18) as u16) + }; + let modal_x = (size.width.saturating_sub(width)) / 2; + let modal_y = (size.height.saturating_sub(height)) / 2; + + let is_outside = mouse.column < modal_x || mouse.column >= modal_x + width || mouse.row < modal_y || mouse.row >= modal_y + height; + let is_esc = mouse.row <= modal_y + 2 && mouse.column >= modal_x + width.saturating_sub(10) && mouse.column <= modal_x + width; + let is_inside_list = mouse.row >= modal_y + 2 && mouse.row < modal_y + height - 1 && mouse.column > modal_x && mouse.column < modal_x + width - 1; + + if is_outside || is_esc { + app.show_menu = false; + app.show_provider_menu = false; + app.show_model_menu = false; + app.show_settings_menu = false; + } else if is_inside_list && matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + && app.show_settings_menu { + let idx = (mouse.row - (modal_y + 2)) as usize + app.menu_state.offset(); + if idx < app.settings_items.len() { + if let Some(SettingsMenuItem::Option { key, val: _, .. }) = app.settings_items.get(idx) { + let key = key.clone(); + apply_settings_toggle(app, &key).await; + } + } + } + } + } else if app.screen == Screen::Session { + let has_thinking = app.history.iter().any(|m| m.thought.is_some()); + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) { + if let Ok(size) = terminal.size() { + if let Some(msg_idx) = compute_message_hover(app, size) { + if app.history[msg_idx].role == Role::User { + app.show_user_msg_modal = Some(msg_idx); + app.user_msg_modal_selected = 0; + return Ok(()); + } + } + } + } + + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + let in_cooldown = app.last_toggle_time.is_some_and(|t| t.elapsed() < std::time::Duration::from_millis(400)); + + if !in_cooldown && has_thinking { + let is_double_click = if let Some((last_time, col, row)) = app.last_click_up { + let col_diff = (col as i32 - mouse.column as i32).abs(); + let row_diff = (row as i32 - mouse.row as i32).abs(); + last_time.elapsed() < std::time::Duration::from_millis(600) && col_diff <= 4 && row_diff <= 3 + } else { + false + }; + + if is_double_click { + app.collapse_thinking = !app.collapse_thinking; + app.last_click_up = None; + app.mouse_down_start = None; + app.last_toggle_time = Some(std::time::Instant::now()); + } else if let Ok(size) = terminal.size() { + // Compute hover FRESH with current mouse position + let hover = compute_thinking_hover(app, size); + if hover { + app.last_click_up = Some((std::time::Instant::now(), mouse.column, mouse.row)); + app.mouse_down_start = Some((std::time::Instant::now(), mouse.column, mouse.row)); + } else { + app.last_click_up = None; + } + } + } + } + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) { + app.mouse_down_start = None; + app.temp_expand_thinking = false; + } + } else if app.screen == Screen::Welcome && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { + if let Ok(size) = terminal.size() { + let logo_height = if size.height < 20 { 0 } else { 6 }; + let spacer_height = if size.height < 15 { 0 } else { size.height / 3 }; + if logo_height > 0 && mouse.row >= spacer_height && mouse.row < spacer_height + logo_height { + app.logo_anim_frames = 20; // 2 seconds at 100ms tick + } + } + } + } + _ => {} + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use routecode_sdk::agents::AIProvider; + use routecode_sdk::agents::traits::StreamResponse; + use routecode_sdk::core::{AgentOrchestrator, Config}; + use routecode_sdk::tools::ToolRegistry; + use std::sync::Arc; + use tokio::sync::Mutex; + + struct MockProvider; + #[async_trait] + impl AIProvider for MockProvider { + fn name(&self) -> &str { "Mock" } + async fn list_models(&self) -> Result, anyhow::Error> { Ok(vec![]) } + async fn ask(&self, _: Arc>, _: &str, _: Arc>>, _: Option<&str>) -> Result { + Err(anyhow::anyhow!("Not implemented")) + } + } + + #[tokio::test] + async fn test_user_msg_modal_rewind() { + let orchestrator = Arc::new(AgentOrchestrator::new( + Arc::new(MockProvider), + Arc::new(ToolRegistry::new()), + Arc::new(Mutex::new(Config::default())), + )); + let mut app = App::new(orchestrator, "Mock".to_string(), "gpt-4o".to_string()); + + app.history.push(Message::user("First message".to_string())); + app.history.push(Message::assistant(Some("Assistant reply".into()), None, None)); + app.history.push(Message::user("Second message".to_string())); + + app.show_user_msg_modal = Some(2); + app.user_msg_modal_selected = 1; + + let enter_key = event::KeyEvent::new(event::KeyCode::Enter, event::KeyModifiers::empty()); + let res = handle_key_event(&mut app, enter_key, false).await.unwrap(); + + assert_eq!(res, KeyEventResult::Continue); + assert_eq!(app.show_user_msg_modal, None); + assert_eq!(app.history.len(), 2); + assert_eq!(app.history[0].role, Role::User); + assert_eq!(app.history[1].role, Role::Assistant); + assert_eq!(app.input.lines()[0], "Second message"); + } + + #[tokio::test] + async fn test_update_system_modal() { + let orchestrator = Arc::new(AgentOrchestrator::new( + Arc::new(MockProvider), + Arc::new(ToolRegistry::new()), + Arc::new(Mutex::new(Config::default())), + )); + let mut app = App::new(orchestrator, "Mock".to_string(), "gpt-4o".to_string()); + + app.pending_update = Some("v1.15.4".to_string()); + app.update_modal_selected = 1; + + let left_key = event::KeyEvent::new(event::KeyCode::Left, event::KeyModifiers::empty()); + let res = handle_key_event(&mut app, left_key, false).await.unwrap(); + assert_eq!(res, KeyEventResult::Continue); + assert_eq!(app.update_modal_selected, 0); + + let enter_key = event::KeyEvent::new(event::KeyCode::Enter, event::KeyModifiers::empty()); + let res = handle_key_event(&mut app, enter_key, false).await.unwrap(); + assert_eq!(res, KeyEventResult::Continue); + assert_eq!(app.pending_update, None); + assert!(!app.pending_update_install); + + app.pending_update = Some("v1.15.4".to_string()); + app.update_modal_selected = 1; + let res = handle_key_event(&mut app, enter_key, false).await.unwrap(); + assert_eq!(res, KeyEventResult::Exit); + assert!(app.pending_update_install); + } +} diff --git a/apps/cli/src/ui/menus.rs b/apps/cli/src/ui/menus.rs index f4f5da7..9e39de1 100644 --- a/apps/cli/src/ui/menus.rs +++ b/apps/cli/src/ui/menus.rs @@ -70,7 +70,6 @@ pub fn render_api_key_dialog(f: &mut Frame, app: &mut App) { ApiKeyInputStage::CloudflareAccountId => ("Enter Cloudflare Account ID:".to_string(), " Account ID..."), ApiKeyInputStage::CloudflareGatewayId => ("Enter Cloudflare Gateway ID:".to_string(), " Gateway ID..."), ApiKeyInputStage::CloudflareApiKey => ("Enter Cloudflare API Token:".to_string(), " API Token..."), - ApiKeyInputStage::VertexProject => ("Enter your GCP project ID:".to_string(), " my-project-123..."), ApiKeyInputStage::VertexLocation => ("Enter GCP location (us-central1, europe-west4, us):".to_string(), " us-central1..."), _ => (format!("Enter API key for {}:", provider_name), " Paste your API key here..."), }; diff --git a/apps/cli/src/ui/mod.rs b/apps/cli/src/ui/mod.rs index d1bf4b8..f1ed86c 100644 --- a/apps/cli/src/ui/mod.rs +++ b/apps/cli/src/ui/mod.rs @@ -1,19 +1,8 @@ -use crossterm::event::{self, Event, KeyCode, KeyEventKind, MouseEventKind, MouseButton}; -use ratatui::{ - layout::{Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, ListState, Paragraph}, - Frame, Terminal, -}; -use routecode_sdk::agents::types::ConfirmationResponse; -use routecode_sdk::agents::StreamChunk; -use routecode_sdk::core::{AgentOrchestrator, Message, Role, DynamicModelInfo}; -use routecode_sdk::utils::costs::Usage; -use std::io; -use std::sync::Arc; -use tokio::task::JoinSet; -use tui_textarea::TextArea; +pub mod app; +pub mod types; +pub mod events; +pub mod streaming; +pub mod render; pub mod components; pub mod welcome; @@ -21,1973 +10,7 @@ pub mod session; pub mod menus; pub mod logic; +pub use app::*; +pub use types::*; +pub use render::*; pub use components::*; -pub use logic::*; -pub use menus::*; -pub use session::*; -pub use welcome::*; - -type ConfirmationSender = std::sync::Arc>>>; - -pub struct ProviderInfo { - pub id: &'static str, - pub name: &'static str, -} - -pub const PROVIDERS: &[ProviderInfo] = &[ - ProviderInfo { id: "openrouter", name: "OpenRouter" }, - ProviderInfo { id: "nvidia", name: "NVIDIA" }, - ProviderInfo { id: "opencode-zen", name: "OpenCode Zen" }, - ProviderInfo { id: "opencode-go", name: "OpenCode Go" }, - ProviderInfo { id: "openai", name: "OpenAI" }, - ProviderInfo { id: "anthropic", name: "Anthropic" }, - ProviderInfo { id: "gemini", name: "Google Gemini" }, - ProviderInfo { id: "deepseek", name: "DeepSeek" }, - ProviderInfo { id: "cloudflare-workers", name: "Cloudflare Workers AI" }, - ProviderInfo { id: "cloudflare-gateway", name: "Cloudflare AI Gateway" }, - ProviderInfo { id: "vertex", name: "Google Vertex AI" }, -]; - -#[derive(Clone, Debug)] -pub enum ModelMenuItem { - Header(String), - Model(DynamicModelInfo), -} - -pub struct Command { - pub name: &'static str, - pub description: &'static str, -} - -pub const COMMANDS: &[Command] = &[ - Command { name: "/model", description: "Switch model" }, - Command { name: "/resume", description: "Resume a session" }, - Command { name: "/sessions", description: "List saved sessions" }, - Command { name: "/clear", description: "Clear history" }, - Command { name: "/thinking", description: "Set thinking level (low/max)" }, - Command { name: "/help", description: "Show help" }, - Command { name: "/stop", description: "Stop AI generation" }, - Command { name: "/provider", description: "Manage providers" }, - Command { name: "/settings", description: "Manage settings" }, - Command { name: "/exit", description: "Exit application" }, -]; - -#[derive(Debug, PartialEq, Clone, Copy)] -#[allow(clippy::upper_case_acronyms)] -pub enum ApprovalMode { - Normal, - Plan, - YOLO, - Shell, -} - -impl ApprovalMode { - pub fn next(&self) -> Self { - match self { - ApprovalMode::Normal => ApprovalMode::Plan, - ApprovalMode::Plan => ApprovalMode::YOLO, - ApprovalMode::YOLO => ApprovalMode::Shell, - ApprovalMode::Shell => ApprovalMode::Normal, - } - } - - pub fn label(&self) -> &'static str { - match self { - ApprovalMode::Normal => "NORMAL", - ApprovalMode::Plan => "PLAN", - ApprovalMode::YOLO => "YOLO", - ApprovalMode::Shell => "SHELL", - } - } -} - -#[derive(Debug, PartialEq)] -pub enum Screen { - Welcome, - Session, -} - -#[derive(Debug, PartialEq, Clone, Copy)] -pub enum ApiKeyInputStage { - None, - ApiKey, - VertexProject, - VertexLocation, - CloudflareAccountId, - CloudflareGatewayId, - CloudflareApiKey, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum SettingsMenuItem { - Header(String), - Option { name: String, val: String, key: String }, -} - -pub struct App { - pub screen: Screen, - pub input: TextArea<'static>, - pub history: Vec, - pub orchestrator: Arc, - pub current_model: String, - pub current_provider_id: String, - pub provider_name: String, - pub show_menu: bool, - pub show_provider_menu: bool, - pub show_model_menu: bool, - pub show_settings_menu: bool, - pub menu_state: ListState, - pub filtered_commands: Vec<&'static Command>, - pub filtered_models: Vec, - pub all_available_models: Vec, - pub history_scroll: u16, - pub max_scroll: u16, - pub auto_scroll: bool, - pub is_generating: bool, - pub tick_count: u64, - pub active_tool: Option, - pub tasks: JoinSet<()>, - pub prompt_history: Vec, - pub prompt_history_index: Option, - pub api_key_input: TextArea<'static>, - pub model_search_input: TextArea<'static>, - pub is_inputting_api_key: bool, - pub pending_provider_id: Option, - pub api_key_input_stage: ApiKeyInputStage, - pub pending_account_id: Option, - pub pending_gateway_id: Option, - pub pending_clear: bool, - pub pending_exit: bool, - pub is_fetching_models: bool, - pub collapse_thinking: bool, - pub mouse_row: Option, - pub mouse_col: Option, - pub mouse_moved: bool, - pub mouse_events_count: u64, - pub logo_anim_frames: u16, - pub rx: tokio::sync::mpsc::UnboundedReceiver, - pub tx: tokio::sync::mpsc::UnboundedSender, - pub settings_items: Vec, - pub last_click_up: Option<(std::time::Instant, u16, u16)>, - pub mouse_down_start: Option<(std::time::Instant, u16, u16)>, - pub temp_expand_thinking: bool, - pub last_toggle_time: Option, - pub thinking_hover_rendered: bool, - pub usage: Usage, - pub cached_history_len: usize, - pub cached_width: u16, - pub cached_is_collapsed: bool, - pub cached_thinking_hovered: bool, - pub cached_total_height: usize, - pub cached_text: Option>, - pub cached_layout: Vec<(usize, bool)>, - pub pending_command_confirmation: Option<(String, String, ConfirmationSender)>, - pub inputting_command_feedback: bool, - pub show_user_msg_modal: Option, - pub user_msg_modal_selected: usize, - pub cached_hovered_msg_idx: Option, - pub session_id: String, - pub pending_update: Option, - pub pending_update_changelog: String, - pub pending_update_published_at: String, - pub update_modal_selected: usize, - pub pending_update_install: bool, - pub render_dirty: bool, - pub last_cache_update: std::time::Instant, - pub approval_mode: ApprovalMode, - pub startup_input_buffer: Vec, - pub startup_ready: bool, - pub hide_cwd: bool, - pub hide_model_info: bool, - pub hide_context_summary: bool, -} - -impl App { - pub fn new(orchestrator: Arc, provider_name: String, default_model: String) -> Self { - let mut input = TextArea::default(); - input.set_cursor_line_style(Style::default()); - input.set_placeholder_style(Style::default().fg(COLOR_SECONDARY)); - input.set_placeholder_text(" Ask anything... \"How do I use this?\""); - - let mut api_key_input = TextArea::default(); - api_key_input.set_cursor_line_style(Style::default()); - api_key_input.set_placeholder_text(" Paste your API key here..."); - - let mut model_search_input = TextArea::default(); - model_search_input.set_cursor_line_style(Style::default()); - model_search_input.set_placeholder_text(" Search models..."); - model_search_input.set_placeholder_style(Style::default().fg(COLOR_SECONDARY)); - - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - - Self { - screen: Screen::Welcome, - input, - history: Vec::new(), - orchestrator, - current_model: default_model, - current_provider_id: provider_name.clone(), - provider_name, - show_menu: false, - show_provider_menu: false, - show_model_menu: false, - show_settings_menu: false, - menu_state: ListState::default(), - filtered_commands: Vec::new(), - filtered_models: Vec::new(), - all_available_models: Vec::new(), - settings_items: Vec::new(), - history_scroll: 0, - max_scroll: 0, - auto_scroll: true, - is_generating: false, - tick_count: 0, - active_tool: None, - tasks: JoinSet::new(), - prompt_history: Vec::new(), - prompt_history_index: None, - api_key_input, - model_search_input, - is_inputting_api_key: false, - pending_provider_id: None, - api_key_input_stage: ApiKeyInputStage::None, - pending_account_id: None, - pending_gateway_id: None, - pending_clear: false, - pending_exit: false, - is_fetching_models: false, - collapse_thinking: false, - mouse_row: None, - mouse_col: None, - mouse_moved: false, - mouse_events_count: 0, - logo_anim_frames: 0, - rx, - tx, - usage: Usage::default(), - last_click_up: None, - mouse_down_start: None, - temp_expand_thinking: false, - last_toggle_time: None, - thinking_hover_rendered: false, - cached_history_len: 0, - cached_width: 0, - cached_is_collapsed: false, - cached_thinking_hovered: false, - cached_total_height: 0, - cached_text: None, - cached_layout: Vec::new(), - pending_command_confirmation: None, - inputting_command_feedback: false, - show_user_msg_modal: None, - user_msg_modal_selected: 0, - cached_hovered_msg_idx: None, - session_id: format!("session_{}", uuid::Uuid::new_v4()), - pending_update: None, - pending_update_changelog: String::new(), - pending_update_published_at: String::new(), - update_modal_selected: 1, - pending_update_install: false, - render_dirty: true, - last_cache_update: std::time::Instant::now(), - approval_mode: ApprovalMode::Normal, - startup_input_buffer: Vec::new(), - startup_ready: false, - hide_cwd: false, - hide_model_info: false, - hide_context_summary: false, - } - } - - pub async fn populate_settings(&mut self) { - let config = self.orchestrator.config.lock().await; - self.settings_items = vec![ - SettingsMenuItem::Header("Appearance".to_string()), - SettingsMenuItem::Option { - name: "Logo Animation".to_string(), - val: config.logo_animation.clone(), - key: "logo_animation".to_string(), - }, - SettingsMenuItem::Option { - name: "Animation Theme".to_string(), - val: config.logo_animation_color.clone(), - key: "logo_animation_color".to_string(), - }, - SettingsMenuItem::Header("Footer".to_string()), - SettingsMenuItem::Option { - name: "Show Model Info".to_string(), - val: if self.hide_model_info { "hide" } else { "show" }.to_string(), - key: "hide_model_info".to_string(), - }, - SettingsMenuItem::Option { - name: "Show Context Summary".to_string(), - val: if self.hide_context_summary { "hide" } else { "show" }.to_string(), - key: "hide_context_summary".to_string(), - }, - SettingsMenuItem::Option { - name: "Show Directory".to_string(), - val: if self.hide_cwd { "hide" } else { "show" }.to_string(), - key: "hide_cwd".to_string(), - }, - ]; - } - - pub fn update_filtered_commands(&mut self) { - let input_line = self.input.lines().first().map(|l| l.to_lowercase()).unwrap_or_default(); - if input_line.starts_with('/') { - self.filtered_commands = COMMANDS - .iter() - .filter(|c| c.name.to_lowercase().starts_with(&input_line)) - .collect(); - self.show_menu = !self.filtered_commands.is_empty(); - if self.show_menu { - self.menu_state.select(Some(0)); - } - } else { - self.show_menu = false; - } - } -} - -/// Compute whether the mouse is hovering over a thinking block, accounting for text wrapping. -/// Uses the same wrapping calculation as the auto-scroll logic in ui_session. -pub fn compute_thinking_hover(app: &App, size: ratatui::layout::Rect) -> bool { - let mouse_row = match app.mouse_row { - Some(r) => r, - None => return false, - }; - if app.screen != Screen::Session { - return false; - } - let has_thinking = app.history.iter().any(|m| m.thought.is_some()); - if !has_thinking { - return false; - } - - // Compute layout: header=1 row, then history area, then input, then status bar - let input_height = (app.input.lines().len() as u16 + 2).min(12); - // area starts at row 1 (after header). History is area minus input and status. - let area_height = size.height.saturating_sub(1); // main area below header - let history_height = area_height.saturating_sub(input_height).saturating_sub(1); - - // Check mouse is in history area (row 1 to 1+history_height exclusive) - if mouse_row < 1 || mouse_row > history_height { - return false; - } - - // The visual row within the history viewport (0-indexed from top of visible area) - let viewport_row = mouse_row - 1; - // The absolute visual row including scroll - let target_visual_row = viewport_row as usize + app.history_scroll as usize; - - if let Some(&(_, is_thinking)) = app.cached_layout.get(target_visual_row) { - return is_thinking; - } - false -} - -/// Compute which message is hovered by the mouse. -pub fn compute_message_hover(app: &App, size: ratatui::layout::Rect) -> Option { - let mouse_row = app.mouse_row?; - if app.screen != Screen::Session { - return None; - } - - let input_height = (app.input.lines().len() as u16 + 2).min(12); - let area_height = size.height.saturating_sub(1); - let history_height = area_height.saturating_sub(input_height).saturating_sub(1); - - if mouse_row < 1 || mouse_row > history_height { - return None; - } - - let viewport_row = mouse_row - 1; - let target_visual_row = viewport_row as usize + app.history_scroll as usize; - - if let Some(&(msg_idx, _)) = app.cached_layout.get(target_visual_row) { - return Some(msg_idx); - } - - None -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum KeyEventResult { - Continue, - Exit, -} - -async fn handle_key_event( - app: &mut App, - key: event::KeyEvent, - is_burst: bool, -) -> io::Result { - if app.pending_update.is_some() { - match key.code { - KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => { - app.update_modal_selected = if app.update_modal_selected == 0 { 1 } else { 0 }; - } - KeyCode::Left | KeyCode::Char('h') => { - app.update_modal_selected = if app.update_modal_selected == 1 { 0 } else { 1 }; - } - KeyCode::Enter => { - if app.update_modal_selected == 1 { - app.pending_update_install = true; - return Ok(KeyEventResult::Exit); - } else { - app.pending_update = None; - } - } - KeyCode::Esc => { - app.pending_update = None; - } - _ => {} - } - return Ok(KeyEventResult::Continue); - } - if let Some(msg_idx) = app.show_user_msg_modal { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - app.user_msg_modal_selected = if app.user_msg_modal_selected == 0 { 1 } else { 0 }; - } - KeyCode::Down | KeyCode::Char('j') => { - app.user_msg_modal_selected = if app.user_msg_modal_selected == 1 { 0 } else { 1 }; - } - KeyCode::Enter => { - let text = app.history[msg_idx].content.as_ref().map(|s| s.to_string()).unwrap_or_default(); - if app.user_msg_modal_selected == 0 { - let text_clone = text.clone(); - tokio::task::spawn_blocking(move || { - if let Err(e) = copy_to_clipboard(&text_clone) { - log::error!("Clipboard copy failed: {}", e); - } - }); - app.history.push(Message::system("Message copied to clipboard!".to_string())); - } else { - app.history.truncate(msg_idx); - app.input = tui_textarea::TextArea::from(text.lines().map(|s| s.to_string())); - app.input.move_cursor(tui_textarea::CursorMove::End); - } - app.show_user_msg_modal = None; - } - KeyCode::Esc => { - app.show_user_msg_modal = None; - } - _ => {} - } - return Ok(KeyEventResult::Continue); - } - if app.pending_command_confirmation.is_some() { - if app.inputting_command_feedback { - match key.code { - KeyCode::Esc => { - app.inputting_command_feedback = false; - app.input.delete_line_by_head(); - while app.input.cursor() != (0, 0) { - app.input.move_cursor(tui_textarea::CursorMove::Head); - app.input.delete_line_by_head(); - } - app.input.set_placeholder_text(" Ask anything... \"How do I use this?\""); - } - KeyCode::Enter => { - if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { - let lines = app.input.lines().to_vec(); - app.input.delete_line_by_head(); - while app.input.cursor() != (0, 0) { - app.input.move_cursor(tui_textarea::CursorMove::Head); - app.input.delete_line_by_head(); - } - app.input.set_placeholder_text(" Ask anything... \"How do I use this?\""); - - let msg = lines.join("\n").trim().to_string(); - let feedback = if msg.is_empty() { "Command cancelled.".to_string() } else { msg }; - - let mut tx_opt = tx_mutex.lock().await; - if let Some(tx) = tx_opt.take() { - let _ = tx.send(routecode_sdk::agents::types::ConfirmationResponse::Feedback(feedback)); - } - } - app.inputting_command_feedback = false; - } - _ => { - app.input.input(key); - } - } - } else { - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { - let mut tx_opt = tx_mutex.lock().await; - if let Some(tx) = tx_opt.take() { - let _ = tx.send(routecode_sdk::agents::types::ConfirmationResponse::AllowOnce); - } - } - } - KeyCode::Char('s') | KeyCode::Char('S') => { - let mut config = routecode_sdk::utils::storage::load_session_config(&app.session_id).unwrap_or_default(); - config.allow_all_commands = true; - let _ = routecode_sdk::utils::storage::save_session_config(&app.session_id, &config); - - if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { - let mut tx_opt = tx_mutex.lock().await; - if let Some(tx) = tx_opt.take() { - let _ = tx.send(routecode_sdk::agents::types::ConfirmationResponse::AllowSession); - } - } - } - KeyCode::Char('w') | KeyCode::Char('W') => { - let mut config = routecode_sdk::utils::storage::load_workspace_config().unwrap_or_default(); - config.allow_all_outside_access = true; - let _ = routecode_sdk::utils::storage::save_workspace_config(&config); - - if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { - let mut tx_opt = tx_mutex.lock().await; - if let Some(tx) = tx_opt.take() { - let _ = tx.send(routecode_sdk::agents::types::ConfirmationResponse::AllowWorkspace); - } - } - } - KeyCode::Char('d') | KeyCode::Char('D') | KeyCode::Esc => { - if let Some((_, _, tx_mutex)) = app.pending_command_confirmation.take() { - let mut tx_opt = tx_mutex.lock().await; - if let Some(tx) = tx_opt.take() { - let _ = tx.send(routecode_sdk::agents::types::ConfirmationResponse::Deny); - } - } - } - KeyCode::Char('f') | KeyCode::Char('F') => { - app.inputting_command_feedback = true; - app.input.set_placeholder_text(" Tell agent (e.g. 'don't run without backup')..."); - } - _ => {} - } - } - return Ok(KeyEventResult::Continue); - } - - if app.pending_clear { - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { - app.history.clear(); - app.screen = Screen::Welcome; - app.history_scroll = 0; - app.pending_clear = false; - } - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { - app.pending_clear = false; - } - _ => {} - } - return Ok(KeyEventResult::Continue); - } - if app.pending_exit { - match key.code { - KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { - app.tasks.abort_all(); - app.is_generating = false; - app.active_tool = None; - if !app.history.is_empty() { - let session = routecode_sdk::utils::storage::Session { - messages: app.history.clone(), - model: app.current_model.clone(), - usage: app.orchestrator.usage.lock().await.clone(), - timestamp: chrono::Utc::now().timestamp(), - }; - let _ = routecode_sdk::utils::storage::save_session(&app.session_id, &session); - } - return Ok(KeyEventResult::Exit); - } - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { - app.pending_exit = false; - } - _ => {} - } - return Ok(KeyEventResult::Continue); - } - match key.code { - KeyCode::Char('p') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - app.show_menu = true; - app.menu_state.select(Some(0)); - app.update_filtered_commands(); - } - KeyCode::Char('a') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - if app.show_model_menu { app.show_model_menu = false; } - app.show_provider_menu = true; - app.menu_state.select(Some(0)); - } - KeyCode::Char('c') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - if app.is_generating { - app.tasks.abort_all(); - app.is_generating = false; - app.active_tool = None; - } - } - KeyCode::Char('l') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - app.history.clear(); - app.screen = Screen::Welcome; - app.history_scroll = 0; - } - KeyCode::Enter if key.modifiers.contains(event::KeyModifiers::SHIFT) || key.modifiers.contains(event::KeyModifiers::ALT) => { - app.input.insert_newline(); - } - KeyCode::Enter => { - let mut should_send = !is_burst; - if should_send { - let lines = app.input.lines(); - if let Some(last_line) = lines.last() { - if last_line.ends_with('\\') { - app.input.delete_char(); - app.input.insert_newline(); - should_send = false; - } - } - } - - if !should_send { - app.input.insert_newline(); - } else if app.show_menu { - if let Some(selected) = app.menu_state.selected() { - if let Some(cmd) = app.filtered_commands.get(selected) { - let name = cmd.name.to_string(); - app.show_menu = false; - app.input = TextArea::default(); - handle_command(app, &name).await; - } - } - } else if app.show_provider_menu { - if let Some(selected) = app.menu_state.selected() { - if let Some(p) = PROVIDERS.get(selected) { - app.pending_provider_id = Some(p.id.to_string()); - app.is_inputting_api_key = true; - app.api_key_input = TextArea::default(); - app.show_provider_menu = false; - if p.id == "cloudflare-workers" || p.id == "cloudflare-gateway" { - app.api_key_input_stage = ApiKeyInputStage::CloudflareAccountId; - } else { - app.api_key_input_stage = ApiKeyInputStage::ApiKey; - } - } - } - } else if app.show_settings_menu { - if let Some(selected) = app.menu_state.selected() { - if let Some(SettingsMenuItem::Option { key, val: _, .. }) = app.settings_items.get(selected) { - if key == "logo_animation" { - let next_val = { - let config = app.orchestrator.config.lock().await; - match config.logo_animation.as_str() { - "always" => "hover", - "hover" => "click", - _ => "always", - } - }; - { - let mut config = app.orchestrator.config.lock().await; - config.logo_animation = next_val.to_string(); - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { - log::error!("Failed to save config: {}", e); -} - } - app.populate_settings().await; - } else if key == "logo_animation_color" { - let next_val = { - let config = app.orchestrator.config.lock().await; - match config.logo_animation_color.as_str() { - "rainbow" => "neon", - "neon" => "cyberpunk", - "cyberpunk" => "sunset", - "sunset" => "mono", - _ => "rainbow", - } - }; - { - let mut config = app.orchestrator.config.lock().await; - config.logo_animation_color = next_val.to_string(); - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { - log::error!("Failed to save config: {}", e); -} - } - app.populate_settings().await; - } else if key == "hide_cwd" { - app.hide_cwd = !app.hide_cwd; - app.populate_settings().await; - } else if key == "hide_model_info" { - app.hide_model_info = !app.hide_model_info; - app.populate_settings().await; - } else if key == "hide_context_summary" { - app.hide_context_summary = !app.hide_context_summary; - app.populate_settings().await; - } - } - } - } else if app.show_model_menu { - if let Some(selected) = app.menu_state.selected() { - if let Some(ModelMenuItem::Model(model_info)) = app.filtered_models.get(selected) { - let model_info = model_info.clone(); - let provider_id = &model_info.provider_id; - let model_name = &model_info.name; - let mut config = app.orchestrator.config.lock().await; - let env_key = format!("{}_API_KEY", provider_id.to_uppercase().replace("-", "_")); - let api_key = std::env::var(env_key).ok().or_else(|| config.api_keys.get(provider_id).cloned()); - if let Some(key) = api_key { - config.model = model_name.clone(); - config.provider = provider_id.clone(); - config.recent_models.retain(|m| m.name != *model_name || m.provider_id != *provider_id); - config.recent_models.insert(0, model_info.clone()); - config.recent_models.truncate(3); - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { - log::error!("Failed to save config: {}", e); -} - if app.provider_name.to_lowercase() != *provider_id { - let vertex_project = config.vertex_project.clone(); - let vertex_location = config.vertex_location.clone(); - drop(config); - let provider = if provider_id == "vertex" { - routecode_sdk::agents::resolve_provider_with_config(provider_id, key, &vertex_project, &vertex_location) - } else { - routecode_sdk::agents::resolve_provider(provider_id, key) - }; - app.provider_name = provider.name().to_string(); - app.current_provider_id = provider_id.clone(); - app.orchestrator.change_provider(provider).await; - } else { drop(config); } - app.current_model = model_name.clone(); - app.history.push(Message::system(format!("Switched to {} on {}", model_name, app.provider_name))); - app.show_model_menu = false; - } else { - app.history.push(Message::system(format!("Error: No API key for {}", provider_id))); - } - } - } - } else if app.is_inputting_api_key { - let input_value = app.api_key_input.lines().join("\n").trim().to_string(); - if !input_value.is_empty() { - match app.api_key_input_stage { - ApiKeyInputStage::ApiKey => { - if let Some(provider_id) = app.pending_provider_id.clone() { - if provider_id == "vertex" { - app.api_key_input_stage = ApiKeyInputStage::VertexProject; - app.api_key_input = TextArea::default(); - app.api_key_input.set_placeholder_text(" Your GCP project ID..."); - } else { - app.pending_provider_id.take(); - let mut config = app.orchestrator.config.lock().await; - config.api_keys.insert(provider_id, input_value); - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { - log::error!("Failed to save config: {}", e); -} - app.history.push(Message::system("API Key saved")); - app.is_inputting_api_key = false; - app.api_key_input_stage = ApiKeyInputStage::None; - } - } else { - app.is_inputting_api_key = false; - app.api_key_input_stage = ApiKeyInputStage::None; - } - } - ApiKeyInputStage::VertexProject => { - app.pending_account_id = Some(input_value); - app.api_key_input = TextArea::default(); - app.api_key_input_stage = ApiKeyInputStage::VertexLocation; - } - ApiKeyInputStage::VertexLocation => { - if let Some(provider_id) = app.pending_provider_id.take() { - let project = app.pending_account_id.take().unwrap_or_default(); - let location = input_value; - let api_key = app.api_key_input.lines().join("\n").trim().to_string(); - let mut config = app.orchestrator.config.lock().await; - config.vertex_project = project; - config.vertex_location = location; - config.api_keys.insert(provider_id, api_key); - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { - log::error!("Failed to save config: {}", e); -} - app.history.push(Message::system("Vertex AI credentials saved")); - } - app.is_inputting_api_key = false; - app.api_key_input_stage = ApiKeyInputStage::None; - } - ApiKeyInputStage::CloudflareAccountId => { - app.pending_account_id = Some(input_value); - app.api_key_input = TextArea::default(); - if app.pending_provider_id.as_deref() == Some("cloudflare-gateway") { - app.api_key_input_stage = ApiKeyInputStage::CloudflareGatewayId; - } else { app.api_key_input_stage = ApiKeyInputStage::CloudflareApiKey; } - } - ApiKeyInputStage::CloudflareGatewayId => { - app.pending_gateway_id = Some(input_value); - app.api_key_input = TextArea::default(); - app.api_key_input_stage = ApiKeyInputStage::CloudflareApiKey; - } - ApiKeyInputStage::CloudflareApiKey => { - if let Some(provider_id) = app.pending_provider_id.take() { - let account_id = app.pending_account_id.take().unwrap_or_default(); - let final_key = if provider_id == "cloudflare-gateway" { - let gateway_id = app.pending_gateway_id.take().unwrap_or_default(); - format!("{}:{}:{}", account_id, gateway_id, input_value) - } else { format!("{}:{}", account_id, input_value) }; - let mut config = app.orchestrator.config.lock().await; - config.api_keys.insert(provider_id.clone(), final_key); - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { - log::error!("Failed to save config: {}", e); -} - app.history.push(Message::system(format!("Credentials saved for {}", provider_id))); - } - app.is_inputting_api_key = false; - app.api_key_input_stage = ApiKeyInputStage::None; - } - _ => { app.is_inputting_api_key = false; } - } - } else { - app.is_inputting_api_key = false; - app.api_key_input_stage = ApiKeyInputStage::None; - } - } else { - let input_text = app.input.lines().join("\n"); - if !input_text.trim().is_empty() { - if input_text.starts_with('/') { - handle_command(app, &input_text).await; - } else if !app.startup_ready { - app.startup_input_buffer.push(input_text.clone()); - app.history.push(Message::system(format!("Queued: {}", input_text))); - app.input = TextArea::default(); - } else { - let provider_id = &app.current_provider_id; - let env_key = format!("{}_API_KEY", provider_id.to_uppercase().replace("-", "_")); - let mut api_key = std::env::var(&env_key).ok(); - if api_key.is_none() && provider_id.starts_with("cloudflare") { - api_key = std::env::var("CLOUDFLARE_API_KEY").ok(); - } - if api_key.is_none() { - let config = app.orchestrator.config.lock().await; - api_key = config.api_keys.get(provider_id).cloned(); - } - - let has_valid_key = api_key.map_or(false, |k| !k.trim().is_empty()); - - if !has_valid_key && provider_id != "opencode-zen" && provider_id != "opencode-go" { - app.history.push(Message::system(format!("No API key found for {}. Please enter it to continue.", provider_id))); - app.show_provider_menu = true; - if let Some(pos) = PROVIDERS.iter().position(|p| p.id == *provider_id) { - app.menu_state.select(Some(pos)); - } else { - app.menu_state.select(Some(0)); - } - app.input = TextArea::default(); - return Ok(KeyEventResult::Continue); - } - - app.history.push(Message::user(input_text.clone())); - app.prompt_history.push(input_text.clone()); - app.prompt_history.truncate(100); - app.prompt_history_index = None; - app.input = TextArea::default(); - app.screen = Screen::Session; - app.is_generating = true; - app.auto_scroll = true; - let orchestrator = app.orchestrator.clone(); - let mut history = app.history.clone(); - let model = app.current_model.clone(); - let tx = app.tx.clone(); - app.tasks.spawn(async move { - if let Err(e) = orchestrator.run(&mut history, &model, Some(tx)).await { - log::error!("Orchestrator run failed: {}", e); - } - }); - } - app.input = TextArea::default(); - } - } - } - KeyCode::Esc => { - if app.show_menu { app.show_menu = false; } - else if app.show_provider_menu { app.show_provider_menu = false; } - else if app.show_model_menu { app.show_model_menu = false; } - else if app.show_settings_menu { app.show_settings_menu = false; } - else if app.is_inputting_api_key { - app.is_inputting_api_key = false; - app.api_key_input_stage = ApiKeyInputStage::None; - app.pending_account_id = None; - app.pending_gateway_id = None; - } else if app.is_generating { - app.tasks.abort_all(); - app.is_generating = false; - app.active_tool = None; - } else { - app.pending_exit = true; - } - } - KeyCode::Char('t') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - app.auto_scroll = !app.auto_scroll; - app.history.push(Message::system(format!("Auto-scroll {}", if app.auto_scroll { "enabled" } else { "disabled" }))); - } - KeyCode::Char('o') if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - app.collapse_thinking = !app.collapse_thinking; - } - KeyCode::End => { - app.auto_scroll = true; - app.history_scroll = app.max_scroll; - } - KeyCode::Up if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - let (row, _) = app.input.cursor(); - if row == 0 && app.input.lines().len() == 1 && app.input.lines()[0].is_empty() && !app.prompt_history.is_empty() { - let idx = match app.prompt_history_index { - Some(i) => if i == 0 { 0 } else { i - 1 }, - None => app.prompt_history.len() - 1, - }; - app.prompt_history_index = Some(idx); - let prev = app.prompt_history[idx].clone(); - app.input = TextArea::from(prev.lines().map(|s| s.to_string())); - app.input.move_cursor(tui_textarea::CursorMove::End); - } - } - KeyCode::Down if key.modifiers.contains(event::KeyModifiers::CONTROL) => { - let (row, _) = app.input.cursor(); - let lines_len = app.input.lines().len(); - if row >= lines_len - 1 && app.prompt_history_index.is_some() { - let idx = app.prompt_history_index.unwrap(); - if idx >= app.prompt_history.len() - 1 { - app.prompt_history_index = None; - app.input = TextArea::default(); - } else { - let new_idx = idx + 1; - app.prompt_history_index = Some(new_idx); - let next = app.prompt_history[new_idx].clone(); - app.input = TextArea::from(next.lines().map(|s| s.to_string())); - app.input.move_cursor(tui_textarea::CursorMove::End); - } - } - } - KeyCode::Up => { - if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { - let items_len = if app.show_menu { app.filtered_commands.len() } - else if app.show_provider_menu { PROVIDERS.len() } - else if app.show_settings_menu { app.settings_items.len() } - else { app.filtered_models.len() }; - if items_len > 0 { - let selected = app.menu_state.selected().unwrap_or(0); - let mut new_selected = if selected == 0 { items_len - 1 } else { selected - 1 }; - if app.show_model_menu { - while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(new_selected) { - new_selected = if new_selected == 0 { items_len - 1 } else { new_selected - 1 }; - if new_selected == selected { break; } - } - } else if app.show_settings_menu { - while let Some(SettingsMenuItem::Header(_)) = app.settings_items.get(new_selected) { - new_selected = if new_selected == 0 { items_len - 1 } else { new_selected - 1 }; - if new_selected == selected { break; } - } - } - app.menu_state.select(Some(new_selected)); - } - } else if app.input.lines().len() == 1 && app.input.lines()[0].is_empty() || app.history_scroll > 0 || app.is_generating || key.modifiers.contains(event::KeyModifiers::SHIFT) { - app.history_scroll = app.history_scroll.saturating_sub(15); - app.auto_scroll = false; - } else { - app.input.input(Event::Key(key)); - } - } - KeyCode::Down => { - if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { - let items_len = if app.show_menu { app.filtered_commands.len() } - else if app.show_provider_menu { PROVIDERS.len() } - else if app.show_settings_menu { app.settings_items.len() } - else { app.filtered_models.len() }; - if items_len > 0 { - let selected = app.menu_state.selected().unwrap_or(0); - let mut new_selected = if selected >= items_len - 1 { 0 } else { selected + 1 }; - if app.show_model_menu { - while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(new_selected) { - new_selected = if new_selected >= items_len - 1 { 0 } else { new_selected + 1 }; - if new_selected == selected { break; } - } - } else if app.show_settings_menu { - while let Some(SettingsMenuItem::Header(_)) = app.settings_items.get(new_selected) { - new_selected = if new_selected >= items_len - 1 { 0 } else { new_selected + 1 }; - if new_selected == selected { break; } - } - } - app.menu_state.select(Some(new_selected)); - } - } else if app.input.lines().len() == 1 && app.input.lines()[0].is_empty() || app.history_scroll < app.max_scroll || app.is_generating || key.modifiers.contains(event::KeyModifiers::SHIFT) { - app.history_scroll = app.history_scroll.saturating_add(15); - if app.history_scroll >= app.max_scroll { app.auto_scroll = true; } - } else { - app.input.input(Event::Key(key)); - } - } - KeyCode::Right if app.show_model_menu => { - let len = app.filtered_models.len(); - if len > 0 { - let current = app.menu_state.selected().unwrap_or(0); - let mut next_header_idx = None; - for i in (current + 1)..len { if let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(i) { next_header_idx = Some(i); break; } } - if next_header_idx.is_none() { for i in 0..current { if let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(i) { next_header_idx = Some(i); break; } } } - if let Some(h_idx) = next_header_idx { - let mut target = (h_idx + 1) % len; - while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(target) { target = (target + 1) % len; if target == h_idx { break; } } - app.menu_state.select(Some(target)); - } - } - } - KeyCode::Left if app.show_model_menu => { - let len = app.filtered_models.len(); - if len > 0 { - let current = app.menu_state.selected().unwrap_or(0); - let mut headers = Vec::new(); - for (i, item) in app.filtered_models.iter().enumerate() { if let ModelMenuItem::Header(_) = item { headers.push(i); } } - if !headers.is_empty() { - let current_header_idx_in_headers = headers.iter().enumerate().rev().find(|(_, &h_idx)| h_idx < current).map(|(i, _)| i); - let target_header_idx = match current_header_idx_in_headers { Some(i) => if i == 0 { *headers.last().unwrap() } else { headers[i - 1] }, None => *headers.last().unwrap() }; - let mut target = (target_header_idx + 1) % len; - while let Some(ModelMenuItem::Header(_)) = app.filtered_models.get(target) { target = (target + 1) % len; if target == target_header_idx { break; } } - app.menu_state.select(Some(target)); - } - } - } - KeyCode::Char('f') if key.modifiers.contains(event::KeyModifiers::CONTROL) && app.show_model_menu => { - if let Some(selected) = app.menu_state.selected() { - if let Some(ModelMenuItem::Model(model_info)) = app.filtered_models.get(selected) { - let model_info = model_info.clone(); - let mut config = app.orchestrator.config.lock().await; - if config.favorites.iter().any(|m| m.name == model_info.name && m.provider_id == model_info.provider_id) { config.favorites.retain(|m| m.name != model_info.name || m.provider_id != model_info.provider_id); app.history.push(Message::system(format!("Removed {} from favorites", model_info.name))); } - else { config.favorites.push(model_info.clone()); app.history.push(Message::system(format!("Added {} to favorites", model_info.name))); } - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { - log::error!("Failed to save config: {}", e); -} - } - } - } - KeyCode::BackTab => { - app.approval_mode = app.approval_mode.next(); - let info = match app.approval_mode { - ApprovalMode::YOLO => "YOLO — commands will auto-approve", - ApprovalMode::Plan => "PLAN — tool calls will be denied (read-only review)", - ApprovalMode::Shell => "SHELL — shell commands shown first, auto-approved", - ApprovalMode::Normal => "Normal mode — confirm each tool call", - }; - app.history.push(Message::system(format!("Mode: {}", info))); - } - _ => { - let event = Event::Key(key); - if app.is_inputting_api_key { app.api_key_input.input(event); } - else if app.show_model_menu { if app.model_search_input.input(event) { let search = app.model_search_input.lines().first().map(|l| l.trim().to_lowercase()).unwrap_or_default(); handle_model_search(app, &search, true).await; } } - else { app.input.input(event); app.update_filtered_commands(); } - } - } - Ok(KeyEventResult::Continue) -} - -async fn handle_mouse_event( - app: &mut App, - mouse: event::MouseEvent, - terminal: &mut Terminal, -) -> io::Result<()> { - app.mouse_events_count += 1; - // Always store current mouse position for render-time hover detection - app.mouse_row = Some(mouse.row); - app.mouse_col = Some(mouse.column); - match mouse.kind { - MouseEventKind::Moved => { - app.mouse_moved = true; - } - MouseEventKind::ScrollUp => { - if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { - let mut current = app.menu_state.selected().unwrap_or(0); - current = current.saturating_sub(3); - if app.show_model_menu { - while current > 0 && matches!(app.filtered_models.get(current), Some(crate::ui::ModelMenuItem::Header(_))) { - current -= 1; - } - } else if app.show_settings_menu { - while current > 0 && matches!(app.settings_items.get(current), Some(crate::ui::SettingsMenuItem::Header(_))) { - current -= 1; - } - } - app.menu_state.select(Some(current)); - } else { - app.history_scroll = app.history_scroll.saturating_sub(15); - app.auto_scroll = false; - } - } - MouseEventKind::ScrollDown => { - if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { - let current = app.menu_state.selected().unwrap_or(0); - let max = if app.show_menu { - app.filtered_commands.len() - } else if app.show_provider_menu { - crate::ui::PROVIDERS.len() - } else if app.show_settings_menu { - app.settings_items.len() - } else { - app.filtered_models.len() - }; - let mut next = current.saturating_add(3).min(max.saturating_sub(1)); - if app.show_model_menu { - while next < max - 1 && matches!(app.filtered_models.get(next), Some(crate::ui::ModelMenuItem::Header(_))) { - next += 1; - } - } else if app.show_settings_menu { - while next < max - 1 && matches!(app.settings_items.get(next), Some(crate::ui::SettingsMenuItem::Header(_))) { - next += 1; - } - } - app.menu_state.select(Some(next)); - } else { - app.history_scroll = app.history_scroll.saturating_add(15); - if app.history_scroll >= app.max_scroll { app.auto_scroll = true; } - } - } - MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) => { - if let Some(msg_idx) = app.show_user_msg_modal { - if let Ok(size) = terminal.size() { - let width = (size.width as f32 * 0.40) as u16; - let height = 8; - let modal_x = (size.width.saturating_sub(width)) / 2; - let modal_y = (size.height.saturating_sub(height)) / 2; - - let is_outside = mouse.column < modal_x || mouse.column >= modal_x + width || mouse.row < modal_y || mouse.row >= modal_y + height; - - if is_outside { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - app.show_user_msg_modal = None; - } - } else if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) { - let click_row = mouse.row; - if click_row == modal_y + 2 { - app.user_msg_modal_selected = 0; - let text = app.history[msg_idx].content.as_ref().map(|s| s.to_string()).unwrap_or_default(); - let text_clone = text.clone(); - tokio::task::spawn_blocking(move || { - if let Err(e) = copy_to_clipboard(&text_clone) { - log::error!("Clipboard copy failed: {}", e); - } - }); - app.history.push(Message::system("Message copied to clipboard!".to_string())); - app.show_user_msg_modal = None; - } else if click_row == modal_y + 3 { - app.user_msg_modal_selected = 1; - let text = app.history[msg_idx].content.as_ref().map(|s| s.to_string()).unwrap_or_default(); - app.history.truncate(msg_idx); - app.input = tui_textarea::TextArea::from(text.lines().map(|s| s.to_string())); - app.input.move_cursor(tui_textarea::CursorMove::End); - app.show_user_msg_modal = None; - } - } - } - } else if app.pending_update.is_some() { - if let Ok(size) = terminal.size() { - let width = (size.width as f32 * 0.50) as u16; - let height = 8; - let modal_x = (size.width.saturating_sub(width)) / 2; - let modal_y = (size.height.saturating_sub(height)) / 2; - - let is_outside = mouse.column < modal_x || mouse.column >= modal_x + width || mouse.row < modal_y || mouse.row >= modal_y + height; - - if is_outside { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - app.pending_update = None; - } - } else if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) - && mouse.row == modal_y + height.saturating_sub(2) { - if mouse.column >= modal_x + width.saturating_sub(25) && mouse.column < modal_x + width.saturating_sub(15) { - app.pending_update = None; - } else if mouse.column >= modal_x + width.saturating_sub(15) && mouse.column < modal_x + width { - app.pending_update_install = true; - } - } - } - } else if app.show_menu || app.show_provider_menu || app.show_model_menu || app.show_settings_menu { - if let Ok(size) = terminal.size() { - let (width, height) = if app.show_menu { - (60, (app.filtered_commands.len() + 6).min(15) as u16) - } else if app.show_provider_menu { - (60, (crate::ui::PROVIDERS.len() + 6).min(15) as u16) - } else if app.show_settings_menu { - (60, (app.settings_items.len() + 6).min(15) as u16) - } else { - (70, (app.filtered_models.len() + 7).min(18) as u16) - }; - let modal_x = (size.width.saturating_sub(width)) / 2; - let modal_y = (size.height.saturating_sub(height)) / 2; - - let is_outside = mouse.column < modal_x || mouse.column >= modal_x + width || mouse.row < modal_y || mouse.row >= modal_y + height; - let is_esc = mouse.row <= modal_y + 2 && mouse.column >= modal_x + width.saturating_sub(10) && mouse.column <= modal_x + width; - let is_inside_list = mouse.row >= modal_y + 2 && mouse.row < modal_y + height - 1 && mouse.column > modal_x && mouse.column < modal_x + width - 1; - - if is_outside || is_esc { - app.show_menu = false; - app.show_provider_menu = false; - app.show_model_menu = false; - app.show_settings_menu = false; - } else if is_inside_list && matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) - && app.show_settings_menu { - let idx = (mouse.row - (modal_y + 2)) as usize + app.menu_state.offset(); - if idx < app.settings_items.len() { - if let Some(SettingsMenuItem::Option { key, val: _, .. }) = app.settings_items.get(idx) { - if key == "logo_animation" { - let next_val = { - let config = app.orchestrator.config.lock().await; - match config.logo_animation.as_str() { - "always" => "hover", - "hover" => "click", - _ => "always", - } - }; - { - let mut config = app.orchestrator.config.lock().await; - config.logo_animation = next_val.to_string(); - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { - log::error!("Failed to save config: {}", e); -} - } - app.populate_settings().await; - } else if key == "logo_animation_color" { - let next_val = { - let config = app.orchestrator.config.lock().await; - match config.logo_animation_color.as_str() { - "rainbow" => "neon", - "neon" => "cyberpunk", - "cyberpunk" => "sunset", - "sunset" => "mono", - _ => "rainbow", - } - }; - { - let mut config = app.orchestrator.config.lock().await; - config.logo_animation_color = next_val.to_string(); - if let Err(e) = routecode_sdk::utils::storage::save_config(&config) { - log::error!("Failed to save config: {}", e); -} - } - app.populate_settings().await; - } else if key == "hide_cwd" { - app.hide_cwd = !app.hide_cwd; - app.populate_settings().await; - } else if key == "hide_model_info" { - app.hide_model_info = !app.hide_model_info; - app.populate_settings().await; - } else if key == "hide_context_summary" { - app.hide_context_summary = !app.hide_context_summary; - app.populate_settings().await; - } - } - } - } - } - } else if app.screen == Screen::Session { - let has_thinking = app.history.iter().any(|m| m.thought.is_some()); - if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) { - if let Ok(size) = terminal.size() { - if let Some(msg_idx) = compute_message_hover(app, size) { - if app.history[msg_idx].role == Role::User { - app.show_user_msg_modal = Some(msg_idx); - app.user_msg_modal_selected = 0; - return Ok(()); - } - } - } - } - - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - let in_cooldown = app.last_toggle_time.is_some_and(|t| t.elapsed() < std::time::Duration::from_millis(400)); - - if !in_cooldown && has_thinking { - let is_double_click = if let Some((last_time, col, row)) = app.last_click_up { - let col_diff = (col as i32 - mouse.column as i32).abs(); - let row_diff = (row as i32 - mouse.row as i32).abs(); - last_time.elapsed() < std::time::Duration::from_millis(600) && col_diff <= 4 && row_diff <= 3 - } else { - false - }; - - if is_double_click { - app.collapse_thinking = !app.collapse_thinking; - app.last_click_up = None; - app.mouse_down_start = None; - app.last_toggle_time = Some(std::time::Instant::now()); - } else if let Ok(size) = terminal.size() { - // Compute hover FRESH with current mouse position - let hover = compute_thinking_hover(app, size); - if hover { - app.last_click_up = Some((std::time::Instant::now(), mouse.column, mouse.row)); - app.mouse_down_start = Some((std::time::Instant::now(), mouse.column, mouse.row)); - } else { - app.last_click_up = None; - } - } - } - } - if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) { - app.mouse_down_start = None; - app.temp_expand_thinking = false; - } - } else if app.screen == Screen::Welcome && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - if let Ok(size) = terminal.size() { - let logo_height = if size.height < 20 { 0 } else { 6 }; - let spacer_height = if size.height < 15 { 0 } else { size.height / 3 }; - if logo_height > 0 && mouse.row >= spacer_height && mouse.row < spacer_height + logo_height { - app.logo_anim_frames = 20; // 2 seconds at 100ms tick - } - } - } - } - _ => {} - } - Ok(()) -} - -async fn handle_stream_chunks(app: &mut App) { - let max_per_frame: u32 = 50; - let mut processed: u32 = 0; - while processed < max_per_frame { - let chunk = match app.rx.try_recv() { - Ok(c) => c, - Err(_) => break, - }; - processed += 1; - // Throttle cache invalidation: mark dirty only once per burst - if processed <= 1 { - app.render_dirty = true; - } - match chunk { - StreamChunk::Text { content } => { - if let Some(last) = app.history.last_mut() { - if last.role == Role::Assistant { - let mut current = last.content.as_ref().map(|s| s.to_string()).unwrap_or_default(); - current.push_str(&content); - last.content = Some(std::sync::Arc::from(current)); - } else { app.history.push(Message::assistant(Some(std::sync::Arc::from(content)), None, None)); } - } else { app.history.push(Message::assistant(Some(std::sync::Arc::from(content)), None, None)); } - } - StreamChunk::Thought { content } => { - if let Some(last) = app.history.last_mut() { - if last.role == Role::Assistant { - let mut current = last.thought.as_ref().map(|s| s.to_string()).unwrap_or_default(); - current.push_str(&content); - last.thought = Some(std::sync::Arc::from(current)); - } else { app.history.push(Message::assistant(None, Some(std::sync::Arc::from(content)), None)); } - } else { app.history.push(Message::assistant(None, Some(std::sync::Arc::from(content)), None)); } - } - StreamChunk::ToolCall { tool_call } => { - app.active_tool = Some(tool_call.function.name.clone()); - if let Some(last) = app.history.last_mut() { - if last.role == Role::Assistant { - let mut calls = last.tool_calls.clone().unwrap_or_default(); - if let Some(idx) = tool_call.index { if let Some(existing) = calls.iter_mut().find(|tc| tc.index == Some(idx)) { *existing = tool_call; } else { calls.push(tool_call); } } - else if !calls.iter().any(|tc| tc.id == tool_call.id && !tc.id.is_empty()) { calls.push(tool_call); } - last.tool_calls = Some(calls); - } else { app.history.push(Message::assistant(None, None, Some(vec![tool_call]))); } - } else { app.history.push(Message::assistant(None, None, Some(vec![tool_call]))); } - } - StreamChunk::ToolResult { name, content, tool_call_id } => { app.active_tool = None; app.history.push(Message::tool(tool_call_id, name, content)); } - StreamChunk::Done => { - app.is_generating = false; - app.active_tool = None; - if !app.history.is_empty() { - let session = routecode_sdk::utils::storage::Session { - messages: app.history.clone(), - model: app.current_model.clone(), - usage: app.orchestrator.usage.lock().await.clone(), - timestamp: chrono::Utc::now().timestamp(), - }; - if let Err(e) = routecode_sdk::utils::storage::save_session(&app.session_id, &session) { - log::error!("Failed to auto-save session: {}", e); - } - } - } - StreamChunk::Error { content } => { - let mut display_error = content.clone(); - let json_part = if let Some(idx) = content.find('{') { &content[idx..] } else { &content }; - if json_part.len() > 10 && json_part.starts_with('{') { - if let Ok(val) = serde_json::from_str::(json_part) { - if let Some(msg) = val["error"]["message"].as_str() { display_error = msg.to_string(); } - else if let Some(error_obj) = val["error"].as_object() { if let Some(msg) = error_obj["message"].as_str() { display_error = msg.to_string(); } } - else if let Some(msg) = val["message"].as_str() { display_error = msg.to_string(); } - else if let Some(errors) = val["errors"].as_array() { if let Some(msg) = errors.first().and_then(|e| e["message"].as_str()) { display_error = msg.to_string(); } } - } - } - app.history.push(Message::system(format!("Error: {}", display_error))); - app.is_generating = false; - app.active_tool = None; - } - StreamChunk::Models { models } => { - app.all_available_models.extend(models); - let search = app.model_search_input.lines().first().map(|l| l.trim().to_lowercase()).unwrap_or_default(); - handle_model_search(app, &search, false).await; - } - StreamChunk::ModelsDone => { - app.is_fetching_models = false; - if !app.startup_ready { - app.startup_ready = true; - let buffered = app.startup_input_buffer.drain(..).collect::>(); - for msg in buffered { - app.history.push(Message::user(msg)); - app.screen = Screen::Session; - app.prompt_history.truncate(100); - app.prompt_history_index = None; - app.input = TextArea::default(); - app.is_generating = true; - app.auto_scroll = true; - let orchestrator = app.orchestrator.clone(); - let mut history = app.history.clone(); - let model = app.current_model.clone(); - let tx = app.tx.clone(); - app.tasks.spawn(async move { - if let Err(e) = orchestrator.run(&mut history, &model, Some(tx)).await { - log::error!("Orchestrator run failed: {}", e); - } - }); - } - } - } - StreamChunk::FinalHistory { history } => { app.history = history; } - StreamChunk::RequestConfirmation { message, target, tx } => { - match app.approval_mode { - ApprovalMode::YOLO | ApprovalMode::Shell => { - if let Some(sender) = tx { - let mut tx_opt = sender.lock().await; - if let Some(tx) = tx_opt.take() { - let _ = tx.send(ConfirmationResponse::AllowOnce); - } - } - } - ApprovalMode::Plan => { - if let Some(sender) = tx { - let mut tx_opt = sender.lock().await; - if let Some(tx) = tx_opt.take() { - let _ = tx.send(ConfirmationResponse::Deny); - } - } - } - ApprovalMode::Normal => { - if let Some(sender) = tx { - app.pending_command_confirmation = Some((message, target, sender)); - } else { - log::error!("RequestConfirmation received without a response channel"); - } - } - } - } - StreamChunk::UpdateAvailable { version, changelog, published_at } => { - app.pending_update = Some(version); - app.pending_update_changelog = changelog; - app.pending_update_published_at = published_at; - app.update_modal_selected = 1; - } - _ => {} - } - } -} - -pub async fn run_app( - terminal: &mut Terminal, - mut app: App, -) -> io::Result { - let mut last_tick = std::time::Instant::now(); - let tick_rate = std::time::Duration::from_millis(100); - let render_rate = std::time::Duration::from_millis(16); // ~60 FPS for smooth rendering - - loop { - terminal.draw(|f| ui(f, &mut app))?; - - let timeout = render_rate; - - if event::poll(timeout)? { - let mut events = Vec::new(); - while event::poll(std::time::Duration::from_millis(0))? { - events.push(event::read()?); - } - - let is_burst = events.len() > 1; - - for event in events { - match event { - Event::Key(key) => { - if key.kind == KeyEventKind::Press { - match handle_key_event(&mut app, key, is_burst).await? { - KeyEventResult::Exit => return Ok(app.pending_update_install), - KeyEventResult::Continue => {} - } - } - } - Event::Paste(text) => { app.input.insert_str(&text); } - Event::Mouse(mouse) => { - handle_mouse_event(&mut app, mouse, terminal).await?; - } - Event::Resize(_, _) => { - app.cached_text = None; - } - _ => {} - } - } - } - - if last_tick.elapsed() >= tick_rate { - app.tick_count += 1; - app.logo_anim_frames = app.logo_anim_frames.saturating_sub(1); - - if app.screen == Screen::Session { - if let Some((start_time, _, _)) = app.mouse_down_start { - if start_time.elapsed() >= std::time::Duration::from_millis(400) - && app.thinking_hover_rendered { - app.temp_expand_thinking = true; - } - } - } - - last_tick = std::time::Instant::now(); - } - - if app.pending_update_install { - return Ok(true); - } - handle_stream_chunks(&mut app).await; - } -} - -fn ui(f: &mut Frame, app: &mut App) { - let area = f.size(); - f.render_widget(Block::default().style(Style::default().bg(COLOR_BG)), area); - let main_layout = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Min(0)]).split(area); - let current_dir = std::env::current_dir().map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string()).unwrap_or_else(|_| "workspace".to_string()); - - let mode_label = app.approval_mode.label(); - let mode_style = match app.approval_mode { - ApprovalMode::Normal => Style::default().fg(COLOR_SECONDARY), - ApprovalMode::Plan => Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), - ApprovalMode::YOLO => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ApprovalMode::Shell => Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), - }; - let mode_indicator = format!("[{}]", mode_label); - - let mut header_left = Vec::new(); - if app.approval_mode != ApprovalMode::Normal { - header_left.push(Span::styled(mode_indicator, mode_style)); - header_left.push(Span::raw(" ")); - } - if !app.hide_cwd { - header_left.push(Span::styled(format!("{} ", current_dir), Style::default().fg(COLOR_SECONDARY))); - } - - let header_right_len = if app.hide_model_info { 0u16 } else { 25u16 }; - let header_layout = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Min(0), Constraint::Length(header_right_len)]).split(main_layout[0]); - f.render_widget(Paragraph::new(Line::from(header_left)), header_layout[0]); - - if !app.hide_model_info { - let version = env!("CARGO_PKG_VERSION"); - let header_title = format!(" RouteCode v{} ", version); - f.render_widget(Paragraph::new(Span::styled(header_title, Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD))).alignment(ratatui::layout::Alignment::Right), header_layout[1]); - } - let input_area = match app.screen { - Screen::Welcome => ui_welcome(f, app, main_layout[1]), - Screen::Session => ui_session(f, app, main_layout[1]), - }; - if app.show_menu { render_menu(f, app, input_area); } - else if app.show_provider_menu { render_provider_menu(f, app, input_area); } - else if app.show_model_menu { render_model_menu(f, app, input_area); } - else if app.show_settings_menu { render_settings_menu(f, app, input_area); } - else if app.is_inputting_api_key { render_api_key_dialog(f, app); } - else if app.pending_clear { render_confirmation_dialog(f, "Are you sure you want to clear all history? (y/n)"); } - else if app.pending_exit { render_confirmation_dialog(f, "Are you sure you want to exit RouteCode? (y/n)"); } - else if app.pending_command_confirmation.is_some() { render_command_confirmation_dialog(f, app); } - else if app.show_user_msg_modal.is_some() { render_user_msg_modal(f, app); } - else if app.pending_update.is_some() { render_update_modal(f, app); } - app.mouse_moved = false; -} - -fn render_command_confirmation_dialog(f: &mut Frame, app: &mut App) { - let area = f.size(); - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(30), - Constraint::Length(10), - Constraint::Percentage(30), - ]) - .split(area); - - let popup_horiz = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(15), - Constraint::Percentage(70), - Constraint::Percentage(15), - ]) - .split(popup_layout[1]); - - let inner_area = popup_horiz[1]; - - let block = Block::default() - .title(" Command Confirmation Required ") - .borders(ratatui::widgets::Borders::ALL) - .border_style(Style::default().fg(COLOR_PRIMARY)) - .style(Style::default().bg(COLOR_BG)); - - let (message, target, _) = app.pending_command_confirmation.as_ref().unwrap(); - - let mut lines = vec![ - ratatui::text::Line::from(vec![Span::styled(message, Style::default().fg(COLOR_TEXT))]), - ratatui::text::Line::from(vec![Span::styled(format!("> {}", target), Style::default().fg(COLOR_SYSTEM).add_modifier(Modifier::BOLD))]), - ratatui::text::Line::from(""), - ]; - - if app.inputting_command_feedback { - lines.push(ratatui::text::Line::from(vec![Span::styled("Please type your feedback below and press Enter (Esc to cancel):", Style::default().fg(COLOR_SECONDARY))])); - } else { - lines.push(ratatui::text::Line::from(vec![ - Span::styled("[Y]", Style::default().fg(COLOR_SUCCESS).add_modifier(Modifier::BOLD)), - Span::raw(" Allow once "), - Span::styled("[S]", Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)), - Span::raw(" Allow for session "), - Span::styled("[W]", Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)), - Span::raw(" Allow for Workspace "), - Span::styled("[F]", Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::BOLD)), - Span::raw(" Tell Agent something else "), - Span::styled("[D] or [Esc]", Style::default().fg(ratatui::style::Color::Red).add_modifier(Modifier::BOLD)), - Span::raw(" Deny"), - ])); - } - - let paragraph = Paragraph::new(lines).block(block).wrap(ratatui::widgets::Wrap { trim: false }); - f.render_widget(ratatui::widgets::Clear, inner_area); - f.render_widget(paragraph, inner_area); - - if app.inputting_command_feedback { - let input_rect = ratatui::layout::Rect { - x: inner_area.x + 2, - y: inner_area.y + 5, - width: inner_area.width.saturating_sub(4), - height: 3, - }; - let input_block = Block::default().borders(ratatui::widgets::Borders::ALL).border_style(Style::default().fg(COLOR_PRIMARY)); - app.input.set_block(input_block); - f.render_widget(app.input.widget(), input_rect); - f.set_cursor(input_rect.x + app.input.cursor().1 as u16 + 1, input_rect.y + app.input.cursor().0 as u16 + 1); - } -} - -fn render_confirmation_dialog(f: &mut Frame, message: &str) { - let area = f.size(); - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(40), - Constraint::Length(5), - Constraint::Percentage(40), - ]) - .split(area); - - let popup_horiz = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(25), - Constraint::Percentage(50), - Constraint::Percentage(25), - ]) - .split(popup_layout[1]); - - let block = Block::default() - .title(" Confirmation ") - .borders(ratatui::widgets::Borders::ALL) - .border_style(Style::default().fg(COLOR_PRIMARY)); - - let p = Paragraph::new(Span::styled(message, Style::default().fg(COLOR_TEXT).add_modifier(Modifier::BOLD))) - .alignment(ratatui::layout::Alignment::Center) - .block(block); - - f.render_widget(ratatui::widgets::Clear, popup_horiz[1]); - f.render_widget(p, popup_horiz[1]); -} - -fn copy_to_clipboard(text: &str) -> std::io::Result<()> { - use std::io::Write; - use std::process::{Command, Stdio}; - let (prog, args): (&str, &[&str]) = if cfg!(target_os = "windows") { - ("clip", &[]) - } else if cfg!(target_os = "macos") { - ("pbcopy", &[]) - } else { - ("xclip", &["-selection", "clipboard"]) - }; - let mut child = Command::new(prog).args(args).stdin(Stdio::piped()).spawn()?; - if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(text.as_bytes())?; - } - let result = child.wait(); - match result { - Ok(status) if status.success() => Ok(()), - Ok(_) => Err(std::io::Error::other("clipboard command failed")), - Err(e) => Err(e), - } -} - -fn render_user_msg_modal(f: &mut Frame, app: &mut App) { - let area = f.size(); - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(40), - Constraint::Length(8), - Constraint::Percentage(40), - ]) - .split(area); - - let popup_horiz = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), - Constraint::Percentage(40), - Constraint::Percentage(30), - ]) - .split(popup_layout[1]); - - let inner_area = popup_horiz[1]; - - let block = Block::default() - .title(" Message Action ") - .borders(ratatui::widgets::Borders::ALL) - .border_style(Style::default().fg(COLOR_PRIMARY)) - .style(Style::default().bg(COLOR_BG)); - - let options = ["Copy Message", "Rewind & Edit"]; - let mut lines = vec![ - ratatui::text::Line::from(vec![Span::styled(" Choose an action:", Style::default().fg(COLOR_SECONDARY))]), - ratatui::text::Line::from(""), - ]; - - for (idx, opt) in options.iter().enumerate() { - let is_selected = idx == app.user_msg_modal_selected; - let prefix = if is_selected { " ➜ " } else { " " }; - let style = if is_selected { - Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(COLOR_TEXT) - }; - lines.push(ratatui::text::Line::from(vec![ - Span::styled(prefix, Style::default().fg(COLOR_PRIMARY)), - Span::styled(opt.to_string(), style), - ])); - } - - lines.push(ratatui::text::Line::from("")); - lines.push(ratatui::text::Line::from(vec![Span::styled(" Press Enter/Click to select, Esc to close", Style::default().fg(COLOR_DIM))])); - - let paragraph = Paragraph::new(lines).block(block); - f.render_widget(ratatui::widgets::Clear, inner_area); - f.render_widget(paragraph, inner_area); -} - -fn render_update_modal(f: &mut Frame, app: &mut App) { - let version = app.pending_update.as_ref().unwrap(); - let area = f.size(); - - let modal_height = 12; - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(((area.height as f32 * 0.3) as u16).min(area.height.saturating_sub(modal_height + 2))), - Constraint::Length(modal_height), - Constraint::Percentage(((area.height as f32 * 0.3) as u16).min(area.height.saturating_sub(modal_height + 2))), - ]) - .split(area); - - let popup_horiz = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(15), - Constraint::Percentage(70), - Constraint::Percentage(15), - ]) - .split(popup_layout[1]); - - let inner_area = popup_horiz[1]; - - f.render_widget(ratatui::widgets::Clear, inner_area); - - let block = Block::default() - .borders(ratatui::widgets::Borders::ALL) - .border_style(Style::default().fg(COLOR_PRIMARY)) - .title(ratatui::widgets::block::Title::from(Span::styled(" Update Available ", Style::default().fg(COLOR_TEXT).add_modifier(Modifier::BOLD))).alignment(ratatui::layout::Alignment::Left)) - .title(ratatui::widgets::block::Title::from(Span::styled(" esc ", Style::default().fg(COLOR_DIM))).alignment(ratatui::layout::Alignment::Right)) - .style(Style::default().bg(COLOR_BG)); - - f.render_widget(block, inner_area); - - let content_area = ratatui::layout::Rect { - x: inner_area.x + 2, - y: inner_area.y + 1, - width: inner_area.width.saturating_sub(4), - height: inner_area.height.saturating_sub(4), - }; - - let mut lines = vec![ - ratatui::text::Line::from(vec![Span::styled(format!("Version {} is available (current: {})", version, env!("CARGO_PKG_VERSION")), Style::default().fg(COLOR_TEXT))]), - ratatui::text::Line::from(""), - ]; - - if !app.pending_update_changelog.is_empty() { - let changelog_lines: Vec<&str> = app.pending_update_changelog.lines().take(5).collect(); - for line in changelog_lines { - let trimmed = if line.len() > 60 { format!("{}...", &line[..57]) } else { line.to_string() }; - lines.push(ratatui::text::Line::from(vec![Span::styled(trimmed.to_string(), Style::default().fg(COLOR_SECONDARY))])); - } - } - - let p = Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: true }); - f.render_widget(p, content_area); - - let button_area = ratatui::layout::Rect { - x: inner_area.x + 2, - y: inner_area.y + inner_area.height.saturating_sub(2), - width: inner_area.width.saturating_sub(4), - height: 1, - }; - - let skip_style = if app.update_modal_selected == 0 { Style::default().fg(ratatui::style::Color::Black).bg(COLOR_TEXT).add_modifier(Modifier::BOLD) } else { Style::default().fg(COLOR_DIM) }; - let confirm_style = if app.update_modal_selected == 1 { Style::default().fg(ratatui::style::Color::Black).bg(ratatui::style::Color::Rgb(255, 179, 138)).add_modifier(Modifier::BOLD) } else { Style::default().fg(ratatui::style::Color::Rgb(255, 179, 138)) }; - - let buttons = ratatui::text::Line::from(vec![ - Span::styled(" Skip ", skip_style), - Span::raw(" "), - Span::styled(" Confirm ", confirm_style), - ]); - - let p_buttons = Paragraph::new(buttons).alignment(ratatui::layout::Alignment::Right); - f.render_widget(p_buttons, button_area); -} - -/// Try to lock config with short retry loop for use in sync render paths. -/// Rare contention from config saves resolves within microseconds; this avoids -/// silently showing "Loading..." or stale fallbacks. -pub fn try_lock_config(app: &App) -> Option> { - for _ in 0..10 { - match app.orchestrator.config.try_lock() { - Ok(guard) => return Some(guard), - Err(_) => std::thread::sleep(std::time::Duration::from_micros(200)), - } - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - use routecode_sdk::tools::ToolRegistry; - use routecode_sdk::core::Config; - use tokio::sync::Mutex; - use async_trait::async_trait; - use routecode_sdk::agents::AIProvider; - - struct MockProvider; - #[async_trait] - impl AIProvider for MockProvider { - fn name(&self) -> &str { "Mock" } - async fn list_models(&self) -> Result, anyhow::Error> { Ok(vec![]) } - async fn ask(&self, _: Vec, _: &str, _: Option>, _: Option<&str>) -> Result { - Err(anyhow::anyhow!("Not implemented")) - } - } - - #[test] - fn test_app_initialization() { - let orchestrator = Arc::new(AgentOrchestrator::new( - Arc::new(MockProvider), - Arc::new(ToolRegistry::new()), - Arc::new(Mutex::new(Config::default())), - )); - let app = App::new(orchestrator, "Mock".to_string(), "gpt-4o".to_string()); - assert_eq!(app.screen, Screen::Welcome); - assert!(app.history.is_empty()); - assert_eq!(app.current_model, "gpt-4o"); - } - - #[test] - fn test_update_filtered_commands() { - let orchestrator = Arc::new(AgentOrchestrator::new( - Arc::new(MockProvider), - Arc::new(ToolRegistry::new()), - Arc::new(Mutex::new(Config::default())), - )); - let mut app = App::new(orchestrator, "Mock".to_string(), "gpt-4o".to_string()); - - app.input.insert_str("/hel"); - app.update_filtered_commands(); - - assert!(app.show_menu); - assert_eq!(app.filtered_commands.len(), 1); - assert_eq!(app.filtered_commands[0].name, "/help"); - } - - #[test] - fn test_update_filtered_commands_no_match() { - let orchestrator = Arc::new(AgentOrchestrator::new( - Arc::new(MockProvider), - Arc::new(ToolRegistry::new()), - Arc::new(Mutex::new(Config::default())), - )); - let mut app = App::new(orchestrator, "Mock".to_string(), "gpt-4o".to_string()); - - app.input.insert_str("/nonexistent"); - app.update_filtered_commands(); - - assert!(!app.show_menu); - assert!(app.filtered_commands.is_empty()); - } - - #[tokio::test] - async fn test_user_msg_modal_rewind() { - let orchestrator = Arc::new(AgentOrchestrator::new( - Arc::new(MockProvider), - Arc::new(ToolRegistry::new()), - Arc::new(Mutex::new(Config::default())), - )); - let mut app = App::new(orchestrator, "Mock".to_string(), "gpt-4o".to_string()); - - app.history.push(Message::user("First message".to_string())); - app.history.push(Message::assistant(Some("Assistant reply".into()), None, None)); - app.history.push(Message::user("Second message".to_string())); - - app.show_user_msg_modal = Some(2); - app.user_msg_modal_selected = 1; - - let enter_key = event::KeyEvent::new(event::KeyCode::Enter, event::KeyModifiers::empty()); - let res = handle_key_event(&mut app, enter_key, false).await.unwrap(); - - assert_eq!(res, KeyEventResult::Continue); - assert_eq!(app.show_user_msg_modal, None); - assert_eq!(app.history.len(), 2); - assert_eq!(app.history[0].role, Role::User); - assert_eq!(app.history[1].role, Role::Assistant); - assert_eq!(app.input.lines()[0], "Second message"); - } - - #[tokio::test] - async fn test_update_system_modal() { - let orchestrator = Arc::new(AgentOrchestrator::new( - Arc::new(MockProvider), - Arc::new(ToolRegistry::new()), - Arc::new(Mutex::new(Config::default())), - )); - let mut app = App::new(orchestrator, "Mock".to_string(), "gpt-4o".to_string()); - - app.pending_update = Some("v1.15.4".to_string()); - app.update_modal_selected = 1; - - let left_key = event::KeyEvent::new(event::KeyCode::Left, event::KeyModifiers::empty()); - let res = handle_key_event(&mut app, left_key, false).await.unwrap(); - assert_eq!(res, KeyEventResult::Continue); - assert_eq!(app.update_modal_selected, 0); - - let enter_key = event::KeyEvent::new(event::KeyCode::Enter, event::KeyModifiers::empty()); - let res = handle_key_event(&mut app, enter_key, false).await.unwrap(); - assert_eq!(res, KeyEventResult::Continue); - assert_eq!(app.pending_update, None); - assert!(!app.pending_update_install); - - app.pending_update = Some("v1.15.4".to_string()); - app.update_modal_selected = 1; - let res = handle_key_event(&mut app, enter_key, false).await.unwrap(); - assert_eq!(res, KeyEventResult::Exit); - assert!(app.pending_update_install); - } -} diff --git a/apps/cli/src/ui/render.rs b/apps/cli/src/ui/render.rs new file mode 100644 index 0000000..0c6409c --- /dev/null +++ b/apps/cli/src/ui/render.rs @@ -0,0 +1,407 @@ +use crossterm::event::{self, Event, KeyEventKind}; +use ratatui::{ + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph}, + Frame, Terminal, +}; +use std::io; + +use super::app::App; +use super::components::{COLOR_BG, COLOR_PRIMARY, COLOR_SECONDARY, COLOR_TEXT, COLOR_DIM}; +use super::events::{handle_key_event, handle_mouse_event, KeyEventResult}; +use super::menus::{render_api_key_dialog, render_menu, render_model_menu, render_provider_menu, render_settings_menu}; +use super::streaming::handle_stream_chunks; +use super::types::{ApprovalMode, Screen}; +use super::welcome::ui_welcome; + +pub async fn run_app( + terminal: &mut Terminal, + mut app: App, +) -> io::Result { + let mut last_tick = std::time::Instant::now(); + let tick_rate = std::time::Duration::from_millis(100); + let render_rate = std::time::Duration::from_millis(16); // ~60 FPS for smooth rendering + + loop { + terminal.draw(|f| ui(f, &mut app))?; + + let timeout = render_rate; + + if event::poll(timeout)? { + let mut events = Vec::new(); + while event::poll(std::time::Duration::from_millis(0))? { + events.push(event::read()?); + } + + let is_burst = events.len() > 1; + + for event in events { + match event { + Event::Key(key) => { + if key.kind == KeyEventKind::Press { + match handle_key_event(&mut app, key, is_burst).await? { + KeyEventResult::Exit => return Ok(app.pending_update_install), + KeyEventResult::Continue => {} + } + } + } + Event::Paste(text) => { app.input.insert_str(&text); } + Event::Mouse(mouse) => { + handle_mouse_event(&mut app, mouse, terminal).await?; + } + Event::Resize(_, _) => { + app.cached_text = None; + } + _ => {} + } + } + } + + if last_tick.elapsed() >= tick_rate { + app.tick_count += 1; + app.logo_anim_frames = app.logo_anim_frames.saturating_sub(1); + + if app.screen == Screen::Session { + if let Some((start_time, _, _)) = app.mouse_down_start { + if start_time.elapsed() >= std::time::Duration::from_millis(400) + && app.thinking_hover_rendered { + app.temp_expand_thinking = true; + } + } + } + + last_tick = std::time::Instant::now(); + } + + if app.pending_update_install { + return Ok(true); + } + handle_stream_chunks(&mut app).await; + } +} + +fn ui(f: &mut Frame, app: &mut App) { + let area = f.size(); + f.render_widget(Block::default().style(Style::default().bg(COLOR_BG)), area); + let main_layout = Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(1), Constraint::Min(0)]).split(area); + let current_dir = std::env::current_dir().map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string()).unwrap_or_else(|_| "workspace".to_string()); + + let mode_label = app.approval_mode.label(); + let mode_style = match app.approval_mode { + ApprovalMode::Normal => Style::default().fg(COLOR_SECONDARY), + ApprovalMode::Plan => Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ApprovalMode::YOLO => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ApprovalMode::Shell => Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + }; + let mode_indicator = format!("[{}]", mode_label); + + let mut header_left = Vec::new(); + if app.approval_mode != ApprovalMode::Normal { + header_left.push(Span::styled(mode_indicator, mode_style)); + header_left.push(Span::raw(" ")); + } + if !app.hide_cwd { + header_left.push(Span::styled(format!("{} ", current_dir), Style::default().fg(COLOR_SECONDARY))); + } + + let header_right_len = if app.hide_model_info { 0u16 } else { 25u16 }; + let header_layout = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Min(0), Constraint::Length(header_right_len)]).split(main_layout[0]); + f.render_widget(Paragraph::new(Line::from(header_left)), header_layout[0]); + + if !app.hide_model_info { + let version = env!("CARGO_PKG_VERSION"); + let header_title = format!(" RouteCode v{} ", version); + f.render_widget(Paragraph::new(Span::styled(header_title, Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD))).alignment(ratatui::layout::Alignment::Right), header_layout[1]); + } + let input_area = match app.screen { + Screen::Welcome => ui_welcome(f, app, main_layout[1]), + Screen::Session => super::session::ui_session(f, app, main_layout[1]), + }; + if app.show_menu { render_menu(f, app, input_area); } + else if app.show_provider_menu { render_provider_menu(f, app, input_area); } + else if app.show_model_menu { render_model_menu(f, app, input_area); } + else if app.show_settings_menu { render_settings_menu(f, app, input_area); } + else if app.is_inputting_api_key { render_api_key_dialog(f, app); } + else if app.pending_clear { render_confirmation_dialog(f, "Are you sure you want to clear all history? (y/n)"); } + else if app.pending_exit { render_confirmation_dialog(f, "Are you sure you want to exit RouteCode? (y/n)"); } + else if app.pending_command_confirmation.is_some() { render_command_confirmation_dialog(f, app); } + else if app.show_user_msg_modal.is_some() { render_user_msg_modal(f, app); } + else if app.pending_update.is_some() { render_update_modal(f, app); } + app.mouse_moved = false; +} + +fn render_command_confirmation_dialog(f: &mut Frame, app: &mut App) { + let area = f.size(); + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(30), + Constraint::Length(10), + Constraint::Percentage(30), + ]) + .split(area); + + let popup_horiz = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(15), + Constraint::Percentage(70), + Constraint::Percentage(15), + ]) + .split(popup_layout[1]); + + let inner_area = popup_horiz[1]; + + let block = Block::default() + .title(" Command Confirmation Required ") + .borders(ratatui::widgets::Borders::ALL) + .border_style(Style::default().fg(COLOR_PRIMARY)) + .style(Style::default().bg(COLOR_BG)); + + let (message, target, _) = app.pending_command_confirmation.as_ref().unwrap(); + + let mut lines = vec![ + Line::from(vec![Span::styled(message, Style::default().fg(COLOR_TEXT))]), + Line::from(vec![Span::styled(format!("> {}", target), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))]), + Line::from(""), + ]; + + if app.inputting_command_feedback { + lines.push(Line::from(vec![Span::styled("Please type your feedback below and press Enter (Esc to cancel):", Style::default().fg(COLOR_SECONDARY))])); + } else { + lines.push(Line::from(vec![ + Span::styled("[Y]", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(" Allow once "), + Span::styled("[S]", Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)), + Span::raw(" Allow for session "), + Span::styled("[W]", Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD)), + Span::raw(" Allow for Workspace "), + Span::styled("[F]", Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::BOLD)), + Span::raw(" Tell Agent something else "), + Span::styled("[D] or [Esc]", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw(" Deny"), + ])); + } + + let paragraph = Paragraph::new(lines).block(block).wrap(ratatui::widgets::Wrap { trim: false }); + f.render_widget(ratatui::widgets::Clear, inner_area); + f.render_widget(paragraph, inner_area); + + if app.inputting_command_feedback { + let input_rect = ratatui::layout::Rect { + x: inner_area.x + 2, + y: inner_area.y + 5, + width: inner_area.width.saturating_sub(4), + height: 3, + }; + let input_block = Block::default().borders(ratatui::widgets::Borders::ALL).border_style(Style::default().fg(COLOR_PRIMARY)); + app.input.set_block(input_block); + f.render_widget(app.input.widget(), input_rect); + f.set_cursor(input_rect.x + app.input.cursor().1 as u16 + 1, input_rect.y + app.input.cursor().0 as u16 + 1); + } +} + +fn render_confirmation_dialog(f: &mut Frame, message: &str) { + let area = f.size(); + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(40), + Constraint::Length(5), + Constraint::Percentage(40), + ]) + .split(area); + + let popup_horiz = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(50), + Constraint::Percentage(25), + ]) + .split(popup_layout[1]); + + let block = Block::default() + .title(" Confirmation ") + .borders(ratatui::widgets::Borders::ALL) + .border_style(Style::default().fg(COLOR_PRIMARY)); + + let p = Paragraph::new(Span::styled(message, Style::default().fg(COLOR_TEXT).add_modifier(Modifier::BOLD))) + .alignment(ratatui::layout::Alignment::Center) + .block(block); + + f.render_widget(ratatui::widgets::Clear, popup_horiz[1]); + f.render_widget(p, popup_horiz[1]); +} + +pub(crate) fn copy_to_clipboard(text: &str) -> std::io::Result<()> { + use std::io::Write; + use std::process::{Command, Stdio}; + let (prog, args): (&str, &[&str]) = if cfg!(target_os = "windows") { + ("clip", &[]) + } else if cfg!(target_os = "macos") { + ("pbcopy", &[]) + } else { + ("xclip", &["-selection", "clipboard"]) + }; + let mut child = Command::new(prog).args(args).stdin(Stdio::piped()).spawn()?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(text.as_bytes())?; + } + let result = child.wait(); + match result { + Ok(status) if status.success() => Ok(()), + Ok(_) => Err(std::io::Error::other("clipboard command failed")), + Err(e) => Err(e), + } +} + +fn render_user_msg_modal(f: &mut Frame, app: &mut App) { + let area = f.size(); + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(40), + Constraint::Length(8), + Constraint::Percentage(40), + ]) + .split(area); + + let popup_horiz = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), + Constraint::Percentage(40), + Constraint::Percentage(30), + ]) + .split(popup_layout[1]); + + let inner_area = popup_horiz[1]; + + let block = Block::default() + .title(" Message Action ") + .borders(ratatui::widgets::Borders::ALL) + .border_style(Style::default().fg(COLOR_PRIMARY)) + .style(Style::default().bg(COLOR_BG)); + + let options = ["Copy Message", "Rewind & Edit"]; + let mut lines = vec![ + Line::from(vec![Span::styled(" Choose an action:", Style::default().fg(COLOR_SECONDARY))]), + Line::from(""), + ]; + + for (idx, opt) in options.iter().enumerate() { + let is_selected = idx == app.user_msg_modal_selected; + let prefix = if is_selected { " -> " } else { " " }; + let style = if is_selected { + Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(COLOR_TEXT) + }; + lines.push(Line::from(vec![ + Span::styled(prefix, Style::default().fg(COLOR_PRIMARY)), + Span::styled(opt.to_string(), style), + ])); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled(" Press Enter/Click to select, Esc to close", Style::default().fg(COLOR_DIM))])); + + let paragraph = Paragraph::new(lines).block(block); + f.render_widget(ratatui::widgets::Clear, inner_area); + f.render_widget(paragraph, inner_area); +} + +fn render_update_modal(f: &mut Frame, app: &mut App) { + let version = app.pending_update.as_ref().unwrap(); + let area = f.size(); + + let modal_height = 12; + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(((area.height as f32 * 0.3) as u16).min(area.height.saturating_sub(modal_height + 2))), + Constraint::Length(modal_height), + Constraint::Percentage(((area.height as f32 * 0.3) as u16).min(area.height.saturating_sub(modal_height + 2))), + ]) + .split(area); + + let popup_horiz = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(15), + Constraint::Percentage(70), + Constraint::Percentage(15), + ]) + .split(popup_layout[1]); + + let inner_area = popup_horiz[1]; + + f.render_widget(ratatui::widgets::Clear, inner_area); + + let block = Block::default() + .borders(ratatui::widgets::Borders::ALL) + .border_style(Style::default().fg(COLOR_PRIMARY)) + .title(ratatui::widgets::block::Title::from(Span::styled(" Update Available ", Style::default().fg(COLOR_TEXT).add_modifier(Modifier::BOLD))).alignment(ratatui::layout::Alignment::Left)) + .title(ratatui::widgets::block::Title::from(Span::styled(" esc ", Style::default().fg(COLOR_DIM))).alignment(ratatui::layout::Alignment::Right)) + .style(Style::default().bg(COLOR_BG)); + + f.render_widget(block, inner_area); + + let content_area = ratatui::layout::Rect { + x: inner_area.x + 2, + y: inner_area.y + 1, + width: inner_area.width.saturating_sub(4), + height: inner_area.height.saturating_sub(4), + }; + + let mut lines = vec![ + Line::from(vec![Span::styled(format!("Version {} is available (current: {})", version, env!("CARGO_PKG_VERSION")), Style::default().fg(COLOR_TEXT))]), + Line::from(""), + ]; + + if !app.pending_update_changelog.is_empty() { + let changelog_lines: Vec<&str> = app.pending_update_changelog.lines().take(5).collect(); + for line in changelog_lines { + let trimmed = if line.len() > 60 { format!("{}...", &line[..57]) } else { line.to_string() }; + lines.push(Line::from(vec![Span::styled(trimmed.to_string(), Style::default().fg(COLOR_SECONDARY))])); + } + } + + let p = Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: true }); + f.render_widget(p, content_area); + + let button_area = ratatui::layout::Rect { + x: inner_area.x + 2, + y: inner_area.y + inner_area.height.saturating_sub(2), + width: inner_area.width.saturating_sub(4), + height: 1, + }; + + let skip_style = if app.update_modal_selected == 0 { Style::default().fg(Color::Black).bg(COLOR_TEXT).add_modifier(Modifier::BOLD) } else { Style::default().fg(COLOR_DIM) }; + let confirm_style = if app.update_modal_selected == 1 { Style::default().fg(Color::Black).bg(Color::Rgb(255, 179, 138)).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Rgb(255, 179, 138)) }; + + let buttons = Line::from(vec![ + Span::styled(" Skip ", skip_style), + Span::raw(" "), + Span::styled(" Confirm ", confirm_style), + ]); + + let p_buttons = Paragraph::new(buttons).alignment(ratatui::layout::Alignment::Right); + f.render_widget(p_buttons, button_area); +} + +/// Try to lock config with short retry loop for use in sync render paths. +/// Rare contention from config saves resolves within microseconds; this avoids +/// silently showing "Loading..." or stale fallbacks. +pub fn try_lock_config(app: &App) -> Option> { + for _ in 0..10 { + match app.orchestrator.config.try_lock() { + Ok(guard) => return Some(guard), + Err(_) => std::thread::sleep(std::time::Duration::from_micros(200)), + } + } + None +} diff --git a/apps/cli/src/ui/session.rs b/apps/cli/src/ui/session.rs index abc9493..6655397 100644 --- a/apps/cli/src/ui/session.rs +++ b/apps/cli/src/ui/session.rs @@ -147,8 +147,20 @@ pub fn ui_session(f: &mut Frame, app: &mut App, area: Rect) -> Rect { left_spans.push(Span::styled(format!(" {} ", cleaned_model), Style::default().fg(COLOR_PRIMARY).add_modifier(Modifier::BOLD))); left_spans.push(Span::styled(thinking_tag, Style::default().fg(COLOR_SYSTEM).add_modifier(Modifier::BOLD))); } + if let Some(qir) = app.qir_retry_status { + let (color, label) = if qir.is_recovered() { + (COLOR_SUCCESS, format!(" ∞ {} ", qir.label())) + } else { + (COLOR_SYSTEM, format!(" ∞ {} ", qir.label())) + }; + left_spans.push(Span::styled(label, Style::default().fg(color).add_modifier(Modifier::BOLD))); + } if !app.hide_context_summary { - left_spans.push(Span::styled(format!(" • Tokens: {} • Cost: ${:.4} ", app.usage.total_tokens, app.usage.total_cost), Style::default().fg(COLOR_SECONDARY))); + let mut summary = format!(" • Tokens: {} • Cost: ${:.4}", app.usage.total_tokens, app.usage.total_cost); + if app.usage.qir_attempts > 0 { + summary.push_str(&format!(" • Retries: {}", app.usage.qir_attempts)); + } + left_spans.push(Span::styled(summary, Style::default().fg(COLOR_SECONDARY))); left_spans.push(Span::styled(format!(" • Scroll: {}/{} ", app.history_scroll, app.max_scroll), Style::default().fg(COLOR_SECONDARY).add_modifier(Modifier::DIM))); } left_spans.push(Span::styled(generating_text, Style::default().fg(COLOR_SYSTEM))); diff --git a/apps/cli/src/ui/streaming.rs b/apps/cli/src/ui/streaming.rs new file mode 100644 index 0000000..a46bb50 --- /dev/null +++ b/apps/cli/src/ui/streaming.rs @@ -0,0 +1,154 @@ +use routecode_sdk::agents::types::ConfirmationResponse; +use routecode_sdk::agents::StreamChunk; +use routecode_sdk::core::{Message, Role}; +use tui_textarea::TextArea; + +use super::app::App; +use super::types::{format_error_for_display, parse_qir_status, ApprovalMode, Screen}; + +pub(crate) async fn handle_stream_chunks(app: &mut App) { + let max_per_frame: u32 = 50; + let mut processed: u32 = 0; + while processed < max_per_frame { + let chunk = match app.rx.try_recv() { + Ok(c) => c, + Err(_) => break, + }; + processed += 1; + // Throttle cache invalidation: mark dirty only once per burst + if processed <= 1 { + app.render_dirty = true; + } + match chunk { + StreamChunk::Text { content } => { + if let Some(last) = app.history.last_mut() { + if last.role == Role::Assistant { + let mut current = last.content.as_ref().map(|s| s.to_string()).unwrap_or_default(); + current.push_str(&content); + last.content = Some(std::sync::Arc::from(current)); + } else { app.history.push(Message::assistant(Some(std::sync::Arc::from(content)), None, None)); } + } else { app.history.push(Message::assistant(Some(std::sync::Arc::from(content)), None, None)); } + } + StreamChunk::Thought { content } => { + if let Some(last) = app.history.last_mut() { + if last.role == Role::Assistant { + let mut current = last.thought.as_ref().map(|s| s.to_string()).unwrap_or_default(); + current.push_str(&content); + last.thought = Some(std::sync::Arc::from(current)); + } else { app.history.push(Message::assistant(None, Some(std::sync::Arc::from(content)), None)); } + } else { app.history.push(Message::assistant(None, Some(std::sync::Arc::from(content)), None)); } + } + StreamChunk::ToolCall { tool_call } => { + app.active_tool = Some(tool_call.function.name.clone()); + if let Some(last) = app.history.last_mut() { + if last.role == Role::Assistant { + let mut calls = last.tool_calls.clone().unwrap_or_default(); + if let Some(idx) = tool_call.index { if let Some(existing) = calls.iter_mut().find(|tc| tc.index == Some(idx)) { *existing = tool_call; } else { calls.push(tool_call); } } + else if !calls.iter().any(|tc| tc.id == tool_call.id && !tc.id.is_empty()) { calls.push(tool_call); } + last.tool_calls = Some(calls); + } else { app.history.push(Message::assistant(None, None, Some(vec![tool_call]))); } + } else { app.history.push(Message::assistant(None, None, Some(vec![tool_call]))); } + } + StreamChunk::ToolResult { name, content, tool_call_id } => { app.active_tool = None; app.history.push(Message::tool(tool_call_id, name, content)); } + StreamChunk::Status { content } => { + if let Some(qir) = parse_qir_status(&content) { + app.qir_retry_status = Some(qir); + } + app.history.push(Message::system(format!("[QIR] {}", content))); + } + StreamChunk::SessionStats { total_tokens, total_cost, qir_attempts } => { + app.usage.total_tokens = total_tokens; + app.usage.total_cost = total_cost; + app.usage.qir_attempts = qir_attempts; + } + StreamChunk::Done => { + app.is_generating = false; + app.active_tool = None; + app.qir_retry_status = None; + if !app.history.is_empty() { + let session = routecode_sdk::utils::storage::Session { + messages: app.history.clone(), + model: app.current_model.clone(), + usage: app.orchestrator.usage.lock().await.clone(), + timestamp: chrono::Utc::now().timestamp(), + }; + if let Err(e) = routecode_sdk::utils::storage::save_session(&app.session_id, &session) { + log::error!("Failed to auto-save session: {}", e); + } + } + } + StreamChunk::Error { content } => { + let display = format_error_for_display(&content); + app.history.push(Message::system(format!("Error: {}", display))); + app.is_generating = false; + app.active_tool = None; + app.qir_retry_status = None; + } + StreamChunk::Models { models } => { + app.all_available_models.extend(models); + let search = app.model_search_input.lines().first().map(|l| l.trim().to_lowercase()).unwrap_or_default(); + super::logic::handle_model_search(app, &search, false).await; + } + StreamChunk::ModelsDone => { + app.is_fetching_models = false; + if !app.startup_ready { + app.startup_ready = true; + let buffered = app.startup_input_buffer.drain(..).collect::>(); + for msg in buffered { + app.history.push(Message::user(msg)); + app.screen = Screen::Session; + app.prompt_history.truncate(100); + app.prompt_history_index = None; + app.input = TextArea::default(); + app.is_generating = true; + app.auto_scroll = true; + let orchestrator = app.orchestrator.clone(); + let mut history = app.history.clone(); + let model = app.current_model.clone(); + let tx = app.tx.clone(); + app.tasks.spawn(async move { + if let Err(e) = orchestrator.run(&mut history, &model, Some(tx), None).await { + log::error!("Orchestrator run failed: {}", e); + } + }); + } + } + } + StreamChunk::FinalHistory { history } => { app.history = history; } + StreamChunk::RequestConfirmation { message, target, tx } => { + match app.approval_mode { + ApprovalMode::YOLO | ApprovalMode::Shell => { + if let Some(sender) = tx { + let mut tx_opt = sender.lock().await; + if let Some(tx) = tx_opt.take() { + let _ = tx.send(ConfirmationResponse::AllowOnce); + } + } + } + ApprovalMode::Plan => { + if let Some(sender) = tx { + let mut tx_opt = sender.lock().await; + if let Some(tx) = tx_opt.take() { + let _ = tx.send(ConfirmationResponse::Deny); + } + } + } + ApprovalMode::Normal => { + if let Some(sender) = tx { + app.pending_command_confirmation = Some((message, target, sender)); + } else { + log::error!("RequestConfirmation received without a response channel"); + } + } + } + } + StreamChunk::UpdateAvailable { version, changelog, published_at } => { + app.pending_update = Some(version); + app.pending_update_changelog = changelog; + app.pending_update_published_at = published_at; + app.update_modal_selected = 1; + } + _ => {} + } + } +} diff --git a/apps/cli/src/ui/types.rs b/apps/cli/src/ui/types.rs new file mode 100644 index 0000000..1455661 --- /dev/null +++ b/apps/cli/src/ui/types.rs @@ -0,0 +1,352 @@ +use routecode_sdk::agents::types::ConfirmationResponse; +use routecode_sdk::core::DynamicModelInfo; + +pub type ConfirmationSender = std::sync::Arc>>>; + +pub struct ProviderInfo { + pub id: &'static str, + pub name: &'static str, + /// Whether this provider requires a user-supplied API key. Keyless + /// providers (e.g. OpenCode Zen/Go) skip the "no key configured" gate. + pub requires_api_key: bool, +} + +pub const PROVIDERS: &[ProviderInfo] = &[ + ProviderInfo { id: "openrouter", name: "OpenRouter", requires_api_key: true }, + ProviderInfo { id: "nvidia", name: "NVIDIA", requires_api_key: true }, + ProviderInfo { id: "opencode-zen", name: "OpenCode Zen", requires_api_key: false }, + ProviderInfo { id: "opencode-go", name: "OpenCode Go", requires_api_key: false }, + ProviderInfo { id: "openai", name: "OpenAI", requires_api_key: true }, + ProviderInfo { id: "anthropic", name: "Anthropic", requires_api_key: true }, + ProviderInfo { id: "gemini", name: "Google Gemini", requires_api_key: true }, + ProviderInfo { id: "deepseek", name: "DeepSeek", requires_api_key: true }, + ProviderInfo { id: "cloudflare-workers", name: "Cloudflare Workers AI", requires_api_key: true }, + ProviderInfo { id: "cloudflare-gateway", name: "Cloudflare AI Gateway", requires_api_key: true }, + ProviderInfo { id: "vertex", name: "Google Vertex AI", requires_api_key: true }, +]; + +/// Look up a `ProviderInfo` by id. Returns `None` for unknown providers +/// (e.g. legacy config values) -- callers must treat `None` as "unknown, +/// require a key" for safety. +pub fn provider_info(id: &str) -> Option<&'static ProviderInfo> { + PROVIDERS.iter().find(|p| p.id == id) +} + +/// Whether the given provider requires a user-supplied API key. Unknown +/// providers default to `true` (require a key) so the user is prompted +/// rather than silently failing. +pub fn provider_requires_api_key(id: &str) -> bool { + provider_info(id).is_none_or(|p| p.requires_api_key) +} + +#[derive(Clone, Debug)] +pub enum ModelMenuItem { + Header(String), + Model(DynamicModelInfo), +} + +pub struct Command { + pub name: &'static str, + pub description: &'static str, +} + +pub const COMMANDS: &[Command] = &[ + Command { name: "/model", description: "Switch model" }, + Command { name: "/resume", description: "Resume a session" }, + Command { name: "/sessions", description: "List saved sessions" }, + Command { name: "/clear", description: "Clear history" }, + Command { name: "/thinking", description: "Set thinking level (low/max)" }, + Command { name: "/help", description: "Show help" }, + Command { name: "/stop", description: "Stop AI generation" }, + Command { name: "/provider", description: "Manage providers" }, + Command { name: "/settings", description: "Manage settings" }, + Command { name: "/exit", description: "Exit application" }, +]; + +#[derive(Debug, PartialEq, Clone, Copy)] +#[allow(clippy::upper_case_acronyms)] +pub enum ApprovalMode { + Normal, + Plan, + YOLO, + Shell, +} + +impl ApprovalMode { + pub fn next(&self) -> Self { + match self { + ApprovalMode::Normal => ApprovalMode::Plan, + ApprovalMode::Plan => ApprovalMode::YOLO, + ApprovalMode::YOLO => ApprovalMode::Shell, + ApprovalMode::Shell => ApprovalMode::Normal, + } + } + + pub fn label(&self) -> &'static str { + match self { + ApprovalMode::Normal => "NORMAL", + ApprovalMode::Plan => "PLAN", + ApprovalMode::YOLO => "YOLO", + ApprovalMode::Shell => "SHELL", + } + } +} + +#[derive(Debug, PartialEq)] +pub enum Screen { + Welcome, + Session, +} + +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum ApiKeyInputStage { + None, + ApiKey, + VertexLocation, + CloudflareAccountId, + CloudflareGatewayId, + CloudflareApiKey, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum SettingsMenuItem { + Header(String), + Option { name: String, val: String, key: String }, +} + +/// State of the most recent QIR retry event emitted by the SDK's +/// `RetryingProvider` wrapper. Mirrors the desktop app's `qirRetryStatus` +/// state and is rendered in the status bar so the user can see retries in +/// real time even when the relevant chunks have scrolled out of history. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum QirStatus { + Retrying { attempt: u32 }, + StreamInterrupted { attempt: u32 }, + Recovered { attempt: u32 }, +} + +impl QirStatus { + pub fn label(&self) -> String { + match self { + QirStatus::Retrying { attempt } => format!("retring (attempt {})", attempt), + QirStatus::StreamInterrupted { attempt } => { + format!("stream interrupted (attempt {})", attempt) + } + QirStatus::Recovered { attempt } => format!("recovered on attempt {}", attempt), + } + } + + pub fn is_recovered(&self) -> bool { + matches!(self, QirStatus::Recovered { .. }) + } +} + +/// Parse a `StreamChunk::Status` content string emitted by the SDK's +/// `RetryingProvider` wrapper into a `QirStatus`. Recognized prefixes +/// (stable, see `libs/sdk/src/agents/retry.rs`): +/// +/// - `"QIR retrying (attempt N) -- ..."` +/// - `"QIR stream interrupted (attempt N) -- ..."` +/// - `"QIR recovered on attempt N"` +/// +/// Returns `None` for any other content (so non-QIR status chunks are +/// ignored). +pub fn parse_qir_status(content: &str) -> Option { + let attempt_of = |s: &str| -> Option { + const KEY: &str = "attempt "; + let i = s.find(KEY)? + KEY.len(); + let rest = &s[i..]; + let end = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); + rest[..end].parse().ok() + }; + if content.starts_with("QIR recovered") { + Some(QirStatus::Recovered { attempt: attempt_of(content)? }) + } else if content.starts_with("QIR stream interrupted") { + Some(QirStatus::StreamInterrupted { attempt: attempt_of(content)? }) + } else if content.starts_with("QIR retrying") { + Some(QirStatus::Retrying { attempt: attempt_of(content)? }) + } else { + None + } +} + +/// Format a `StreamChunk::Error` content string for display. +/// +/// The content originates in `orchestrator.run()` wrapping `anyhow::Error` +/// via `Display`. The SDK's `HttpStatusError` produces +/// `"HTTP : "` -- we detect that prefix, surface the +/// status code (so the user can tell 401/403/429/5xx apart at a glance), +/// and extract a human-readable message from the JSON body when possible. +/// Anything that doesn't match the prefix is returned unchanged (covers +/// transport errors, `StreamChunk::Error` mid-stream, etc.). +pub fn format_error_for_display(content: &str) -> String { + let Some(rest) = content.strip_prefix("HTTP ") else { + return content.to_string(); + }; + let Some(colon_idx) = rest.find(':') else { + return rest.trim().to_string(); + }; + let status_line = rest[..colon_idx].trim(); + let body = rest[colon_idx + 1..].trim(); + let message = extract_message_from_json(body).unwrap_or_else(|| body.to_string()); + if message.is_empty() { + status_line.to_string() + } else { + format!("{}: {}", status_line, message) + } +} + +/// Try to extract a human message from a JSON body, walking the common +/// provider shapes. Returns `None` if the body is not JSON or matches no +/// known shape (callers fall through to the raw body). +fn extract_message_from_json(body: &str) -> Option { + if !body.starts_with('{') { + return None; + } + let val: serde_json::Value = serde_json::from_str(body).ok()?; + if let Some(msg) = val["error"]["message"].as_str() { + return Some(msg.to_string()); + } + if let Some(error_obj) = val["error"].as_object() { + if let Some(msg) = error_obj["message"].as_str() { + return Some(msg.to_string()); + } + } + if let Some(msg) = val["error"].as_str() { + return Some(msg.to_string()); + } + if let Some(msg) = val["message"].as_str() { + return Some(msg.to_string()); + } + if let Some(errors) = val["errors"].as_array() { + if let Some(msg) = errors.first().and_then(|e| e["message"].as_str()) { + return Some(msg.to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_qir_status_retrying() { + let s = parse_qir_status("QIR retrying (attempt 3) -- 503 Service Unavailable"); + assert_eq!(s, Some(QirStatus::Retrying { attempt: 3 })); + } + + #[test] + fn test_parse_qir_status_stream_interrupted() { + let s = parse_qir_status("QIR stream interrupted (attempt 7) -- connection reset"); + assert_eq!(s, Some(QirStatus::StreamInterrupted { attempt: 7 })); + } + + #[test] + fn test_parse_qir_status_recovered() { + let s = parse_qir_status("QIR recovered on attempt 4"); + assert_eq!(s, Some(QirStatus::Recovered { attempt: 4 })); + } + + #[test] + fn test_parse_qir_status_ignores_unrelated() { + assert_eq!(parse_qir_status("Thinking..."), None); + assert_eq!(parse_qir_status(""), None); + assert_eq!(parse_qir_status("QIR something else"), None); + } + + #[test] + fn test_qir_status_label_and_recovered() { + let r = QirStatus::Retrying { attempt: 2 }; + assert!(!r.is_recovered()); + assert!(r.label().contains("attempt 2")); + let ok = QirStatus::Recovered { attempt: 3 }; + assert!(ok.is_recovered()); + assert!(ok.label().contains("attempt 3")); + } + + #[test] + fn test_format_error_http_with_json_body() { + // OpenAI-style: {"error": {"message": "Incorrect API key provided"}} + let body = r#"HTTP 401 Unauthorized: {"error":{"message":"Incorrect API key provided","type":"invalid_request_error"}}"#; + let s = format_error_for_display(body); + assert!(s.contains("401 Unauthorized"), "{}", s); + assert!(s.contains("Incorrect API key provided"), "{}", s); + assert!(!s.contains("invalid_request_error"), "raw JSON leaked: {}", s); + } + + #[test] + fn test_format_error_http_with_plain_body() { + let s = format_error_for_display("HTTP 503 Service Unavailable: rate limited"); + assert!(s.contains("503 Service Unavailable"), "{}", s); + assert!(s.contains("rate limited"), "{}", s); + } + + #[test] + fn test_format_error_http_with_top_level_message() { + // Some providers put `message` at the top level. + let body = r#"HTTP 400 Bad Request: {"message":"prompt too long"}"#; + let s = format_error_for_display(body); + assert!(s.contains("400 Bad Request"), "{}", s); + assert!(s.contains("prompt too long"), "{}", s); + } + + #[test] + fn test_format_error_http_with_errors_array() { + // Some providers return an array of errors. + let body = r#"HTTP 422 Unprocessable Entity: {"errors":[{"message":"field x required"}]}"#; + let s = format_error_for_display(body); + assert!(s.contains("422"), "{}", s); + assert!(s.contains("field x required"), "{}", s); + } + + #[test] + fn test_format_error_http_no_body() { + let s = format_error_for_display("HTTP 500 Internal Server Error"); + assert!(s.contains("500 Internal Server Error"), "{}", s); + } + + #[test] + fn test_format_error_non_http_passthrough() { + // Transport errors, mid-stream "Provider error: ..." etc. + assert_eq!(format_error_for_display("Provider error: rate-limited"), "Provider error: rate-limited"); + assert_eq!(format_error_for_display("connection reset"), "connection reset"); + assert_eq!(format_error_for_display(""), ""); + } + + #[test] + fn test_keyless_providers_are_marked() { + assert!(!provider_requires_api_key("opencode-zen")); + assert!(!provider_requires_api_key("opencode-go")); + } + + #[test] + fn test_known_providers_require_key() { + for id in ["openrouter", "openai", "anthropic", "gemini", "deepseek", "vertex", "cloudflare-workers", "cloudflare-gateway", "nvidia"] { + assert!( + provider_requires_api_key(id), + "{} should require an API key", + id + ); + } + } + + #[test] + fn test_unknown_provider_defaults_to_require_key() { + // Fail-closed: an unknown provider id (e.g. legacy config) must + // not silently skip the "enter your key" gate. + assert!(provider_requires_api_key("does-not-exist")); + } + + #[test] + fn test_every_provider_id_is_unique() { + // Catches copy-paste additions to the PROVIDERS table. + let mut seen: Vec<&str> = PROVIDERS.iter().map(|p| p.id).collect(); + seen.sort(); + let original_len = seen.len(); + seen.dedup(); + assert_eq!(seen.len(), original_len, "duplicate provider id in PROVIDERS"); + } +} diff --git a/apps/desktop-t/src-tauri/Cargo.toml b/apps/desktop-t/src-tauri/Cargo.toml index 17cbb71..d9313f9 100644 --- a/apps/desktop-t/src-tauri/Cargo.toml +++ b/apps/desktop-t/src-tauri/Cargo.toml @@ -24,5 +24,6 @@ tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1.37", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } routecode-sdk = { path = "../../../libs/sdk" } diff --git a/apps/desktop-t/src-tauri/src/lib.rs b/apps/desktop-t/src-tauri/src/lib.rs index ecd82d4..cd4cd4a 100644 --- a/apps/desktop-t/src-tauri/src/lib.rs +++ b/apps/desktop-t/src-tauri/src/lib.rs @@ -1,16 +1,20 @@ use std::sync::Arc; use tokio::sync::Mutex; use tauri::{AppHandle, Emitter, State, Manager}; +use tokio_util::sync::CancellationToken; use routecode_sdk::core::{AgentOrchestrator, Message, Config}; use routecode_sdk::agents::types::{ConfirmationResponse, StreamChunk}; -use routecode_sdk::tools::ToolRegistry; -use routecode_sdk::tools::bash::BashTool; -use routecode_sdk::tools::file_ops::{FileEditTool, FileReadTool, FileWriteTool}; -use routecode_sdk::tools::navigation::{GrepTool, LsTool, TreeTool}; +use routecode_sdk::tools::{ + bash::BashTool, file_ops::{FileEditTool, FileReadTool, FileWriteTool, ApplyPatchTool}, + lsp_tool::LspTool, mcp::manager::McpManager, navigation::{GrepTool, LsTool, TreeTool}, + subagent::SubAgentTool, web::{fetch::WebFetchTool, search::WebSearchTool}, + ToolRegistry, +}; use routecode_sdk::utils::storage::{ - Session, save_session, load_session, list_sessions, sanitize_session_name, - find_project_root, get_base_dir + Session, SessionConfig, WorkspaceConfig, save_session, load_session, list_sessions, + load_session_config, save_session_config, load_workspace_config, save_workspace_config, + sanitize_session_name, find_project_root, get_base_dir }; type PendingConfirmation = Arc>>>; @@ -19,6 +23,7 @@ type PendingConfirmation = Arc>>, pub pending_confirmation: Mutex>, + pub cancel_token: Mutex>, } impl Default for AppState { @@ -32,6 +37,7 @@ impl AppState { Self { orchestrator: Mutex::new(None), pending_confirmation: Mutex::new(None), + cancel_token: Mutex::new(None), } } } @@ -153,6 +159,7 @@ async fn init_engine( routecode_sdk::agents::resolve_provider(&provider_name, api_key) }; + // Register Secure Tools into Registry let mut tool_registry = ToolRegistry::new(); tool_registry.register(Arc::new(FileReadTool)); @@ -162,6 +169,26 @@ async fn init_engine( tool_registry.register(Arc::new(LsTool)); tool_registry.register(Arc::new(TreeTool)); tool_registry.register(Arc::new(GrepTool)); + tool_registry.register(Arc::new(LspTool::new())); + tool_registry.register(Arc::new(ApplyPatchTool)); + tool_registry.register(Arc::new(WebFetchTool)); + tool_registry.register(Arc::new(WebSearchTool)); + + // Initialize MCP Manager and load dynamic tools + let mcp_manager = McpManager::new(); + if let Err(e) = mcp_manager.load_and_register_tools(&mut tool_registry).await { + println!("Warning: Failed to load MCP tools: {}", e); + } + + if config.sub_agents_enabled { + let registry_clone = Arc::new(tool_registry.clone()); + tool_registry.register(Arc::new(SubAgentTool::new( + provider.clone(), + registry_clone, + Arc::new(Mutex::new(config.clone())), + ))); + } + let tool_registry = Arc::new(tool_registry); // Build the Mutex Config and Orchestrator @@ -179,6 +206,32 @@ async fn init_engine( Ok("RouteCode SDK Engine Initialized Successfully".to_string()) } +// 7b. Fetch available models for a given provider using its API key. This +// mirrors the CLI's `/model` flow: resolve the provider trait, call +// `list_models()` (which hits the OpenAI-compatible `/models` endpoint for +// most providers, the Anthropic/Cloudflare fallbacks for those). +#[tauri::command] +async fn fetch_provider_models( + provider_id: String, + api_key: String, +) -> Result, String> { + println!("Fetching models for provider={}", provider_id); + + if provider_id.trim().is_empty() { + return Err("provider_id is empty".to_string()); + } + + let provider = routecode_sdk::agents::resolve_provider(&provider_id, api_key); + let mut models = provider + .list_models() + .await + .map_err(|e| format!("Failed to list models for {}: {}", provider_id, e))?; + + models.sort(); + models.dedup(); + Ok(models) +} + // 8. Stream Agent Response Command #[tauri::command] async fn send_message( @@ -198,12 +251,25 @@ async fn send_message( } }; + // Mint a fresh cancellation token for this request. Cancel any prior + // in-flight request first to keep the invariant that at most one + // request is active at a time. + let cancel_token = CancellationToken::new(); + { + let mut guard = state.cancel_token.lock().await; + if let Some(prev) = guard.take() { + prev.cancel(); + } + *guard = Some(cancel_token.clone()); + } + // Run the orchestrator in a spawned background thread let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); let mut history_mut = history.clone(); + let cancel_for_task = cancel_token.clone(); tokio::spawn(async move { - let _ = orchestrator.run(&mut history_mut, &model, Some(tx)).await; + let _ = orchestrator.run(&mut history_mut, &model, Some(tx), Some(cancel_for_task)).await; }); // Listen to the unbounded channel and stream to the frontend @@ -238,13 +304,86 @@ async fn send_message( Ok("Streaming started".to_string()) } -// 9. User confirmation Response Command +// 8b. Cancel an in-flight agent run +#[tauri::command] +async fn cancel_message(state: State<'_, AppState>) -> Result { + println!("Cancellation requested for in-flight agent run"); + + let token = { + let guard = state.cancel_token.lock().await; + guard.clone() + }; + + match token { + Some(t) => { + t.cancel(); + Ok("Cancellation signal sent to agent".to_string()) + } + None => Err("No in-flight agent run to cancel".to_string()), + } +} + +// 9. User confirmation Response Command. The `response` string maps to a +// `ConfirmationResponse` variant the orchestrator awaits on: +// +// "allow_once" -> AllowOnce +// "allow_session" -> AllowSession (sets the in-memory atomic and +// persists `allow_all_commands` / +// `allow_all_outside_access` to the active session) +// "allow_workspace" -> AllowWorkspace (sets the atomic, persists to the +// active session, AND to the workspace config so +// every session in this workspace inherits it) +// "deny" -> Deny +// "feedback:" -> Feedback(String) — the LLM sees the feedback as a +// denial reason #[tauri::command] async fn respond_confirmation( state: State<'_, AppState>, - allowed: bool, + app: AppHandle, + response: String, + session_name: Option, ) -> Result { - println!("User responded to confirmation dialog: allowed={}", allowed); + println!("User responded to confirmation: {}", response); + + let parsed = parse_confirmation_response(&response); + + // For session/workspace allows we also flip the orchestrator's atomic + // eagerly and persist the change. The orchestrator's own branch in the + // await arm does the same flip; doing it here lets the modal close + // immediately and the next tool call in this session skips the prompt. + if matches!( + parsed, + ConfirmationResponse::AllowSession | ConfirmationResponse::AllowWorkspace + ) { + let orch_guard = state.orchestrator.lock().await; + if let Some(orch) = orch_guard.as_ref() { + use std::sync::atomic::Ordering; + orch.allow_session_commands.store(true, Ordering::SeqCst); + orch.allow_session_outside_access.store(true, Ordering::SeqCst); + } + + if let Some(name) = session_name.as_deref() { + // Persist session-level permission. + if let Ok(mut sc) = load_session_config(name) { + sc.allow_all_commands = true; + sc.allow_all_outside_access = true; + if let Err(e) = save_session_config(name, &sc) { + eprintln!("Failed to save session config: {}", e); + } + } + // For AllowWorkspace also persist to workspace config. + if matches!(parsed, ConfirmationResponse::AllowWorkspace) { + let wc = WorkspaceConfig { + allow_all_outside_access: true, + allowed_outside_paths: vec![], + }; + if let Err(e) = save_workspace_config(&wc) { + eprintln!("Failed to save workspace config: {}", e); + } + } + } + let _ = app; + } let sender_opt = { let mut pending_guard = state.pending_confirmation.lock().await; @@ -254,13 +393,7 @@ async fn respond_confirmation( if let Some(tx_mutex) = sender_opt { let mut tx_guard = tx_mutex.lock().await; if let Some(tx) = tx_guard.take() { - let response = if allowed { - ConfirmationResponse::AllowOnce - } else { - ConfirmationResponse::Deny - }; - - let _ = tx.send(response); + let _ = tx.send(parsed); return Ok("Permission response sent to agent".to_string()); } } @@ -268,6 +401,70 @@ async fn respond_confirmation( Err("No pending confirmation request found".to_string()) } +fn parse_confirmation_response(s: &str) -> ConfirmationResponse { + if let Some(rest) = s.strip_prefix("feedback:") { + ConfirmationResponse::Feedback(rest.to_string()) + } else { + match s { + "allow_once" => ConfirmationResponse::AllowOnce, + "allow_session" => ConfirmationResponse::AllowSession, + "allow_workspace" => ConfirmationResponse::AllowWorkspace, + "deny" => ConfirmationResponse::Deny, + // Back-compat: a bare "true"/"false" still maps to allow-once/deny + "true" => ConfirmationResponse::AllowOnce, + "false" => ConfirmationResponse::Deny, + other => ConfirmationResponse::Feedback(format!( + "Unknown response '{}' treated as deny", + other + )), + } + } +} + +// 9b. Load per-session sandbox config. Defaults to `SessionConfig::default()` +// (all denies) if no file exists on disk. +#[tauri::command] +async fn load_session_config_cmd(name: String) -> Result { + load_session_config(&name).map_err(|e| format!("Failed to load session config: {}", e)) +} + +// 9c. Persist per-session sandbox config. Used by the React side when the +// user clicks "Allow for this session" in the confirmation modal. +#[tauri::command] +async fn save_session_config_cmd(name: String, config: SessionConfig) -> Result { + save_session_config(&name, &config) + .map_err(|e| format!("Failed to save session config: {}", e))?; + Ok("Session config saved".to_string()) +} + +// 9d. Apply a session's persisted sandbox flags to the running orchestrator's +// atomics. Called on session load / tab switch so the user's previous +// "Allow for this session" choice is honored on the next run. +#[tauri::command] +async fn set_session_permissions( + state: State<'_, AppState>, + name: String, +) -> Result { + let sc = load_session_config(&name).map_err(|e| format!("Failed to load session config: {}", e))?; + let wc = load_workspace_config().unwrap_or_default(); + + use std::sync::atomic::Ordering; + let orch_guard = state.orchestrator.lock().await; + if let Some(orch) = orch_guard.as_ref() { + let allow_commands = sc.allow_all_commands || wc.allow_all_outside_access; + orch.allow_session_commands.store(allow_commands, Ordering::SeqCst); + orch.allow_session_outside_access + .store(sc.allow_all_outside_access || wc.allow_all_outside_access, Ordering::SeqCst); + } + Ok(sc) +} + +// 9e. Load workspace config (per-folder sandbox rules). +#[tauri::command] +async fn load_workspace_config_cmd() -> Result { + Ok(load_workspace_config().unwrap_or_default()) +} + // 10. Check for updates #[tauri::command] async fn check_update(app: AppHandle) -> Result { @@ -341,8 +538,14 @@ pub fn run() { .manage(AppState::new()) .invoke_handler(tauri::generate_handler![ init_engine, + fetch_provider_models, send_message, + cancel_message, respond_confirmation, + load_session_config_cmd, + save_session_config_cmd, + set_session_permissions, + load_workspace_config_cmd, get_config, save_config, list_saved_sessions, diff --git a/apps/desktop-t/src/App.tsx b/apps/desktop-t/src/App.tsx index d82705a..64723b6 100644 --- a/apps/desktop-t/src/App.tsx +++ b/apps/desktop-t/src/App.tsx @@ -1,19 +1,46 @@ -import React, { useState, useRef, useEffect } from "react"; -import { - ChevronRight, - Trash2, - Download +import { useState, useRef, useEffect } from "react"; +import { + Trash2, + Download, + Coins, + Infinity as InfinityIcon, + Command as CommandIcon, + Search, } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; // Import Modular Components -import Sidebar from "./components/Sidebar"; +import TabBar from "./components/TabBar"; import SettingsModal from "./components/SettingsModal"; import ConfirmationModal from "./components/ConfirmationModal"; import ChatArea from "./components/ChatArea"; import ChatInput from "./components/ChatInput"; import UpdateModal from "./components/UpdateModal"; +import PromptModal from "./components/PromptModal"; +import Toaster from "./components/Toaster"; +import CommandPalette from "./components/CommandPalette"; +import ErrorBoundary from "./components/ErrorBoundary"; +import { ToastProvider, useToast } from "./lib/toast"; +import type { CommandContext } from "./lib/commands"; +import { fetchProviderModels, type FetchResult } from "./lib/models"; +import ModeIndicator, { type ApprovalMode } from "./components/ModeIndicator"; + +export type ToolEventStatus = "pending" | "running" | "success" | "error" | "denied"; + +export interface ToolEvent { + id: string; + name: string; + args: string; + status: ToolEventStatus; + resultRaw?: string; + resultContent?: string; + resultError?: string; + resultDiff?: string; + resultSuccess?: boolean; + startedAt: number; + finishedAt?: number; +} interface Message { id: string; @@ -22,6 +49,8 @@ interface Message { model?: string; thought?: string; isStreaming?: boolean; + qirStatus?: string; + toolEvents?: ToolEvent[]; } // SDK-compatible message shape @@ -31,7 +60,28 @@ interface SDKMessage { reasoning_content?: string | null; } +const PROVIDER_OPTIONS = [ + { id: "anthropic", label: "Anthropic" }, + { id: "openai", label: "OpenAI" }, + { id: "openrouter", label: "OpenRouter" }, + { id: "deepseek", label: "DeepSeek" }, + { id: "google", label: "Google" }, + { id: "nvidia", label: "NVIDIA NIM" }, + { id: "cloudflare-workers", label: "Cloudflare Workers" }, +]; + export default function App() { + return ( + + + + + + + ); +} + +function AppInner() { // Session States const [sessions, setSessions] = useState([]); const [activeSession, setActiveSession] = useState("default_session"); @@ -50,6 +100,8 @@ export default function App() { // Persistent SDK Config Values const [activeProvider, setActiveProvider] = useState("anthropic"); const [activeModel, setActiveModel] = useState("claude-sonnet-4-5"); + const [quickInfiniteRetry, setQuickInfiniteRetry] = useState(false); + const [approvalMode, setApprovalMode] = useState("normal"); const [apiKeys, setApiKeys] = useState>({ anthropic: "", openai: "", @@ -62,14 +114,32 @@ export default function App() { // UI & UX States const [inputValue, setInputValue] = useState(""); - const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [isGenerating, setIsGenerating] = useState(false); + const [qirRetryStatus, setQirRetryStatus] = useState(null); + const [agentStatus, setAgentStatus] = useState(null); + const [sessionStats, setSessionStats] = useState<{ totalTokens: number; totalCost: number; qirAttempts: number }>({ + totalTokens: 0, + totalCost: 0, + qirAttempts: 0, + }); const [showUpdateModal, setShowUpdateModal] = useState(false); const [updateInfo, setUpdateInfo] = useState(null); const [isInstalling, setIsInstalling] = useState(false); const [installComplete, setInstallComplete] = useState(false); + const [promptModalOpen, setPromptModalOpen] = useState(false); + + // Per-provider model cache. Populated by fetching from the provider's + // `/models` endpoint (same path the CLI uses) when the user opens the + // model menu or saves a new API key. `null` means "not yet fetched". + const [providerModels, setProviderModels] = useState>({}); + const [fetchingProviderFor, setFetchingProviderFor] = useState(null); + + // Global shell state + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const toast = useToast(); + const [expandedThoughts, setExpandedThoughts] = useState>({ welcome: true }); @@ -78,7 +148,8 @@ export default function App() { const [modalDetails, setModalDetails] = useState<{ command: string; cwd: string; - }>({ command: "", cwd: "" }); + toolName: string; + }>({ command: "", cwd: "", toolName: "bash" }); const messagesEndRef = useRef(null); const textareaRef = useRef(null); @@ -109,16 +180,38 @@ export default function App() { console.log("Loaded persistent config:", cfg); if (cfg.provider) setActiveProvider(cfg.provider); if (cfg.model) setActiveModel(cfg.model); + // Read the retry policy. Accept both the new tagged shape + // (`retry_policy: {strategy: "qir"}`) and the legacy bool + // (`quick_infinite_retry: true`) for backward compat. + const policy = cfg.retry_policy; + if (policy && typeof policy === "object" && "strategy" in policy) { + setQuickInfiniteRetry(policy.strategy === "qir"); + } else if (typeof cfg.quick_infinite_retry === "boolean") { + setQuickInfiniteRetry(cfg.quick_infinite_retry); + } if (cfg.api_keys) { setApiKeys(prev => ({ ...prev, ...cfg.api_keys })); } - + if (cfg.approval_mode) { + const am = cfg.approval_mode; + if (am && typeof am === "object" && "strategy" in am) { + setApprovalMode(am.strategy === "yolo" ? "yolo" : "normal"); + } + } + // Once config loads, trigger active engine initialization invoke("init_engine", { providerName: cfg.provider || "anthropic", modelName: cfg.model || "claude-sonnet-4-5" }) .catch(err => console.error("Initial init_engine failed:", err)); + + // Auto-fetch models for the active provider. Uses the same flow as + // the CLI's `/model` command. Result populates the model menu + // dropdown so the user can pick a real model without typing the id. + const activeProv = cfg.provider || "anthropic"; + const activeKey = (cfg.api_keys && cfg.api_keys[activeProv]) || ""; + handleFetchProviderModels(activeProv, activeKey); }) .catch(err => console.error("Failed to load config:", err)); @@ -139,6 +232,7 @@ export default function App() { if (info && info.is_update_available) { setUpdateInfo(info); setShowUpdateModal(true); + toast.info("Update available", `Version ${info.version}`); } }) .catch(err => console.error("Update check failed:", err)); @@ -146,7 +240,58 @@ export default function App() { return () => clearTimeout(timer); } - }, [isTauri]); + }, [isTauri, toast]); + + // Global keyboard shortcuts. Only Ctrl/Cmd-modified shortcuts to avoid + // hijacking normal typing in the chat input. Handlers are read through + // refs so we can reference them in the listener without depending on + // their (re-created) identity every render. + const handlerRefs = useRef({ + newSession: () => {}, + clearChat: () => {}, + }); + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + const isEditable = + !!target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable); + const mod = e.ctrlKey || e.metaKey; + + if (mod && e.key.toLowerCase() === "k") { + e.preventDefault(); + setCommandPaletteOpen(p => !p); + return; + } + if (mod && e.key.toLowerCase() === "n" && !isEditable) { + e.preventDefault(); + handlerRefs.current.newSession(); + return; + } + if (mod && e.key.toLowerCase() === "l" && !isEditable) { + e.preventDefault(); + handlerRefs.current.clearChat(); + return; + } + if (mod && e.key === ",") { + e.preventDefault(); + setShowSettings(true); + return; + } + if (e.key === "Tab" && e.shiftKey) { + e.preventDefault(); + handleToggleApprovalMode(); + return; + } + if (e.key === "Escape" && commandPaletteOpen) { + setCommandPaletteOpen(false); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [commandPaletteOpen]); const refreshSessionsList = (selectSessionName?: string) => { if (isTauri) { @@ -189,6 +334,10 @@ export default function App() { // Load a session's messages from disk const loadActiveSessionMessages = (sessionName: string) => { if (isTauri) { + // Re-apply any persisted per-session permission flags (e.g. a prior + // "Allow for this session" choice) to the live orchestrator atomics. + invoke("set_session_permissions", { name: sessionName }) + .catch(err => console.warn("set_session_permissions failed:", err)); invoke("load_saved_session", { name: sessionName }) .then((session: any) => { console.log("Loaded messages from disk for session:", sessionName); @@ -238,15 +387,130 @@ export default function App() { ); break; case "request_confirmation": - setModalDetails({ - command: chunk.target || "Bash Sandbox Execution", - cwd: chunk.message || "d:\\DEV\\Apps\\RouteCode" + if (approvalMode === "yolo") { + // Auto-allow without showing the modal. The user can still see + // what ran via the ToolCallCard that follows the result chunk. + invoke("respond_confirmation", { + response: "allow_once", + sessionName: activeSession, + }) + .then(() => { + toast.info( + "Auto-allowed (YOLO)", + chunk.target || "tool execution" + ); + }) + .catch(err => console.error("YOLO auto-allow failed:", err)); + } else { + setModalDetails({ + command: chunk.target || "Bash Sandbox Execution", + cwd: chunk.message || "d:\\DEV\\Apps\\RouteCode", + toolName: chunk.tool_name || "bash", + }); + setModalOpen(true); + } + break; + case "tool_call": { + const tc = chunk.tool_call; + if (!tc || !tc.id) break; + const newEvent: ToolEvent = { + id: tc.id, + name: tc.function?.name ?? "tool", + args: tc.function?.arguments ?? "{}", + status: "running", + startedAt: Date.now(), + }; + setMessages(prev => + prev.map(m => { + if (m.id !== streamId) return m; + const events = m.toolEvents ? [...m.toolEvents] : []; + const existingIdx = events.findIndex(e => e.id === newEvent.id); + if (existingIdx >= 0) { + events[existingIdx] = { ...events[existingIdx], ...newEvent }; + } else { + events.push(newEvent); + } + return { ...m, toolEvents: events }; + }) + ); + break; + } + case "tool_result": { + const toolCallId: string = chunk.tool_call_id; + const resultContent: string = chunk.content ?? ""; + let parsed: { + success?: boolean; + content?: string; + error?: string; + diff?: string; + } = {}; + try { + parsed = JSON.parse(resultContent); + } catch { + parsed = { content: resultContent }; + } + const success = + parsed.success === true || + (parsed.error === undefined && parsed.success !== false); + setMessages(prev => + prev.map(m => { + if (m.id !== streamId) return m; + const events = m.toolEvents ? [...m.toolEvents] : []; + const idx = events.findIndex(e => e.id === toolCallId); + const status: ToolEventStatus = success ? "success" : "error"; + if (idx >= 0) { + events[idx] = { + ...events[idx], + status, + resultRaw: resultContent, + resultContent: parsed.content, + resultError: parsed.error, + resultDiff: parsed.diff, + resultSuccess: success, + finishedAt: Date.now(), + }; + } else { + // Result arrived before/without a tool_call chunk (rare). + events.push({ + id: toolCallId, + name: chunk.name ?? "tool", + args: "{}", + status, + resultRaw: resultContent, + resultContent: parsed.content, + resultError: parsed.error, + resultDiff: parsed.diff, + resultSuccess: success, + startedAt: Date.now(), + finishedAt: Date.now(), + }); + } + return { ...m, toolEvents: events }; + }) + ); + break; + } + case "status": + setQirRetryStatus(chunk.content); + setMessages(prev => + prev.map(m => m.id === streamId ? { ...m, qirStatus: chunk.content } : m) + ); + break; + case "agent_status": + setAgentStatus(chunk.content || null); + break; + case "session_stats": + setSessionStats({ + totalTokens: chunk.total_tokens ?? 0, + totalCost: chunk.total_cost ?? 0, + qirAttempts: chunk.qir_attempts ?? 0, }); - setModalOpen(true); break; case "done": + setQirRetryStatus(null); + setAgentStatus(null); setMessages(prev => { - const updated = prev.map(m => m.id === streamId ? { ...m, isStreaming: false } : m); + const updated = prev.map(m => m.id === streamId ? { ...m, isStreaming: false, qirStatus: undefined } : m); saveSessionToDisk(activeSession, updated); return updated; }); @@ -254,8 +518,10 @@ export default function App() { unlisten(); break; case "error": + setQirRetryStatus(null); + setAgentStatus(null); setMessages(prev => { - const updated = prev.map(m => m.id === streamId ? { ...m, text: `Engine Error: ${chunk.content}`, isStreaming: false } : m); + const updated = prev.map(m => m.id === streamId ? { ...m, text: `Engine Error: ${chunk.content}`, isStreaming: false, qirStatus: undefined } : m); saveSessionToDisk(activeSession, updated); return updated; }); @@ -277,17 +543,23 @@ export default function App() { const runSimulatedAgentFlow = (userQuery: string, streamId: string) => { const isCommand = userQuery.toLowerCase().includes("run") || userQuery.toLowerCase().includes("write") || userQuery.toLowerCase().includes("/"); - + if (isCommand) { setTimeout(() => { setModalDetails({ command: "cargo build --workspace", - cwd: "d:\\DEV\\Apps\\RouteCode" + cwd: "d:\\DEV\\Apps\\RouteCode", + toolName: "bash", }); setModalOpen(true); }, 1200); } + setAgentStatus("Thinking"); + setTimeout(() => setAgentStatus("Reading 1 read"), 350); + setTimeout(() => setAgentStatus(`Exploring 3 reads`), 900); + setTimeout(() => setAgentStatus("Thinking Fix plan:"), 1500); + const replyText = `[Web Demo Fallback] I have received your request regarding: "${userQuery}". Since the workspace is locked securely, all code analysis, AST parsing, and modifications are verified relative to the root boundaries. How would you like me to proceed?`; let currentText = ""; let index = 0; @@ -295,12 +567,13 @@ export default function App() { const interval = setInterval(() => { if (index < replyText.length) { currentText += replyText[index]; - setMessages(prev => + setMessages(prev => prev.map(m => m.id === streamId ? { ...m, text: currentText } : m) ); index++; } else { clearInterval(interval); + setAgentStatus(null); setMessages(prev => { const updated = prev.map(m => m.id === streamId ? { ...m, isStreaming: false } : m); setIsGenerating(false); @@ -330,11 +603,42 @@ export default function App() { } }; + const handleStopGeneration = () => { + if (isTauri) { + invoke("cancel_message") + .then(() => { + setMessages(prev => [ + ...prev, + { + id: `sys-succ-${Date.now()}`, + sender: "system-success", + text: "Agent run cancelled by user." + } + ]); + }) + .catch(err => { + console.error("Failed to cancel run:", err); + setMessages(prev => [ + ...prev, + { + id: `sys-err-${Date.now()}`, + sender: "system-error", + text: "Failed to cancel agent run." + } + ]); + }); + } else { + setIsGenerating(false); + } + }; + const handleSendMessage = () => { const query = inputValue.trim(); if (!query || isGenerating) return; setIsGenerating(true); + setQirRetryStatus(null); + setAgentStatus("Thinking"); const userMsgId = `user-${Date.now()}`; const updatedMessages: Message[] = [ @@ -388,7 +692,11 @@ export default function App() { // CRUD Operations on Saved Sessions const handleNewSession = () => { - const name = prompt("Enter new session name:")?.trim(); + setPromptModalOpen(true); + }; + + const handleCreateSession = (name: string) => { + setPromptModalOpen(false); if (name) { const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, ""); if (sanitized) { @@ -422,8 +730,7 @@ export default function App() { } }; - const handleDeleteSession = (sessionName: string, e: React.MouseEvent) => { - e.stopPropagation(); + const handleDeleteSession = (sessionName: string) => { if (confirm(`Are you sure you want to delete session '${sessionName}'?`)) { if (isTauri) { invoke("delete_session", { name: sessionName }) @@ -434,13 +741,49 @@ export default function App() { } else { const next = sessions.filter(s => s !== sessionName); setSessions(next); - if (next.length > 0) { + if (activeSession === sessionName && next.length > 0) { setActiveSession(next[0]); } } } }; + const handleRenameSession = (oldName: string, newName: string) => { + if (oldName === newName) return; + const sanitized = newName.replace(/[^a-zA-Z0-9_-]/g, ""); + if (!sanitized) { + toast.error("Rename failed", "Name must contain letters, numbers, _ or -"); + return; + } + if (isTauri) { + invoke("load_saved_session", { name: oldName }) + .then((session: any) => { + const messages = session?.messages ?? []; + const model = session?.model ?? activeModel; + invoke("save_saved_session", { name: sanitized, messages, model }) + .then(() => { + invoke("delete_session", { name: oldName }) + .then(() => { + refreshSessionsList(sanitized); + if (activeSession === oldName) { + setActiveSession(sanitized); + setSessionStats({ totalTokens: 0, totalCost: 0, qirAttempts: 0 }); + loadActiveSessionMessages(sanitized); + } + toast.success("Session renamed", `${oldName} -> ${sanitized}`); + }) + .catch(err => console.error("Rename: failed to delete old session:", err)); + }) + .catch(err => console.error("Rename: failed to create new session:", err)); + }) + .catch(err => console.error("Rename: failed to load old session:", err)); + } else { + setSessions(prev => prev.map(s => (s === oldName ? sanitized : s))); + if (activeSession === oldName) setActiveSession(sanitized); + toast.success("Session renamed", `${oldName} -> ${sanitized}`); + } + }; + const handleClearChat = () => { const clearedMessages: Message[] = [ { @@ -453,6 +796,14 @@ export default function App() { saveSessionToDisk(activeSession, clearedMessages); }; + // Keep the keyboard shortcut handler in sync with the latest handler + // identities. Storing them in a ref avoids re-binding the global listener + // on every state change. + useEffect(() => { + handlerRefs.current.newSession = handleNewSession; + handlerRefs.current.clearChat = handleClearChat; + }); + // Persists configuration modifications directly to RouteCode config.json const handleSaveSettings = () => { if (isTauri) { @@ -467,12 +818,15 @@ export default function App() { recent_models: [], thinking_level: "default", logo_animation: "always", - logo_animation_color: "rainbow" + logo_animation_color: "rainbow", + retry_policy: { strategy: quickInfiniteRetry ? "qir" : "disabled" }, + approval_mode: { strategy: approvalMode }, }; invoke("save_config", { config: configObj }) .then(() => { setShowSettings(false); + toast.success("Settings saved", `Engine reloading with ${activeProvider} / ${activeModel}`); // Re-initialize active SDK orchestrator with the new configuration invoke("init_engine", { providerName: activeProvider, modelName: activeModel }) .then(() => { @@ -484,26 +838,172 @@ export default function App() { text: `SDK Engine updated and re-loaded: Switched to ${activeModel} on ${activeProvider}` } ]); + // Re-fetch models for the active provider now that the engine is + // re-initialized. This is the same path the CLI uses when you + // run `/model` after saving a key. + handleFetchProviderModels(activeProvider, apiKeys[activeProvider] ?? ""); }); }) - .catch(err => alert("Failed to save config: " + err)); + .catch(err => toast.error("Failed to save config", String(err))); } else { setShowSettings(false); - alert("Settings updated (Simulation Mode)"); + toast.info("Settings updated", "Simulation mode"); + } + }; + + // Fetch the list of available models for a given provider using the + // configured API key. Mirrors the CLI's `handle_command("/model")` flow: + // resolve the provider trait and call `list_models()`. Results are cached + // in `providerModels` keyed by provider id. + const handleFetchProviderModels = async (providerId: string, apiKey: string) => { + if (!providerId) return; + if (fetchingProviderFor === providerId) return; + setFetchingProviderFor(providerId); + try { + const result = await fetchProviderModels(providerId, apiKey); + setProviderModels(prev => ({ ...prev, [providerId]: result })); + if (result.source === "live") { + toast.success(`Models loaded`, `${result.models.length} from ${providerId}`); + } else if (result.error) { + toast.warning(`Using fallback models`, result.error); + } + } finally { + setFetchingProviderFor(null); + } + }; + + // Toggle the approval mode (Normal <-> YOLO) and persist it. YOLO means + // every tool call from the agent is auto-allowed without showing the + // confirmation modal. Plan-mode-style reads are still available via the + // `/plan` sub-agent flow that the LLM can invoke itself. + const handleToggleApprovalMode = () => { + setApprovalMode(prev => { + const next: ApprovalMode = prev === "yolo" ? "normal" : "yolo"; + if (isTauri) { + invoke("get_config") + .then((cfg: any) => { + const configObj = { + ...(cfg || {}), + model: cfg?.model ?? activeModel, + provider: cfg?.provider ?? activeProvider, + approval_mode: { strategy: next }, + }; + return invoke("save_config", { config: configObj }); + }) + .then(() => { + toast.info( + next === "yolo" ? "YOLO mode ON" : "Normal mode ON", + next === "yolo" + ? "All tool calls will be auto-allowed. Use with caution." + : "Every tool call needs your approval before execution." + ); + }) + .catch(err => { + toast.error("Failed to persist mode", String(err)); + setApprovalMode(prev); + }); + } else { + toast.info( + next === "yolo" ? "YOLO mode ON" : "Normal mode ON", + "Simulation mode - preference is not persisted." + ); + } + return next; + }); + }; + + // Build a Markdown export of the current session and trigger a browser download. + const handleExportLogs = () => { + try { + const md = messages + .filter(m => m.sender === "user" || m.sender === "assistant") + .map(m => { + const who = m.sender === "user" ? "User" : "Assistant"; + const thought = m.thought + ? `\n\n
Reasoning\n\n\`\`\`\n${m.thought}\n\`\`\`\n\n
\n` + : ""; + return `## ${who}\n\n${m.text || ""}${thought}`; + }) + .join("\n\n---\n\n"); + const header = + `# RouteCode Session: ${activeSession}\n\n` + + `**Model:** ${activeProvider} / ${activeModel} \n` + + `**Exported:** ${new Date().toISOString()}\n\n---\n\n`; + const blob = new Blob([header + md], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${activeSession}.md`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success("Session exported", `Saved as ${activeSession}.md`); + } catch (err) { + toast.error("Export failed", String(err)); } }; - const handleAllowTool = () => { + // Manually re-trigger the update check from the command palette. + const handleCheckUpdate = () => { + if (!isTauri) { + toast.info("Update check", "Web preview — skipping remote call"); + return; + } + toast.info("Checking for updates", "Querying release feed..."); + invoke("check_update") + .then((result: any) => { + const info = typeof result === "string" ? JSON.parse(result) : result; + if (info && info.is_update_available) { + setUpdateInfo(info); + setShowUpdateModal(true); + toast.info("Update available", `Version ${info.version}`); + } else { + toast.success("Up to date", "You're on the latest RouteCode build"); + } + }) + .catch(err => toast.error("Update check failed", String(err))); + }; + + const handleToggleQir = () => { + setQuickInfiniteRetry(prev => { + const next = !prev; + toast.info( + next ? "QIR enabled" : "QIR disabled", + next + ? "Failed requests will be retried indefinitely (experimental)" + : "Failed requests will be surfaced immediately" + ); + return next; + }); + }; + + const handleQuit = () => { + toast.info("Goodbye", "Closing RouteCode..."); + window.setTimeout(() => { + if (typeof window !== "undefined") window.close(); + }, 250); + }; + + const handleAllowTool = (scope: "once" | "session" | "workspace") => { setModalOpen(false); + const response = + scope === "once" ? "allow_once" : + scope === "session" ? "allow_session" : + "allow_workspace"; + const scopeLabel = + scope === "once" ? "once" : + scope === "session" ? "this session" : + "this workspace"; if (isTauri) { - invoke("respond_confirmation", { allowed: true }) + invoke("respond_confirmation", { response, sessionName: activeSession }) .then(() => { setMessages(prev => [ ...prev, { id: `sys-succ-${Date.now()}`, sender: "system-success", - text: "Tool execution approved and completed successfully in sandbox." + text: `Tool execution approved (${scopeLabel}) and completed successfully in sandbox.` } ]); }) @@ -514,7 +1014,7 @@ export default function App() { { id: `sys-succ-${Date.now()}`, sender: "system-success", - text: "Command 'cargo build --workspace' completed with exit status code 0." + text: `Command 'cargo build --workspace' completed with exit status code 0.` } ]); } @@ -523,7 +1023,7 @@ export default function App() { const handleDenyTool = () => { setModalOpen(false); if (isTauri) { - invoke("respond_confirmation", { allowed: false }) + invoke("respond_confirmation", { response: "deny", sessionName: activeSession }) .then(() => { setMessages(prev => [ ...prev, @@ -557,7 +1057,7 @@ export default function App() { .catch(err => { console.error("Install error:", err); setIsInstalling(false); - alert("Update installation failed: " + err); + toast.error("Update installation failed", String(err)); }); }; @@ -566,68 +1066,65 @@ export default function App() { }; return ( -
- - {/* 3D Ambient Backdrop Glow Blobs */} -
-
- - {/* Extracted Sidebar Navigation */} - { - setActiveSession(name); - loadActiveSessionMessages(name); - }} - onNewSession={handleNewSession} - onDeleteSession={handleDeleteSession} - activeProvider={activeProvider} - activeModel={activeModel} - isOpen={isSidebarOpen} - isTauri={isTauri} - onOpenSettings={() => setShowSettings(true)} - /> - - {/* Main Workspace Frame */} -
- {/* Header Bar */} -
-
- -
-
-
-
-
- - {activeSession} - -
+
+ {/* Tab strip replaces the sidebar. Sessions live as browser-style tabs at the top. */} +
+ { + if (name === activeSession) return; + setActiveSession(name); + setSessionStats({ totalTokens: 0, totalCost: 0, qirAttempts: 0 }); + setAgentStatus(null); + loadActiveSessionMessages(name); + }} + onNewSession={handleNewSession} + onDeleteSession={handleDeleteSession} + onRenameSession={handleRenameSession} + /> + + {/* Slim header bar showing the active session + utility actions */} +
+
+ + {activeSession} +
-
- + + + -
- {/* Extracted Chat Area */} + {/* Chat Area */} {/* Extracted Safe Tool Confirmation Modal */} @@ -649,10 +1157,18 @@ export default function App() { isOpen={modalOpen} command={modalDetails.command} cwd={modalDetails.cwd} + toolName={modalDetails.toolName} onAllow={handleAllowTool} onDeny={handleDenyTool} /> + {/* Extracted Prompt Modal */} + setPromptModalOpen(false)} + /> + {/* Extracted Translucent Settings Modal */} @@ -674,7 +1192,67 @@ export default function App() { isInstalling={isInstalling} installComplete={installComplete} /> + + {/* Global command palette (Ctrl+K) */} + setCommandPaletteOpen(false)} + ctx={buildCommandContext()} + />
); + + // Build the command context inside the component so handlers are in scope + // and stable across renders. + function buildCommandContext(): CommandContext { + return { + sessions, + activeSession, + onSelectSession: (name: string) => { + setActiveSession(name); + setSessionStats({ totalTokens: 0, totalCost: 0, qirAttempts: 0 }); + loadActiveSessionMessages(name); + toast.success("Session switched", name); + }, + onNewSession: handleNewSession, + onClearChat: handleClearChat, + onOpenSettings: () => setShowSettings(true), + onExportLogs: handleExportLogs, + onQuit: handleQuit, + onCheckUpdate: handleCheckUpdate, + onToggleQir: handleToggleQir, + onToggleApprovalMode: handleToggleApprovalMode, + quickInfiniteRetry, + approvalMode, + }; + } +} + +function UsageBadge({ stats }: { stats: { totalTokens: number; totalCost: number; qirAttempts: number } }) { + const formatTokens = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`; + const formatCost = (n: number) => `$${n.toFixed(4)}`; + const hasQir = stats.qirAttempts > 0; + return ( +
+ + {formatTokens(stats.totalTokens)} tok + · + {formatCost(stats.totalCost)} + {hasQir && ( + <> + · + + {stats.qirAttempts} retr{stats.qirAttempts === 1 ? "y" : "ies"} + + )} +
+ ); } diff --git a/apps/desktop-t/src/components/AgentStatus.tsx b/apps/desktop-t/src/components/AgentStatus.tsx new file mode 100644 index 0000000..91d6061 --- /dev/null +++ b/apps/desktop-t/src/components/AgentStatus.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Loader2, FileSearch, Brain } from "lucide-react"; + +interface AgentStatusProps { + status: string | null; +} + +function classify(status: string): { icon: React.ReactNode; label: string } { + const s = status.trim(); + const exploring = s.match(/^(?:Exploring|Reading|Scanning)\s+(?:the\s+)?(?:.*?\s+)?(\d+)\s+reads?/i); + if (exploring) { + return { + icon: , + label: `Exploring ${exploring[1]} read${exploring[1] === "1" ? "" : "s"}`, + }; + } + const thinking = s.match(/^Thinking[:\s]+(.+)/i); + if (thinking) { + return { + icon: , + label: `Thinking ${thinking[1].trim()}`, + }; + } + const reading = s.match(/^(?:Reading|Scanning)\s+(.+)/i); + if (reading) { + return { + icon: , + label: `Reading ${reading[1].trim()}`, + }; + } + return { + icon: , + label: s, + }; +} + +export default function AgentStatus({ status }: AgentStatusProps) { + if (!status) return null; + const { icon, label } = classify(status); + return ( +
+ {icon} + {label} +
+ ); +} diff --git a/apps/desktop-t/src/components/ChatArea.tsx b/apps/desktop-t/src/components/ChatArea.tsx index a5e72ab..ebadc03 100644 --- a/apps/desktop-t/src/components/ChatArea.tsx +++ b/apps/desktop-t/src/components/ChatArea.tsx @@ -1,12 +1,22 @@ import React from "react"; -import { - CheckCircle2, - XCircle, - ChevronRight, - Cpu +import { + CheckCircle2, + XCircle, + Cpu, + Infinity as InfinityIcon, + AlertTriangle, + CheckCheck, } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { + HighlightedParagraph, + HighlightedSpan, + HighlightedListItem, + detectCallout, +} from "../lib/textHighlight"; +import ToolCallCard from "./ToolCallCard"; +import type { ToolEvent } from "../App"; interface Message { id: string; @@ -15,6 +25,8 @@ interface Message { model?: string; thought?: string; isStreaming?: boolean; + qirStatus?: string; + toolEvents?: ToolEvent[]; } interface ChatAreaProps { @@ -24,14 +36,24 @@ interface ChatAreaProps { messagesEndRef: React.RefObject; } +function plainTextOf(children: React.ReactNode): string { + if (typeof children === "string") return children; + if (typeof children === "number") return String(children); + if (Array.isArray(children)) return children.map(plainTextOf).join(""); + if (React.isValidElement(children)) { + return plainTextOf((children.props as { children?: React.ReactNode }).children); + } + return ""; +} + export default function ChatArea({ messages, expandedThoughts, onToggleThought, - messagesEndRef + messagesEndRef, }: ChatAreaProps) { return ( -
+
{messages.map(msg => { const isUser = msg.sender === "user"; const isSuccess = msg.sender === "system-success"; @@ -40,9 +62,9 @@ export default function ChatArea({ if (isSuccess) { return (
-
- - {msg.text} +
+ + {msg.text}
); @@ -51,134 +73,191 @@ export default function ChatArea({ if (isError) { return (
-
- - {msg.text} +
+ + {msg.text}
); } return ( -
- {/* Bubble Sender Label */} -
- {isUser ? ( - <> - You -
- - ) : ( - <> -
- - RouteCode Core {msg.model && `(${msg.model})`} - - - )} -
+ {!isUser && msg.isStreaming && msg.qirStatus && ( + + )} - {/* Bubble Container */} -
- {/* Collapsible Thoughts Block */} - {msg.thought && ( -
-
onToggleThought(msg.id)} - className="flex items-center justify-between px-4 py-3 cursor-pointer select-none hover:bg-amber-500/[0.03] text-amber-400/90 text-xs font-extrabold gap-2" - > - - - 💡 Reasoning Chain - - -
- - {expandedThoughts[msg.id] && ( -
- {msg.thought} -
- )} +
+ {!isUser && msg.toolEvents && msg.toolEvents.length > 0 && ( +
+ {msg.toolEvents.map(ev => ( + + ))}
)} +
+ {msg.thought && ( +
+
onToggleThought(msg.id)} + className="flex items-center justify-between px-3 py-2 cursor-pointer select-none hover:bg-[var(--secondary)] text-[var(--muted-foreground)] text-xs gap-2" + > + + + Reasoning + + + {expandedThoughts[msg.id] ? "collapse" : "expand"} + +
- {isUser ? ( - msg.text - ) : ( - <>{props.children}, - // Custom code styling - code: ({ node, className, children, ...props }) => { - const match = /language-(\w+)/.exec(className || ''); - const isInline = !match && !String(children).includes('\n'); - - if (isInline) { - return ( - - {children} - - ); - } + {expandedThoughts[msg.id] && ( +
+ {msg.thought} +
+ )} +
+ )} - return ( -
-
- {match ? match[1] : "code"} -
-
-                            
+                {isUser ? (
+                  msg.text
+                ) : (
+                   <>{children},
+                      code: ({ className, children, ...props }) => {
+                        const match = /language-(\w+)/.exec(className || '');
+                        const text = String(children ?? "");
+                        const isInline = !match && !text.includes('\n');
+
+                        if (isInline) {
+                          return (
+                            
                               {children}
                             
-                          
+ ); + } + + return ( +
+
+ {match ? match[1] : "code"} +
+
+                              
+                                {children}
+                              
+                            
+
+ ); + }, + p: ({ children }) => { + const txt = plainTextOf(children); + const callout = detectCallout(txt); + return ( + + {children} + + ); + }, + ul: ({ children }) => ( +
    + {React.Children.map(children, (child, i) => ( + {child} + ))} +
+ ), + ol: ({ children }) => ( +
    + {React.Children.map(children, (child, i) => ( + {child} + ))} +
+ ), + li: ({ children }) => {children}, + h1: ({ children }) => ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + h4: ({ children }) => ( +

+ {children} +

+ ), + blockquote: ({ children }) => ( +
+ {children} +
+ ), + a: ({ children, ...props }) => ( + + {children} + + ), + strong: ({ children }) => ( + + {children} + + ), + em: ({ children }) => {children}, + table: ({ children }) => ( +
+ + {children} +
- ); - }, - p: ({ node, ...props }) =>

, - ul: ({ node, ...props }) =>