1use std::path::Path;
2use std::sync::Arc;
3
4use serde_json::Value;
5
6use crate::provider::ProviderRegistry;
7use crate::session::Session;
8use crate::tui::app::codex_sessions;
9use crate::tui::app::file_share::attach_file_to_input;
10use crate::tui::app::model_picker::open_model_picker;
11use crate::tui::app::session_sync::{refresh_sessions, return_to_chat};
12use crate::tui::app::settings::{
13 autocomplete_status_message, network_access_status_message, set_network_access,
14 set_slash_autocomplete,
15};
16use crate::tui::app::state::{App, SpawnedAgent, agent_profile};
17use crate::tui::app::text::{
18 command_with_optional_args, normalize_easy_command, normalize_slash_command,
19};
20use crate::tui::chat::message::{ChatMessage, MessageType};
21use crate::tui::models::ViewMode;
22
23fn auto_apply_flag_label(enabled: bool) -> &'static str {
24 if enabled { "ON" } else { "OFF" }
25}
26
27pub fn auto_apply_status_message(enabled: bool) -> String {
28 format!("TUI edit auto-apply: {}", auto_apply_flag_label(enabled))
29}
30
31pub async fn set_auto_apply_edits(app: &mut App, session: &mut Session, next: bool) {
32 app.state.auto_apply_edits = next;
33 session.metadata.auto_apply_edits = next;
34
35 match session.save().await {
36 Ok(()) => {
37 app.state.status = auto_apply_status_message(next);
38 }
39 Err(error) => {
40 app.state.status = format!(
41 "{} (not persisted: {error})",
42 auto_apply_status_message(next)
43 );
44 }
45 }
46}
47
48pub async fn toggle_auto_apply_edits(app: &mut App, session: &mut Session) {
49 set_auto_apply_edits(app, session, !app.state.auto_apply_edits).await;
50}
51
52fn push_system_message(app: &mut App, content: impl Into<String>) {
53 app.state
54 .messages
55 .push(ChatMessage::new(MessageType::System, content.into()));
56 app.state.scroll_to_bottom();
57}
58
59async fn handle_mcp_command(app: &mut App, raw: &str) {
60 let rest = raw.trim();
61 if rest.is_empty() {
62 app.state.status =
63 "Usage: /mcp connect <name> <command...> | /mcp servers | /mcp tools [server] | /mcp call <server> <tool> [json]"
64 .to_string();
65 return;
66 }
67
68 if let Some(value) = rest.strip_prefix("connect ") {
69 let mut parts = value.trim().splitn(2, char::is_whitespace);
70 let Some(name) = parts.next().filter(|part| !part.is_empty()) else {
71 app.state.status = "Usage: /mcp connect <name> <command...>".to_string();
72 return;
73 };
74 let Some(command) = parts.next().map(str::trim).filter(|part| !part.is_empty()) else {
75 app.state.status = "Usage: /mcp connect <name> <command...>".to_string();
76 return;
77 };
78
79 match app.state.mcp_registry.connect(name, command).await {
80 Ok(tool_count) => {
81 app.state.status = format!("Connected MCP server '{name}' ({tool_count} tools)");
82 push_system_message(
83 app,
84 format!("Connected MCP server `{name}` with {tool_count} tools."),
85 );
86 }
87 Err(error) => {
88 app.state.status = format!("MCP connect failed: {error}");
89 push_system_message(app, format!("MCP connect failed for `{name}`: {error}"));
90 }
91 }
92 return;
93 }
94
95 if rest == "servers" {
96 let servers = app.state.mcp_registry.list_servers().await;
97 if servers.is_empty() {
98 app.state.status = "No MCP servers connected".to_string();
99 push_system_message(app, "No MCP servers connected.");
100 } else {
101 app.state.status = format!("{} MCP server(s) connected", servers.len());
102 let body = servers
103 .into_iter()
104 .map(|server| {
105 format!(
106 "- {} ({} tools) :: {}",
107 server.name, server.tool_count, server.command
108 )
109 })
110 .collect::<Vec<_>>()
111 .join("\n");
112 push_system_message(app, format!("Connected MCP servers:\n{body}"));
113 }
114 return;
115 }
116
117 if let Some(value) = rest.strip_prefix("tools") {
118 let server = value.trim();
119 let server = if server.is_empty() {
120 None
121 } else {
122 Some(server)
123 };
124 match app.state.mcp_registry.list_tools(server).await {
125 Ok(tools) => {
126 if tools.is_empty() {
127 app.state.status = "No MCP tools available".to_string();
128 push_system_message(app, "No MCP tools available.");
129 } else {
130 app.state.status = format!("{} MCP tool(s) available", tools.len());
131 let body = tools
132 .into_iter()
133 .map(|(server_name, tool)| {
134 let description = tool
135 .description
136 .unwrap_or_else(|| "Remote MCP tool".to_string());
137 format!("- [{server_name}] {} — {}", tool.name, description)
138 })
139 .collect::<Vec<_>>()
140 .join("\n");
141 push_system_message(app, format!("Available MCP tools:\n{body}"));
142 }
143 }
144 Err(error) => {
145 app.state.status = format!("MCP tools failed: {error}");
146 push_system_message(app, format!("Failed to list MCP tools: {error}"));
147 }
148 }
149 return;
150 }
151
152 if let Some(value) = rest.strip_prefix("call ") {
153 let mut parts = value.trim().splitn(3, char::is_whitespace);
154 let Some(server_name) = parts.next().filter(|part| !part.is_empty()) else {
155 app.state.status = "Usage: /mcp call <server> <tool> [json]".to_string();
156 return;
157 };
158 let Some(tool_name) = parts.next().filter(|part| !part.is_empty()) else {
159 app.state.status = "Usage: /mcp call <server> <tool> [json]".to_string();
160 return;
161 };
162 let arguments = match parts.next().map(str::trim).filter(|part| !part.is_empty()) {
163 Some(raw_json) => match serde_json::from_str::<Value>(raw_json) {
164 Ok(value) => value,
165 Err(error) => {
166 app.state.status = format!("Invalid MCP JSON args: {error}");
167 return;
168 }
169 },
170 None => Value::Object(Default::default()),
171 };
172
173 match app
174 .state
175 .mcp_registry
176 .call_tool(server_name, tool_name, arguments)
177 .await
178 {
179 Ok(output) => {
180 app.state.status = format!("MCP tool finished: {server_name}/{tool_name}");
181 push_system_message(
182 app,
183 format!("MCP `{server_name}` / `{tool_name}` result:\n{output}"),
184 );
185 }
186 Err(error) => {
187 app.state.status = format!("MCP call failed: {error}");
188 push_system_message(
189 app,
190 format!("MCP `{server_name}` / `{tool_name}` failed: {error}"),
191 );
192 }
193 }
194 return;
195 }
196
197 app.state.status =
198 "Usage: /mcp connect <name> <command...> | /mcp servers | /mcp tools [server] | /mcp call <server> <tool> [json]"
199 .to_string();
200}
201
202pub async fn handle_slash_command(
203 app: &mut App,
204 cwd: &std::path::Path,
205 session: &mut Session,
206 registry: Option<&Arc<ProviderRegistry>>,
207 command: &str,
208) {
209 let normalized = normalize_easy_command(command);
210 let normalized = normalize_slash_command(&normalized);
211
212 if let Some(rest) = command_with_optional_args(&normalized, "/image") {
213 let cleaned = rest.trim().trim_matches(|c| c == '"' || c == '\'');
214 if cleaned.is_empty() {
215 app.state.status =
216 "Usage: /image <path> (png, jpg, jpeg, gif, webp, bmp, svg).".to_string();
217 } else {
218 let path = Path::new(cleaned);
219 let resolved = if path.is_absolute() {
220 path.to_path_buf()
221 } else {
222 cwd.join(path)
223 };
224 match crate::tui::app::input::attach_image_file(&resolved) {
225 Ok(attachment) => {
226 let display = resolved.display();
227 app.state.pending_images.push(attachment);
228 let count = app.state.pending_images.len();
229 app.state.status = format!(
230 "📷 Attached {display}. {count} image(s) pending. Press Enter to send."
231 );
232 push_system_message(
233 app,
234 format!(
235 "📷 Image attached: {display}. Type a message and press Enter to send."
236 ),
237 );
238 }
239 Err(msg) => {
240 push_system_message(app, format!("Failed to attach image: {msg}"));
241 }
242 }
243 }
244 return;
245 }
246
247 if let Some(rest) = command_with_optional_args(&normalized, "/file") {
248 let cleaned = rest.trim().trim_matches(|c| c == '"' || c == '\'');
249 if cleaned.is_empty() {
250 app.state.status =
251 "Usage: /file <path> (relative to workspace or absolute).".to_string();
252 } else {
253 attach_file_to_input(app, cwd, Path::new(cleaned));
254 }
255 return;
256 }
257
258 if let Some(rest) = command_with_optional_args(&normalized, "/autoapply") {
259 let action = rest.trim().to_ascii_lowercase();
260 let current = app.state.auto_apply_edits;
261 let desired = match action.as_str() {
262 "" | "toggle" => Some(!current),
263 "status" => None,
264 "on" | "true" | "yes" | "enable" | "enabled" => Some(true),
265 "off" | "false" | "no" | "disable" | "disabled" => Some(false),
266 _ => {
267 app.state.status = "Usage: /autoapply [on|off|toggle|status]".to_string();
268 return;
269 }
270 };
271
272 if let Some(next) = desired {
273 set_auto_apply_edits(app, session, next).await;
274 } else {
275 app.state.status = auto_apply_status_message(current);
276 }
277 return;
278 }
279
280 if let Some(rest) = command_with_optional_args(&normalized, "/network") {
281 let current = app.state.allow_network;
282 let desired = match rest.trim().to_ascii_lowercase().as_str() {
283 "" | "toggle" => Some(!current),
284 "status" => None,
285 "on" | "true" | "yes" | "enable" | "enabled" => Some(true),
286 "off" | "false" | "no" | "disable" | "disabled" => Some(false),
287 _ => {
288 app.state.status = "Usage: /network [on|off|toggle|status]".to_string();
289 return;
290 }
291 };
292
293 if let Some(next) = desired {
294 set_network_access(app, session, next).await;
295 } else {
296 app.state.status = network_access_status_message(current);
297 }
298 return;
299 }
300
301 if let Some(rest) = command_with_optional_args(&normalized, "/autocomplete") {
302 let current = app.state.slash_autocomplete;
303 let desired = match rest.trim().to_ascii_lowercase().as_str() {
304 "" | "toggle" => Some(!current),
305 "status" => None,
306 "on" | "true" | "yes" | "enable" | "enabled" => Some(true),
307 "off" | "false" | "no" | "disable" | "disabled" => Some(false),
308 _ => {
309 app.state.status = "Usage: /autocomplete [on|off|toggle|status]".to_string();
310 return;
311 }
312 };
313
314 if let Some(next) = desired {
315 set_slash_autocomplete(app, session, next).await;
316 } else {
317 app.state.status = autocomplete_status_message(current);
318 }
319 return;
320 }
321
322 if let Some(rest) = command_with_optional_args(&normalized, "/steer") {
323 let value = rest.trim();
324 if value.eq_ignore_ascii_case("clear") {
325 app.state.clear_steering();
326 app.state.status = "Cleared queued steering".to_string();
327 push_system_message(app, "Cleared queued steering.");
328 } else if value.eq_ignore_ascii_case("status") || value.is_empty() {
329 let count = app.state.steering_count();
330 app.state.status = format!("Queued steering: {count}");
331 let body = if count == 0 {
332 "No queued steering.".to_string()
333 } else {
334 app.state
335 .queued_steering
336 .iter()
337 .enumerate()
338 .map(|(idx, item)| format!("{}. {item}", idx + 1))
339 .collect::<Vec<_>>()
340 .join("\n")
341 };
342 push_system_message(app, format!("Queued steering\n{body}"));
343 } else {
344 app.state.queue_steering(value);
345 let count = app.state.steering_count();
346 app.state.status = format!("Queued steering ({count}) for next turn");
347 push_system_message(app, format!("Queued steering for next turn: {value}"));
348 }
349 return;
350 }
351
352 if let Some(rest) = command_with_optional_args(&normalized, "/mcp") {
353 handle_mcp_command(app, rest).await;
354 return;
355 }
356
357 match normalized.as_str() {
358 "/help" => {
359 app.state.show_help = true;
360 app.state.help_scroll.offset = 0;
361 app.state.status = "Help".to_string();
362 }
363 "/sessions" | "/session" => {
364 refresh_sessions(app, cwd).await;
365 app.state.clear_session_filter();
366 app.state.set_view_mode(ViewMode::Sessions);
367 app.state.status = "Session picker".to_string();
368 }
369 "/import-codex" => {
370 codex_sessions::import_workspace_sessions(app, cwd).await;
371 }
372 "/swarm" => {
373 app.state.swarm.mark_active("TUI swarm monitor");
374 app.state.set_view_mode(ViewMode::Swarm);
375 }
376 "/ralph" => {
377 app.state
378 .ralph
379 .mark_active(app.state.cwd_display.clone(), "TUI Ralph monitor");
380 app.state.set_view_mode(ViewMode::Ralph);
381 }
382 "/bus" | "/protocol" => {
383 app.state.set_view_mode(ViewMode::Bus);
384 app.state.status = "Protocol bus log".to_string();
385 }
386 "/model" => open_model_picker(app, session, registry).await,
387 "/settings" => app.state.set_view_mode(ViewMode::Settings),
388 "/lsp" => app.state.set_view_mode(ViewMode::Lsp),
389 "/rlm" => app.state.set_view_mode(ViewMode::Rlm),
390 "/latency" => {
391 app.state.set_view_mode(ViewMode::Latency);
392 app.state.status = "Latency inspector".to_string();
393 }
394 "/inspector" => {
395 app.state.set_view_mode(ViewMode::Inspector);
396 app.state.status = "Inspector".to_string();
397 }
398 "/chat" | "/home" | "/main" => return_to_chat(app),
399 "/webview" => {
400 app.state.chat_layout_mode =
401 crate::tui::ui::webview::layout_mode::ChatLayoutMode::Webview;
402 app.state.status = "Layout: Webview".to_string();
403 }
404 "/classic" => {
405 app.state.chat_layout_mode =
406 crate::tui::ui::webview::layout_mode::ChatLayoutMode::Classic;
407 app.state.status = "Layout: Classic".to_string();
408 }
409 "/symbols" | "/symbol" => {
410 app.state.symbol_search.open();
411 app.state.status = "Symbol search".to_string();
412 }
413 "/new" => {
414 match Session::new().await {
416 Ok(mut new_session) => {
417 if let Err(error) = session.save().await {
420 tracing::warn!(error = %error, "Failed to save current session before /new");
421 app.state.status = format!(
422 "Failed to save current session before creating new session: {error}"
423 );
424 return;
425 }
426
427 new_session.metadata.auto_apply_edits = app.state.auto_apply_edits;
429 new_session.metadata.allow_network = app.state.allow_network;
430 new_session.metadata.slash_autocomplete = app.state.slash_autocomplete;
431 new_session.metadata.use_worktree = app.state.use_worktree;
432 new_session.metadata.model = session.metadata.model.clone();
433
434 *session = new_session;
435 session.attach_global_bus_if_missing();
436 if let Err(error) = session.save().await {
437 tracing::warn!(error = %error, "Failed to save new session");
438 app.state.status =
439 format!("New chat session created, but failed to persist: {error}");
440 } else {
441 app.state.status = "New chat session".to_string();
442 }
443 app.state.session_id = Some(session.id.clone());
444 app.state.messages.clear();
445 app.state.streaming_text.clear();
446 app.state.processing = false;
447 app.state.clear_request_timing();
448 app.state.scroll_to_bottom();
449 app.state.set_view_mode(ViewMode::Chat);
450 refresh_sessions(app, cwd).await;
451 }
452 Err(err) => {
453 app.state.status = format!("Failed to create new session: {err}");
454 }
455 }
456 }
457 "/undo" => {
458 let mut found_user = false;
461 while let Some(msg) = app.state.messages.last() {
462 if matches!(msg.message_type, MessageType::User) {
463 if found_user {
464 break; }
466 found_user = true;
467 }
468 if matches!(msg.message_type, MessageType::System) && !found_user {
470 break;
471 }
472 app.state.messages.pop();
473 }
474
475 if !found_user {
476 push_system_message(app, "Nothing to undo.");
477 return;
478 }
479
480 let mut found_session_user = false;
483 while let Some(msg) = session.messages.last() {
484 if msg.role == crate::provider::Role::User {
485 if found_session_user {
486 break;
487 }
488 found_session_user = true;
489 }
490 if msg.role == crate::provider::Role::System && !found_session_user {
491 break;
492 }
493 session.messages.pop();
494 }
495 if let Err(error) = session.save().await {
496 tracing::warn!(error = %error, "Failed to save session after undo");
497 }
498
499 push_system_message(app, "Undid last message and response.");
500 }
501 "/keys" => {
502 app.state.status =
503 "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"
504 .to_string();
505 }
506 _ => {}
507 }
508
509 if let Some(rest) = command_with_optional_args(&normalized, "/spawn") {
512 handle_spawn_command(app, rest).await;
513 return;
514 }
515
516 if let Some(rest) = command_with_optional_args(&normalized, "/kill") {
517 handle_kill_command(app, rest);
518 return;
519 }
520
521 if command_with_optional_args(&normalized, "/agents").is_some() {
522 handle_agents_command(app);
523 return;
524 }
525
526 if let Some(rest) = command_with_optional_args(&normalized, "/autochat") {
527 handle_autochat_command(app, rest);
528 return;
529 }
530
531 if !matches!(
534 normalized.as_str(),
535 "/help"
536 | "/sessions"
537 | "/import-codex"
538 | "/session"
539 | "/swarm"
540 | "/ralph"
541 | "/bus"
542 | "/protocol"
543 | "/model"
544 | "/settings"
545 | "/lsp"
546 | "/rlm"
547 | "/latency"
548 | "/chat"
549 | "/home"
550 | "/main"
551 | "/symbols"
552 | "/symbol"
553 | "/new"
554 | "/undo"
555 | "/keys"
556 | "/file"
557 | "/image"
558 | "/autoapply"
559 | "/network"
560 | "/autocomplete"
561 | "/mcp"
562 | "/spawn"
563 | "/kill"
564 | "/agents"
565 | "/agent"
566 | "/autochat"
567 ) {
568 app.state.status = format!("Unknown command: {normalized}");
569 }
570}
571
572async fn handle_spawn_command(app: &mut App, rest: &str) {
573 let rest = rest.trim();
574 if rest.is_empty() {
575 app.state.status = "Usage: /spawn <name> [instructions]".to_string();
576 return;
577 }
578
579 let mut parts = rest.splitn(2, char::is_whitespace);
580 let Some(name) = parts.next().filter(|s| !s.is_empty()) else {
581 app.state.status = "Usage: /spawn <name> [instructions]".to_string();
582 return;
583 };
584
585 if app.state.spawned_agents.contains_key(name) {
586 app.state.status = format!("Agent '{name}' already exists. Use /kill {name} first.");
587 push_system_message(app, format!("Agent '{name}' already exists."));
588 return;
589 }
590
591 let instructions = parts.next().unwrap_or("").trim().to_string();
592 let profile = agent_profile(name);
593
594 let system_prompt = if instructions.is_empty() {
595 format!(
596 "You are an AI assistant codenamed '{}' ({}) working as a sub-agent.
597 Personality: {}
598 Collaboration style: {}
599 Signature move: {}",
600 profile.codename,
601 profile.profile,
602 profile.personality,
603 profile.collaboration_style,
604 profile.signature_move,
605 )
606 } else {
607 instructions.clone()
608 };
609
610 match Session::new().await {
611 Ok(mut agent_session) => {
612 agent_session.agent = format!("spawned:{}", name);
613 agent_session.messages.push(crate::provider::Message {
614 role: crate::provider::Role::System,
615 content: vec![crate::provider::ContentPart::Text {
616 text: system_prompt,
617 }],
618 });
619
620 if let Err(e) = agent_session.save().await {
623 tracing::warn!(error = %e, "Failed to save spawned agent session");
624 }
625
626 let display_name = if instructions.is_empty() {
627 format!("{} [{}]", name, profile.codename)
628 } else {
629 name.to_string()
630 };
631
632 app.state.spawned_agents.insert(
633 name.to_string(),
634 SpawnedAgent {
635 name: display_name.clone(),
636 instructions,
637 session: agent_session,
638 is_processing: false,
639 },
640 );
641
642 app.state.status = format!("Spawned agent: {display_name}");
643 push_system_message(
644 app,
645 format!(
646 "Spawned agent '{}' [{}] — ready for messages.",
647 name, profile.codename
648 ),
649 );
650 }
651 Err(error) => {
652 app.state.status = format!("Failed to create agent session: {error}");
653 push_system_message(app, format!("Failed to spawn agent '{name}': {error}"));
654 }
655 }
656}
657
658fn handle_kill_command(app: &mut App, rest: &str) {
659 let name = rest.trim();
660 if name.is_empty() {
661 app.state.status = "Usage: /kill <name>".to_string();
662 return;
663 }
664
665 if app.state.spawned_agents.remove(name).is_some() {
666 if app.state.active_spawned_agent.as_deref() == Some(name) {
667 app.state.active_spawned_agent = None;
668 }
669 app.state.streaming_agent_texts.remove(name);
670 app.state.status = format!("Agent '{name}' removed.");
671 push_system_message(app, format!("Agent '{name}' has been shut down."));
672 } else {
673 app.state.status = format!("Agent '{name}' not found.");
674 }
675}
676
677fn handle_agents_command(app: &mut App) {
678 if app.state.spawned_agents.is_empty() {
679 app.state.status = "No spawned agents.".to_string();
680 push_system_message(app, "No spawned agents. Use /spawn <name> to create one.");
681 } else {
682 let count = app.state.spawned_agents.len();
683 let lines: Vec<String> = app
684 .state
685 .spawned_agents
686 .iter()
687 .map(|(key, agent)| {
688 let msg_count = agent.session.messages.len();
689 let model = agent.session.metadata.model.as_deref().unwrap_or("default");
690 let active = if app.state.active_spawned_agent.as_deref() == Some(key) {
691 " [active]"
692 } else {
693 ""
694 };
695 format!(
696 " {}{} — {} messages — model: {}",
697 agent.name, active, msg_count, model
698 )
699 })
700 .collect();
701
702 let body = lines.join(
703 "
704",
705 );
706 app.state.status = format!("{count} spawned agent(s)");
707 push_system_message(
708 app,
709 format!(
710 "Spawned agents ({count}):
711{body}"
712 ),
713 );
714 }
715}
716async fn handle_go_command(
717 app: &mut App,
718 session: &mut Session,
719 _registry: Option<&Arc<ProviderRegistry>>,
720 rest: &str,
721) {
722 use crate::tui::app::okr_gate::{PendingOkrApproval, ensure_okr_repository, next_go_model};
723 use crate::tui::constants::AUTOCHAT_MAX_AGENTS;
724
725 let task = rest.trim();
726 if task.is_empty() {
727 app.state.status = "Usage: /go <task description>".to_string();
728 return;
729 }
730
731 let current_model = session.metadata.model.as_deref();
733 let model = next_go_model(current_model);
734 session.metadata.model = Some(model.clone());
735 if let Err(error) = session.save().await {
736 tracing::warn!(error = %error, "Failed to save session after model swap");
737 }
738
739 ensure_okr_repository(&mut app.state.okr_repository).await;
741
742 let pending = PendingOkrApproval::propose(task.to_string(), AUTOCHAT_MAX_AGENTS, model).await;
744
745 push_system_message(app, pending.approval_prompt());
746
747 app.state.pending_okr_approval = Some(pending);
748 app.state.status = "OKR draft awaiting approval \u{2014} [A]pprove or [D]eny".to_string();
749}
750
751fn handle_autochat_command(app: &mut App, rest: &str) {
752 let task = rest.trim().to_string();
753 if task.is_empty() {
754 app.state.status = "Usage: /autochat <task description>".to_string();
755 return;
756 }
757 if app.state.autochat.running {
758 app.state.status = "Autochat relay already running.".to_string();
759 return;
760 }
761 let model = app.state.last_completion_model.clone().unwrap_or_default();
762 let rx = super::autochat::worker::start_autochat_relay(task, model);
763 app.state.autochat.running = true;
764 app.state.autochat.rx = Some(rx);
765 app.state.status = "Autochat relay started.".to_string();
766}