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