From 0578eb701e090d23d444547cd56e3f2db9a26fd1 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 29 Apr 2026 09:38:04 -0500 Subject: [PATCH] Add shell jobs and MCP manager to the TUI --- crates/tui/src/commands/config.rs | 51 ++++ crates/tui/src/commands/jobs.rs | 110 +++++++ crates/tui/src/commands/mcp.rs | 116 ++++++++ crates/tui/src/commands/mod.rs | 16 ++ crates/tui/src/core/engine.rs | 6 +- crates/tui/src/mcp.rs | 363 ++++++++++++++++++++++++ crates/tui/src/runtime_threads.rs | 1 + crates/tui/src/tools/shell.rs | 227 +++++++++++++-- crates/tui/src/tools/shell/tests.rs | 65 +++++ crates/tui/src/tools/spec.rs | 2 + crates/tui/src/tools/tasks.rs | 10 - crates/tui/src/tui/app.rs | 67 ++++- crates/tui/src/tui/command_palette.rs | 255 ++++++++++++++++- crates/tui/src/tui/mcp_routing.rs | 160 +++++++++++ crates/tui/src/tui/mod.rs | 2 + crates/tui/src/tui/shell_job_routing.rs | 181 ++++++++++++ crates/tui/src/tui/transcript.rs | 87 +++++- crates/tui/src/tui/ui.rs | 172 +++++++++++ crates/tui/src/tui/views/mod.rs | 6 + docs/CONFIGURATION.md | 3 + docs/MCP.md | 37 ++- docs/TOOL_SURFACE.md | 22 ++ 22 files changed, 1912 insertions(+), 47 deletions(-) create mode 100644 crates/tui/src/commands/jobs.rs create mode 100644 crates/tui/src/commands/mcp.rs create mode 100644 crates/tui/src/tui/mcp_routing.rs create mode 100644 crates/tui/src/tui/shell_job_routing.rs diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index c8c5e718..acaa7140 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -72,6 +72,34 @@ pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Resu Ok(path) } +fn persist_root_string_key(key: &str, value: &str) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + table.insert(key.to_string(), toml::Value::String(value.to_string())); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + /// Resolve the path to `~/.deepseek/config.toml` (or /// `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we /// never write to a different file than the one we read. @@ -125,6 +153,29 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> ), }; } + "mcp_config_path" | "mcp" => { + if value.trim().is_empty() { + return CommandResult::error("mcp_config_path cannot be empty"); + } + app.mcp_config_path = PathBuf::from(expand_tilde(value)); + app.mcp_restart_required = true; + let message = if persist { + match persist_root_string_key("mcp_config_path", value) { + Ok(path) => format!( + "mcp_config_path = {} (saved to {}; restart required for MCP tool pool)", + app.mcp_config_path.display(), + path.display() + ), + Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + } + } else { + format!( + "mcp_config_path = {} (session only; restart required for MCP tool pool)", + app.mcp_config_path.display() + ) + }; + return CommandResult::message(message); + } _ => {} } diff --git a/crates/tui/src/commands/jobs.rs b/crates/tui/src/commands/jobs.rs new file mode 100644 index 00000000..b213b88e --- /dev/null +++ b/crates/tui/src/commands/jobs.rs @@ -0,0 +1,110 @@ +//! Shell job-center commands. + +use crate::tui::app::{App, AppAction, ShellJobAction}; + +use super::CommandResult; + +pub fn jobs(_app: &mut App, args: Option<&str>) -> CommandResult { + let raw = args.unwrap_or("").trim(); + if raw.is_empty() || raw.eq_ignore_ascii_case("list") { + return CommandResult::action(AppAction::ShellJob(ShellJobAction::List)); + } + + let mut parts = raw.splitn(3, char::is_whitespace); + let action = parts.next().unwrap_or("").to_ascii_lowercase(); + let id = parts.next().map(str::trim).filter(|s| !s.is_empty()); + let rest = parts.next().map(str::trim).unwrap_or(""); + + match action.as_str() { + "list" => CommandResult::action(AppAction::ShellJob(ShellJobAction::List)), + "show" | "inspect" => match id { + Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Show { + id: id.to_string(), + })), + None => CommandResult::error("Usage: /jobs show "), + }, + "poll" | "wait" => match id { + Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Poll { + id: id.to_string(), + wait: action == "wait", + })), + None => CommandResult::error("Usage: /jobs poll "), + }, + "stdin" | "send" => match id { + Some(id) if !rest.is_empty() => { + CommandResult::action(AppAction::ShellJob(ShellJobAction::SendStdin { + id: id.to_string(), + input: rest.to_string(), + close: false, + })) + } + _ => CommandResult::error("Usage: /jobs stdin "), + }, + "close-stdin" | "eof" => match id { + Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::SendStdin { + id: id.to_string(), + input: String::new(), + close: true, + })), + None => CommandResult::error("Usage: /jobs close-stdin "), + }, + "cancel" | "kill" | "stop" => match id { + Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Cancel { + id: id.to_string(), + })), + None => CommandResult::error("Usage: /jobs cancel "), + }, + _ => CommandResult::error( + "Usage: /jobs [list|show |poll |wait |stdin |close-stdin |cancel ]", + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::TuiOptions; + use std::path::PathBuf; + + fn app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: PathBuf::from("."), + allow_shell: false, + use_alt_screen: false, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 2, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }, + &Config::default(), + ) + } + + #[test] + fn parses_job_actions() { + let mut app = app(); + let show = jobs(&mut app, Some("show shell_abcd")); + assert!(matches!( + show.action, + Some(AppAction::ShellJob(ShellJobAction::Show { id })) if id == "shell_abcd" + )); + + let send = jobs(&mut app, Some("stdin shell_abcd y")); + assert!(matches!( + send.action, + Some(AppAction::ShellJob(ShellJobAction::SendStdin { id, input, close: false })) + if id == "shell_abcd" && input == "y" + )); + } +} diff --git a/crates/tui/src/commands/mcp.rs b/crates/tui/src/commands/mcp.rs new file mode 100644 index 00000000..71c0f08c --- /dev/null +++ b/crates/tui/src/commands/mcp.rs @@ -0,0 +1,116 @@ +//! In-TUI MCP manager command parser. + +use crate::tui::app::{App, AppAction, McpUiAction}; + +use super::CommandResult; + +pub fn mcp(_app: &mut App, args: Option<&str>) -> CommandResult { + let raw = args.unwrap_or("").trim(); + if raw.is_empty() || raw.eq_ignore_ascii_case("status") || raw.eq_ignore_ascii_case("list") { + return CommandResult::action(AppAction::Mcp(McpUiAction::Show)); + } + + let mut parts = raw.split_whitespace(); + let action = parts.next().unwrap_or("").to_ascii_lowercase(); + match action.as_str() { + "init" => CommandResult::action(AppAction::Mcp(McpUiAction::Init { + force: parts.any(|part| part == "--force" || part == "-f"), + })), + "add" => parse_add(parts.collect()), + "enable" => match parse_name(parts.next(), "Usage: /mcp enable ") { + Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Enable { name })), + Err(msg) => CommandResult::error(msg), + }, + "disable" => match parse_name(parts.next(), "Usage: /mcp disable ") { + Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Disable { name })), + Err(msg) => CommandResult::error(msg), + }, + "remove" | "rm" => match parse_name(parts.next(), "Usage: /mcp remove ") { + Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Remove { name })), + Err(msg) => CommandResult::error(msg), + }, + "validate" => CommandResult::action(AppAction::Mcp(McpUiAction::Validate)), + "reload" | "reconnect" => CommandResult::action(AppAction::Mcp(McpUiAction::Reload)), + _ => CommandResult::error( + "Usage: /mcp [init|add stdio [args...]|add http |enable |disable |remove |validate|reload]", + ), + } +} + +fn parse_name(name: Option<&str>, usage: &str) -> Result { + match name { + Some(name) if !name.trim().is_empty() => Ok(name.to_string()), + _ => Err(usage.to_string()), + } +} + +fn parse_add(parts: Vec<&str>) -> CommandResult { + if parts.len() < 3 { + return CommandResult::error( + "Usage: /mcp add stdio [args...] OR /mcp add http ", + ); + } + match parts[0].to_ascii_lowercase().as_str() { + "stdio" => CommandResult::action(AppAction::Mcp(McpUiAction::AddStdio { + name: parts[1].to_string(), + command: parts[2].to_string(), + args: parts[3..].iter().map(|s| (*s).to_string()).collect(), + })), + "http" | "sse" => CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp { + name: parts[1].to_string(), + url: parts[2].to_string(), + })), + _ => CommandResult::error( + "Usage: /mcp add stdio [args...] OR /mcp add http ", + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::TuiOptions; + use std::path::PathBuf; + + fn app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: PathBuf::from("."), + allow_shell: false, + use_alt_screen: false, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 2, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + }, + &Config::default(), + ) + } + + #[test] + fn parses_add_and_validate() { + let mut app = app(); + let add = mcp(&mut app, Some("add stdio local node server.js")); + assert!(matches!( + add.action, + Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args })) + if name == "local" && command == "node" && args == vec!["server.js".to_string()] + )); + + let validate = mcp(&mut app, Some("validate")); + assert!(matches!( + validate.action, + Some(AppAction::Mcp(McpUiAction::Validate)) + )); + } +} diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index a1091c05..187d10bf 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -9,6 +9,8 @@ mod core; mod cycle; mod debug; mod init; +mod jobs; +mod mcp; mod note; mod provider; mod queue; @@ -184,6 +186,18 @@ pub const COMMANDS: &[CommandInfo] = &[ description: "Manage background tasks", usage: "/task [add |list|show |cancel ]", }, + CommandInfo { + name: "jobs", + aliases: &["job"], + description: "Inspect and control background shell jobs", + usage: "/jobs [list|show |poll |wait |stdin |cancel ]", + }, + CommandInfo { + name: "mcp", + aliases: &[], + description: "Open or manage MCP servers", + usage: "/mcp [init|add stdio [args...]|add http |enable |disable |remove |validate|reload]", + }, // Session commands CommandInfo { name: "save", @@ -389,6 +403,8 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "note" => note::note(app, arg), "attach" | "image" | "media" => attachment::attach(app, arg), "task" | "tasks" => task::task(app, arg), + "jobs" | "job" => jobs::jobs(app, arg), + "mcp" => mcp::mcp(app, arg), // Session commands "save" => session::save(app, arg), diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 7815b8f2..bcb665db 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1269,7 +1269,11 @@ impl Engine { let subagent_manager = new_shared_subagent_manager(config.workspace.clone(), config.max_subagents); - let shell_manager = new_shared_shell_manager(config.workspace.clone()); + let shell_manager = config + .runtime_services + .shell_manager + .clone() + .unwrap_or_else(|| new_shared_shell_manager(config.workspace.clone())); let capacity_controller = CapacityController::new(config.capacity.clone()); // Create Flash seam manager for layered context (#159). v0.7.5 keeps diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index 23b61d3f..024b604c 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -1432,6 +1432,308 @@ impl McpPool { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpWriteStatus { + Created, + Overwritten, + SkippedExists, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpDiscoveredItem { + pub name: String, + pub model_name: String, + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpServerSnapshot { + pub name: String, + pub enabled: bool, + pub required: bool, + pub transport: String, + pub command_or_url: String, + pub connect_timeout: u64, + pub execute_timeout: u64, + pub read_timeout: u64, + pub connected: bool, + pub error: Option, + pub tools: Vec, + pub resources: Vec, + pub prompts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpManagerSnapshot { + pub config_path: std::path::PathBuf, + pub config_exists: bool, + pub restart_required: bool, + pub servers: Vec, +} + +pub fn load_config(path: &Path) -> Result { + if !path.exists() { + return Ok(McpConfig::default()); + } + let contents = fs::read_to_string(path) + .with_context(|| format!("Failed to read MCP config {}", path.display()))?; + serde_json::from_str(&contents) + .with_context(|| format!("Failed to parse MCP config {}", path.display())) +} + +pub fn save_config(path: &Path, cfg: &McpConfig) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create MCP config directory {}", parent.display()) + })?; + } + let rendered = serde_json::to_string_pretty(cfg).context("Failed to serialize MCP config")?; + fs::write(path, rendered) + .with_context(|| format!("Failed to write MCP config {}", path.display()))?; + Ok(()) +} + +fn mcp_template_json() -> Result { + let mut cfg = McpConfig::default(); + cfg.servers.insert( + "example".to_string(), + McpServerConfig { + command: Some("node".to_string()), + args: vec!["./path/to/your-mcp-server.js".to_string()], + env: HashMap::new(), + url: None, + connect_timeout: None, + execute_timeout: None, + read_timeout: None, + disabled: true, + enabled: true, + required: false, + enabled_tools: Vec::new(), + disabled_tools: Vec::new(), + }, + ); + serde_json::to_string_pretty(&cfg).context("Failed to render MCP template JSON") +} + +pub fn init_config(path: &Path, force: bool) -> Result { + if path.exists() && !force { + return Ok(McpWriteStatus::SkippedExists); + } + let status = if path.exists() { + McpWriteStatus::Overwritten + } else { + McpWriteStatus::Created + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create MCP config directory {}", parent.display()) + })?; + } + fs::write(path, mcp_template_json()?) + .with_context(|| format!("Failed to write MCP config {}", path.display()))?; + Ok(status) +} + +pub fn add_server_config( + path: &Path, + name: String, + command: Option, + url: Option, + args: Vec, +) -> Result<()> { + if command.is_none() && url.is_none() { + anyhow::bail!("Provide either a command or URL for MCP server '{name}'."); + } + let mut cfg = load_config(path)?; + cfg.servers.insert( + name, + McpServerConfig { + command, + args, + env: HashMap::new(), + url, + connect_timeout: None, + execute_timeout: None, + read_timeout: None, + disabled: false, + enabled: true, + required: false, + enabled_tools: Vec::new(), + disabled_tools: Vec::new(), + }, + ); + save_config(path, &cfg) +} + +pub fn remove_server_config(path: &Path, name: &str) -> Result<()> { + let mut cfg = load_config(path)?; + if cfg.servers.remove(name).is_none() { + anyhow::bail!("MCP server '{name}' not found"); + } + save_config(path, &cfg) +} + +pub fn set_server_enabled(path: &Path, name: &str, enabled: bool) -> Result<()> { + let mut cfg = load_config(path)?; + let server = cfg + .servers + .get_mut(name) + .ok_or_else(|| anyhow::anyhow!("MCP server '{name}' not found"))?; + server.enabled = enabled; + server.disabled = !enabled; + save_config(path, &cfg) +} + +pub fn manager_snapshot_from_config( + path: &Path, + restart_required: bool, +) -> Result { + let cfg = load_config(path)?; + Ok(snapshot_from_config( + path, + path.exists(), + restart_required, + &cfg, + None, + )) +} + +pub async fn discover_manager_snapshot( + path: &Path, + network_policy: Option, + restart_required: bool, +) -> Result { + let cfg = load_config(path)?; + let mut pool = McpPool::new(cfg.clone()); + if let Some(policy) = network_policy { + pool = pool.with_network_policy(policy); + } + let errors = pool + .connect_all() + .await + .into_iter() + .map(|(name, err)| (name, err.to_string())) + .collect::>(); + Ok(snapshot_from_config( + path, + path.exists(), + restart_required, + &cfg, + Some((&pool, &errors)), + )) +} + +fn snapshot_from_config( + path: &Path, + config_exists: bool, + restart_required: bool, + cfg: &McpConfig, + discovery: Option<(&McpPool, &HashMap)>, +) -> McpManagerSnapshot { + let mut servers = cfg + .servers + .iter() + .map(|(name, server)| { + let transport = if server.url.is_some() { + "http/sse" + } else { + "stdio" + }; + let command_or_url = server.url.clone().unwrap_or_else(|| { + let mut command = server + .command + .clone() + .unwrap_or_else(|| "(missing)".to_string()); + if !server.args.is_empty() { + command.push(' '); + command.push_str(&server.args.join(" ")); + } + command + }); + let mut snapshot = McpServerSnapshot { + name: name.clone(), + enabled: server.is_enabled(), + required: server.required, + transport: transport.to_string(), + command_or_url, + connect_timeout: server.effective_connect_timeout(&cfg.timeouts), + execute_timeout: server.effective_execute_timeout(&cfg.timeouts), + read_timeout: server.effective_read_timeout(&cfg.timeouts), + connected: false, + error: if server.is_enabled() { + None + } else { + Some("disabled".to_string()) + }, + tools: Vec::new(), + resources: Vec::new(), + prompts: Vec::new(), + }; + + if let Some((pool, errors)) = discovery { + if let Some(error) = errors.get(name) { + snapshot.error = Some(error.clone()); + } + if let Some(conn) = pool.connections.get(name) { + snapshot.connected = conn.is_ready(); + snapshot.tools = conn + .tools() + .iter() + .filter(|tool| conn.config().is_tool_enabled(&tool.name)) + .map(|tool| McpDiscoveredItem { + name: tool.name.clone(), + model_name: format!("mcp_{}_{}", name, tool.name), + description: tool.description.clone(), + }) + .collect(); + snapshot.resources = + conn.resources() + .iter() + .map(|resource| McpDiscoveredItem { + name: resource.name.clone(), + model_name: format!( + "mcp_{}_{}", + name, + resource.name.replace(' ', "_").to_lowercase() + ), + description: resource.description.clone(), + }) + .chain(conn.resource_templates().iter().map(|template| { + McpDiscoveredItem { + name: template.name.clone(), + model_name: format!( + "mcp_{}_{}", + name, + template.name.replace(' ', "_").to_lowercase() + ), + description: template.description.clone(), + } + })) + .collect(); + snapshot.prompts = conn + .prompts() + .iter() + .map(|prompt| McpDiscoveredItem { + name: prompt.name.clone(), + model_name: format!("mcp_{}_{}", name, prompt.name), + description: prompt.description.clone(), + }) + .collect(); + } + } + + snapshot + }) + .collect::>(); + servers.sort_by(|a, b| a.name.cmp(&b.name)); + McpManagerSnapshot { + config_path: path.to_path_buf(), + config_exists, + restart_required, + servers, + } +} + // === Helper Functions === /// Format MCP tool result for display @@ -1817,6 +2119,67 @@ mod tests { assert_eq!(server.env.get("FOO"), Some(&"bar".to_string())); } + #[test] + fn test_mcp_config_parse_mcp_servers_alias_and_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mcp.json"); + fs::write( + &path, + r#"{ + "mcpServers": { + "disabled": { + "command": "node", + "args": ["server.js"], + "disabled": true + } + } + }"#, + ) + .unwrap(); + + let cfg = load_config(&path).unwrap(); + assert!(cfg.servers.contains_key("disabled")); + let snapshot = manager_snapshot_from_config(&path, true).unwrap(); + assert!(snapshot.restart_required); + assert_eq!(snapshot.servers[0].name, "disabled"); + assert!(!snapshot.servers[0].enabled); + assert_eq!(snapshot.servers[0].error.as_deref(), Some("disabled")); + } + + #[test] + fn test_mcp_config_manager_actions_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mcp.json"); + + assert_eq!(init_config(&path, false).unwrap(), McpWriteStatus::Created); + assert_eq!( + init_config(&path, false).unwrap(), + McpWriteStatus::SkippedExists + ); + + add_server_config( + &path, + "local".to_string(), + Some("node".to_string()), + None, + vec!["server.js".to_string()], + ) + .unwrap(); + set_server_enabled(&path, "local", false).unwrap(); + let disabled = manager_snapshot_from_config(&path, true).unwrap(); + let local = disabled + .servers + .iter() + .find(|server| server.name == "local") + .unwrap(); + assert!(!local.enabled); + assert_eq!(local.transport, "stdio"); + + remove_server_config(&path, "local").unwrap(); + let removed = manager_snapshot_from_config(&path, true).unwrap(); + assert!(removed.servers.iter().all(|server| server.name != "local")); + } + #[test] fn test_server_effective_timeouts() { let global = McpTimeouts::default(); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 8297f09f..976af997 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1557,6 +1557,7 @@ impl RuntimeThreadManager { task_data_dir: Some(self.manager_cfg.task_data_dir.clone()), active_task_id: thread.task_id.clone(), active_thread_id: Some(thread.id.clone()), + shell_manager: None, }, }; diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index bd8b53bd..c892b389 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -34,7 +34,7 @@ use crate::sandbox::{ }; /// Status of a shell process -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ShellStatus { Running, Completed, @@ -81,10 +81,37 @@ pub struct ShellResult { pub sandbox_denied: bool, } -struct ShellDeltaResult { - result: ShellResult, - stdout_total_len: usize, - stderr_total_len: usize, +/// Compact, UI-oriented view of a tracked background shell job. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShellJobSnapshot { + pub id: String, + pub job_id: String, + pub command: String, + pub cwd: PathBuf, + pub status: ShellStatus, + pub exit_code: Option, + pub elapsed_ms: u64, + pub stdout_tail: String, + pub stderr_tail: String, + pub stdout_len: usize, + pub stderr_len: usize, + pub stdin_available: bool, + pub stale: bool, + pub linked_task_id: Option, +} + +/// Full output view used by `/jobs show `. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShellJobDetail { + pub snapshot: ShellJobSnapshot, + pub stdout: String, + pub stderr: String, +} + +pub struct ShellDeltaResult { + pub result: ShellResult, + pub stdout_total_len: usize, + pub stderr_total_len: usize, } enum ShellChild { @@ -209,14 +236,13 @@ fn spawn_reader_thread( /// A background shell process being tracked pub struct BackgroundShell { pub id: String, - #[allow(dead_code)] pub command: String, - #[allow(dead_code)] pub working_dir: PathBuf, pub status: ShellStatus, pub exit_code: Option, pub started_at: Instant, pub sandbox_type: SandboxType, + pub linked_task_id: Option, stdout_buffer: Arc>>, stderr_buffer: Option>>>, stdout_cursor: usize, @@ -387,6 +413,35 @@ impl BackgroundShell { sandbox_denied: self.sandbox_denied(), } } + + fn job_snapshot(&self) -> ShellJobSnapshot { + let (stdout_full, stderr_full, stdout_len, stderr_len) = self.full_output(); + ShellJobSnapshot { + id: self.id.clone(), + job_id: self.id.clone(), + command: self.command.clone(), + cwd: self.working_dir.clone(), + status: self.status.clone(), + exit_code: self.exit_code, + elapsed_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + stdout_tail: tail_text(&stdout_full, 1200), + stderr_tail: tail_text(&stderr_full, 1200), + stdout_len, + stderr_len, + stdin_available: self.stdin.is_some() && self.status == ShellStatus::Running, + stale: false, + linked_task_id: self.linked_task_id.clone(), + } + } + + fn job_detail(&self) -> ShellJobDetail { + let (stdout, stderr, _, _) = self.full_output(); + ShellJobDetail { + snapshot: self.job_snapshot(), + stdout, + stderr, + } + } } impl Drop for BackgroundShell { @@ -403,16 +458,29 @@ impl Drop for BackgroundShell { /// Manages background shell processes with optional sandboxing. pub struct ShellManager { processes: HashMap, + stale_jobs: HashMap, default_workspace: PathBuf, sandbox_manager: SandboxManager, sandbox_policy: ExecutionSandboxPolicy, } +impl std::fmt::Debug for ShellManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShellManager") + .field("processes", &self.processes.len()) + .field("stale_jobs", &self.stale_jobs.len()) + .field("default_workspace", &self.default_workspace) + .field("sandbox_policy", &self.sandbox_policy) + .finish() + } +} + impl ShellManager { /// Create a new `ShellManager` with default (no sandbox) policy. pub fn new(workspace: PathBuf) -> Self { Self { processes: HashMap::new(), + stale_jobs: HashMap::new(), default_workspace: workspace, sandbox_manager: SandboxManager::new(), sandbox_policy: ExecutionSandboxPolicy::default(), @@ -424,6 +492,7 @@ impl ShellManager { pub fn with_sandbox(workspace: PathBuf, policy: ExecutionSandboxPolicy) -> Self { Self { processes: HashMap::new(), + stale_jobs: HashMap::new(), default_workspace: workspace, sandbox_manager: SandboxManager::new(), sandbox_policy: policy, @@ -898,6 +967,7 @@ impl ShellManager { exit_code: None, started_at: started, sandbox_type, + linked_task_id: None, stdout_buffer, stderr_buffer, stdout_cursor: 0, @@ -1061,18 +1131,91 @@ impl ShellManager { Ok(shell.snapshot()) } - /// List all background processes - #[allow(dead_code)] - pub fn list(&mut self) -> Vec { - // Poll all processes first + /// Poll a background process and return incremental output. + pub fn poll_delta( + &mut self, + task_id: &str, + wait: bool, + timeout_ms: u64, + ) -> Result { + self.get_output_delta(task_id, wait, timeout_ms) + } + + /// Attach durable task context to a live shell job. + pub fn tag_linked_task(&mut self, task_id: &str, linked_task_id: Option) -> Result<()> { + let shell = self + .processes + .get_mut(task_id) + .ok_or_else(|| anyhow!("Task {task_id} not found"))?; + shell.linked_task_id = linked_task_id; + Ok(()) + } + + /// Inspect full output for a live or stale job. + pub fn inspect_job(&mut self, task_id: &str) -> Result { + if let Some(shell) = self.processes.get_mut(task_id) { + shell.poll(); + return Ok(shell.job_detail()); + } + if let Some(snapshot) = self.stale_jobs.get(task_id) { + return Ok(ShellJobDetail { + snapshot: snapshot.clone(), + stdout: snapshot.stdout_tail.clone(), + stderr: snapshot.stderr_tail.clone(), + }); + } + Err(anyhow!("Task {task_id} not found")) + } + + /// List all live and known-stale background shell jobs for the TUI. + pub fn list_jobs(&mut self) -> Vec { for shell in self.processes.values_mut() { shell.poll(); } - self.processes + let mut jobs = self + .processes .values() - .map(BackgroundShell::snapshot) - .collect() + .map(BackgroundShell::job_snapshot) + .collect::>(); + jobs.extend(self.stale_jobs.values().cloned()); + jobs.sort_by(|a, b| { + job_status_rank(&a.status, a.stale) + .cmp(&job_status_rank(&b.status, b.stale)) + .then_with(|| a.id.cmp(&b.id)) + }); + jobs + } + + /// Remember a restart-stale job so the UI can show it instead of hiding it. + #[allow(dead_code)] + pub fn remember_stale_job( + &mut self, + id: impl Into, + command: impl Into, + cwd: PathBuf, + linked_task_id: Option, + ) { + let id = id.into(); + self.stale_jobs.insert( + id.clone(), + ShellJobSnapshot { + id: id.clone(), + job_id: id, + command: command.into(), + cwd, + status: ShellStatus::Killed, + exit_code: None, + elapsed_ms: 0, + stdout_tail: String::new(), + stderr_tail: "Process is no longer attached to this TUI session.".to_string(), + stdout_len: 0, + stderr_len: 0, + stdin_available: false, + stale: true, + linked_task_id, + }, + ); } /// Clean up completed processes older than the given duration @@ -1097,6 +1240,33 @@ fn take_delta_from_buffer(buffer: &Arc>>, cursor: &mut usize) -> ( (delta, data.len()) } +fn tail_text(text: &str, max_chars: usize) -> String { + if text.chars().count() <= max_chars { + return text.to_string(); + } + let tail = text + .chars() + .rev() + .take(max_chars) + .collect::>() + .into_iter() + .rev() + .collect::(); + format!("...{tail}") +} + +fn job_status_rank(status: &ShellStatus, stale: bool) -> u8 { + if stale { + return 4; + } + match status { + ShellStatus::Running => 0, + ShellStatus::Failed | ShellStatus::TimedOut => 1, + ShellStatus::Killed => 2, + ShellStatus::Completed => 3, + } +} + /// Thread-safe wrapper for `ShellManager` pub type SharedShellManager = Arc>; @@ -1229,6 +1399,10 @@ impl ToolSpec for ExecShellTool { "type": "string", "description": "Optional stdin data to send before waiting (non-interactive only)" }, + "cwd": { + "type": "string", + "description": "Optional working directory for the command" + }, "tty": { "type": "boolean", "description": "Allocate a pseudo-terminal for interactive programs (implies background)" @@ -1337,12 +1511,23 @@ impl ToolSpec for ExecShellTool { } let policy_override = context.elevated_sandbox_policy.clone(); + let working_dir = input + .get("cwd") + .or_else(|| input.get("working_dir")) + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let result = if interactive { let mut manager = context .shell_manager .lock() .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?; - manager.execute_interactive_with_policy(command, None, timeout_ms, policy_override) + manager.execute_interactive_with_policy( + command, + working_dir.as_deref(), + timeout_ms, + policy_override, + ) } else if background { let mut manager = context .shell_manager @@ -1350,7 +1535,7 @@ impl ToolSpec for ExecShellTool { .map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?; manager.execute_with_options( command, - None, + working_dir.as_deref(), timeout_ms, true, stdin_data.as_deref(), @@ -1370,6 +1555,16 @@ impl ToolSpec for ExecShellTool { match result { Ok(result) => { + if background + && let (Some(shell_id), Some(task_id)) = ( + result.task_id.as_deref(), + context.runtime.active_task_id.clone(), + ) + && let Ok(mut manager) = context.shell_manager.lock() + { + let _ = manager.tag_linked_task(shell_id, Some(task_id)); + } + let was_cancelled = context .cancel_token .as_ref() diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index a8b7d1c7..d6f30d1c 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -147,6 +147,71 @@ fn test_write_stdin_streams_output() { assert!(delta2.result.stdout.is_empty()); } +#[test] +fn test_job_list_poll_cancel_and_stale_snapshot() { + let tmp = tempdir().expect("tempdir"); + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + + let started = manager + .execute(&sleep_then_echo_command(1, "done"), None, 5000, true) + .expect("execute"); + let task_id = started.task_id.expect("task id"); + manager + .tag_linked_task(&task_id, Some("task_123".to_string())) + .expect("tag linked task"); + + let running = manager.list_jobs(); + let job = running + .iter() + .find(|job| job.id == task_id) + .expect("running job"); + assert_eq!(job.status, ShellStatus::Running); + assert_eq!(job.linked_task_id.as_deref(), Some("task_123")); + assert!(job.command.contains("done")); + assert_eq!(job.cwd, tmp.path()); + + let completed = manager + .poll_delta(&task_id, true, 5000) + .expect("poll delta"); + assert_eq!(completed.result.status, ShellStatus::Completed); + assert!(completed.result.stdout.contains("done")); + + let detail = manager.inspect_job(&task_id).expect("inspect"); + assert!(detail.stdout.contains("done")); + assert_eq!(detail.snapshot.status, ShellStatus::Completed); + + manager.remember_stale_job( + "shell_stale", + "cargo test", + tmp.path().to_path_buf(), + Some("task_old".to_string()), + ); + let stale = manager + .list_jobs() + .into_iter() + .find(|job| job.id == "shell_stale") + .expect("stale job"); + assert!(stale.stale); + assert_eq!(stale.linked_task_id.as_deref(), Some("task_old")); +} + +#[test] +fn test_job_cancel_updates_completion_state() { + let tmp = tempdir().expect("tempdir"); + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + + let started = manager + .execute(&sleep_command(60), None, 5000, true) + .expect("execute"); + let task_id = started.task_id.expect("task id"); + + let killed = manager.kill(&task_id).expect("kill"); + assert_eq!(killed.status, ShellStatus::Killed); + let job = manager.inspect_job(&task_id).expect("inspect"); + assert_eq!(job.snapshot.status, ShellStatus::Killed); + assert!(!job.snapshot.stdin_available); +} + #[test] fn test_output_truncation() { let long_output = "x".repeat(50_000); diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index e486d7ae..2209d277 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -29,6 +29,7 @@ pub use deepseek_tools::{ /// attached. #[derive(Clone, Default)] pub struct RuntimeToolServices { + pub shell_manager: Option, pub task_manager: Option, pub automations: Option, pub task_data_dir: Option, @@ -39,6 +40,7 @@ pub struct RuntimeToolServices { impl std::fmt::Debug for RuntimeToolServices { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RuntimeToolServices") + .field("shell_manager", &self.shell_manager.is_some()) .field("task_manager", &self.task_manager.is_some()) .field("automations", &self.automations.is_some()) .field("task_data_dir", &self.task_data_dir) diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index d607b863..2c3ce95b 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -425,11 +425,6 @@ impl ToolSpec for TaskShellStartTool { }); if let Some(cwd) = optional_str(&input, "cwd") { let cwd = resolve_cwd(context, Some(cwd))?; - shell_input["command"] = json!(format!( - "cd {} && {}", - shell_escape(&cwd), - required_str(&input, "command")? - )); shell_input["cwd"] = json!(cwd); } if let Some(stdin) = optional_str(&input, "stdin") { @@ -984,11 +979,6 @@ fn sanitize_filename(input: &str) -> String { } } -fn shell_escape(path: &Path) -> String { - let raw = path.display().to_string(); - format!("'{}'", raw.replace('\'', "'\\''")) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 0223e62d..93be0410 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -21,6 +21,7 @@ use crate::palette::{self, UiTheme}; use crate::session_manager::SessionContextReference; use crate::settings::Settings; use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; +use crate::tools::shell::new_shared_shell_manager; use crate::tools::spec::RuntimeToolServices; use crate::tools::subagent::SubAgentResult; use crate::tools::todo::{SharedTodoList, new_shared_todo_list}; @@ -417,6 +418,7 @@ pub struct App { /// Cycled via Shift+Tab; initialized from config at startup. pub reasoning_effort: ReasoningEffort, pub workspace: PathBuf, + pub mcp_config_path: PathBuf, pub skills_dir: PathBuf, pub use_alt_screen: bool, pub use_mouse_capture: bool, @@ -519,6 +521,10 @@ pub struct App { pub todos: SharedTodoList, /// Durable runtime services exposed to model-visible task/automation tools. pub runtime_services: RuntimeToolServices, + /// Last MCP manager/discovery snapshot shown in the UI. + pub mcp_snapshot: Option, + /// Set after in-TUI MCP config edits because the engine caches its MCP pool. + pub mcp_restart_required: bool, /// Tool execution log pub tool_log: Vec, /// Session cost tracking @@ -749,7 +755,7 @@ impl App { skills_dir: global_skills_dir, memory_path: _, notes_path: _, - mcp_config_path: _, + mcp_config_path, use_memory: _, start_in_agent_mode, skip_onboarding, @@ -798,6 +804,7 @@ impl App { None }; let allow_shell = allow_shell || initial_mode == AppMode::Yolo; + let shell_manager = new_shared_shell_manager(workspace.clone()); // Initialize hooks executor from config let hooks_config = config.hooks_config(); @@ -851,6 +858,7 @@ impl App { ReasoningEffort::from_setting(s) }), workspace, + mcp_config_path, skills_dir, use_alt_screen, use_mouse_capture, @@ -926,7 +934,12 @@ impl App { plan_prompt_pending: false, plan_tool_used_in_turn: false, todos: new_shared_todo_list(), - runtime_services: RuntimeToolServices::default(), + runtime_services: RuntimeToolServices { + shell_manager: Some(shell_manager), + ..RuntimeToolServices::default() + }, + mcp_snapshot: None, + mcp_restart_required: false, tool_log: Vec::new(), session_cost: 0.0, subagent_cost: 0.0, @@ -2303,6 +2316,56 @@ pub enum AppAction { TaskCancel { id: String, }, + ShellJob(ShellJobAction), + Mcp(McpUiAction), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShellJobAction { + List, + Show { + id: String, + }, + Poll { + id: String, + wait: bool, + }, + SendStdin { + id: String, + input: String, + close: bool, + }, + Cancel { + id: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum McpUiAction { + Show, + Init { + force: bool, + }, + AddStdio { + name: String, + command: String, + args: Vec, + }, + AddHttp { + name: String, + url: String, + }, + Enable { + name: String, + }, + Disable { + name: String, + }, + Remove { + name: String, + }, + Validate, + Reload, } #[cfg(test)] diff --git a/crates/tui/src/tui/command_palette.rs b/crates/tui/src/tui/command_palette.rs index 163b5a4a..e8f2af9a 100644 --- a/crates/tui/src/tui/command_palette.rs +++ b/crates/tui/src/tui/command_palette.rs @@ -26,6 +26,7 @@ enum PaletteSection { Command, Skill, Tool, + Mcp, } #[derive(Debug, Clone)] @@ -44,7 +45,12 @@ pub struct CommandPaletteView { selected: usize, } -pub fn build_entries(skills_dir: &Path, workspace: &Path) -> Vec { +pub fn build_entries( + skills_dir: &Path, + workspace: &Path, + mcp_config_path: &Path, + mcp_snapshot: Option<&crate::mcp::McpManagerSnapshot>, +) -> Vec { let mut entries = Vec::new(); for command in commands::COMMANDS { @@ -154,11 +160,173 @@ pub fn build_entries(skills_dir: &Path, workspace: &Path) -> Vec, +) -> Vec { + let owned_snapshot = if mcp_snapshot.is_none() { + crate::mcp::manager_snapshot_from_config(mcp_config_path, false).ok() + } else { + None + }; + let snapshot = mcp_snapshot.or(owned_snapshot.as_ref()); + let mut entries = vec![CommandPaletteEntry { + section: PaletteSection::Mcp, + label: "mcp:manager".to_string(), + description: format!("Open MCP manager ({})", mcp_config_path.display()), + command: "/mcp".to_string(), + action: CommandPaletteAction::ExecuteCommand { + command: "/mcp".to_string(), + }, + }]; + + let Some(snapshot) = snapshot else { + return entries; + }; + + for server in &snapshot.servers { + let state = if server.enabled { + if server.connected { + "connected" + } else if server.error.is_some() { + "failed" + } else { + "enabled" + } + } else { + "disabled" + }; + entries.push(CommandPaletteEntry { + section: PaletteSection::Mcp, + label: format!("mcp:{}", server.name), + description: format!( + "{} {} [{}] tools={} resources={} prompts={}", + server.transport, + server.command_or_url, + state, + server.tools.len(), + server.resources.len(), + server.prompts.len() + ), + command: format!("/mcp show {}", server.name), + action: CommandPaletteAction::OpenTextPager { + title: format!("MCP Server: {}", server.name), + content: format_mcp_server_details(snapshot, server), + }, + }); + + for tool in &server.tools { + entries.push(CommandPaletteEntry { + section: PaletteSection::Mcp, + label: format!("mcp:{}:tool:{}", server.name, tool.name), + description: format!( + "{}{}", + tool.model_name, + tool.description + .as_ref() + .map_or(String::new(), |desc| format!(" - {desc}")) + ), + command: tool.model_name.clone(), + action: CommandPaletteAction::OpenTextPager { + title: format!("MCP Tool: {}", tool.model_name), + content: format!( + "Server: {}\nRuntime name: {}\nKind: tool\n\n{}", + server.name, + tool.model_name, + tool.description.as_deref().unwrap_or("(no description)") + ), + }, + }); + } + + for resource in &server.resources { + entries.push(CommandPaletteEntry { + section: PaletteSection::Mcp, + label: format!("mcp:{}:resource:{}", server.name, resource.name), + description: resource + .description + .clone() + .unwrap_or_else(|| "MCP resource".to_string()), + command: resource.name.clone(), + action: CommandPaletteAction::OpenTextPager { + title: format!("MCP Resource: {}", resource.name), + content: format!( + "Server: {}\nResource: {}\nModel helper: list_mcp_resources / read_mcp_resource", + server.name, resource.name + ), + }, + }); + } + + for prompt in &server.prompts { + entries.push(CommandPaletteEntry { + section: PaletteSection::Mcp, + label: format!("mcp:{}:prompt:{}", server.name, prompt.name), + description: format!( + "{}{}", + prompt.model_name, + prompt + .description + .as_ref() + .map_or(String::new(), |desc| format!(" - {desc}")) + ), + command: prompt.model_name.clone(), + action: CommandPaletteAction::OpenTextPager { + title: format!("MCP Prompt: {}", prompt.model_name), + content: format!( + "Server: {}\nRuntime name: {}\nKind: prompt", + server.name, prompt.model_name + ), + }, + }); + } + } + + entries +} + +fn format_mcp_server_details( + snapshot: &crate::mcp::McpManagerSnapshot, + server: &crate::mcp::McpServerSnapshot, +) -> String { + let mut lines = vec![ + format!("Config: {}", snapshot.config_path.display()), + format!("Server: {}", server.name), + format!("Enabled: {}", server.enabled), + format!("Connected: {}", server.connected), + format!("Transport: {}", server.transport), + format!("Target: {}", server.command_or_url), + format!( + "Timeouts: connect={}s execute={}s read={}s", + server.connect_timeout, server.execute_timeout, server.read_timeout + ), + ]; + if let Some(error) = server.error.as_ref() { + lines.push(format!("Error: {error}")); + } + lines.push(String::new()); + lines.push(format!("Tools ({})", server.tools.len())); + for tool in &server.tools { + lines.push(format!(" - {}", tool.model_name)); + } + lines.push(format!("Resources ({})", server.resources.len())); + for resource in &server.resources { + lines.push(format!(" - {}", resource.name)); + } + lines.push(format!("Prompts ({})", server.prompts.len())); + for prompt in &server.prompts { + lines.push(format!(" - {}", prompt.model_name)); + } + lines.join("\n") +} + fn modal_block() -> Block<'static> { Block::default() .borders(Borders::ALL) @@ -179,6 +347,7 @@ fn parse_section_term(term: &str) -> Option<(PaletteSection, String)> { "c" | "cmd" | "command" | "commands" => PaletteSection::Command, "s" | "skill" | "skills" => PaletteSection::Skill, "t" | "tool" | "tools" => PaletteSection::Tool, + "m" | "mcp" => PaletteSection::Mcp, _ => return None, }; @@ -190,6 +359,7 @@ fn section_tag(section: PaletteSection) -> &'static str { PaletteSection::Command => "command", PaletteSection::Skill => "skill", PaletteSection::Tool => "tool", + PaletteSection::Mcp => "mcp", } } @@ -198,6 +368,7 @@ fn section_rank(section: PaletteSection) -> usize { PaletteSection::Command => 0, PaletteSection::Skill => 1, PaletteSection::Tool => 2, + PaletteSection::Mcp => 3, } } @@ -231,6 +402,8 @@ fn command_runs_directly(name: &str) -> bool { | "settings" | "skills" | "cost" + | "jobs" + | "mcp" | "task" ) } @@ -360,7 +533,7 @@ impl CommandPaletteView { } fn scope_hint_lines() -> Line<'static> { - let hint = "scope: c:/cmd: , s:/skill: , t:/tool:"; + let hint = "scope: c:/cmd: , s:/skill: , t:/tool: , m:/mcp:"; Line::from(Span::styled( hint, Style::default() @@ -374,6 +547,7 @@ impl CommandPaletteView { PaletteSection::Command => "Commands", PaletteSection::Skill => "Skills", PaletteSection::Tool => "Tools", + PaletteSection::Mcp => "MCP", }; Line::from(vec![Span::styled( format!(" {title} ({count}) "), @@ -398,6 +572,10 @@ impl CommandPaletteView { " t: Tool-only e.g. t:git", Style::default().fg(palette::TEXT_MUTED), )), + Line::from(Span::styled( + " m: MCP-only e.g. m:filesystem", + Style::default().fg(palette::TEXT_MUTED), + )), ] } @@ -506,15 +684,17 @@ impl ModalView for CommandPaletteView { lines.extend(Self::scope_examples()); lines.push(Line::from("")); - let visible = popup_height.saturating_sub(6) as usize; + let visible = popup_height.saturating_sub(7) as usize; let mut command_count = 0usize; let mut skill_count = 0usize; let mut tool_count = 0usize; + let mut mcp_count = 0usize; for idx in &self.filtered { match self.entries[*idx].section { PaletteSection::Command => command_count += 1, PaletteSection::Skill => skill_count += 1, PaletteSection::Tool => tool_count += 1, + PaletteSection::Mcp => mcp_count += 1, } } if self.filtered.is_empty() { @@ -540,6 +720,7 @@ impl ModalView for CommandPaletteView { PaletteSection::Command => command_count, PaletteSection::Skill => skill_count, PaletteSection::Tool => tool_count, + PaletteSection::Mcp => mcp_count, }; lines.push(Self::format_section_label(entry.section, count)); active_section = Some(entry.section); @@ -630,6 +811,7 @@ mod tests { "search utility", "search", ), + palette_entry(PaletteSection::Mcp, "mcp:fs", "filesystem", "mcp_fs_read"), ]; let mut view = CommandPaletteView::new(entries); @@ -644,6 +826,10 @@ mod tests { view.query = "t:search".to_string(); view.refilter(); assert_eq!(view.filtered, vec![3]); + + view.query = "m:fs".to_string(); + view.refilter(); + assert_eq!(view.filtered, vec![4]); } #[test] @@ -714,7 +900,7 @@ mod tests { #[test] fn command_palette_command_entries_include_links_and_config_but_not_removed_commands() { - let entries = build_entries(Path::new("."), Path::new(".")); + let entries = build_entries(Path::new("."), Path::new("."), Path::new("mcp.json"), None); let command_labels = entries .iter() .filter(|entry| entry.section == PaletteSection::Command) @@ -729,7 +915,7 @@ mod tests { #[test] fn command_palette_inserts_model_command_for_argument_entry() { - let entries = build_entries(Path::new("."), Path::new(".")); + let entries = build_entries(Path::new("."), Path::new("."), Path::new("mcp.json"), None); let model = entries .iter() .find(|entry| entry.section == PaletteSection::Command && entry.label == "/model") @@ -742,6 +928,65 @@ mod tests { )); } + #[test] + fn command_palette_includes_mcp_discovery_and_failed_servers() { + let snapshot = crate::mcp::McpManagerSnapshot { + config_path: Path::new("mcp.json").to_path_buf(), + config_exists: true, + restart_required: false, + servers: vec![ + crate::mcp::McpServerSnapshot { + name: "fs".to_string(), + enabled: true, + required: false, + transport: "stdio".to_string(), + command_or_url: "node server.js".to_string(), + connect_timeout: 10, + execute_timeout: 60, + read_timeout: 120, + connected: true, + error: None, + tools: vec![crate::mcp::McpDiscoveredItem { + name: "read".to_string(), + model_name: "mcp_fs_read".to_string(), + description: Some("Read files".to_string()), + }], + resources: Vec::new(), + prompts: Vec::new(), + }, + crate::mcp::McpServerSnapshot { + name: "broken".to_string(), + enabled: true, + required: false, + transport: "http/sse".to_string(), + command_or_url: "https://example.invalid/mcp".to_string(), + connect_timeout: 10, + execute_timeout: 60, + read_timeout: 120, + connected: false, + error: Some("connect failed".to_string()), + tools: Vec::new(), + resources: Vec::new(), + prompts: Vec::new(), + }, + ], + }; + let entries = build_entries( + Path::new("."), + Path::new("."), + Path::new("mcp.json"), + Some(&snapshot), + ); + + assert!(entries.iter().any(|entry| entry.label == "mcp:manager")); + assert!(entries.iter().any(|entry| entry.command == "mcp_fs_read")); + let failed = entries + .iter() + .find(|entry| entry.label == "mcp:broken") + .expect("failed server visible"); + assert!(failed.description.contains("failed")); + } + #[test] fn command_palette_emits_actions_not_raw_insertions() { let entries = vec![CommandPaletteEntry { diff --git a/crates/tui/src/tui/mcp_routing.rs b/crates/tui/src/tui/mcp_routing.rs new file mode 100644 index 00000000..6b76a5b7 --- /dev/null +++ b/crates/tui/src/tui/mcp_routing.rs @@ -0,0 +1,160 @@ +//! MCP manager formatting and UI action helpers. + +use crate::mcp::{McpManagerSnapshot, McpServerSnapshot}; +use crate::tui::app::App; +use crate::tui::history::HistoryCell; +use crate::tui::pager::PagerView; + +pub(super) fn format_mcp_manager(snapshot: &McpManagerSnapshot) -> String { + let mut lines = vec![ + format!("MCP config: {}", snapshot.config_path.display()), + format!("Config exists: {}", snapshot.config_exists), + ]; + if snapshot.restart_required { + lines.push( + "Restart required: MCP config changed; the current model-visible MCP tool pool is not hot-reloaded." + .to_string(), + ); + } else { + lines.push("Restart required: no pending in-TUI config change.".to_string()); + } + lines.push(String::new()); + + if snapshot.servers.is_empty() { + lines.push("No MCP servers configured.".to_string()); + } else { + lines.push(format!("Servers ({})", snapshot.servers.len())); + lines.push("----------------------------------------".to_string()); + for server in &snapshot.servers { + push_server(lines.as_mut(), server); + } + } + + lines.push(String::new()); + lines.push( + "Actions: /mcp init, /mcp add stdio [args...], /mcp add http , /mcp enable , /mcp disable , /mcp remove , /mcp validate, /mcp reload." + .to_string(), + ); + lines.join("\n") +} + +fn push_server(lines: &mut Vec, server: &McpServerSnapshot) { + let state = if server.enabled { + if server.connected { + "connected" + } else if server.error.is_some() { + "failed" + } else { + "enabled" + } + } else { + "disabled" + }; + let required = if server.required { " required" } else { "" }; + lines.push(format!( + "- {} [{}{}] {} {}", + server.name, state, required, server.transport, server.command_or_url + )); + lines.push(format!( + " timeouts: connect={}s execute={}s read={}s", + server.connect_timeout, server.execute_timeout, server.read_timeout + )); + if let Some(error) = server.error.as_ref() { + lines.push(format!(" error: {error}")); + } + lines.push(format!( + " discovered: {} tools, {} resources, {} prompts", + server.tools.len(), + server.resources.len(), + server.prompts.len() + )); + for tool in &server.tools { + lines.push(format!( + " tool {}{}", + tool.model_name, + tool.description + .as_ref() + .map_or(String::new(), |desc| format!(" - {desc}")) + )); + } + for resource in &server.resources { + lines.push(format!(" resource {}", resource.name)); + } + for prompt in &server.prompts { + lines.push(format!(" prompt {}", prompt.model_name)); + } +} + +pub(super) fn open_mcp_manager_pager(app: &mut App, snapshot: &McpManagerSnapshot) { + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(100) + .saturating_sub(4); + app.view_stack.push(PagerView::from_text( + "MCP Manager".to_string(), + &format_mcp_manager(snapshot), + width.max(60), + )); +} + +pub(super) fn add_mcp_message(app: &mut App, content: String) { + app.add_message(HistoryCell::System { content }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::McpDiscoveredItem; + use std::path::PathBuf; + + #[test] + fn manager_text_shows_failed_disabled_and_runtime_names() { + let snapshot = McpManagerSnapshot { + config_path: PathBuf::from("/tmp/mcp.json"), + config_exists: true, + restart_required: true, + servers: vec![ + McpServerSnapshot { + name: "fs".to_string(), + enabled: true, + required: false, + transport: "stdio".to_string(), + command_or_url: "node server.js".to_string(), + connect_timeout: 10, + execute_timeout: 60, + read_timeout: 120, + connected: true, + error: None, + tools: vec![McpDiscoveredItem { + name: "read".to_string(), + model_name: "mcp_fs_read".to_string(), + description: Some("Read a file".to_string()), + }], + resources: Vec::new(), + prompts: Vec::new(), + }, + McpServerSnapshot { + name: "bad".to_string(), + enabled: true, + required: false, + transport: "http/sse".to_string(), + command_or_url: "https://example.invalid/mcp".to_string(), + connect_timeout: 10, + execute_timeout: 60, + read_timeout: 120, + connected: false, + error: Some("boom".to_string()), + tools: Vec::new(), + resources: Vec::new(), + prompts: Vec::new(), + }, + ], + }; + let text = format_mcp_manager(&snapshot); + assert!(text.contains("Restart required")); + assert!(text.contains("mcp_fs_read")); + assert!(text.contains("[failed]")); + assert!(text.contains("boom")); + } +} diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 5fb0ff02..4c729298 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -19,6 +19,7 @@ pub mod history; pub mod keybindings; pub mod live_transcript; pub mod markdown_render; +mod mcp_routing; pub mod model_picker; pub mod notifications; pub mod onboarding; @@ -30,6 +31,7 @@ pub mod provider_picker; pub mod scrolling; pub mod selection; pub mod session_picker; +mod shell_job_routing; pub mod sidebar; pub mod slash_menu; pub mod streaming; diff --git a/crates/tui/src/tui/shell_job_routing.rs b/crates/tui/src/tui/shell_job_routing.rs new file mode 100644 index 00000000..428c4351 --- /dev/null +++ b/crates/tui/src/tui/shell_job_routing.rs @@ -0,0 +1,181 @@ +//! Background shell job-center helpers for slash commands and pagers. + +use crate::tools::shell::{ShellJobDetail, ShellJobSnapshot, ShellResult, ShellStatus}; +use crate::tui::app::App; +use crate::tui::history::HistoryCell; +use crate::tui::pager::PagerView; + +fn status_label(status: &ShellStatus, stale: bool) -> &'static str { + if stale { + return "stale"; + } + match status { + ShellStatus::Running => "running", + ShellStatus::Completed => "complete", + ShellStatus::Failed => "failed", + ShellStatus::Killed => "killed", + ShellStatus::TimedOut => "timeout", + } +} + +fn format_elapsed(ms: u64) -> String { + if ms == 0 { + return "-".to_string(); + } + if ms < 60_000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else { + format!("{:.1}m", ms as f64 / 60_000.0) + } +} + +pub(super) fn format_shell_job_list(jobs: &[ShellJobSnapshot]) -> String { + if jobs.is_empty() { + return "No live background shell jobs. Jobs are process-local; after a restart, inspect durable task artifacts for prior command output.".to_string(); + } + + let mut lines = vec![ + format!("Background shell jobs ({})", jobs.len()), + "----------------------------------------".to_string(), + ]; + for job in jobs { + let task = job + .linked_task_id + .as_ref() + .map(|id| format!(" task={id}")) + .unwrap_or_default(); + lines.push(format!( + "{} {:8} {} exit={:?}{}", + job.id, + status_label(&job.status, job.stale), + format_elapsed(job.elapsed_ms), + job.exit_code, + task + )); + lines.push(format!(" cwd: {}", job.cwd.display())); + lines.push(format!(" cmd: {}", job.command)); + let tail = if !job.stderr_tail.trim().is_empty() { + job.stderr_tail.trim() + } else { + job.stdout_tail.trim() + }; + if !tail.is_empty() { + lines.push(format!(" tail: {}", tail.replace('\n', "\\n"))); + } + } + lines.push( + "Controls: /jobs show , /jobs poll , /jobs wait , /jobs stdin , /jobs cancel ." + .to_string(), + ); + lines.join("\n") +} + +pub(super) fn format_shell_poll(result: &ShellResult) -> String { + let mut lines = vec![ + format!( + "Shell job {}: {} exit={:?} elapsed={}", + result.task_id.as_deref().unwrap_or("(unknown)"), + status_label(&result.status, false), + result.exit_code, + format_elapsed(result.duration_ms) + ), + String::new(), + ]; + if result.stdout.is_empty() && result.stderr.is_empty() { + lines.push("(no new output)".to_string()); + } else { + if !result.stdout.is_empty() { + lines.push("STDOUT:".to_string()); + lines.push(result.stdout.clone()); + } + if !result.stderr.is_empty() { + lines.push("STDERR:".to_string()); + lines.push(result.stderr.clone()); + } + } + lines.join("\n") +} + +pub(super) fn open_shell_job_pager(app: &mut App, detail: &ShellJobDetail) { + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(100) + .saturating_sub(4); + app.view_stack.push(PagerView::from_text( + format!("Shell Job {}", detail.snapshot.id), + &format_shell_job_detail(detail), + width.max(60), + )); +} + +fn format_shell_job_detail(detail: &ShellJobDetail) -> String { + let job = &detail.snapshot; + let mut lines = vec![ + format!("Job: {}", job.id), + format!("Status: {}", status_label(&job.status, job.stale)), + format!("Command: {}", job.command), + format!("Cwd: {}", job.cwd.display()), + format!("Elapsed: {}", format_elapsed(job.elapsed_ms)), + format!("Exit Code: {:?}", job.exit_code), + format!("Stdin Available: {}", job.stdin_available), + ]; + if let Some(task_id) = job.linked_task_id.as_ref() { + lines.push(format!("Linked Task: {task_id}")); + } + if job.stale { + lines.push("Completion State: stale after restart; process is not attached.".to_string()); + } else { + lines.push("Completion State: live in this TUI process.".to_string()); + } + lines.push(String::new()); + lines.push(format!("STDOUT ({} bytes):", job.stdout_len)); + lines.push(if detail.stdout.is_empty() { + "(empty)".to_string() + } else { + detail.stdout.clone() + }); + lines.push(String::new()); + lines.push(format!("STDERR ({} bytes):", job.stderr_len)); + lines.push(if detail.stderr.is_empty() { + "(empty)".to_string() + } else { + detail.stderr.clone() + }); + lines.join("\n") +} + +pub(super) fn add_shell_job_message(app: &mut App, content: String) { + app.add_message(HistoryCell::System { content }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn list_shows_controls_and_stale_state() { + let jobs = vec![ShellJobSnapshot { + id: "shell_dead".to_string(), + job_id: "shell_dead".to_string(), + command: "cargo test".to_string(), + cwd: PathBuf::from("/tmp/repo"), + status: ShellStatus::Killed, + exit_code: None, + elapsed_ms: 0, + stdout_tail: String::new(), + stderr_tail: "detached".to_string(), + stdout_len: 0, + stderr_len: 8, + stdin_available: false, + stale: true, + linked_task_id: Some("task_1".to_string()), + }]; + let formatted = format_shell_job_list(&jobs); + assert!(formatted.contains("shell_dead")); + assert!(formatted.contains("stale")); + assert!(formatted.contains("/jobs poll ")); + assert!(formatted.contains("task=task_1")); + } +} diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index 7fe3e28e..47e3fb33 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -134,7 +134,13 @@ impl TranscriptViewCache { // Track whether anything actually changed; if all cells are reused at // the same indices, we can skip the reflatten. - let mut any_dirty = layout_changed || self.per_cell.len() != total_cells; + let old_len = self.per_cell.len(); + let mut any_dirty = layout_changed || old_len != total_cells; + let mut first_dirty: Option = if old_len != total_cells { + Some(old_len.min(total_cells)) + } else { + None + }; let mut new_per_cell: Vec = Vec::with_capacity(total_cells); let revisions_match = cell_revisions.len() == total_cells; @@ -164,6 +170,7 @@ impl TranscriptViewCache { } any_dirty = true; + first_dirty = Some(first_dirty.map_or(idx, |current| current.min(idx))); let is_tool_groupable = matches!(cell, HistoryCell::Tool(_)); let render_width = if is_tool_groupable { width.saturating_sub(2).max(1) @@ -200,15 +207,47 @@ impl TranscriptViewCache { return; } - self.flatten(options.spacing); + let rebuild_from = if layout_changed { + 0 + } else { + first_dirty.unwrap_or(0).saturating_sub(1) + }; + self.flatten_from(options.spacing, rebuild_from); } /// Reassemble flat `lines` / `line_meta` from `per_cell` plus spacers. fn flatten(&mut self, spacing: TranscriptSpacing) { - let mut lines = Vec::with_capacity(self.lines.capacity()); - let mut meta = Vec::with_capacity(self.line_meta.capacity()); + self.lines.clear(); + self.line_meta.clear(); + self.append_flattened_cells(spacing, 0); + } - for (cell_index, cached) in self.per_cell.iter().enumerate() { + /// Reassemble only the suffix starting at `first_cell`. + /// + /// Streaming usually mutates the active tail cell. Rebuilding from the + /// previous cell preserves spacer correctness while avoiding a full + /// O(total transcript lines) flatten on every token chunk. + fn flatten_from(&mut self, spacing: TranscriptSpacing, first_cell: usize) { + if first_cell == 0 || self.lines.is_empty() || self.line_meta.is_empty() { + self.flatten(spacing); + return; + } + + let truncate_at = self + .line_meta + .iter() + .position(|meta| match meta { + TranscriptLineMeta::CellLine { cell_index, .. } => *cell_index >= first_cell, + TranscriptLineMeta::Spacer => false, + }) + .unwrap_or(self.lines.len()); + self.lines.truncate(truncate_at); + self.line_meta.truncate(truncate_at); + self.append_flattened_cells(spacing, first_cell); + } + + fn append_flattened_cells(&mut self, spacing: TranscriptSpacing, start_cell: usize) { + for (cell_index, cached) in self.per_cell.iter().enumerate().skip(start_cell) { if cached.is_empty { continue; } @@ -217,7 +256,7 @@ impl TranscriptViewCache { // Deref is zero-cost and gives us &[Line]. let rendered_line_count = cached.lines.len(); for (line_in_cell, line) in cached.lines.iter().enumerate() { - lines.push(line_with_group_rail( + self.lines.push(line_with_group_rail( line, tool_group_rail( self.per_cell.as_slice(), @@ -227,7 +266,7 @@ impl TranscriptViewCache { ), usize::from(self.width), )); - meta.push(TranscriptLineMeta::CellLine { + self.line_meta.push(TranscriptLineMeta::CellLine { cell_index, line_in_cell, }); @@ -236,14 +275,11 @@ impl TranscriptViewCache { if let Some(next) = self.per_cell.get(cell_index + 1) { let spacer_rows = spacer_rows_between(cached, next, spacing); for _ in 0..spacer_rows { - lines.push(Line::from("")); - meta.push(TranscriptLineMeta::Spacer); + self.lines.push(Line::from("")); + self.line_meta.push(TranscriptLineMeta::Spacer); } } } - - self.lines = lines; - self.line_meta = meta; } /// Return cached lines. @@ -588,6 +624,33 @@ mod tests { assert_eq!(cache.per_cell[2].revision, 1); } + #[test] + fn tail_update_suffix_rebuild_matches_fresh_flatten() { + let mut cells = vec![ + user_cell("first message"), + assistant_cell("stable answer", false), + user_cell("tail prompt"), + ]; + let mut revisions = vec![1u64, 1, 1]; + let mut cache = TranscriptViewCache::new(); + cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default()); + + cells.push(assistant_cell("streaming tail", true)); + revisions.push(1); + cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default()); + + if let HistoryCell::Assistant { content, .. } = cells.last_mut().unwrap() { + content.push_str(" plus delta"); + } + *revisions.last_mut().unwrap() += 1; + cache.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default()); + let incremental = plain_lines(&cache); + + let mut fresh = TranscriptViewCache::new(); + fresh.ensure(&cells, &revisions, 40, TranscriptRenderOptions::default()); + assert_eq!(incremental, plain_lines(&fresh)); + } + #[test] fn width_change_rerenders_all_cells() { let cells = vec![ diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 0e696bd7..b837e743 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -53,12 +53,16 @@ use crate::tui::command_palette::{ use crate::tui::context_inspector::build_context_inspector_text; use crate::tui::event_broker::EventBroker; use crate::tui::live_transcript::LiveTranscriptOverlay; +use crate::tui::mcp_routing::{add_mcp_message, open_mcp_manager_pager}; use crate::tui::onboarding; use crate::tui::pager::PagerView; use crate::tui::plan_prompt::PlanPromptView; use crate::tui::scrolling::{ScrollDirection, TranscriptScroll}; use crate::tui::selection::TranscriptSelectionPoint; use crate::tui::session_picker::SessionPickerView; +use crate::tui::shell_job_routing::{ + add_shell_job_message, format_shell_job_list, format_shell_poll, open_shell_job_pager, +}; use crate::tui::subagent_routing::{ format_task_list, handle_subagent_mailbox, open_task_pager, reconcile_subagent_activity_state, running_agent_count, sort_subagents_in_place, task_mode_label, task_summary_to_panel_entry, @@ -259,7 +263,13 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { automation_cancel.clone(), AutomationSchedulerConfig::default(), ); + let shell_manager = app + .runtime_services + .shell_manager + .clone() + .unwrap_or_else(|| crate::tools::shell::new_shared_shell_manager(app.workspace.clone())); app.runtime_services = RuntimeToolServices { + shell_manager: Some(shell_manager), task_manager: Some(task_manager.clone()), automations: Some(automations), task_data_dir: Some(task_manager.data_dir()), @@ -1359,6 +1369,8 @@ async fn run_event_loop( .push(CommandPaletteView::new(build_command_palette_entries( &app.skills_dir, &app.workspace, + &app.mcp_config_path, + app.mcp_snapshot.as_ref(), ))); continue; } @@ -3058,12 +3070,172 @@ async fn apply_command_result( .map(task_summary_to_panel_entry) .collect(); } + AppAction::ShellJob(action) => { + handle_shell_job_action(app, action); + } + AppAction::Mcp(action) => { + handle_mcp_ui_action(app, config, action).await; + } } } Ok(false) } +async fn handle_mcp_ui_action( + app: &mut App, + config: &Config, + action: crate::tui::app::McpUiAction, +) { + use crate::mcp::{self, McpWriteStatus}; + + let path = app.mcp_config_path.clone(); + let mut changed = false; + let mut message = None; + let discover = matches!( + action, + crate::tui::app::McpUiAction::Validate | crate::tui::app::McpUiAction::Reload + ); + + let action_result = match action { + crate::tui::app::McpUiAction::Show => Ok(()), + crate::tui::app::McpUiAction::Init { force } => { + changed = true; + match mcp::init_config(&path, force) { + Ok(McpWriteStatus::Created) => { + message = Some(format!("Created MCP config at {}", path.display())); + Ok(()) + } + Ok(McpWriteStatus::Overwritten) => { + message = Some(format!("Overwrote MCP config at {}", path.display())); + Ok(()) + } + Ok(McpWriteStatus::SkippedExists) => { + changed = false; + message = Some(format!( + "MCP config already exists at {} (use /mcp init --force to overwrite)", + path.display() + )); + Ok(()) + } + Err(err) => Err(err), + } + } + crate::tui::app::McpUiAction::AddStdio { + name, + command, + args, + } => { + changed = true; + mcp::add_server_config(&path, name.clone(), Some(command), None, args) + .map(|()| message = Some(format!("Added MCP stdio server '{name}'"))) + } + crate::tui::app::McpUiAction::AddHttp { name, url } => { + changed = true; + mcp::add_server_config(&path, name.clone(), None, Some(url), Vec::new()) + .map(|()| message = Some(format!("Added MCP HTTP/SSE server '{name}'"))) + } + crate::tui::app::McpUiAction::Enable { name } => { + changed = true; + mcp::set_server_enabled(&path, &name, true) + .map(|()| message = Some(format!("Enabled MCP server '{name}'"))) + } + crate::tui::app::McpUiAction::Disable { name } => { + changed = true; + mcp::set_server_enabled(&path, &name, false) + .map(|()| message = Some(format!("Disabled MCP server '{name}'"))) + } + crate::tui::app::McpUiAction::Remove { name } => { + changed = true; + mcp::remove_server_config(&path, &name) + .map(|()| message = Some(format!("Removed MCP server '{name}'"))) + } + crate::tui::app::McpUiAction::Validate | crate::tui::app::McpUiAction::Reload => Ok(()), + }; + + if let Err(err) = action_result { + add_mcp_message(app, format!("MCP action failed: {err}")); + return; + } + + if changed { + app.mcp_restart_required = true; + } + if let Some(message) = message { + add_mcp_message(app, message); + } + + let snapshot_result = if discover { + let network_policy = config.network.clone().map(|toml_cfg| { + crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) + }); + mcp::discover_manager_snapshot(&path, network_policy, app.mcp_restart_required).await + } else { + mcp::manager_snapshot_from_config(&path, app.mcp_restart_required) + }; + + match snapshot_result { + Ok(snapshot) => { + if discover { + add_mcp_message( + app, + "MCP discovery refreshed for the UI. Restart the TUI after config edits to rebuild the model-visible MCP tool pool.".to_string(), + ); + } + app.mcp_snapshot = Some(snapshot.clone()); + open_mcp_manager_pager(app, &snapshot); + } + Err(err) => add_mcp_message(app, format!("MCP snapshot failed: {err}")), + } +} + +fn handle_shell_job_action(app: &mut App, action: crate::tui::app::ShellJobAction) { + let Some(shell_manager) = app.runtime_services.shell_manager.clone() else { + add_shell_job_message(app, "Shell job center is not attached.".to_string()); + return; + }; + + let mut manager = match shell_manager.lock() { + Ok(manager) => manager, + Err(_) => { + add_shell_job_message(app, "Shell job center lock is poisoned.".to_string()); + return; + } + }; + + match action { + crate::tui::app::ShellJobAction::List => { + let jobs = manager.list_jobs(); + add_shell_job_message(app, format_shell_job_list(&jobs)); + } + crate::tui::app::ShellJobAction::Show { id } => match manager.inspect_job(&id) { + Ok(detail) => open_shell_job_pager(app, &detail), + Err(err) => add_shell_job_message(app, format!("Shell job lookup failed: {err}")), + }, + crate::tui::app::ShellJobAction::Poll { id, wait } => { + match manager.poll_delta(&id, wait, if wait { 5_000 } else { 1_000 }) { + Ok(delta) => add_shell_job_message(app, format_shell_poll(&delta.result)), + Err(err) => add_shell_job_message(app, format!("Shell job poll failed: {err}")), + } + } + crate::tui::app::ShellJobAction::SendStdin { id, input, close } => { + match manager.write_stdin(&id, &input, close) { + Ok(()) => match manager.poll_delta(&id, false, 1_000) { + Ok(delta) => add_shell_job_message(app, format_shell_poll(&delta.result)), + Err(err) => { + add_shell_job_message(app, format!("Shell stdin sent; poll failed: {err}")); + } + }, + Err(err) => add_shell_job_message(app, format!("Shell stdin failed: {err}")), + } + } + crate::tui::app::ShellJobAction::Cancel { id } => match manager.kill(&id) { + Ok(result) => add_shell_job_message(app, format_shell_poll(&result)), + Err(err) => add_shell_job_message(app, format!("Shell job cancel failed: {err}")), + }, + } +} + async fn execute_command_input( app: &mut App, engine_handle: &mut EngineHandle, diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 9bda0686..cc4e122b 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -402,6 +402,12 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + key: "mcp_config_path".to_string(), + value: app.mcp_config_path.display().to_string(), + editable: true, + scope: ConfigScope::Saved, + }, ]; Self { diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5032f701..fe31707e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -187,6 +187,9 @@ If you are upgrading from older releases: - `max_subagents` (int, optional): defaults to `5` and is clamped to `1..=20`. - `skills_dir` (string, optional): defaults to `~/.deepseek/skills` (each skill is a directory containing `SKILL.md`). Workspace-local `.agents/skills` or `./skills` are preferred when present. - `mcp_config_path` (string, optional): defaults to `~/.deepseek/mcp.json`. + It is visible in `/config` and can be changed from the TUI. The new path is + used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool + requires restarting the TUI. - `notes_path` (string, optional): defaults to `~/.deepseek/notes.txt` and is used by the `note` tool. - `memory_path` (string, optional): defaults to `~/.deepseek/memory.md`. - `snapshots.*` (optional): side-git workspace snapshots for file rollback: diff --git a/docs/MCP.md b/docs/MCP.md index 589a4f87..f0396adf 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -35,6 +35,32 @@ deepseek-tui mcp remove deepseek-tui mcp validate ``` +## In-TUI Manager + +Inside the interactive TUI, `/mcp` opens a compact manager for the resolved +MCP config path. It shows each configured server, whether it is enabled or +disabled, its transport, command or URL, timeout values, connection errors, +and discovered tools/resources/prompts when discovery has been run. + +Supported in-TUI actions: + +```text +/mcp init +/mcp init --force +/mcp add stdio [args...] +/mcp add http +/mcp enable +/mcp disable +/mcp remove +/mcp validate +/mcp reload +``` + +`/mcp validate` and `/mcp reload` reconnect for UI discovery and refresh the +manager snapshot. Config edits made from the TUI are written immediately, but +the model-visible MCP tool pool is not hot-reloaded; the manager marks this as +restart-required until the TUI is restarted. + ## Config File Location Default path: @@ -48,7 +74,11 @@ Overrides: `deepseek-tui mcp init` (and `deepseek-tui setup --mcp`) writes to this resolved path. -After editing the file, restart the TUI. +The interactive `/config` editor also exposes `mcp_config_path`. Changing it in +the TUI updates the path used by `/mcp`, and requires a restart before the +model-visible MCP tool pool is rebuilt. + +After editing the file or changing `mcp_config_path`, restart the TUI. ## Tool Naming @@ -58,6 +88,10 @@ Discovered MCP tools are exposed to the model as: Example: a server named `git` with a tool named `status` becomes `mcp_git_status`. +The command palette includes MCP entries grouped by server. It shows disabled +and failed servers instead of hiding them, and uses the same runtime tool names +shown to the model. + ## Resource and Prompt Helpers The CLI also exposes helper tools when MCP is enabled: @@ -185,5 +219,6 @@ You should still only configure MCP servers you trust, and treat MCP server conf ## Troubleshooting - Run `deepseek-tui doctor` to confirm the MCP config path it resolved and whether it exists. +- In the TUI, run `/mcp validate` to refresh the visible server/tool snapshot. - If the MCP config is missing, run `deepseek-tui mcp init --force` to regenerate it. - If tools don’t appear, verify the server command works from your shell and that the server supports MCP `tools/list`. diff --git a/docs/TOOL_SURFACE.md b/docs/TOOL_SURFACE.md index 8ce2c884..9ba12f4a 100644 --- a/docs/TOOL_SURFACE.md +++ b/docs/TOOL_SURFACE.md @@ -46,6 +46,28 @@ chosen over the available shell equivalent. Companion to `crates/tui/src/prompts | `task_shell_start` | Start a long-running command in the background and return immediately. Preferred over foreground shell for diagnostics, tests, searches, and servers that may run for minutes. | | `task_shell_wait` | Poll a background command. If `gate` is supplied after completion, record structured gate evidence on the active durable task. | +Interactive shell jobs are also visible through `/jobs`. The TUI job center is +fed by the same shell manager as `exec_shell`/`task_shell_start`, and shows the +command, cwd, elapsed time, status, output tail, process-local shell id, and +linked durable task id when available. `/jobs show`, `/jobs poll`, `/jobs wait`, +`/jobs stdin`, and `/jobs cancel` provide inspect, polling, stdin, and cancel +controls for live jobs. Jobs are process-local; after restart, detached entries +are marked stale rather than presented as live processes. + +### MCP manager and palette discovery + +MCP server configuration is surfaced in the TUI through `/mcp` and the +`mcp_config_path` row in `/config`. `/mcp` shows the resolved config path, +server enabled/disabled state, transport, command or URL, timeouts, connection +errors, and discovered tools/resources/prompts. It supports narrow manager +actions for init, add, enable, disable, remove, validate, and reload/reconnect. +Config edits are written immediately, but the model-visible MCP tool pool is +restart-required after edits. + +The command palette includes MCP entries grouped by server. Disabled and failed +servers stay visible, and discovered tools/prompts use the runtime names shown +to the model, such as `mcp__`. + ### Git / diagnostics / testing | Tool | Niche |