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    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
371/// Fork the current session: create a new session with a copy of the current
372/// conversation (optionally truncated), switch the TUI to it, and leave the
373/// original session untouched on disk.
374///
375/// Usage:
376/// - `/fork` — fork at the current point (copy everything).
377/// - `/fork N` — fork keeping only the first (total - N) turns (i.e. undo N
378///   turns in the *fork* while leaving the parent intact).
379async 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    // Persist current session first — fork must not lose the parent's tail.
402    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    // Build the child session.
410    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    // Compute cut for the fork's copy.
419    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    // Swap in the child session.
458    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    // Switch TUI state to the forked conversation.
468    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
494/// Parse and dispatch a `/ralph ...` subcommand.
495///
496/// Returns `true` when the input matched a recognised subcommand (`run`,
497/// `status`). Returns `false` for the bare `/ralph` (which falls through to
498/// the "open monitor view" branch below).
499async 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        // Bare `/ralph` — let caller open the monitor view.
509        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
592/// Parse positional / flag arguments for `/ralph run`.
593///
594/// Accepts: `[prd_path] [--iters N]` in either order.
595fn 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
618/// Resolve a user-provided PRD path against the working directory.
619fn 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
629/// Spawn a background task that drives a [`RalphLoop`] to completion and
630/// streams events into the TUI through `event_tx`. The sender is dropped on
631/// task exit, which the TUI notices via a disconnected `try_recv` and uses
632/// to detach the receiver.
633fn 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        // Fall through to the bare `/ralph` view-open handler below.
818    }
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            // Create a fresh session so the old one is preserved on disk.
898            match Session::new().await {
899                Ok(mut new_session) => {
900                    // Save the old session first — abort if persistence fails to
901                    // avoid silently discarding the user's conversation.
902                    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                    // Carry over user preferences into the new session.
911                    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    // --- commands with rest arguments handled below the simple match ---
949
950    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 we get here, none of the handlers above matched.
971    // Easy-mode aliases are already normalized before reaching this point,
972    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            // Persist the agent session to disk so it can be recovered if
1063            // the TUI crashes before the agent sends its first prompt.
1064            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    // Rotate model for /go
1174    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    // Initialize OKR repository if needed
1182    ensure_okr_repository(&mut app.state.okr_repository).await;
1183
1184    // Draft OKR and present for approval
1185    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}