diff --git a/AGENTS.md b/AGENTS.md index 93b33c03..e2997719 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,12 +21,28 @@ See README.md for project overview. This project uses Git. See .gitignore for excluded files. +## Advanced Capabilities + +### Model Context Protocol (MCP) +This CLI supports MCP for extending tool access. +- Use `mcp_read_resource` to read context from external servers. +- Use `mcp_get_prompt` to leverage pre-defined expert prompts from servers. +- You can connect to HTTP/SSE servers by adding their URL to `mcp.json`. + +### Multi-Agent Orchestration +For complex, multi-step tasks, you should delegate work: +- **Sub-agents**: Use `agent_spawn` (or its alias `delegate_to_agent`) to launch a background assistant for a specific sub-task. Use `agent_result` to get their output. +- **Swarms**: Use `agent_swarm` to orchestrate multiple sub-agents with dependencies. This is ideal for parallel exploration or complex refactoring where different parts of the project can be analyzed concurrently. + +### Project Mapping +- Use `project_map` to get a comprehensive view of the codebase structure. This tool respects `.gitignore` and provides a summary of key files. + ## Guidelines -- Follow existing code style and patterns -- Write tests for new functionality -- Keep changes focused and atomic -- Document public APIs +- **Proactive Investigation**: Always start by exploring the codebase using `project_map` and `file_search`. +- **Parallelism**: When you need to read multiple files or search across different areas, use parallel tool calls if possible. +- **Delegation**: If a task is large, break it down into sub-tasks and use `agent_swarm` or `agent_spawn`. +- **Testing**: Rigorously verify changes using `cargo test` and `cargo check`. ## Important Notes diff --git a/CHANGELOG.md b/CHANGELOG.md index e57c127c..c344517b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.5] - 2026-01-30 + +### Added +- Intelligent context offloading: large tool results (>15k chars) are automatically moved to RLM memory to preserve the context window +- Persistent history context: compacted messages are offloaded to RLM `history` variable for recall +- Full MCP protocol support: SSE transport, Resources (`resources/list`, `resources/read`), and Prompts (`prompts/list`, `prompts/get`) +- `mcp_read_resource` and `mcp_get_prompt` virtual tools exposed to the model +- Dialectical Duo mode with specialized TUI rendering (`Player` / `Coach` history cells) +- Dynamic system prompt refreshing at each turn for up-to-date RLM/Duo/working-set context +- `project_map` tool for automatic codebase structure discovery +- `delegate_to_agent` alias for streamlined sub-agent delegation + +### Changed +- Default theme changed to 'Whale' with updated color palette +- `with_agent_tools` now includes `project_map`, `test_runner`, and conditionally RLM tools for all agent modes +- MCP `McpServerConfig.command` is now `Option` to support URL-only (SSE) servers + +### Fixed +- MCP test compilation errors for updated `McpServerConfig` struct shape + +## [0.3.4] - 2026-01-29 + +### Changed +- Updated Cargo.lock dependencies + +### Fixed +- Compaction tool-call pairing: enforce bidirectional tool-call/tool-result integrity with fixpoint convergence +- Safety net scanning to drop orphan tool results in the request builder +- Double-dispatch race in parallel tool execution + +## [0.3.3] - 2026-01-28 + +### Added +- TUI polish: Kimi-style footer with mode/model/token display +- Streaming thinking blocks with dedicated rendering +- Loading animation improvements + ## [0.3.2] - 2026-01-28 ### Fixed @@ -145,7 +182,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.2...HEAD +[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.5...HEAD +[0.3.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.4...v0.3.5 +[0.3.4]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.3...v0.3.4 +[0.3.3]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.2...v0.3.3 [0.3.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.1...v0.3.2 [0.3.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.2...v0.3.0 diff --git a/Cargo.lock b/Cargo.lock index 1f23144f..1022fd53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -646,7 +646,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.3.4" +version = "0.3.5" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 165b46b9..d91fdd31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deepseek-tui" -version = "0.3.4" +version = "0.3.5" edition = "2024" description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting" license = "MIT" diff --git a/src/commands/session.rs b/src/commands/session.rs index 62d180e4..3941ec44 100644 --- a/src/commands/session.rs +++ b/src/commands/session.rs @@ -157,6 +157,8 @@ pub fn export(app: &mut App, path: Option<&str>) -> CommandResult { let (role, body) = match cell { HistoryCell::User { content } => ("**You:**", content.clone()), HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()), + HistoryCell::Player { content, .. } => ("**Player:**", content.clone()), + HistoryCell::Coach { content, .. } => ("**Coach:**", content.clone()), HistoryCell::System { content } => ("*System:*", content.clone()), HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()), HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)), diff --git a/src/compaction.rs b/src/compaction.rs index 1f4d934b..5ef8f1a4 100644 --- a/src/compaction.rs +++ b/src/compaction.rs @@ -528,6 +528,8 @@ pub struct CompactionResult { pub messages: Vec, /// Summary system prompt pub summary_prompt: Option, + /// Messages that were removed from the active window + pub removed_messages: Vec, /// Number of retries used before success pub retries_used: u32, } @@ -587,10 +589,11 @@ pub async fn compact_messages_safe( ) .await { - Ok((msgs, prompt)) => { + Ok((msgs, prompt, removed)) => { return Ok(CompactionResult { messages: msgs, summary_prompt: prompt, + removed_messages: removed, retries_used: attempt, }); } @@ -615,9 +618,9 @@ pub async fn compact_messages( workspace: Option<&Path>, external_pins: Option<&[usize]>, external_working_set_paths: Option<&[String]>, -) -> Result<(Vec, Option)> { +) -> Result<(Vec, Option, Vec)> { if messages.is_empty() { - return Ok((Vec::new(), None)); + return Ok((Vec::new(), None, Vec::new())); } let plan = plan_compaction( @@ -628,7 +631,7 @@ pub async fn compact_messages( external_working_set_paths, ); if plan.summarize_indices.is_empty() { - return Ok((messages.to_vec(), None)); + return Ok((messages.to_vec(), None, Vec::new())); } let to_summarize: Vec = plan @@ -664,6 +667,7 @@ pub async fn compact_messages( Ok(( pinned_messages, Some(SystemPrompt::Blocks(vec![summary_block])), + to_summarize, )) } diff --git a/src/core/engine.rs b/src/core/engine.rs index 3bdc01b9..a7d059c8 100644 --- a/src/core/engine.rs +++ b/src/core/engine.rs @@ -7,6 +7,7 @@ //! - Proper cancellation support //! - Tool execution orchestration +use std::fmt::Write; use std::path::PathBuf; use std::pin::pin; use std::sync::{Arc, Mutex}; @@ -713,13 +714,19 @@ impl Engine { .with_todo_tool(todo_list.clone()) .with_plan_tool(plan_state.clone()) } else { + let rlm_opt = if self.config.features.enabled(Feature::Rlm) { + Some(self.config.rlm_session.clone()) + } else { + None + }; + ToolRegistryBuilder::new() - .with_file_tools() - .with_note_tool() - .with_search_tools() - .with_git_tools() - .with_diagnostics_tool() - .with_test_runner_tool() + .with_agent_tools( + self.session.allow_shell, + rlm_opt, + self.deepseek_client.clone(), + self.session.model.clone(), + ) .with_todo_tool(todo_list.clone()) .with_plan_tool(plan_state.clone()) }; @@ -828,6 +835,42 @@ impl Engine { ) } + /// Automatically offload large tool results to RLM memory if enabled. + /// Returns either the original content or a pointer to the RLM context. + fn offload_to_rlm_if_needed(&self, tool_name: &str, content: String) -> String { + const OFFLOAD_THRESHOLD: usize = 15_000; + + if !self.config.features.enabled(Feature::Rlm) || content.len() < OFFLOAD_THRESHOLD { + return content; + } + + let mut session = match self.config.rlm_session.lock() { + Ok(s) => s, + Err(_) => return content, + }; + + let context_id = format!( + "auto_{}_{}", + tool_name, + &uuid::Uuid::new_v4().to_string()[..8] + ); + let char_count = content.len(); + let line_count = content.lines().count(); + + session.load_context(&context_id, content, None); + + format!( + "[AUTOMATIC RLM OFFLOAD]\n\ + The output of '{tool_name}' was too large ({char_count} chars, {line_count} lines) \ + and has been moved to RLM memory to preserve your context window.\n\n\ + Context ID: {context_id}\n\n\ + You can explore this data using RLM tools:\n\ + - `rlm_exec(code=\"lines(1, 100)\", context_id=\"{context_id}\")` to see the start\n\ + - `rlm_exec(code=\"search(\\\"pattern\\\")\", context_id=\"{context_id}\")` to search\n\ + - `rlm_query(query=\"...\", context_id=\"{context_id}\")` for deep analysis" + ) + } + async fn ensure_mcp_pool(&mut self) -> Result>, ToolError> { if let Some(pool) = self.mcp_pool.as_ref() { return Ok(Arc::clone(pool)); @@ -985,6 +1028,9 @@ impl Engine { break; } + // Ensure system prompt is up to date with latest session states + self.refresh_system_prompt(_mode); + if turn.at_max_steps() { let _ = self .tx_event @@ -1025,6 +1071,49 @@ impl Engine { Ok(result) => { // Only update if we got valid messages (never corrupt state) if !result.messages.is_empty() || self.session.messages.is_empty() { + // Offload removed messages to RLM history if enabled + if self.config.features.enabled(Feature::Rlm) + && !result.removed_messages.is_empty() + { + if let Ok(mut rlm) = self.config.rlm_session.lock() { + let mut history_text = String::new(); + for msg in &result.removed_messages { + let role = if msg.role == "user" { + "User" + } else { + "Assistant" + }; + for block in &msg.content { + match block { + ContentBlock::Text { text, .. } => { + let _ = + writeln!(history_text, "{role}: {text}\n"); + } + ContentBlock::ToolUse { name, input, .. } => { + let _ = writeln!( + history_text, + "{role}: [Used tool: {name}] Input: {input}\n" + ); + } + ContentBlock::ToolResult { content, .. } => { + let _ = writeln!( + history_text, + "Tool result: {content}\n" + ); + } + ContentBlock::Thinking { thinking } => { + let _ = writeln!( + history_text, + "{role} (thinking): {thinking}\n" + ); + } + } + } + } + rlm.append_var("history", history_text); + } + } + self.session.messages = result.messages; self.session.system_prompt = merge_system_prompts( self.session.system_prompt.as_ref(), @@ -1567,7 +1656,10 @@ impl Engine { match outcome.result { Ok(output) => { - let output_content = output.content; + let original_content = output.content; + let output_content = + self.offload_to_rlm_if_needed(&outcome.name, original_content); + tool_call.set_result(output_content.clone(), duration); self.session.working_set.observe_tool_call( &tool_name_for_ws, @@ -1635,6 +1727,36 @@ impl Engine { pub fn session_mut(&mut self) -> &mut Session { &mut self.session } + + /// Refresh the system prompt based on current mode and context. + fn refresh_system_prompt(&mut self, mode: AppMode) { + let rlm_summary = self + .config + .rlm_session + .lock() + .ok() + .map(|session| rlm_session_summary(&session)); + + let duo_summary = self + .config + .duo_session + .lock() + .ok() + .map(|s| duo_session_summary(&s)); + + let working_set_summary = self + .session + .working_set + .summary_block(&self.config.workspace); + + self.session.system_prompt = Some(prompts::system_prompt_for_mode_with_context( + mode, + &self.config.workspace, + working_set_summary.as_deref(), + rlm_summary.as_deref(), + duo_summary.as_deref(), + )); + } } /// Spawn the engine in a background task diff --git a/src/main.rs b/src/main.rs index 358af331..f22855d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -638,9 +638,10 @@ fn mcp_template_json() -> Result { cfg.servers.insert( "example".to_string(), McpServerConfig { - command: "node".to_string(), + command: Some("node".to_string()), args: vec!["./path/to/your-mcp-server.js".to_string()], env: std::collections::HashMap::new(), + url: None, connect_timeout: None, execute_timeout: None, read_timeout: None, @@ -1485,7 +1486,14 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { } else { format!(" {}", server.args.join(" ")) }; - println!(" - {name} [{status}] {}{}", server.command, args); + let cmd_str = if let Some(cmd) = server.command { + format!("{cmd}{args}") + } else if let Some(url) = server.url { + url + } else { + "unknown".to_string() + }; + println!(" - {name} [{status}] {cmd_str}"); } Ok(()) } diff --git a/src/mcp.rs b/src/mcp.rs index 88363ef0..1aa135a7 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -68,11 +68,12 @@ impl Default for McpTimeouts { /// Configuration for a single MCP server #[derive(Debug, Clone, Deserialize, Serialize)] pub struct McpServerConfig { - pub command: String, + pub command: Option, #[serde(default)] pub args: Vec, #[serde(default)] pub env: HashMap, + pub url: Option, #[serde(default)] pub connect_timeout: Option, #[serde(default)] @@ -109,6 +110,37 @@ pub struct McpTool { pub input_schema: serde_json::Value, } +/// Resource discovered from an MCP server +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct McpResource { + pub uri: String, + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(rename = "mimeType", default)] + pub mime_type: Option, +} + +/// Prompt discovered from an MCP server +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct McpPrompt { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub arguments: Vec, +} + +/// Argument for an MCP prompt +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct McpPromptArgument { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub required: bool, +} + // === Connection State === /// State of an MCP connection @@ -121,13 +153,176 @@ pub enum ConnectionState { // === McpConnection - Async Connection Management === -/// Manages a single async connection to an MCP server -pub struct McpConnection { - name: String, +// === Transport Trait === + +#[async_trait::async_trait] +pub trait McpTransport: Send + Sync { + async fn send(&mut self, msg: serde_json::Value) -> Result<()>; + async fn recv(&mut self) -> Result; +} + +pub struct StdioTransport { _child: Child, stdin: ChildStdin, reader: tokio::io::BufReader, +} + +#[async_trait::async_trait] +impl McpTransport for StdioTransport { + async fn send(&mut self, msg: serde_json::Value) -> Result<()> { + let line = serde_json::to_string(&msg)? + "\n"; + self.stdin.write_all(line.as_bytes()).await?; + self.stdin.flush().await?; + Ok(()) + } + + async fn recv(&mut self) -> Result { + let mut line = String::new(); + loop { + line.clear(); + let bytes = self.reader.read_line(&mut line).await?; + if bytes == 0 { + anyhow::bail!("Stdio transport closed"); + } + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if let Ok(value) = serde_json::from_str::(trimmed) { + return Ok(value); + } + } + } +} + +pub struct SseTransport { + client: reqwest::Client, + base_url: String, + endpoint_url: Option, + receiver: tokio::sync::mpsc::UnboundedReceiver, +} + +impl SseTransport { + pub async fn connect(client: reqwest::Client, url: String) -> Result { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let client_clone = client.clone(); + let url_clone = url.clone(); + + // Start SSE background task + tokio::spawn(async move { + if let Err(e) = Self::run_sse_loop(client_clone, url_clone, tx).await { + tracing::error!("SSE loop error: {}", e); + } + }); + + // The endpoint URL will be discovered from the first "endpoint" event + Ok(Self { + client, + base_url: url, + endpoint_url: None, + receiver: rx, + }) + } + + async fn run_sse_loop( + client: reqwest::Client, + url: String, + tx: tokio::sync::mpsc::UnboundedSender, + ) -> Result<()> { + let response = client.get(&url).send().await?; + if !response.status().is_success() { + anyhow::bail!("Failed to connect to SSE: {}", response.status()); + } + + let mut stream = response.bytes_stream(); + use futures_util::StreamExt; + let mut buffer = String::new(); + + while let Some(item) = stream.next().await { + let chunk = item?; + let s = String::from_utf8_lossy(&chunk); + buffer.push_str(&s); + + while let Some(pos) = buffer.find("\n\n") { + let event_block = buffer[..pos].to_string(); + buffer = buffer[pos + 2..].to_string(); + + let mut event_type = "message"; + let mut data = String::new(); + + for line in event_block.lines() { + if let Some(stripped) = line.strip_prefix("event: ") { + event_type = stripped; + } else if let Some(stripped) = line.strip_prefix("data: ") { + data.push_str(stripped); + } + } + + match event_type { + "endpoint" => { + // Special internal message to set endpoint + let _ = tx.send(serde_json::json!({ + "__internal_sse_endpoint__": data + })); + } + "message" => { + if let Ok(val) = serde_json::from_str::(&data) { + let _ = tx.send(val); + } + } + _ => {} + } + } + } + Ok(()) + } +} + +#[async_trait::async_trait] +impl McpTransport for SseTransport { + async fn send(&mut self, msg: serde_json::Value) -> Result<()> { + let endpoint = self + .endpoint_url + .as_ref() + .context("SSE endpoint not yet discovered")?; + let response = self.client.post(endpoint).json(&msg).send().await?; + if !response.status().is_success() { + anyhow::bail!("Failed to send message via SSE POST: {}", response.status()); + } + Ok(()) + } + + async fn recv(&mut self) -> Result { + loop { + let msg = self.receiver.recv().await.context("SSE transport closed")?; + if let Some(endpoint) = msg.get("__internal_sse_endpoint__") { + let url_str = endpoint.as_str().context("Invalid endpoint format")?; + // Handle relative vs absolute URLs + if url_str.starts_with("http") { + self.endpoint_url = Some(url_str.to_string()); + } else { + let base = reqwest::Url::parse(&self.base_url)?; + let joined = base.join(url_str)?; + self.endpoint_url = Some(joined.to_string()); + } + continue; + } + return Ok(msg); + } + } +} + +// === McpConnection - Async Connection Management === + +/// Manages a single async connection to an MCP server +pub struct McpConnection { + name: String, + transport: Box, tools: Vec, + resources: Vec, + prompts: Vec, request_id: AtomicU64, state: ConnectionState, config: McpServerConfig, @@ -142,30 +337,48 @@ impl McpConnection { ) -> Result { let connect_timeout_secs = config.effective_connect_timeout(global_timeouts); - let mut cmd = tokio::process::Command::new(&config.command); - cmd.args(&config.args) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .kill_on_drop(true); + let transport: Box = if let Some(url) = &config.url { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(connect_timeout_secs)) + .build()?; + Box::new(SseTransport::connect(client, url.clone()).await?) + } else if let Some(command) = &config.command { + let mut cmd = tokio::process::Command::new(command); + cmd.args(&config.args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); - for (key, value) in &config.env { - cmd.env(key, value); - } + for (key, value) in &config.env { + cmd.env(key, value); + } - let mut child = cmd - .spawn() - .with_context(|| format!("Failed to spawn MCP server '{name}'"))?; + let mut child = cmd + .spawn() + .with_context(|| format!("Failed to spawn MCP server '{name}'"))?; - let stdin = child.stdin.take().context("Failed to get MCP stdin")?; - let stdout = child.stdout.take().context("Failed to get MCP stdout")?; + let stdin = child.stdin.take().context("Failed to get MCP stdin")?; + let stdout = child.stdout.take().context("Failed to get MCP stdout")?; + + Box::new(StdioTransport { + _child: child, + stdin, + reader: tokio::io::BufReader::new(stdout), + }) + } else { + anyhow::bail!( + "MCP server '{}' config must have either 'command' or 'url'", + name + ); + }; let mut conn = Self { name: name.clone(), - _child: child, - stdin, - reader: tokio::io::BufReader::new(stdout), + transport, tools: Vec::new(), + resources: Vec::new(), + prompts: Vec::new(), request_id: AtomicU64::new(1), state: ConnectionState::Connecting, config, @@ -176,13 +389,13 @@ impl McpConnection { .await .with_context(|| format!("MCP server '{name}' initialization timed out"))??; - // Discover tools with timeout + // Discover tools, resources, and prompts with timeout tokio::time::timeout( Duration::from_secs(connect_timeout_secs), - conn.discover_tools(), + conn.discover_all(), ) .await - .with_context(|| format!("MCP server '{name}' tool discovery timed out"))??; + .with_context(|| format!("MCP server '{name}' discovery timed out"))??; conn.state = ConnectionState::Ready; Ok(conn) @@ -201,7 +414,11 @@ impl McpConnection { "name": "deepseek-cli", "version": env!("CARGO_PKG_VERSION") }, - "capabilities": { "tools": {} } + "capabilities": { + "tools": {}, + "resources": {}, + "prompts": {} + } } })) .await?; @@ -218,6 +435,16 @@ impl McpConnection { Ok(()) } + /// Discover tools, resources, and prompts + async fn discover_all(&mut self) -> Result<()> { + // We use join! to discover everything concurrently if possible, + // but for now let's keep it sequential for simplicity in error handling + self.discover_tools().await?; + self.discover_resources().await?; + self.discover_prompts().await?; + Ok(()) + } + /// Discover available tools from the MCP server async fn discover_tools(&mut self) -> Result<()> { let list_id = self.next_id(); @@ -240,16 +467,113 @@ impl McpConnection { Ok(()) } + /// Discover available resources from the MCP server + async fn discover_resources(&mut self) -> Result<()> { + let list_id = self.next_id(); + self.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": list_id, + "method": "resources/list", + "params": {} + })) + .await?; + + let response = self.recv(list_id).await?; + + if let Some(result) = response.get("result") + && let Some(resources) = result.get("resources") + { + self.resources = serde_json::from_value(resources.clone()).unwrap_or_default(); + } + + Ok(()) + } + + /// Discover available prompts from the MCP server + async fn discover_prompts(&mut self) -> Result<()> { + let list_id = self.next_id(); + self.send(serde_json::json!({ + "jsonrpc": "2.0", + "id": list_id, + "method": "prompts/list", + "params": {} + })) + .await?; + + let response = self.recv(list_id).await?; + + if let Some(result) = response.get("result") + && let Some(prompts) = result.get("prompts") + { + self.prompts = serde_json::from_value(prompts.clone()).unwrap_or_default(); + } + + Ok(()) + } + /// Call a tool on this MCP server pub async fn call_tool( &mut self, tool_name: &str, arguments: serde_json::Value, timeout_secs: u64, + ) -> Result { + self.call_method( + "tools/call", + serde_json::json!({ + "name": tool_name, + "arguments": arguments + }), + timeout_secs, + ) + .await + } + + /// Read a resource from this MCP server + pub async fn read_resource( + &mut self, + uri: &str, + timeout_secs: u64, + ) -> Result { + self.call_method( + "resources/read", + serde_json::json!({ + "uri": uri + }), + timeout_secs, + ) + .await + } + + /// Get a prompt from this MCP server + pub async fn get_prompt( + &mut self, + prompt_name: &str, + arguments: serde_json::Value, + timeout_secs: u64, + ) -> Result { + self.call_method( + "prompts/get", + serde_json::json!({ + "name": prompt_name, + "arguments": arguments + }), + timeout_secs, + ) + .await + } + + /// Generic method to call an MCP method + async fn call_method( + &mut self, + method: &str, + params: serde_json::Value, + timeout_secs: u64, ) -> Result { if self.state != ConnectionState::Ready { anyhow::bail!( - "Failed to call MCP tool: connection '{}' is not ready", + "Failed to call MCP method '{}': connection '{}' is not ready", + method, self.name ); } @@ -258,11 +582,8 @@ impl McpConnection { self.send(serde_json::json!({ "jsonrpc": "2.0", "id": call_id, - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": arguments - } + "method": method, + "params": params })) .await?; @@ -270,14 +591,15 @@ impl McpConnection { .await .with_context(|| { format!( - "MCP tool '{}' on server '{}' timed out after {}s", - tool_name, self.name, timeout_secs + "MCP method '{}' on server '{}' timed out after {}s", + method, self.name, timeout_secs ) })??; if let Some(error) = response.get("error") { return Err(anyhow::anyhow!( - "MCP error: {}", + "MCP error in '{}': {}", + method, serde_json::to_string_pretty(error)? )); } @@ -293,6 +615,16 @@ impl McpConnection { &self.tools } + /// Get discovered resources + pub fn resources(&self) -> &[McpResource] { + &self.resources + } + + /// Get discovered prompts + pub fn prompts(&self) -> &[McpPrompt] { + &self.prompts + } + /// Get server name pub fn name(&self) -> &str { &self.name @@ -318,50 +650,32 @@ impl McpConnection { } async fn send(&mut self, msg: serde_json::Value) -> Result<()> { - let line = serde_json::to_string(&msg)? + "\n"; - self.stdin.write_all(line.as_bytes()).await?; - self.stdin.flush().await?; - Ok(()) + self.transport.send(msg).await } async fn recv(&mut self, expected_id: u64) -> Result { - let mut line = String::new(); loop { - line.clear(); - let bytes = self.reader.read_line(&mut line).await?; - if bytes == 0 { + let value = self.transport.recv().await.inspect_err(|_e| { self.state = ConnectionState::Disconnected; - anyhow::bail!( - "Failed to read MCP response: server '{}' closed connection", - self.name - ); - } + })?; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - if let Ok(value) = serde_json::from_str::(trimmed) { - // Check if this is a response with the expected id - if value.get("id").and_then(serde_json::Value::as_u64) == Some(expected_id) { - return Ok(value); - } - // Skip notifications (no id) and responses with different ids + // Check if this is a response with the expected id + if value.get("id").and_then(serde_json::Value::as_u64) == Some(expected_id) { + return Ok(value); } + // Skip notifications (no id) and responses with different ids } } /// Gracefully close the connection pub fn close(&mut self) { self.state = ConnectionState::Disconnected; - // Child process will be killed on drop due to kill_on_drop(true) } } impl Drop for McpConnection { fn drop(&mut self) { - // Child is automatically killed due to kill_on_drop(true) + // StdioTransport will be dropped and child killed } } @@ -467,17 +781,125 @@ impl McpPool { tools } + /// Get all discovered resources with server-prefixed names + pub fn all_resources(&self) -> Vec<(String, &McpResource)> { + let mut resources = Vec::new(); + for (server, conn) in &self.connections { + for resource in conn.resources() { + // Format: mcp_{server}_{resource_name} + // Note: resource names might contain spaces, we should probably slugify them + let safe_name = resource.name.replace(' ', "_").to_lowercase(); + resources.push((format!("mcp_{}_{}", server, safe_name), resource)); + } + } + resources + } + + /// Get all discovered prompts with server-prefixed names + pub fn all_prompts(&self) -> Vec<(String, &McpPrompt)> { + let mut prompts = Vec::new(); + for (server, conn) in &self.connections { + for prompt in conn.prompts() { + // Format: mcp_{server}_{prompt} + prompts.push((format!("mcp_{}_{}", server, prompt.name), prompt)); + } + } + prompts + } + + /// Read a resource from a specific server + pub async fn read_resource( + &mut self, + server_name: &str, + uri: &str, + ) -> Result { + let global_timeouts = self.config.timeouts; + let conn = self.get_or_connect(server_name).await?; + let timeout = conn.config().effective_read_timeout(&global_timeouts); + conn.read_resource(uri, timeout).await + } + + /// Get a prompt from a specific server + pub async fn get_prompt( + &mut self, + server_name: &str, + prompt_name: &str, + arguments: serde_json::Value, + ) -> Result { + let global_timeouts = self.config.timeouts; + let conn = self.get_or_connect(server_name).await?; + let timeout = conn.config().effective_execute_timeout(&global_timeouts); + conn.get_prompt(prompt_name, arguments, timeout).await + } + + /// Parse a prefixed name into (server_name, tool_name) + fn parse_prefixed_name<'a>(&self, prefixed_name: &'a str) -> Result<(&'a str, &'a str)> { + if !prefixed_name.starts_with("mcp_") { + anyhow::bail!("Invalid MCP tool name: {}", prefixed_name); + } + let rest = &prefixed_name[4..]; + let Some((server, tool)) = rest.split_once('_') else { + anyhow::bail!("Invalid MCP tool name format: {}", prefixed_name); + }; + Ok((server, tool)) + } + /// Convert discovered tools to API Tool format pub fn to_api_tools(&self) -> Vec { - self.all_tools() - .into_iter() - .map(|(name, tool)| crate::models::Tool { + let mut api_tools = Vec::new(); + + // Add regular tools + for (name, tool) in self.all_tools() { + api_tools.push(crate::models::Tool { name, description: tool.description.clone().unwrap_or_default(), input_schema: tool.input_schema.clone(), cache_control: None, - }) - .collect() + }); + } + + // Add resource reading tools if resources exist + let resources = self.all_resources(); + if !resources.is_empty() { + api_tools.push(crate::models::Tool { + name: "mcp_read_resource".to_string(), + description: "Read a resource from an MCP server using its URI".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "server": { "type": "string", "description": "The name of the MCP server" }, + "uri": { "type": "string", "description": "The URI of the resource to read" } + }, + "required": ["server", "uri"] + }), + cache_control: None, + }); + } + + // Add prompt getting tools if prompts exist + let prompts = self.all_prompts(); + if !prompts.is_empty() { + api_tools.push(crate::models::Tool { + name: "mcp_get_prompt".to_string(), + description: "Get a prompt from an MCP server".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "server": { "type": "string", "description": "The name of the MCP server" }, + "name": { "type": "string", "description": "The name of the prompt" }, + "arguments": { + "type": "object", + "description": "Optional arguments for the prompt", + "additionalProperties": { "type": "string" } + } + }, + "required": ["server", "name"] + }), + cache_control: None, + }); + } + + api_tools } /// Call a tool by its prefixed name (mcp_{server}_{tool}) @@ -486,14 +908,35 @@ impl McpPool { prefixed_name: &str, arguments: serde_json::Value, ) -> Result { - let parts: Vec<&str> = prefixed_name.splitn(3, '_').collect(); - if parts.len() != 3 || parts[0] != "mcp" { - anyhow::bail!( - "Failed to parse MCP tool name '{prefixed_name}': expected format mcp_{{server}}_{{tool}}" - ); + if prefixed_name == "mcp_read_resource" { + let server_name = arguments + .get("server") + .and_then(|v| v.as_str()) + .context("Missing 'server' argument")?; + let uri = arguments + .get("uri") + .and_then(|v| v.as_str()) + .context("Missing 'uri' argument")?; + return self.read_resource(server_name, uri).await; } - let (server_name, tool_name) = (parts[1], parts[2]); + if prefixed_name == "mcp_get_prompt" { + let server_name = arguments + .get("server") + .and_then(|v| v.as_str()) + .context("Missing 'server' argument")?; + let name = arguments + .get("name") + .and_then(|v| v.as_str()) + .context("Missing 'name' argument")?; + let args = arguments + .get("arguments") + .cloned() + .unwrap_or(serde_json::json!({})); + return self.get_prompt(server_name, name, args).await; + } + + let (server_name, tool_name) = self.parse_prefixed_name(prefixed_name)?; // Copy the global timeouts to avoid borrow conflict let global_timeouts = self.config.timeouts; let conn = self.get_or_connect(server_name).await?; @@ -899,7 +1342,7 @@ mod tests { assert!(config.servers.contains_key("test")); let server = config.servers.get("test").unwrap(); - assert_eq!(server.command, "node"); + assert_eq!(server.command, Some("node".to_string())); assert_eq!(server.args, vec!["server.js"]); assert_eq!(server.env.get("FOO"), Some(&"bar".to_string())); } @@ -909,9 +1352,10 @@ mod tests { let global = McpTimeouts::default(); let server_with_override = McpServerConfig { - command: "test".to_string(), + command: Some("test".to_string()), args: vec![], env: HashMap::new(), + url: None, connect_timeout: Some(20), execute_timeout: None, read_timeout: Some(180), diff --git a/src/palette.rs b/src/palette.rs index bb3f2f9a..bc66b8e4 100644 --- a/src/palette.rs +++ b/src/palette.rs @@ -84,6 +84,12 @@ pub fn ui_theme(name: &str) -> UiTheme { selection_bg: Color::Rgb(38, 64, 112), header_bg: DEEPSEEK_SLATE, }, + "whale" => UiTheme { + name: "whale", + composer_bg: DEEPSEEK_SLATE, + selection_bg: DEEPSEEK_NAVY, + header_bg: DEEPSEEK_INK, + }, _ => UiTheme { name: "default", composer_bg: COMPOSER_BG, diff --git a/src/prompts.rs b/src/prompts.rs index 4c09694f..61709acf 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -51,7 +51,15 @@ pub fn system_prompt_for_mode_with_context( let mut full_prompt = if let Some(project_block) = project_context.as_system_block() { format!("{}\n\n{}", base_prompt.trim(), project_block) } else { - base_prompt.trim().to_string() + // Fallback: Generate an automatic project map summary + let summary = crate::utils::summarize_project(workspace); + let tree = crate::utils::project_tree(workspace, 2); // Shallow tree for prompt + format!( + "{}\n\n### Project Structure (Automatic Map)\n**Summary:** {}\n\n**Tree:**\n```\n{}\n```", + base_prompt.trim(), + summary, + tree + ) }; if let Some(summary) = working_set_summary diff --git a/src/prompts/base.txt b/src/prompts/base.txt index d5473849..875896f7 100644 --- a/src/prompts/base.txt +++ b/src/prompts/base.txt @@ -36,3 +36,9 @@ Approval etiquette: - In autonomous modes, warn before risky or irreversible actions. Tone: competent, warm, and concise. Use light humor sparingly when it fits; a rare example is "You're absolutely right! ... maybe." + +Context Management & RLM: +- You have a finite context window. Large tool results may be automatically moved to RLM memory. +- If you see "[AUTOMATIC RLM OFFLOAD]", the content was stored in an RLM context ID. Use `rlm_exec` or `rlm_query` to access it. +- Proactively use `rlm_load` for files larger than 100KB to keep your main window lean. +- If the conversation is long, earlier messages may be summarized. You can search the full history using `rlm_exec(code="get(\"history\")")` if available. diff --git a/src/rlm.rs b/src/rlm.rs index 9768d6f3..ed406611 100644 --- a/src/rlm.rs +++ b/src/rlm.rs @@ -631,6 +631,20 @@ impl RlmSession { pub fn record_query_usage(&mut self, usage: &Usage, chars_sent: usize, chars_received: usize) { self.usage.record(usage, chars_sent, chars_received); } + + pub fn append_var(&mut self, name: &str, value: String) { + let active = self.active_context.clone(); + if let Some(ctx) = self.contexts.get_mut(&active) { + ctx.append_var(name, value); + } else { + // Fallback to default context if active doesn't exist + let ctx = self + .contexts + .entry("default".to_string()) + .or_insert_with(|| RlmContext::new("default", String::new(), None)); + ctx.append_var(name, value); + } + } } pub fn context_id_from_path(path: &Path) -> String { diff --git a/src/settings.rs b/src/settings.rs index e674c5da..e3ea6934 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -34,9 +34,9 @@ pub struct Settings { impl Default for Settings { fn default() -> Self { Self { - theme: "default".to_string(), + theme: "whale".to_string(), auto_compact: true, - auto_rlm: false, + auto_rlm: true, show_thinking: true, show_tool_details: true, default_mode: "agent".to_string(), @@ -92,9 +92,9 @@ impl Settings { pub fn set(&mut self, key: &str, value: &str) -> Result<()> { match key { "theme" => { - if !["default", "dark", "light"].contains(&value) { + if !["default", "dark", "light", "whale"].contains(&value) { anyhow::bail!( - "Failed to update setting: invalid theme '{value}'. Expected: default, dark, light." + "Failed to update setting: invalid theme '{value}'. Expected: default, dark, light, whale." ); } self.theme = value.to_string(); diff --git a/src/tools/mod.rs b/src/tools/mod.rs index cf447e53..f4879e71 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -11,6 +11,7 @@ pub mod file; pub mod file_search; pub mod git; pub mod plan; +pub mod project; pub mod registry; pub mod review; pub mod rlm; diff --git a/src/tools/project.rs b/src/tools/project.rs new file mode 100644 index 00000000..cd9c7808 --- /dev/null +++ b/src/tools/project.rs @@ -0,0 +1,82 @@ +//! Project mapping tool for understanding codebase structure. + +use crate::utils::{is_key_file, project_tree, summarize_project}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Serialize; +use serde_json::{Value, json}; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64, +}; + +pub struct ProjectMapTool; + +#[derive(Debug, Serialize)] +struct ProjectMap { + tree: String, + summary: String, + key_files: Vec, +} + +#[async_trait] +impl ToolSpec for ProjectMapTool { + fn name(&self) -> &'static str { + "project_map" + } + + fn description(&self) -> &'static str { + "Get a high-level map of the project structure, including key files and a tree view." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "max_depth": { + "type": "integer", + "description": "Maximum depth for the tree view (default: 3)." + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let max_depth = optional_u64(&input, "max_depth", 3) as usize; + let map = generate_project_map(&context.workspace, max_depth)?; + ToolResult::json(&map).map_err(|e| ToolError::execution_failed(e.to_string())) + } +} + +fn generate_project_map(root: &std::path::Path, max_depth: usize) -> Result { + let tree = project_tree(root, max_depth); + let summary = summarize_project(root); + + // For key_files, we can just do a quick scan since summarize_project doesn't return them directly anymore + let mut key_files = Vec::new(); + let mut builder = ignore::WalkBuilder::new(root); + builder.hidden(false).follow_links(true).max_depth(Some(2)); + let walker = builder.build(); + + for entry in walker.flatten() { + if is_key_file(entry.path()) { + if let Ok(rel) = entry.path().strip_prefix(root) { + key_files.push(rel.to_string_lossy().to_string()); + } + } + } + + Ok(ProjectMap { + tree, + summary, + key_files, + }) +} diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 833708e0..c8953b2b 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -308,6 +308,13 @@ impl ToolRegistryBuilder { self.with_tool(Arc::new(DiagnosticsTool)) } + /// Include project mapping tools. + #[must_use] + pub fn with_project_tools(self) -> Self { + use super::project::ProjectMapTool; + self.with_tool(Arc::new(ProjectMapTool)) + } + /// Include cargo test runner tool. #[must_use] pub fn with_test_runner_tool(self) -> Self { @@ -345,8 +352,14 @@ impl ToolRegistryBuilder { /// Include all agent tools (file tools + shell + note + search + patch). #[must_use] - pub fn with_agent_tools(self, allow_shell: bool) -> Self { - let builder = self + pub fn with_agent_tools( + self, + allow_shell: bool, + rlm_session: Option, + client: Option, + model: String, + ) -> Self { + let mut builder = self .with_file_tools() .with_note_tool() .with_search_tools() @@ -354,8 +367,13 @@ impl ToolRegistryBuilder { .with_patch_tools() .with_git_tools() .with_diagnostics_tool() + .with_project_tools() .with_test_runner_tool(); + if let Some(session) = rlm_session { + builder = builder.with_rlm_tools(session, client, model); + } + if allow_shell { builder.with_shell_tools() } else { @@ -387,8 +405,11 @@ impl ToolRegistryBuilder { allow_shell: bool, todo_list: super::todo::SharedTodoList, plan_state: super::plan::SharedPlanState, + rlm_session: Option, + client: Option, + model: String, ) -> Self { - self.with_agent_tools(allow_shell) + self.with_agent_tools(allow_shell, rlm_session, client, model) .with_todo_tool(todo_list) .with_plan_tool(plan_state) } @@ -427,13 +448,19 @@ impl ToolRegistryBuilder { manager: super::subagent::SharedSubAgentManager, runtime: super::subagent::SubAgentRuntime, ) -> Self { - use super::subagent::{AgentCancelTool, AgentListTool, AgentResultTool, AgentSpawnTool}; + use super::subagent::{ + AgentCancelTool, AgentListTool, AgentResultTool, AgentSpawnTool, DelegateToAgentTool, + }; use super::swarm::AgentSwarmTool; self.with_tool(Arc::new(AgentSpawnTool::new( manager.clone(), runtime.clone(), ))) + .with_tool(Arc::new(DelegateToAgentTool::new( + manager.clone(), + runtime.clone(), + ))) .with_tool(Arc::new(AgentSwarmTool::new(manager.clone(), runtime))) .with_tool(Arc::new(AgentResultTool::new(manager.clone()))) .with_tool(Arc::new(AgentCancelTool::new(manager.clone()))) diff --git a/src/tools/subagent.rs b/src/tools/subagent.rs index 29f98374..19ed2719 100644 --- a/src/tools/subagent.rs +++ b/src/tools/subagent.rs @@ -659,6 +659,75 @@ impl ToolSpec for AgentListTool { } } +/// Tool to delegate a task to a specialized agent (alias for agent_spawn). +pub struct DelegateToAgentTool { + manager: SharedSubAgentManager, + runtime: SubAgentRuntime, +} + +impl DelegateToAgentTool { + /// Create a new delegation tool. + #[must_use] + pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self { + Self { manager, runtime } + } +} + +#[async_trait] +impl ToolSpec for DelegateToAgentTool { + fn name(&self) -> &'static str { + "delegate_to_agent" + } + + fn description(&self) -> &'static str { + "Delegate a task to a specialized sub-agent. This is an alias for agent_spawn." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "agent_name": { + "type": "string", + "description": "Name or type of the agent: general, explore, plan, review" + }, + "objective": { + "type": "string", + "description": "The goal or task description for the agent" + } + }, + "required": ["objective"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ + ToolCapability::ExecutesCode, + ToolCapability::RequiresApproval, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Required + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let objective = required_str(&input, "objective")?.to_string(); + let agent_type_str = input + .get("agent_name") + .and_then(|v| v.as_str()) + .unwrap_or("general"); + + let spawn_input = json!({ + "prompt": objective, + "type": agent_type_str + }); + + let spawn_tool = AgentSpawnTool::new(self.manager.clone(), self.runtime.clone()); + spawn_tool.execute(spawn_input, context).await + } +} + // === Sub-agent Execution === struct SubAgentTask { diff --git a/src/tui/history.rs b/src/tui/history.rs index afa0c97a..fa49521e 100644 --- a/src/tui/history.rs +++ b/src/tui/history.rs @@ -27,6 +27,8 @@ const TOOL_TEXT_LIMIT: usize = 240; pub enum HistoryCell { User { content: String }, Assistant { content: String, streaming: bool }, + Player { content: String, streaming: bool }, + Coach { content: String, streaming: bool }, System { content: String }, Thinking { content: String, streaming: bool }, Tool(ToolCell), @@ -65,6 +67,30 @@ impl HistoryCell { } lines } + HistoryCell::Player { content, streaming } => { + let mut lines = render_message("Player", content, player_style(), width); + if *streaming { + if let Some(last) = lines.last_mut() { + last.spans.push(Span::styled( + "โ–‹", + Style::default().fg(palette::DEEPSEEK_SKY), + )); + } + } + lines + } + HistoryCell::Coach { content, streaming } => { + let mut lines = render_message("Coach", content, coach_style(), width); + if *streaming { + if let Some(last) = lines.last_mut() { + last.spans.push(Span::styled( + "โ–‹", + Style::default().fg(palette::DEEPSEEK_SKY), + )); + } + } + lines + } HistoryCell::System { content } => { render_message("System", content, system_style(), width) } @@ -122,6 +148,15 @@ impl HistoryCell { /// Convert a message into history cells for rendering. #[must_use] pub fn history_cells_from_message(msg: &Message) -> Vec { + history_cells_from_message_with_mode(msg, None) +} + +/// Convert a message into history cells with optional Duo phase context. +#[must_use] +pub fn history_cells_from_message_with_mode( + msg: &Message, + duo_phase: Option, +) -> Vec { let mut cells = Vec::new(); let mut text_blocks = Vec::new(); let mut thinking_blocks = Vec::new(); @@ -138,10 +173,25 @@ pub fn history_cells_from_message(msg: &Message) -> Vec { let content = text_blocks.join("\n"); match msg.role.as_str() { "user" => cells.push(HistoryCell::User { content }), - "assistant" => cells.push(HistoryCell::Assistant { - content, - streaming: false, - }), + "assistant" => { + let cell = match duo_phase { + Some(crate::duo::DuoPhase::Player) | Some(crate::duo::DuoPhase::Init) => { + HistoryCell::Player { + content, + streaming: false, + } + } + Some(crate::duo::DuoPhase::Coach) => HistoryCell::Coach { + content, + streaming: false, + }, + _ => HistoryCell::Assistant { + content, + streaming: false, + }, + }; + cells.push(cell); + } "system" => cells.push(HistoryCell::System { content }), _ => {} } @@ -1271,6 +1321,16 @@ fn assistant_style() -> Style { Style::default().fg(palette::DEEPSEEK_SKY) } +fn player_style() -> Style { + Style::default().fg(palette::MODE_DUO) +} + +fn coach_style() -> Style { + Style::default() + .fg(palette::DEEPSEEK_BLUE) + .add_modifier(Modifier::BOLD) +} + fn system_style() -> Style { Style::default().fg(palette::TEXT_MUTED).italic() } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 1566d2e8..fb383c42 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -60,8 +60,8 @@ use super::approval::{ use super::history::{ DiffPreviewCell, ExecCell, ExecSource, ExploringCell, ExploringEntry, GenericToolCell, HistoryCell, McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell, - ToolStatus, ViewImageCell, WebSearchCell, history_cells_from_message, summarize_mcp_output, - summarize_tool_args, summarize_tool_output, + ToolStatus, ViewImageCell, WebSearchCell, history_cells_from_message_with_mode, + summarize_mcp_output, summarize_tool_args, summarize_tool_output, }; use super::views::{HelpView, ModalKind, ViewEvent}; use super::widgets::{ChatWidget, ComposerWidget, HeaderData, HeaderWidget, Renderable}; @@ -154,8 +154,19 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { &saved.metadata.id[..8] ), }); + + let duo_phase = if app.mode == AppMode::Duo { + app.duo_session + .lock() + .ok() + .and_then(|s| s.active_state.as_ref().map(|st| st.phase)) + } else { + None + }; + for msg in &saved.messages { - app.history.extend(history_cells_from_message(msg)); + app.history + .extend(history_cells_from_message_with_mode(msg, duo_phase)); } app.mark_history_updated(); app.status_message = Some(format!("Resumed session: {}", &saved.metadata.id[..8])); @@ -268,19 +279,46 @@ async fn run_event_loop( let index = if let Some(index) = app.streaming_message_index { index } else { - app.add_message(HistoryCell::Assistant { - content: String::new(), - streaming: true, - }); + let duo_phase = if app.mode == AppMode::Duo { + app.duo_session + .lock() + .ok() + .and_then(|s| s.active_state.as_ref().map(|st| st.phase)) + } else { + None + }; + + let cell = match duo_phase { + Some(crate::duo::DuoPhase::Player) + | Some(crate::duo::DuoPhase::Init) => HistoryCell::Player { + content: String::new(), + streaming: true, + }, + Some(crate::duo::DuoPhase::Coach) => HistoryCell::Coach { + content: String::new(), + streaming: true, + }, + _ => HistoryCell::Assistant { + content: String::new(), + streaming: true, + }, + }; + + app.add_message(cell); let index = app.history.len().saturating_sub(1); app.streaming_message_index = Some(index); index }; - if let Some(HistoryCell::Assistant { content, .. }) = - app.history.get_mut(index) - { - content.clone_from(¤t_streaming_text); + if let Some(cell) = app.history.get_mut(index) { + match cell { + HistoryCell::Assistant { content, .. } + | HistoryCell::Player { content, .. } + | HistoryCell::Coach { content, .. } => { + content.clone_from(¤t_streaming_text); + } + _ => {} + } app.mark_history_updated(); } } @@ -1961,8 +1999,19 @@ async fn handle_view_events(app: &mut App, engine_handle: &EngineHandle, events: fn apply_loaded_session(app: &mut App, session: &SavedSession) { app.api_messages.clone_from(&session.messages); app.history.clear(); + + let duo_phase = if app.mode == AppMode::Duo { + app.duo_session + .lock() + .ok() + .and_then(|s| s.active_state.as_ref().map(|st| st.phase)) + } else { + None + }; + for msg in &app.api_messages { - app.history.extend(history_cells_from_message(msg)); + app.history + .extend(history_cells_from_message_with_mode(msg, duo_phase)); } app.mark_history_updated(); app.transcript_selection.clear(); @@ -2337,9 +2386,11 @@ fn format_elapsed(start: Instant) -> String { } fn deepseek_squiggle(start: Option) -> &'static str { - const FRAMES: [&str; 8] = ["๐Ÿณ", "๐Ÿณยท", "๐Ÿณยทยท", "๐Ÿณยทยทยท", "๐Ÿณยทยท", "๐Ÿณยท", "๐Ÿณ", "๐Ÿณ~"]; + const FRAMES: [&str; 12] = [ + "๐Ÿณ", "๐Ÿณ.", "๐Ÿณ..", "๐Ÿณ...", "๐Ÿณ..", "๐Ÿณ.", "๐Ÿ‹", "๐Ÿ‹.", "๐Ÿ‹..", "๐Ÿ‹...", "๐Ÿ‹..", "๐Ÿ‹.", + ]; let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis()); - let idx = ((elapsed_ms / 220) as usize) % FRAMES.len(); + let idx = ((elapsed_ms / 180) as usize) % FRAMES.len(); FRAMES[idx] } diff --git a/src/utils.rs b/src/utils.rs index 77e8def1..cc184765 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -6,8 +6,134 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::models::{ContentBlock, Message}; use anyhow::{Context, Result}; +use ignore::WalkBuilder; use serde_json::Value; +// === Project Mapping Helpers === + +/// Identify if a file is a "key" file for project identification. +#[must_use] +pub fn is_key_file(path: &Path) -> bool { + let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else { + return false; + }; + + matches!( + file_name.to_lowercase().as_str(), + "cargo.toml" + | "package.json" + | "requirements.txt" + | "build.gradle" + | "pom.xml" + | "readme.md" + | "agents.md" + | "claude.md" + | "makefile" + | "dockerfile" + | "main.rs" + | "lib.rs" + | "index.js" + | "index.ts" + | "app.py" + ) +} + +/// Generate a high-level summary of the project based on key files. +#[must_use] +pub fn summarize_project(root: &Path) -> String { + let mut key_files = Vec::new(); + + let mut builder = WalkBuilder::new(root); + builder.hidden(false).follow_links(true).max_depth(Some(2)); + let walker = builder.build(); + + for entry in walker { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + if is_key_file(entry.path()) { + if let Ok(rel) = entry.path().strip_prefix(root) { + key_files.push(rel.to_string_lossy().to_string()); + } + } + } + + if key_files.is_empty() { + return "Unknown project type".to_string(); + } + + let mut types = Vec::new(); + if key_files + .iter() + .any(|f| f.to_lowercase().contains("cargo.toml")) + { + types.push("Rust"); + } + if key_files + .iter() + .any(|f| f.to_lowercase().contains("package.json")) + { + types.push("JavaScript/Node.js"); + } + if key_files + .iter() + .any(|f| f.to_lowercase().contains("requirements.txt")) + { + types.push("Python"); + } + + if types.is_empty() { + format!("Project with key files: {}", key_files.join(", ")) + } else { + format!("A {} project", types.join(" and ")) + } +} + +/// Generate a tree-like view of the project structure. +#[must_use] +pub fn project_tree(root: &Path, max_depth: usize) -> String { + let mut tree_lines = Vec::new(); + + let mut builder = WalkBuilder::new(root); + builder + .hidden(false) + .follow_links(true) + .max_depth(Some(max_depth + 1)); + let walker = builder.build(); + + for entry in walker { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + + let path = entry.path(); + let depth = entry.depth(); + + if depth == 0 || depth > max_depth { + continue; + } + + let rel_path = path.strip_prefix(root).unwrap_or(path); + let indent = " ".repeat(depth - 1); + let prefix = if entry.file_type().is_some_and(|ft| ft.is_dir()) { + "DIR: " + } else { + "FILE: " + }; + + tree_lines.push(format!( + "{}{}{}", + indent, + prefix, + rel_path.file_name().unwrap_or_default().to_string_lossy() + )); + } + + tree_lines.join("\n") +} + // === Filesystem Helpers === #[allow(dead_code)]