use std::path::Path;
use std::sync::Arc;
use serde_json::Value;
use crate::provider::ProviderRegistry;
use crate::session::Session;
use crate::tui::app::codex_sessions;
use crate::tui::app::file_share::attach_file_to_input;
use crate::tui::app::model_picker::open_model_picker;
use crate::tui::app::session_sync::{refresh_sessions, return_to_chat};
use crate::tui::app::settings::{
autocomplete_status_message, network_access_status_message, set_network_access,
set_slash_autocomplete,
};
use crate::tui::app::state::{App, SpawnedAgent, agent_profile};
use crate::tui::app::text::{
command_with_optional_args, normalize_easy_command, normalize_slash_command,
};
use crate::tui::chat::message::{ChatMessage, MessageType};
use crate::tui::models::ViewMode;
fn auto_apply_flag_label(enabled: bool) -> &'static str {
if enabled { "ON" } else { "OFF" }
}
pub fn auto_apply_status_message(enabled: bool) -> String {
format!("TUI edit auto-apply: {}", auto_apply_flag_label(enabled))
}
pub async fn set_auto_apply_edits(app: &mut App, session: &mut Session, next: bool) {
app.state.auto_apply_edits = next;
session.metadata.auto_apply_edits = next;
match session.save().await {
Ok(()) => {
app.state.status = auto_apply_status_message(next);
}
Err(error) => {
app.state.status = format!(
"{} (not persisted: {error})",
auto_apply_status_message(next)
);
}
}
}
pub async fn toggle_auto_apply_edits(app: &mut App, session: &mut Session) {
set_auto_apply_edits(app, session, !app.state.auto_apply_edits).await;
}
fn push_system_message(app: &mut App, content: impl Into<String>) {
app.state
.messages
.push(ChatMessage::new(MessageType::System, content.into()));
app.state.scroll_to_bottom();
}
async fn handle_mcp_command(app: &mut App, raw: &str) {
let rest = raw.trim();
if rest.is_empty() {
app.state.status =
"Usage: /mcp connect <name> <command...> | /mcp servers | /mcp tools [server] | /mcp call <server> <tool> [json]"
.to_string();
return;
}
if let Some(value) = rest.strip_prefix("connect ") {
let mut parts = value.trim().splitn(2, char::is_whitespace);
let Some(name) = parts.next().filter(|part| !part.is_empty()) else {
app.state.status = "Usage: /mcp connect <name> <command...>".to_string();
return;
};
let Some(command) = parts.next().map(str::trim).filter(|part| !part.is_empty()) else {
app.state.status = "Usage: /mcp connect <name> <command...>".to_string();
return;
};
match app.state.mcp_registry.connect(name, command).await {
Ok(tool_count) => {
app.state.status = format!("Connected MCP server '{name}' ({tool_count} tools)");
push_system_message(
app,
format!("Connected MCP server `{name}` with {tool_count} tools."),
);
}
Err(error) => {
app.state.status = format!("MCP connect failed: {error}");
push_system_message(app, format!("MCP connect failed for `{name}`: {error}"));
}
}
return;
}
if rest == "servers" {
let servers = app.state.mcp_registry.list_servers().await;
if servers.is_empty() {
app.state.status = "No MCP servers connected".to_string();
push_system_message(app, "No MCP servers connected.");
} else {
app.state.status = format!("{} MCP server(s) connected", servers.len());
let body = servers
.into_iter()
.map(|server| {
format!(
"- {} ({} tools) :: {}",
server.name, server.tool_count, server.command
)
})
.collect::<Vec<_>>()
.join("\n");
push_system_message(app, format!("Connected MCP servers:\n{body}"));
}
return;
}
if let Some(value) = rest.strip_prefix("tools") {
let server = value.trim();
let server = if server.is_empty() {
None
} else {
Some(server)
};
match app.state.mcp_registry.list_tools(server).await {
Ok(tools) => {
if tools.is_empty() {
app.state.status = "No MCP tools available".to_string();
push_system_message(app, "No MCP tools available.");
} else {
app.state.status = format!("{} MCP tool(s) available", tools.len());
let body = tools
.into_iter()
.map(|(server_name, tool)| {
let description = tool
.description
.unwrap_or_else(|| "Remote MCP tool".to_string());
format!("- [{server_name}] {} — {}", tool.name, description)
})
.collect::<Vec<_>>()
.join("\n");
push_system_message(app, format!("Available MCP tools:\n{body}"));
}
}
Err(error) => {
app.state.status = format!("MCP tools failed: {error}");
push_system_message(app, format!("Failed to list MCP tools: {error}"));
}
}
return;
}
if let Some(value) = rest.strip_prefix("call ") {
let mut parts = value.trim().splitn(3, char::is_whitespace);
let Some(server_name) = parts.next().filter(|part| !part.is_empty()) else {
app.state.status = "Usage: /mcp call <server> <tool> [json]".to_string();
return;
};
let Some(tool_name) = parts.next().filter(|part| !part.is_empty()) else {
app.state.status = "Usage: /mcp call <server> <tool> [json]".to_string();
return;
};
let arguments = match parts.next().map(str::trim).filter(|part| !part.is_empty()) {
Some(raw_json) => match serde_json::from_str::<Value>(raw_json) {
Ok(value) => value,
Err(error) => {
app.state.status = format!("Invalid MCP JSON args: {error}");
return;
}
},
None => Value::Object(Default::default()),
};
match app
.state
.mcp_registry
.call_tool(server_name, tool_name, arguments)
.await
{
Ok(output) => {
app.state.status = format!("MCP tool finished: {server_name}/{tool_name}");
push_system_message(
app,
format!("MCP `{server_name}` / `{tool_name}` result:\n{output}"),
);
}
Err(error) => {
app.state.status = format!("MCP call failed: {error}");
push_system_message(
app,
format!("MCP `{server_name}` / `{tool_name}` failed: {error}"),
);
}
}
return;
}
app.state.status =
"Usage: /mcp connect <name> <command...> | /mcp servers | /mcp tools [server] | /mcp call <server> <tool> [json]"
.to_string();
}
pub async fn handle_slash_command(
app: &mut App,
cwd: &std::path::Path,
session: &mut Session,
registry: Option<&Arc<ProviderRegistry>>,
command: &str,
) {
let normalized = normalize_easy_command(command);
let normalized = normalize_slash_command(&normalized);
if let Some(rest) = command_with_optional_args(&normalized, "/image") {
let cleaned = rest.trim().trim_matches(|c| c == '"' || c == '\'');
if cleaned.is_empty() {
app.state.status =
"Usage: /image <path> (png, jpg, jpeg, gif, webp, bmp, svg).".to_string();
} else {
let path = Path::new(cleaned);
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};
match crate::tui::app::input::attach_image_file(&resolved) {
Ok(attachment) => {
let display = resolved.display();
app.state.pending_images.push(attachment);
let count = app.state.pending_images.len();
app.state.status = format!(
"📷 Attached {display}. {count} image(s) pending. Press Enter to send."
);
push_system_message(
app,
format!(
"📷 Image attached: {display}. Type a message and press Enter to send."
),
);
}
Err(msg) => {
push_system_message(app, format!("Failed to attach image: {msg}"));
}
}
}
return;
}
if let Some(rest) = command_with_optional_args(&normalized, "/file") {
let cleaned = rest.trim().trim_matches(|c| c == '"' || c == '\'');
if cleaned.is_empty() {
app.state.status =
"Usage: /file <path> (relative to workspace or absolute).".to_string();
} else {
attach_file_to_input(app, cwd, Path::new(cleaned));
}
return;
}
if let Some(rest) = command_with_optional_args(&normalized, "/autoapply") {
let action = rest.trim().to_ascii_lowercase();
let current = app.state.auto_apply_edits;
let desired = match action.as_str() {
"" | "toggle" => Some(!current),
"status" => None,
"on" | "true" | "yes" | "enable" | "enabled" => Some(true),
"off" | "false" | "no" | "disable" | "disabled" => Some(false),
_ => {
app.state.status = "Usage: /autoapply [on|off|toggle|status]".to_string();
return;
}
};
if let Some(next) = desired {
set_auto_apply_edits(app, session, next).await;
} else {
app.state.status = auto_apply_status_message(current);
}
return;
}
if let Some(rest) = command_with_optional_args(&normalized, "/network") {
let current = app.state.allow_network;
let desired = match rest.trim().to_ascii_lowercase().as_str() {
"" | "toggle" => Some(!current),
"status" => None,
"on" | "true" | "yes" | "enable" | "enabled" => Some(true),
"off" | "false" | "no" | "disable" | "disabled" => Some(false),
_ => {
app.state.status = "Usage: /network [on|off|toggle|status]".to_string();
return;
}
};
if let Some(next) = desired {
set_network_access(app, session, next).await;
} else {
app.state.status = network_access_status_message(current);
}
return;
}
if let Some(rest) = command_with_optional_args(&normalized, "/autocomplete") {
let current = app.state.slash_autocomplete;
let desired = match rest.trim().to_ascii_lowercase().as_str() {
"" | "toggle" => Some(!current),
"status" => None,
"on" | "true" | "yes" | "enable" | "enabled" => Some(true),
"off" | "false" | "no" | "disable" | "disabled" => Some(false),
_ => {
app.state.status = "Usage: /autocomplete [on|off|toggle|status]".to_string();
return;
}
};
if let Some(next) = desired {
set_slash_autocomplete(app, session, next).await;
} else {
app.state.status = autocomplete_status_message(current);
}
return;
}
if let Some(rest) = command_with_optional_args(&normalized, "/steer") {
let value = rest.trim();
if value.eq_ignore_ascii_case("clear") {
app.state.clear_steering();
app.state.status = "Cleared queued steering".to_string();
push_system_message(app, "Cleared queued steering.");
} else if value.eq_ignore_ascii_case("status") || value.is_empty() {
let count = app.state.steering_count();
app.state.status = format!("Queued steering: {count}");
let body = if count == 0 {
"No queued steering.".to_string()
} else {
app.state
.queued_steering
.iter()
.enumerate()
.map(|(idx, item)| format!("{}. {item}", idx + 1))
.collect::<Vec<_>>()
.join("\n")
};
push_system_message(app, format!("Queued steering\n{body}"));
} else {
app.state.queue_steering(value);
let count = app.state.steering_count();
app.state.status = format!("Queued steering ({count}) for next turn");
push_system_message(app, format!("Queued steering for next turn: {value}"));
}
return;
}
if let Some(rest) = command_with_optional_args(&normalized, "/mcp") {
handle_mcp_command(app, rest).await;
return;
}
match normalized.as_str() {
"/help" => {
app.state.show_help = true;
app.state.help_scroll.offset = 0;
app.state.status = "Help".to_string();
}
"/sessions" | "/session" => {
refresh_sessions(app, cwd).await;
app.state.clear_session_filter();
app.state.set_view_mode(ViewMode::Sessions);
app.state.status = "Session picker".to_string();
}
"/import-codex" => {
codex_sessions::import_workspace_sessions(app, cwd).await;
}
"/swarm" => {
app.state.swarm.mark_active("TUI swarm monitor");
app.state.set_view_mode(ViewMode::Swarm);
}
"/ralph" => {
app.state
.ralph
.mark_active(app.state.cwd_display.clone(), "TUI Ralph monitor");
app.state.set_view_mode(ViewMode::Ralph);
}
"/bus" | "/protocol" => {
app.state.set_view_mode(ViewMode::Bus);
app.state.status = "Protocol bus log".to_string();
}
"/model" => open_model_picker(app, session, registry).await,
"/settings" => app.state.set_view_mode(ViewMode::Settings),
"/lsp" => app.state.set_view_mode(ViewMode::Lsp),
"/rlm" => app.state.set_view_mode(ViewMode::Rlm),
"/latency" => {
app.state.set_view_mode(ViewMode::Latency);
app.state.status = "Latency inspector".to_string();
}
"/inspector" => {
app.state.set_view_mode(ViewMode::Inspector);
app.state.status = "Inspector".to_string();
}
"/chat" | "/home" | "/main" => return_to_chat(app),
"/webview" => {
app.state.chat_layout_mode =
crate::tui::ui::webview::layout_mode::ChatLayoutMode::Webview;
app.state.status = "Layout: Webview".to_string();
}
"/classic" => {
app.state.chat_layout_mode =
crate::tui::ui::webview::layout_mode::ChatLayoutMode::Classic;
app.state.status = "Layout: Classic".to_string();
}
"/symbols" | "/symbol" => {
app.state.symbol_search.open();
app.state.status = "Symbol search".to_string();
}
"/new" => {
match Session::new().await {
Ok(mut new_session) => {
if let Err(error) = session.save().await {
tracing::warn!(error = %error, "Failed to save current session before /new");
app.state.status = format!(
"Failed to save current session before creating new session: {error}"
);
return;
}
new_session.metadata.auto_apply_edits = app.state.auto_apply_edits;
new_session.metadata.allow_network = app.state.allow_network;
new_session.metadata.slash_autocomplete = app.state.slash_autocomplete;
new_session.metadata.use_worktree = app.state.use_worktree;
new_session.metadata.model = session.metadata.model.clone();
*session = new_session;
session.attach_global_bus_if_missing();
if let Err(error) = session.save().await {
tracing::warn!(error = %error, "Failed to save new session");
app.state.status =
format!("New chat session created, but failed to persist: {error}");
} else {
app.state.status = "New chat session".to_string();
}
app.state.session_id = Some(session.id.clone());
app.state.messages.clear();
app.state.streaming_text.clear();
app.state.processing = false;
app.state.clear_request_timing();
app.state.scroll_to_bottom();
app.state.set_view_mode(ViewMode::Chat);
refresh_sessions(app, cwd).await;
}
Err(err) => {
app.state.status = format!("Failed to create new session: {err}");
}
}
}
"/undo" => {
let mut found_user = false;
while let Some(msg) = app.state.messages.last() {
if matches!(msg.message_type, MessageType::User) {
if found_user {
break; }
found_user = true;
}
if matches!(msg.message_type, MessageType::System) && !found_user {
break;
}
app.state.messages.pop();
}
if !found_user {
push_system_message(app, "Nothing to undo.");
return;
}
let mut found_session_user = false;
while let Some(msg) = session.messages.last() {
if msg.role == crate::provider::Role::User {
if found_session_user {
break;
}
found_session_user = true;
}
if msg.role == crate::provider::Role::System && !found_session_user {
break;
}
session.messages.pop();
}
if let Err(error) = session.save().await {
tracing::warn!(error = %error, "Failed to save session after undo");
}
push_system_message(app, "Undid last message and response.");
}
"/keys" => {
app.state.status =
"Protocol-first commands: /protocol /bus /file /autoapply /network /autocomplete /mcp /model /sessions /import-codex /swarm /ralph /latency /symbols /settings /lsp /rlm /chat /new /undo /spawn /kill /agents /agent\nEasy aliases: /add /talk /list /remove /focus /home /say /ls /rm /main"
.to_string();
}
_ => {}
}
if let Some(rest) = command_with_optional_args(&normalized, "/spawn") {
handle_spawn_command(app, rest).await;
return;
}
if let Some(rest) = command_with_optional_args(&normalized, "/kill") {
handle_kill_command(app, rest);
return;
}
if command_with_optional_args(&normalized, "/agents").is_some() {
handle_agents_command(app);
return;
}
if let Some(rest) = command_with_optional_args(&normalized, "/autochat") {
handle_autochat_command(app, rest);
return;
}
if !matches!(
normalized.as_str(),
"/help"
| "/sessions"
| "/import-codex"
| "/session"
| "/swarm"
| "/ralph"
| "/bus"
| "/protocol"
| "/model"
| "/settings"
| "/lsp"
| "/rlm"
| "/latency"
| "/chat"
| "/home"
| "/main"
| "/symbols"
| "/symbol"
| "/new"
| "/undo"
| "/keys"
| "/file"
| "/image"
| "/autoapply"
| "/network"
| "/autocomplete"
| "/mcp"
| "/spawn"
| "/kill"
| "/agents"
| "/agent"
| "/autochat"
) {
app.state.status = format!("Unknown command: {normalized}");
}
}
async fn handle_spawn_command(app: &mut App, rest: &str) {
let rest = rest.trim();
if rest.is_empty() {
app.state.status = "Usage: /spawn <name> [instructions]".to_string();
return;
}
let mut parts = rest.splitn(2, char::is_whitespace);
let Some(name) = parts.next().filter(|s| !s.is_empty()) else {
app.state.status = "Usage: /spawn <name> [instructions]".to_string();
return;
};
if app.state.spawned_agents.contains_key(name) {
app.state.status = format!("Agent '{name}' already exists. Use /kill {name} first.");
push_system_message(app, format!("Agent '{name}' already exists."));
return;
}
let instructions = parts.next().unwrap_or("").trim().to_string();
let profile = agent_profile(name);
let system_prompt = if instructions.is_empty() {
format!(
"You are an AI assistant codenamed '{}' ({}) working as a sub-agent.
Personality: {}
Collaboration style: {}
Signature move: {}",
profile.codename,
profile.profile,
profile.personality,
profile.collaboration_style,
profile.signature_move,
)
} else {
instructions.clone()
};
match Session::new().await {
Ok(mut agent_session) => {
agent_session.agent = format!("spawned:{}", name);
agent_session.messages.push(crate::provider::Message {
role: crate::provider::Role::System,
content: vec![crate::provider::ContentPart::Text {
text: system_prompt,
}],
});
if let Err(e) = agent_session.save().await {
tracing::warn!(error = %e, "Failed to save spawned agent session");
}
let display_name = if instructions.is_empty() {
format!("{} [{}]", name, profile.codename)
} else {
name.to_string()
};
app.state.spawned_agents.insert(
name.to_string(),
SpawnedAgent {
name: display_name.clone(),
instructions,
session: agent_session,
is_processing: false,
},
);
app.state.status = format!("Spawned agent: {display_name}");
push_system_message(
app,
format!(
"Spawned agent '{}' [{}] — ready for messages.",
name, profile.codename
),
);
}
Err(error) => {
app.state.status = format!("Failed to create agent session: {error}");
push_system_message(app, format!("Failed to spawn agent '{name}': {error}"));
}
}
}
fn handle_kill_command(app: &mut App, rest: &str) {
let name = rest.trim();
if name.is_empty() {
app.state.status = "Usage: /kill <name>".to_string();
return;
}
if app.state.spawned_agents.remove(name).is_some() {
if app.state.active_spawned_agent.as_deref() == Some(name) {
app.state.active_spawned_agent = None;
}
app.state.streaming_agent_texts.remove(name);
app.state.status = format!("Agent '{name}' removed.");
push_system_message(app, format!("Agent '{name}' has been shut down."));
} else {
app.state.status = format!("Agent '{name}' not found.");
}
}
fn handle_agents_command(app: &mut App) {
if app.state.spawned_agents.is_empty() {
app.state.status = "No spawned agents.".to_string();
push_system_message(app, "No spawned agents. Use /spawn <name> to create one.");
} else {
let count = app.state.spawned_agents.len();
let lines: Vec<String> = app
.state
.spawned_agents
.iter()
.map(|(key, agent)| {
let msg_count = agent.session.messages.len();
let model = agent.session.metadata.model.as_deref().unwrap_or("default");
let active = if app.state.active_spawned_agent.as_deref() == Some(key) {
" [active]"
} else {
""
};
format!(
" {}{} — {} messages — model: {}",
agent.name, active, msg_count, model
)
})
.collect();
let body = lines.join(
"
",
);
app.state.status = format!("{count} spawned agent(s)");
push_system_message(
app,
format!(
"Spawned agents ({count}):
{body}"
),
);
}
}
async fn handle_go_command(
app: &mut App,
session: &mut Session,
_registry: Option<&Arc<ProviderRegistry>>,
rest: &str,
) {
use crate::tui::app::okr_gate::{PendingOkrApproval, ensure_okr_repository, next_go_model};
use crate::tui::constants::AUTOCHAT_MAX_AGENTS;
let task = rest.trim();
if task.is_empty() {
app.state.status = "Usage: /go <task description>".to_string();
return;
}
let current_model = session.metadata.model.as_deref();
let model = next_go_model(current_model);
session.metadata.model = Some(model.clone());
if let Err(error) = session.save().await {
tracing::warn!(error = %error, "Failed to save session after model swap");
}
ensure_okr_repository(&mut app.state.okr_repository).await;
let pending = PendingOkrApproval::propose(task.to_string(), AUTOCHAT_MAX_AGENTS, model).await;
push_system_message(app, pending.approval_prompt());
app.state.pending_okr_approval = Some(pending);
app.state.status = "OKR draft awaiting approval \u{2014} [A]pprove or [D]eny".to_string();
}
fn handle_autochat_command(app: &mut App, rest: &str) {
let task = rest.trim().to_string();
if task.is_empty() {
app.state.status = "Usage: /autochat <task description>".to_string();
return;
}
if app.state.autochat.running {
app.state.status = "Autochat relay already running.".to_string();
return;
}
let model = app.state.last_completion_model.clone().unwrap_or_default();
let rx = super::autochat::worker::start_autochat_relay(task, model);
app.state.autochat.running = true;
app.state.autochat.rx = Some(rx);
app.state.status = "Autochat relay started.".to_string();
}