Add shell jobs and MCP manager to the TUI
This commit is contained in:
@@ -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<PathBuf> {
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <id>"),
|
||||
},
|
||||
"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 <id>"),
|
||||
},
|
||||
"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 <id> <input>"),
|
||||
},
|
||||
"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 <id>"),
|
||||
},
|
||||
"cancel" | "kill" | "stop" => match id {
|
||||
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Cancel {
|
||||
id: id.to_string(),
|
||||
})),
|
||||
None => CommandResult::error("Usage: /jobs cancel <id>"),
|
||||
},
|
||||
_ => CommandResult::error(
|
||||
"Usage: /jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|close-stdin <id>|cancel <id>]",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[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"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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 <name>") {
|
||||
Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Enable { name })),
|
||||
Err(msg) => CommandResult::error(msg),
|
||||
},
|
||||
"disable" => match parse_name(parts.next(), "Usage: /mcp disable <name>") {
|
||||
Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Disable { name })),
|
||||
Err(msg) => CommandResult::error(msg),
|
||||
},
|
||||
"remove" | "rm" => match parse_name(parts.next(), "Usage: /mcp remove <name>") {
|
||||
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 <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|validate|reload]",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_name(name: Option<&str>, usage: &str) -> Result<String, String> {
|
||||
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 <name> <command> [args...] OR /mcp add http <name> <url>",
|
||||
);
|
||||
}
|
||||
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 <name> <command> [args...] OR /mcp add http <name> <url>",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[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))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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 <prompt>|list|show <id>|cancel <id>]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "jobs",
|
||||
aliases: &["job"],
|
||||
description: "Inspect and control background shell jobs",
|
||||
usage: "/jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|cancel <id>]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "mcp",
|
||||
aliases: &[],
|
||||
description: "Open or manage MCP servers",
|
||||
usage: "/mcp [init|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub tools: Vec<McpDiscoveredItem>,
|
||||
pub resources: Vec<McpDiscoveredItem>,
|
||||
pub prompts: Vec<McpDiscoveredItem>,
|
||||
}
|
||||
|
||||
#[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<McpServerSnapshot>,
|
||||
}
|
||||
|
||||
pub fn load_config(path: &Path) -> Result<McpConfig> {
|
||||
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<String> {
|
||||
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<McpWriteStatus> {
|
||||
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<String>,
|
||||
url: Option<String>,
|
||||
args: Vec<String>,
|
||||
) -> 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<McpManagerSnapshot> {
|
||||
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<NetworkPolicyDecider>,
|
||||
restart_required: bool,
|
||||
) -> Result<McpManagerSnapshot> {
|
||||
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::<HashMap<_, _>>();
|
||||
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<String, String>)>,
|
||||
) -> 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::<Vec<_>>();
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+211
-16
@@ -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<i32>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// Full output view used by `/jobs show <id>`.
|
||||
#[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<R: Read + Send + 'static>(
|
||||
/// 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<i32>,
|
||||
pub started_at: Instant,
|
||||
pub sandbox_type: SandboxType,
|
||||
pub linked_task_id: Option<String>,
|
||||
stdout_buffer: Arc<Mutex<Vec<u8>>>,
|
||||
stderr_buffer: Option<Arc<Mutex<Vec<u8>>>>,
|
||||
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<String, BackgroundShell>,
|
||||
stale_jobs: HashMap<String, ShellJobSnapshot>,
|
||||
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<ShellResult> {
|
||||
// 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<ShellDeltaResult> {
|
||||
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<String>) -> 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<ShellJobDetail> {
|
||||
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<ShellJobSnapshot> {
|
||||
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::<Vec<_>>();
|
||||
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<String>,
|
||||
command: impl Into<String>,
|
||||
cwd: PathBuf,
|
||||
linked_task_id: Option<String>,
|
||||
) {
|
||||
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<Mutex<Vec<u8>>>, 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::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
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<Mutex<ShellManager>>;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -29,6 +29,7 @@ pub use deepseek_tools::{
|
||||
/// attached.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RuntimeToolServices {
|
||||
pub shell_manager: Option<SharedShellManager>,
|
||||
pub task_manager: Option<crate::task_manager::SharedTaskManager>,
|
||||
pub automations: Option<crate::automation_manager::SharedAutomationManager>,
|
||||
pub task_data_dir: Option<PathBuf>,
|
||||
@@ -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)
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<crate::mcp::McpManagerSnapshot>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
},
|
||||
AddHttp {
|
||||
name: String,
|
||||
url: String,
|
||||
},
|
||||
Enable {
|
||||
name: String,
|
||||
},
|
||||
Disable {
|
||||
name: String,
|
||||
},
|
||||
Remove {
|
||||
name: String,
|
||||
},
|
||||
Validate,
|
||||
Reload,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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<CommandPaletteEntry> {
|
||||
pub fn build_entries(
|
||||
skills_dir: &Path,
|
||||
workspace: &Path,
|
||||
mcp_config_path: &Path,
|
||||
mcp_snapshot: Option<&crate::mcp::McpManagerSnapshot>,
|
||||
) -> Vec<CommandPaletteEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for command in commands::COMMANDS {
|
||||
@@ -154,11 +160,173 @@ pub fn build_entries(skills_dir: &Path, workspace: &Path) -> Vec<CommandPaletteE
|
||||
tool_entries.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
entries.extend(tool_entries);
|
||||
|
||||
entries.extend(build_mcp_entries(mcp_config_path, mcp_snapshot));
|
||||
|
||||
entries.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
entries.sort_by_key(|entry| entry.section);
|
||||
entries
|
||||
}
|
||||
|
||||
fn build_mcp_entries(
|
||||
mcp_config_path: &Path,
|
||||
mcp_snapshot: Option<&crate::mcp::McpManagerSnapshot>,
|
||||
) -> Vec<CommandPaletteEntry> {
|
||||
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:<term> Tool-only e.g. t:git",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
" m:<term> 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 {
|
||||
|
||||
@@ -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 <name> <command> [args...], /mcp add http <name> <url>, /mcp enable <name>, /mcp disable <name>, /mcp remove <name>, /mcp validate, /mcp reload."
|
||||
.to_string(),
|
||||
);
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn push_server(lines: &mut Vec<String>, 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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <id>, /jobs poll <id>, /jobs wait <id>, /jobs stdin <id> <input>, /jobs cancel <id>."
|
||||
.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 <id>"));
|
||||
assert!(formatted.contains("task=task_1"));
|
||||
}
|
||||
}
|
||||
@@ -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<usize> = if old_len != total_cells {
|
||||
Some(old_len.min(total_cells))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut new_per_cell: Vec<CachedCell> = 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![
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
+36
-1
@@ -35,6 +35,32 @@ deepseek-tui mcp remove <name>
|
||||
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 <name> <command> [args...]
|
||||
/mcp add http <name> <url>
|
||||
/mcp enable <name>
|
||||
/mcp disable <name>
|
||||
/mcp remove <name>
|
||||
/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`.
|
||||
|
||||
@@ -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_<server>_<tool>`.
|
||||
|
||||
### Git / diagnostics / testing
|
||||
|
||||
| Tool | Niche |
|
||||
|
||||
Reference in New Issue
Block a user