Skip to main content

codetether_agent/tui/app/
commands.rs

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
202/// Dispatch a `/goal ...` command — the human operator's manual entry
203/// point for the session task log.
204///
205/// Subcommands:
206/// - `/goal set <objective...>` — set the session objective.
207/// - `/goal done [reason]` — clear the goal.
208/// - `/goal reaffirm <note>` — record progress.
209/// - `/goal show` (or bare `/goal`) — print goal + open tasks.
210async 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
294/// Undo the last `n` user turns in both the TUI and the persisted session.
295///
296/// `rest` may be empty (undo 1 turn) or a positive integer.
297/// Each "turn" is one user message plus all assistant / tool messages that
298/// followed it. Trailing system notices (e.g. a previous "Undid…" line) are
299/// ignored when locating the cut point.
300async 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    // Collect indices of every User message in order; the Nth-from-the-end
322    // is our cut point (truncate to it → drop that user message and
323    // everything after).
324    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    // Index of the (available - undo_count)'th user message = first user turn to drop.
347    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
372/// Fork the current session: create a new session with a copy of the current
373/// conversation (optionally truncated), switch the TUI to it, and leave the
374/// original session untouched on disk.
375///
376/// Usage:
377/// - `/fork` — fork at the current point (copy everything).
378/// - `/fork N` — fork keeping only the first (total - N) turns (i.e. undo N
379///   turns in the *fork* while leaving the parent intact).
380async 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    // Persist current session first — fork must not lose the parent's tail.
403    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    // Build the child session.
411    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    // Compute cut for the fork's copy.
420    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    // Swap in the child session.
467    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    // Switch TUI state to the forked conversation.
477    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
503/// Parse and dispatch a `/ralph ...` subcommand.
504///
505/// Returns `true` when the input matched a recognised subcommand (`run`,
506/// `status`). Returns `false` for the bare `/ralph` (which falls through to
507/// the "open monitor view" branch below).
508async 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        // Bare `/ralph` — let caller open the monitor view.
518        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
601/// Parse positional / flag arguments for `/ralph run`.
602///
603/// Accepts: `[prd_path] [--iters N]` in either order.
604fn 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
627/// Resolve a user-provided PRD path against the working directory.
628fn 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
638/// Spawn a background task that drives a [`RalphLoop`] to completion and
639/// streams events into the TUI through `event_tx`. The sender is dropped on
640/// task exit, which the TUI notices via a disconnected `try_recv` and uses
641/// to detach the receiver.
642fn 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        // Fall through to the bare `/ralph` view-open handler below.
827    }
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            // Create a fresh session so the old one is preserved on disk.
907            match Session::new().await {
908                Ok(mut new_session) => {
909                    // Save the old session first — abort if persistence fails to
910                    // avoid silently discarding the user's conversation.
911                    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                    // Carry over user preferences into the new session.
920                    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    // --- commands with rest arguments handled below the simple match ---
958
959    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 we get here, none of the handlers above matched.
980    // Easy-mode aliases are already normalized before reaching this point,
981    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            // Persist the agent session to disk so it can be recovered if
1072            // the TUI crashes before the agent sends its first prompt.
1073            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    // Rotate model for /go
1183    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    // Initialize OKR repository if needed
1191    ensure_okr_repository(&mut app.state.okr_repository).await;
1192
1193    // Draft OKR and present for approval
1194    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}