Add shell jobs and MCP manager to the TUI

This commit is contained in:
Hunter Bown
2026-04-29 09:38:04 -05:00
parent 41e8f2b5b2
commit 0578eb701e
22 changed files with 1912 additions and 47 deletions
+51
View File
@@ -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);
}
_ => {}
}
+110
View File
@@ -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"
));
}
}
+116
View File
@@ -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))
));
}
}
+16
View File
@@ -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),
+5 -1
View File
@@ -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
+363
View File
@@ -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();
+1
View File
@@ -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
View File
@@ -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()
+65
View File
@@ -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);
+2
View File
@@ -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)
-10
View File
@@ -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::*;
+65 -2
View File
@@ -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)]
+250 -5
View File
@@ -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 {
+160
View File
@@ -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"));
}
}
+2
View File
@@ -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;
+181
View File
@@ -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"));
}
}
+75 -12
View File
@@ -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![
+172
View File
@@ -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,
+6
View File
@@ -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 {
+3
View File
@@ -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
View File
@@ -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 dont appear, verify the server command works from your shell and that the server supports MCP `tools/list`.
+22
View File
@@ -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 |