feat: v0.3.5 — smart context offloading, full MCP protocol, Duo mode, project_map tool

- Intelligent context offloading: large tool results (>15k chars) auto-moved to RLM memory
- Persistent history context: compacted messages offloaded to RLM history variable
- Full MCP protocol: SSE transport, Resources (resources/list, resources/read), Prompts (prompts/list, prompts/get)
- mcp_read_resource and mcp_get_prompt virtual tools exposed to the model
- Dialectical Duo mode with Player/Coach TUI rendering
- Dynamic system prompt refresh 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
- Default theme changed to Whale with updated color palette
- Fix MCP test compilation for updated McpServerConfig struct shape
- Fix clippy warnings (strip_prefix, inspect_err, flatten, is_some_and)
This commit is contained in:
Hunter Bown
2026-01-30 06:02:43 -06:00
parent 18ce9f96f1
commit 3f8d04178c
21 changed files with 1206 additions and 120 deletions
+2
View File
@@ -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)),
+8 -4
View File
@@ -528,6 +528,8 @@ pub struct CompactionResult {
pub messages: Vec<Message>,
/// Summary system prompt
pub summary_prompt: Option<SystemPrompt>,
/// Messages that were removed from the active window
pub removed_messages: Vec<Message>,
/// 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<Message>, Option<SystemPrompt>)> {
) -> Result<(Vec<Message>, Option<SystemPrompt>, Vec<Message>)> {
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<Message> = plan
@@ -664,6 +667,7 @@ pub async fn compact_messages(
Ok((
pinned_messages,
Some(SystemPrompt::Blocks(vec![summary_block])),
to_summarize,
))
}
+129 -7
View File
@@ -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<Arc<AsyncMutex<McpPool>>, 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
+10 -2
View File
@@ -638,9 +638,10 @@ fn mcp_template_json() -> Result<String> {
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(())
}
+517 -73
View File
@@ -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<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
pub url: Option<String>,
#[serde(default)]
pub connect_timeout: Option<u64>,
#[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<String>,
#[serde(rename = "mimeType", default)]
pub mime_type: Option<String>,
}
/// Prompt discovered from an MCP server
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpPrompt {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub arguments: Vec<McpPromptArgument>,
}
/// Argument for an MCP prompt
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpPromptArgument {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[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<serde_json::Value>;
}
pub struct StdioTransport {
_child: Child,
stdin: ChildStdin,
reader: tokio::io::BufReader<ChildStdout>,
}
#[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<serde_json::Value> {
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::<serde_json::Value>(trimmed) {
return Ok(value);
}
}
}
}
pub struct SseTransport {
client: reqwest::Client,
base_url: String,
endpoint_url: Option<String>,
receiver: tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>,
}
impl SseTransport {
pub async fn connect(client: reqwest::Client, url: String) -> Result<Self> {
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<serde_json::Value>,
) -> 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::<serde_json::Value>(&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<serde_json::Value> {
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<dyn McpTransport>,
tools: Vec<McpTool>,
resources: Vec<McpResource>,
prompts: Vec<McpPrompt>,
request_id: AtomicU64,
state: ConnectionState,
config: McpServerConfig,
@@ -142,30 +337,48 @@ impl McpConnection {
) -> Result<Self> {
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<dyn McpTransport> = 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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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::<serde_json::Value>(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<serde_json::Value> {
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<serde_json::Value> {
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<crate::models::Tool> {
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<serde_json::Value> {
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),
+6
View File
@@ -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,
+9 -1
View File
@@ -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
+6
View File
@@ -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.
+14
View File
@@ -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 {
+4 -4
View File
@@ -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();
+1
View File
@@ -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;
+82
View File
@@ -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<String>,
}
#[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<ToolCapability> {
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
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<ProjectMap, ToolError> {
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,
})
}
+31 -4
View File
@@ -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<SharedRlmSession>,
client: Option<DeepSeekClient>,
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<SharedRlmSession>,
client: Option<DeepSeekClient>,
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())))
+69
View File
@@ -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<ToolCapability> {
vec![
ToolCapability::ExecutesCode,
ToolCapability::RequiresApproval,
]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Required
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
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 {
+64 -4
View File
@@ -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<HistoryCell> {
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<crate::duo::DuoPhase>,
) -> Vec<HistoryCell> {
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<HistoryCell> {
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()
}
+65 -14
View File
@@ -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(&current_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(&current_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<Instant>) -> &'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]
}
+126
View File
@@ -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)]