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
202async fn handle_goal_command(app: &mut App, session: &Session, rest: &str) {
211 use crate::session::tasks::{TaskEvent, TaskLog, TaskState, governance_block};
212 use chrono::Utc;
213
214 let log = match TaskLog::for_session(&session.id) {
215 Ok(l) => l,
216 Err(e) => {
217 app.state.status = format!("/goal: {e}");
218 return;
219 }
220 };
221
222 let rest = rest.trim();
223 let (verb, tail) = match rest.split_once(char::is_whitespace) {
224 Some((v, t)) => (v, t.trim()),
225 None => (rest, ""),
226 };
227
228 let event = match verb {
229 "" | "show" | "status" => {
230 let events = log.read_all().await.unwrap_or_default();
231 let state = TaskState::from_log(&events);
232 let text = governance_block(&state)
233 .unwrap_or_else(|| "No goal and no tasks for this session.".to_string());
234 push_system_message(app, text);
235 app.state.status = "Goal shown".to_string();
236 return;
237 }
238 "set" => {
239 if tail.is_empty() {
240 app.state.status = "Usage: /goal set <objective>".to_string();
241 return;
242 }
243 TaskEvent::GoalSet {
244 at: Utc::now(),
245 objective: tail.to_string(),
246 success_criteria: Vec::new(),
247 forbidden: Vec::new(),
248 }
249 }
250 "done" | "clear" => TaskEvent::GoalCleared {
251 at: Utc::now(),
252 reason: if tail.is_empty() {
253 "completed".to_string()
254 } else {
255 tail.to_string()
256 },
257 },
258 "reaffirm" => {
259 if tail.is_empty() {
260 app.state.status = "Usage: /goal reaffirm <progress note>".to_string();
261 return;
262 }
263 TaskEvent::GoalReaffirmed {
264 at: Utc::now(),
265 progress_note: tail.to_string(),
266 }
267 }
268 other => {
269 app.state.status =
270 format!("Unknown /goal subcommand `{other}`. Try: set | done | reaffirm | show");
271 return;
272 }
273 };
274
275 match log.append(&event).await {
276 Ok(()) => {
277 let summary = match &event {
278 TaskEvent::GoalSet { objective, .. } => format!("Goal set: {objective}"),
279 TaskEvent::GoalCleared { reason, .. } => format!("Goal cleared: {reason}"),
280 TaskEvent::GoalReaffirmed { progress_note, .. } => {
281 format!("Goal reaffirmed: {progress_note}")
282 }
283 _ => "Goal updated".to_string(),
284 };
285 push_system_message(app, summary.clone());
286 app.state.status = summary;
287 }
288 Err(e) => {
289 app.state.status = format!("/goal write failed: {e}");
290 }
291 }
292}
293
294async fn handle_undo_command(app: &mut App, session: &mut Session, rest: &str) {
301 if app.state.processing {
302 push_system_message(
303 app,
304 "Cannot undo while a response is in progress. Press Esc to cancel first.",
305 );
306 return;
307 }
308
309 let n: usize = match rest.trim() {
310 "" => 1,
311 s => match s.parse::<usize>() {
312 Ok(v) if v >= 1 => v,
313 _ => {
314 app.state.status =
315 "Usage: /undo [N] (N = how many turns to undo, default 1)".to_string();
316 return;
317 }
318 },
319 };
320
321 let session_user_idxs: Vec<usize> = session
325 .messages
326 .iter()
327 .enumerate()
328 .filter_map(|(i, m)| (m.role == crate::provider::Role::User).then_some(i))
329 .collect();
330 let tui_user_idxs: Vec<usize> = app
331 .state
332 .messages
333 .iter()
334 .enumerate()
335 .filter_map(|(i, m)| matches!(m.message_type, MessageType::User).then_some(i))
336 .collect();
337
338 if session_user_idxs.is_empty() || tui_user_idxs.is_empty() {
339 push_system_message(app, "Nothing to undo.");
340 return;
341 }
342
343 let available = session_user_idxs.len().min(tui_user_idxs.len());
344 let undo_count = n.min(available);
345
346 let s_cut = session_user_idxs[available - undo_count];
348 let t_cut = tui_user_idxs[available - undo_count];
349
350 session.messages.truncate(s_cut);
351 session.pages.truncate(s_cut);
352 app.state.messages.truncate(t_cut);
353 session.updated_at = chrono::Utc::now();
354 app.state.streaming_text.clear();
355 app.state.scroll_to_bottom();
356
357 if let Err(error) = session.save().await {
358 tracing::warn!(error = %error, "Failed to save session after undo");
359 app.state.status = format!("Undid {undo_count} turn(s) (not persisted: {error})");
360 } else {
361 app.state.status = format!("Undid {undo_count} turn(s)");
362 }
363
364 let partial_note = if undo_count < n {
365 format!(" (only {undo_count} available)")
366 } else {
367 String::new()
368 };
369 push_system_message(app, format!("Undid {undo_count} turn(s){partial_note}."));
370}
371
372async fn handle_fork_command(app: &mut App, _cwd: &Path, session: &mut Session, rest: &str) {
381 if app.state.processing {
382 push_system_message(
383 app,
384 "Cannot fork while a response is in progress. Press Esc to cancel first.",
385 );
386 return;
387 }
388
389 let drop_last_n: usize = match rest.trim() {
390 "" => 0,
391 s => match s.parse::<usize>() {
392 Ok(v) => v,
393 Err(_) => {
394 app.state.status =
395 "Usage: /fork [N] (drop last N user turns from the fork; default 0)"
396 .to_string();
397 return;
398 }
399 },
400 };
401
402 if let Err(error) = session.save().await {
404 app.state.status = format!("Fork aborted: failed to save current session: {error}");
405 return;
406 }
407
408 let parent_id = session.id.clone();
409
410 let mut child = match Session::new().await {
412 Ok(s) => s,
413 Err(err) => {
414 app.state.status = format!("Fork failed: {err}");
415 return;
416 }
417 };
418
419 let session_user_idxs: Vec<usize> = session
421 .messages
422 .iter()
423 .enumerate()
424 .filter_map(|(i, m)| (m.role == crate::provider::Role::User).then_some(i))
425 .collect();
426 let session_cut = if drop_last_n == 0 || drop_last_n > session_user_idxs.len() {
427 session.messages.len()
428 } else {
429 session_user_idxs[session_user_idxs.len() - drop_last_n]
430 };
431
432 let tui_user_idxs: Vec<usize> = app
433 .state
434 .messages
435 .iter()
436 .enumerate()
437 .filter_map(|(i, m)| matches!(m.message_type, MessageType::User).then_some(i))
438 .collect();
439 let tui_cut = if drop_last_n == 0 || drop_last_n > tui_user_idxs.len() {
440 app.state.messages.len()
441 } else {
442 tui_user_idxs[tui_user_idxs.len() - drop_last_n]
443 };
444
445 child.messages = session.messages[..session_cut].to_vec();
446 child.pages = if session.pages.len() >= session_cut {
447 session.pages[..session_cut].to_vec()
448 } else {
449 crate::session::pages::classify_all(&child.messages)
450 };
451 child.metadata.auto_apply_edits = session.metadata.auto_apply_edits;
452 child.metadata.allow_network = session.metadata.allow_network;
453 child.metadata.slash_autocomplete = session.metadata.slash_autocomplete;
454 child.metadata.use_worktree = session.metadata.use_worktree;
455 child.metadata.model = session.metadata.model.clone();
456 child.metadata.rlm = session.metadata.rlm.clone();
457 child.metadata.context_policy = session.metadata.context_policy;
458 child.metadata.delegation = session.metadata.delegation.clone();
459 child.metadata.history_sink = session.metadata.history_sink.clone();
460 child.title = session
461 .title
462 .as_ref()
463 .map(|t| format!("{t} (fork)"))
464 .or_else(|| Some("fork".to_string()));
465
466 let child_id = child.id.clone();
468 *session = child;
469 session.attach_global_bus_if_missing();
470
471 if let Err(error) = session.save().await {
472 app.state.status = format!("Fork created but failed to persist: {error}");
473 return;
474 }
475
476 let forked_tui = app.state.messages[..tui_cut].to_vec();
478 app.state.messages = forked_tui;
479 app.state.session_id = Some(session.id.clone());
480 app.state.streaming_text.clear();
481 app.state.clear_request_timing();
482 app.state.scroll_to_bottom();
483 app.state.set_view_mode(ViewMode::Chat);
484
485 let drop_note = if drop_last_n == 0 {
486 String::new()
487 } else {
488 format!(" (dropped last {drop_last_n} turn(s) from fork)")
489 };
490 push_system_message(
491 app,
492 format!(
493 "Forked session {}{}.\n parent: {}\n fork: {}",
494 &child_id[..8.min(child_id.len())],
495 drop_note,
496 parent_id,
497 child_id,
498 ),
499 );
500 app.state.status = format!("Forked → {}", &child_id[..8.min(child_id.len())]);
501}
502
503async fn handle_ralph_subcommand(
509 app: &mut App,
510 cwd: &Path,
511 session: &Session,
512 registry: Option<&Arc<ProviderRegistry>>,
513 rest: &str,
514) -> bool {
515 let rest = rest.trim();
516 if rest.is_empty() {
517 return false;
519 }
520
521 let mut parts = rest.split_whitespace();
522 let verb = parts.next().unwrap_or("");
523 let args: Vec<&str> = parts.collect();
524
525 match verb {
526 "run" => {
527 let (prd_arg, max_iters) = parse_ralph_run_args(&args);
528
529 let Some(registry) = registry.cloned() else {
530 app.state.status = "Ralph run failed: no provider registry available".to_string();
531 return true;
532 };
533
534 let prd_path = resolve_prd_path(cwd, prd_arg);
535 if !prd_path.exists() {
536 app.state.status =
537 format!("Ralph run failed: PRD not found at {}", prd_path.display());
538 return true;
539 }
540
541 let model_str = session
542 .metadata
543 .model
544 .as_deref()
545 .unwrap_or("claude-sonnet-4-5");
546 let (provider, model) = match registry.resolve_model(model_str) {
547 Ok(pair) => pair,
548 Err(err) => {
549 app.state.status = format!("Ralph run failed: {err}");
550 return true;
551 }
552 };
553
554 let (tx, rx) = tokio::sync::mpsc::channel(256);
555 app.state.ralph.attach_event_rx(rx);
556 app.state.set_view_mode(ViewMode::Ralph);
557 app.state.status = format!(
558 "Ralph running: {} (max {max_iters} iterations)",
559 prd_path.display()
560 );
561 push_system_message(
562 app,
563 format!(
564 "Launching Ralph on `{}` via model `{model}` (max {max_iters} iterations).",
565 prd_path.display()
566 ),
567 );
568
569 spawn_ralph_run(prd_path, provider, model, max_iters, tx);
570 true
571 }
572 "status" => {
573 let stories = &app.state.ralph.stories;
574 if stories.is_empty() {
575 app.state.status = "No Ralph run attached".to_string();
576 } else {
577 let passed = stories
578 .iter()
579 .filter(|s| {
580 matches!(s.status, crate::tui::ralph_view::RalphStoryStatus::Passed)
581 })
582 .count();
583 app.state.status = format!(
584 "Ralph: {}/{} stories passed (iteration {}/{})",
585 passed,
586 stories.len(),
587 app.state.ralph.current_iteration,
588 app.state.ralph.max_iterations,
589 );
590 }
591 true
592 }
593 _ => {
594 app.state.status =
595 "Usage: /ralph [run <prd.json> [--iters N]] | /ralph status".to_string();
596 true
597 }
598 }
599}
600
601fn parse_ralph_run_args<'a>(args: &[&'a str]) -> (Option<&'a str>, usize) {
605 let mut prd: Option<&str> = None;
606 let mut iters: usize = 10;
607 let mut i = 0;
608 while i < args.len() {
609 match args[i] {
610 "--iters" | "--max-iterations" => {
611 if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
612 iters = v;
613 i += 2;
614 continue;
615 }
616 }
617 other if !other.starts_with("--") && prd.is_none() => {
618 prd = Some(other);
619 }
620 _ => {}
621 }
622 i += 1;
623 }
624 (prd, iters)
625}
626
627fn resolve_prd_path(cwd: &Path, arg: Option<&str>) -> std::path::PathBuf {
629 let raw = arg.unwrap_or("prd.json");
630 let path = Path::new(raw);
631 if path.is_absolute() {
632 path.to_path_buf()
633 } else {
634 cwd.join(path)
635 }
636}
637
638fn spawn_ralph_run(
643 prd_path: std::path::PathBuf,
644 provider: Arc<dyn crate::provider::Provider>,
645 model: String,
646 max_iters: usize,
647 event_tx: tokio::sync::mpsc::Sender<crate::tui::ralph_view::RalphEvent>,
648) {
649 tokio::spawn(async move {
650 use crate::ralph::{RalphConfig, RalphLoop};
651
652 let config = RalphConfig {
653 prd_path: prd_path.to_string_lossy().to_string(),
654 max_iterations: max_iters,
655 model: Some(model.clone()),
656 ..Default::default()
657 };
658
659 let mut ralph = match RalphLoop::new(prd_path.clone(), provider, model, config).await {
660 Ok(r) => r.with_event_tx(event_tx.clone()),
661 Err(err) => {
662 let _ = event_tx
663 .send(crate::tui::ralph_view::RalphEvent::Error(format!(
664 "Failed to initialise Ralph: {err}"
665 )))
666 .await;
667 return;
668 }
669 };
670
671 if let Err(err) = ralph.run().await {
672 let _ = event_tx
673 .send(crate::tui::ralph_view::RalphEvent::Error(format!(
674 "Ralph loop errored: {err}"
675 )))
676 .await;
677 }
678 });
679}
680
681pub async fn handle_slash_command(
682 app: &mut App,
683 cwd: &std::path::Path,
684 session: &mut Session,
685 registry: Option<&Arc<ProviderRegistry>>,
686 command: &str,
687) {
688 let normalized = normalize_easy_command(command);
689 let normalized = normalize_slash_command(&normalized);
690
691 if let Some(rest) = command_with_optional_args(&normalized, "/image") {
692 let cleaned = rest.trim().trim_matches(|c| c == '"' || c == '\'');
693 if cleaned.is_empty() {
694 app.state.status =
695 "Usage: /image <path> (png, jpg, jpeg, gif, webp, bmp, svg).".to_string();
696 } else {
697 let path = Path::new(cleaned);
698 let resolved = if path.is_absolute() {
699 path.to_path_buf()
700 } else {
701 cwd.join(path)
702 };
703 match crate::tui::app::input::attach_image_file(&resolved) {
704 Ok(attachment) => {
705 let display = resolved.display();
706 app.state.pending_images.push(attachment);
707 let count = app.state.pending_images.len();
708 app.state.status = format!(
709 "📷 Attached {display}. {count} image(s) pending. Press Enter to send."
710 );
711 push_system_message(
712 app,
713 format!(
714 "📷 Image attached: {display}. Type a message and press Enter to send."
715 ),
716 );
717 }
718 Err(msg) => {
719 push_system_message(app, format!("Failed to attach image: {msg}"));
720 }
721 }
722 }
723 return;
724 }
725
726 if let Some(rest) = command_with_optional_args(&normalized, "/file") {
727 let cleaned = rest.trim().trim_matches(|c| c == '"' || c == '\'');
728 if cleaned.is_empty() {
729 app.state.status =
730 "Usage: /file <path> (relative to workspace or absolute).".to_string();
731 } else {
732 attach_file_to_input(app, cwd, Path::new(cleaned));
733 }
734 return;
735 }
736
737 if let Some(rest) = command_with_optional_args(&normalized, "/autoapply") {
738 let action = rest.trim().to_ascii_lowercase();
739 let current = app.state.auto_apply_edits;
740 let desired = match action.as_str() {
741 "" | "toggle" => Some(!current),
742 "status" => None,
743 "on" | "true" | "yes" | "enable" | "enabled" => Some(true),
744 "off" | "false" | "no" | "disable" | "disabled" => Some(false),
745 _ => {
746 app.state.status = "Usage: /autoapply [on|off|toggle|status]".to_string();
747 return;
748 }
749 };
750
751 if let Some(next) = desired {
752 set_auto_apply_edits(app, session, next).await;
753 } else {
754 app.state.status = auto_apply_status_message(current);
755 }
756 return;
757 }
758
759 if let Some(rest) = command_with_optional_args(&normalized, "/network") {
760 let current = app.state.allow_network;
761 let desired = match rest.trim().to_ascii_lowercase().as_str() {
762 "" | "toggle" => Some(!current),
763 "status" => None,
764 "on" | "true" | "yes" | "enable" | "enabled" => Some(true),
765 "off" | "false" | "no" | "disable" | "disabled" => Some(false),
766 _ => {
767 app.state.status = "Usage: /network [on|off|toggle|status]".to_string();
768 return;
769 }
770 };
771
772 if let Some(next) = desired {
773 set_network_access(app, session, next).await;
774 } else {
775 app.state.status = network_access_status_message(current);
776 }
777 return;
778 }
779
780 if let Some(rest) = command_with_optional_args(&normalized, "/autocomplete") {
781 let current = app.state.slash_autocomplete;
782 let desired = match rest.trim().to_ascii_lowercase().as_str() {
783 "" | "toggle" => Some(!current),
784 "status" => None,
785 "on" | "true" | "yes" | "enable" | "enabled" => Some(true),
786 "off" | "false" | "no" | "disable" | "disabled" => Some(false),
787 _ => {
788 app.state.status = "Usage: /autocomplete [on|off|toggle|status]".to_string();
789 return;
790 }
791 };
792
793 if let Some(next) = desired {
794 set_slash_autocomplete(app, session, next).await;
795 } else {
796 app.state.status = autocomplete_status_message(current);
797 }
798 return;
799 }
800
801 if let Some(rest) = command_with_optional_args(&normalized, "/ask") {
802 let question = rest.trim();
803 if question.is_empty() {
804 app.state.status =
805 "Usage: /ask <question> — ephemeral side question (full context, no tools, not saved)"
806 .to_string();
807 push_system_message(
808 app,
809 "`/ask <question>` runs an ephemeral side query with full context but no tools, and is not saved to the session.",
810 );
811 return;
812 }
813 super::ask::run_ask(app, session, registry, question).await;
814 return;
815 }
816
817 if let Some(rest) = command_with_optional_args(&normalized, "/mcp") {
818 handle_mcp_command(app, rest).await;
819 return;
820 }
821
822 if let Some(rest) = command_with_optional_args(&normalized, "/ralph") {
823 if handle_ralph_subcommand(app, cwd, session, registry, rest).await {
824 return;
825 }
826 }
828
829 if let Some(rest) = command_with_optional_args(&normalized, "/goal") {
830 handle_goal_command(app, session, rest).await;
831 return;
832 }
833
834 if let Some(rest) = command_with_optional_args(&normalized, "/undo") {
835 handle_undo_command(app, session, rest).await;
836 return;
837 }
838
839 if let Some(rest) = command_with_optional_args(&normalized, "/fork") {
840 handle_fork_command(app, cwd, session, rest).await;
841 return;
842 }
843
844 match normalized.as_str() {
845 "/help" => {
846 app.state.show_help = true;
847 app.state.help_scroll.offset = 0;
848 app.state.status = "Help".to_string();
849 }
850 "/sessions" | "/session" => {
851 refresh_sessions(app, cwd).await;
852 app.state.clear_session_filter();
853 app.state.set_view_mode(ViewMode::Sessions);
854 app.state.status = "Session picker".to_string();
855 }
856 "/import-codex" => {
857 codex_sessions::import_workspace_sessions(app, cwd).await;
858 }
859 "/swarm" => {
860 app.state.swarm.mark_active("TUI swarm monitor");
861 app.state.set_view_mode(ViewMode::Swarm);
862 }
863 "/ralph" => {
864 app.state
865 .ralph
866 .mark_active(app.state.cwd_display.clone(), "TUI Ralph monitor");
867 app.state.set_view_mode(ViewMode::Ralph);
868 }
869 "/bus" | "/protocol" => {
870 app.state.set_view_mode(ViewMode::Bus);
871 app.state.status = "Protocol bus log".to_string();
872 }
873 "/model" => open_model_picker(app, session, registry).await,
874 "/settings" => app.state.set_view_mode(ViewMode::Settings),
875 "/lsp" => app.state.set_view_mode(ViewMode::Lsp),
876 "/rlm" => app.state.set_view_mode(ViewMode::Rlm),
877 "/latency" => {
878 app.state.set_view_mode(ViewMode::Latency);
879 app.state.status = "Latency inspector".to_string();
880 }
881 "/inspector" => {
882 app.state.set_view_mode(ViewMode::Inspector);
883 app.state.status = "Inspector".to_string();
884 }
885 "/audit" => {
886 crate::tui::audit_view::refresh_audit_snapshot(&mut app.state.audit).await;
887 app.state.set_view_mode(ViewMode::Audit);
888 app.state.status = "Audit — subagent activity".to_string();
889 }
890 "/chat" | "/home" | "/main" => return_to_chat(app),
891 "/webview" => {
892 app.state.chat_layout_mode =
893 crate::tui::ui::webview::layout_mode::ChatLayoutMode::Webview;
894 app.state.status = "Layout: Webview".to_string();
895 }
896 "/classic" => {
897 app.state.chat_layout_mode =
898 crate::tui::ui::webview::layout_mode::ChatLayoutMode::Classic;
899 app.state.status = "Layout: Classic".to_string();
900 }
901 "/symbols" | "/symbol" => {
902 app.state.symbol_search.open();
903 app.state.status = "Symbol search".to_string();
904 }
905 "/new" => {
906 match Session::new().await {
908 Ok(mut new_session) => {
909 if let Err(error) = session.save().await {
912 tracing::warn!(error = %error, "Failed to save current session before /new");
913 app.state.status = format!(
914 "Failed to save current session before creating new session: {error}"
915 );
916 return;
917 }
918
919 new_session.metadata.auto_apply_edits = app.state.auto_apply_edits;
921 new_session.metadata.allow_network = app.state.allow_network;
922 new_session.metadata.slash_autocomplete = app.state.slash_autocomplete;
923 new_session.metadata.use_worktree = app.state.use_worktree;
924 new_session.metadata.model = session.metadata.model.clone();
925
926 *session = new_session;
927 session.attach_global_bus_if_missing();
928 if let Err(error) = session.save().await {
929 tracing::warn!(error = %error, "Failed to save new session");
930 app.state.status =
931 format!("New chat session created, but failed to persist: {error}");
932 } else {
933 app.state.status = "New chat session".to_string();
934 }
935 app.state.session_id = Some(session.id.clone());
936 app.state.messages.clear();
937 app.state.streaming_text.clear();
938 app.state.processing = false;
939 app.state.clear_request_timing();
940 app.state.scroll_to_bottom();
941 app.state.set_view_mode(ViewMode::Chat);
942 refresh_sessions(app, cwd).await;
943 }
944 Err(err) => {
945 app.state.status = format!("Failed to create new session: {err}");
946 }
947 }
948 }
949 "/keys" => {
950 app.state.status =
951 "Protocol-first commands: /protocol /bus /file /autoapply /network /autocomplete /mcp /model /sessions /import-codex /swarm /ralph /latency /symbols /settings /lsp /rlm /chat /new /undo /fork /spawn /kill /agents /agent\nEasy aliases: /add /talk /list /remove /focus /home /say /ls /rm /main"
952 .to_string();
953 }
954 _ => {}
955 }
956
957 if let Some(rest) = command_with_optional_args(&normalized, "/spawn") {
960 handle_spawn_command(app, rest).await;
961 return;
962 }
963
964 if let Some(rest) = command_with_optional_args(&normalized, "/kill") {
965 handle_kill_command(app, rest);
966 return;
967 }
968
969 if command_with_optional_args(&normalized, "/agents").is_some() {
970 handle_agents_command(app);
971 return;
972 }
973
974 if let Some(rest) = command_with_optional_args(&normalized, "/autochat") {
975 handle_autochat_command(app, rest);
976 return;
977 }
978
979 if !matches!(
982 normalized.as_str(),
983 "/help"
984 | "/sessions"
985 | "/import-codex"
986 | "/session"
987 | "/swarm"
988 | "/ralph"
989 | "/bus"
990 | "/protocol"
991 | "/model"
992 | "/settings"
993 | "/lsp"
994 | "/rlm"
995 | "/latency"
996 | "/audit"
997 | "/chat"
998 | "/home"
999 | "/main"
1000 | "/symbols"
1001 | "/symbol"
1002 | "/new"
1003 | "/undo"
1004 | "/keys"
1005 | "/file"
1006 | "/image"
1007 | "/autoapply"
1008 | "/network"
1009 | "/autocomplete"
1010 | "/mcp"
1011 | "/spawn"
1012 | "/kill"
1013 | "/agents"
1014 | "/agent"
1015 | "/autochat"
1016 | "/protocols"
1017 | "/registry"
1018 ) {
1019 app.state.status = format!("Unknown command: {normalized}");
1020 }
1021}
1022
1023async fn handle_spawn_command(app: &mut App, rest: &str) {
1024 let rest = rest.trim();
1025 if rest.is_empty() {
1026 app.state.status = "Usage: /spawn <name> [instructions]".to_string();
1027 return;
1028 }
1029
1030 let mut parts = rest.splitn(2, char::is_whitespace);
1031 let Some(name) = parts.next().filter(|s| !s.is_empty()) else {
1032 app.state.status = "Usage: /spawn <name> [instructions]".to_string();
1033 return;
1034 };
1035
1036 if app.state.spawned_agents.contains_key(name) {
1037 app.state.status = format!("Agent '{name}' already exists. Use /kill {name} first.");
1038 push_system_message(app, format!("Agent '{name}' already exists."));
1039 return;
1040 }
1041
1042 let instructions = parts.next().unwrap_or("").trim().to_string();
1043 let profile = agent_profile(name);
1044
1045 let system_prompt = if instructions.is_empty() {
1046 format!(
1047 "You are an AI assistant codenamed '{}' ({}) working as a sub-agent.
1048 Personality: {}
1049 Collaboration style: {}
1050 Signature move: {}",
1051 profile.codename,
1052 profile.profile,
1053 profile.personality,
1054 profile.collaboration_style,
1055 profile.signature_move,
1056 )
1057 } else {
1058 instructions.clone()
1059 };
1060
1061 match Session::new().await {
1062 Ok(mut agent_session) => {
1063 agent_session.agent = format!("spawned:{}", name);
1064 agent_session.add_message(crate::provider::Message {
1065 role: crate::provider::Role::System,
1066 content: vec![crate::provider::ContentPart::Text {
1067 text: system_prompt,
1068 }],
1069 });
1070
1071 if let Err(e) = agent_session.save().await {
1074 tracing::warn!(error = %e, "Failed to save spawned agent session");
1075 }
1076
1077 let display_name = if instructions.is_empty() {
1078 format!("{} [{}]", name, profile.codename)
1079 } else {
1080 name.to_string()
1081 };
1082
1083 app.state.spawned_agents.insert(
1084 name.to_string(),
1085 SpawnedAgent {
1086 name: display_name.clone(),
1087 instructions,
1088 session: agent_session,
1089 is_processing: false,
1090 },
1091 );
1092
1093 app.state.status = format!("Spawned agent: {display_name}");
1094 push_system_message(
1095 app,
1096 format!(
1097 "Spawned agent '{}' [{}] — ready for messages.",
1098 name, profile.codename
1099 ),
1100 );
1101 }
1102 Err(error) => {
1103 app.state.status = format!("Failed to create agent session: {error}");
1104 push_system_message(app, format!("Failed to spawn agent '{name}': {error}"));
1105 }
1106 }
1107}
1108
1109fn handle_kill_command(app: &mut App, rest: &str) {
1110 let name = rest.trim();
1111 if name.is_empty() {
1112 app.state.status = "Usage: /kill <name>".to_string();
1113 return;
1114 }
1115
1116 if app.state.spawned_agents.remove(name).is_some() {
1117 if app.state.active_spawned_agent.as_deref() == Some(name) {
1118 app.state.active_spawned_agent = None;
1119 }
1120 app.state.streaming_agent_texts.remove(name);
1121 app.state.status = format!("Agent '{name}' removed.");
1122 push_system_message(app, format!("Agent '{name}' has been shut down."));
1123 } else {
1124 app.state.status = format!("Agent '{name}' not found.");
1125 }
1126}
1127
1128fn handle_agents_command(app: &mut App) {
1129 if app.state.spawned_agents.is_empty() {
1130 app.state.status = "No spawned agents.".to_string();
1131 push_system_message(app, "No spawned agents. Use /spawn <name> to create one.");
1132 } else {
1133 let count = app.state.spawned_agents.len();
1134 let lines: Vec<String> = app
1135 .state
1136 .spawned_agents
1137 .iter()
1138 .map(|(key, agent)| {
1139 let msg_count = agent.session.history().len();
1140 let model = agent.session.metadata.model.as_deref().unwrap_or("default");
1141 let active = if app.state.active_spawned_agent.as_deref() == Some(key) {
1142 " [active]"
1143 } else {
1144 ""
1145 };
1146 format!(
1147 " {}{} — {} messages — model: {}",
1148 agent.name, active, msg_count, model
1149 )
1150 })
1151 .collect();
1152
1153 let body = lines.join(
1154 "
1155",
1156 );
1157 app.state.status = format!("{count} spawned agent(s)");
1158 push_system_message(
1159 app,
1160 format!(
1161 "Spawned agents ({count}):
1162{body}"
1163 ),
1164 );
1165 }
1166}
1167async fn handle_go_command(
1168 app: &mut App,
1169 session: &mut Session,
1170 _registry: Option<&Arc<ProviderRegistry>>,
1171 rest: &str,
1172) {
1173 use crate::tui::app::okr_gate::{PendingOkrApproval, ensure_okr_repository, next_go_model};
1174 use crate::tui::constants::AUTOCHAT_MAX_AGENTS;
1175
1176 let task = rest.trim();
1177 if task.is_empty() {
1178 app.state.status = "Usage: /go <task description>".to_string();
1179 return;
1180 }
1181
1182 let current_model = session.metadata.model.as_deref();
1184 let model = next_go_model(current_model);
1185 session.metadata.model = Some(model.clone());
1186 if let Err(error) = session.save().await {
1187 tracing::warn!(error = %error, "Failed to save session after model swap");
1188 }
1189
1190 ensure_okr_repository(&mut app.state.okr_repository).await;
1192
1193 let pending = PendingOkrApproval::propose(task.to_string(), AUTOCHAT_MAX_AGENTS, model).await;
1195
1196 push_system_message(app, pending.approval_prompt());
1197
1198 app.state.pending_okr_approval = Some(pending);
1199 app.state.status = "OKR draft awaiting approval \u{2014} [A]pprove or [D]eny".to_string();
1200}
1201
1202fn handle_autochat_command(app: &mut App, rest: &str) {
1203 let task = rest.trim().to_string();
1204 if task.is_empty() {
1205 app.state.status = "Usage: /autochat <task description>".to_string();
1206 return;
1207 }
1208 if app.state.autochat.running {
1209 app.state.status = "Autochat relay already running.".to_string();
1210 return;
1211 }
1212 let model = app.state.last_completion_model.clone().unwrap_or_default();
1213 let rx = super::autochat::worker::start_autochat_relay(task, model);
1214 app.state.autochat.running = true;
1215 app.state.autochat.rx = Some(rx);
1216 app.state.status = "Autochat relay started.".to_string();
1217}