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
202pub async fn handle_slash_command(
203    app: &mut App,
204    cwd: &std::path::Path,
205    session: &mut Session,
206    registry: Option<&Arc<ProviderRegistry>>,
207    command: &str,
208) {
209    let normalized = normalize_easy_command(command);
210    let normalized = normalize_slash_command(&normalized);
211
212    if let Some(rest) = command_with_optional_args(&normalized, "/image") {
213        let cleaned = rest.trim().trim_matches(|c| c == '"' || c == '\'');
214        if cleaned.is_empty() {
215            app.state.status =
216                "Usage: /image <path> (png, jpg, jpeg, gif, webp, bmp, svg).".to_string();
217        } else {
218            let path = Path::new(cleaned);
219            let resolved = if path.is_absolute() {
220                path.to_path_buf()
221            } else {
222                cwd.join(path)
223            };
224            match crate::tui::app::input::attach_image_file(&resolved) {
225                Ok(attachment) => {
226                    let display = resolved.display();
227                    app.state.pending_images.push(attachment);
228                    let count = app.state.pending_images.len();
229                    app.state.status = format!(
230                        "📷 Attached {display}. {count} image(s) pending. Press Enter to send."
231                    );
232                    push_system_message(
233                        app,
234                        format!(
235                            "📷 Image attached: {display}. Type a message and press Enter to send."
236                        ),
237                    );
238                }
239                Err(msg) => {
240                    push_system_message(app, format!("Failed to attach image: {msg}"));
241                }
242            }
243        }
244        return;
245    }
246
247    if let Some(rest) = command_with_optional_args(&normalized, "/file") {
248        let cleaned = rest.trim().trim_matches(|c| c == '"' || c == '\'');
249        if cleaned.is_empty() {
250            app.state.status =
251                "Usage: /file <path> (relative to workspace or absolute).".to_string();
252        } else {
253            attach_file_to_input(app, cwd, Path::new(cleaned));
254        }
255        return;
256    }
257
258    if let Some(rest) = command_with_optional_args(&normalized, "/autoapply") {
259        let action = rest.trim().to_ascii_lowercase();
260        let current = app.state.auto_apply_edits;
261        let desired = match action.as_str() {
262            "" | "toggle" => Some(!current),
263            "status" => None,
264            "on" | "true" | "yes" | "enable" | "enabled" => Some(true),
265            "off" | "false" | "no" | "disable" | "disabled" => Some(false),
266            _ => {
267                app.state.status = "Usage: /autoapply [on|off|toggle|status]".to_string();
268                return;
269            }
270        };
271
272        if let Some(next) = desired {
273            set_auto_apply_edits(app, session, next).await;
274        } else {
275            app.state.status = auto_apply_status_message(current);
276        }
277        return;
278    }
279
280    if let Some(rest) = command_with_optional_args(&normalized, "/network") {
281        let current = app.state.allow_network;
282        let desired = match rest.trim().to_ascii_lowercase().as_str() {
283            "" | "toggle" => Some(!current),
284            "status" => None,
285            "on" | "true" | "yes" | "enable" | "enabled" => Some(true),
286            "off" | "false" | "no" | "disable" | "disabled" => Some(false),
287            _ => {
288                app.state.status = "Usage: /network [on|off|toggle|status]".to_string();
289                return;
290            }
291        };
292
293        if let Some(next) = desired {
294            set_network_access(app, session, next).await;
295        } else {
296            app.state.status = network_access_status_message(current);
297        }
298        return;
299    }
300
301    if let Some(rest) = command_with_optional_args(&normalized, "/autocomplete") {
302        let current = app.state.slash_autocomplete;
303        let desired = match rest.trim().to_ascii_lowercase().as_str() {
304            "" | "toggle" => Some(!current),
305            "status" => None,
306            "on" | "true" | "yes" | "enable" | "enabled" => Some(true),
307            "off" | "false" | "no" | "disable" | "disabled" => Some(false),
308            _ => {
309                app.state.status = "Usage: /autocomplete [on|off|toggle|status]".to_string();
310                return;
311            }
312        };
313
314        if let Some(next) = desired {
315            set_slash_autocomplete(app, session, next).await;
316        } else {
317            app.state.status = autocomplete_status_message(current);
318        }
319        return;
320    }
321
322    if let Some(rest) = command_with_optional_args(&normalized, "/steer") {
323        let value = rest.trim();
324        if value.eq_ignore_ascii_case("clear") {
325            app.state.clear_steering();
326            app.state.status = "Cleared queued steering".to_string();
327            push_system_message(app, "Cleared queued steering.");
328        } else if value.eq_ignore_ascii_case("status") || value.is_empty() {
329            let count = app.state.steering_count();
330            app.state.status = format!("Queued steering: {count}");
331            let body = if count == 0 {
332                "No queued steering.".to_string()
333            } else {
334                app.state
335                    .queued_steering
336                    .iter()
337                    .enumerate()
338                    .map(|(idx, item)| format!("{}. {item}", idx + 1))
339                    .collect::<Vec<_>>()
340                    .join("\n")
341            };
342            push_system_message(app, format!("Queued steering\n{body}"));
343        } else {
344            app.state.queue_steering(value);
345            let count = app.state.steering_count();
346            app.state.status = format!("Queued steering ({count}) for next turn");
347            push_system_message(app, format!("Queued steering for next turn: {value}"));
348        }
349        return;
350    }
351
352    if let Some(rest) = command_with_optional_args(&normalized, "/mcp") {
353        handle_mcp_command(app, rest).await;
354        return;
355    }
356
357    match normalized.as_str() {
358        "/help" => {
359            app.state.show_help = true;
360            app.state.help_scroll.offset = 0;
361            app.state.status = "Help".to_string();
362        }
363        "/sessions" | "/session" => {
364            refresh_sessions(app, cwd).await;
365            app.state.clear_session_filter();
366            app.state.set_view_mode(ViewMode::Sessions);
367            app.state.status = "Session picker".to_string();
368        }
369        "/import-codex" => {
370            codex_sessions::import_workspace_sessions(app, cwd).await;
371        }
372        "/swarm" => {
373            app.state.swarm.mark_active("TUI swarm monitor");
374            app.state.set_view_mode(ViewMode::Swarm);
375        }
376        "/ralph" => {
377            app.state
378                .ralph
379                .mark_active(app.state.cwd_display.clone(), "TUI Ralph monitor");
380            app.state.set_view_mode(ViewMode::Ralph);
381        }
382        "/bus" | "/protocol" => {
383            app.state.set_view_mode(ViewMode::Bus);
384            app.state.status = "Protocol bus log".to_string();
385        }
386        "/model" => open_model_picker(app, session, registry).await,
387        "/settings" => app.state.set_view_mode(ViewMode::Settings),
388        "/lsp" => app.state.set_view_mode(ViewMode::Lsp),
389        "/rlm" => app.state.set_view_mode(ViewMode::Rlm),
390        "/latency" => {
391            app.state.set_view_mode(ViewMode::Latency);
392            app.state.status = "Latency inspector".to_string();
393        }
394        "/inspector" => {
395            app.state.set_view_mode(ViewMode::Inspector);
396            app.state.status = "Inspector".to_string();
397        }
398        "/chat" | "/home" | "/main" => return_to_chat(app),
399        "/webview" => {
400            app.state.chat_layout_mode =
401                crate::tui::ui::webview::layout_mode::ChatLayoutMode::Webview;
402            app.state.status = "Layout: Webview".to_string();
403        }
404        "/classic" => {
405            app.state.chat_layout_mode =
406                crate::tui::ui::webview::layout_mode::ChatLayoutMode::Classic;
407            app.state.status = "Layout: Classic".to_string();
408        }
409        "/symbols" | "/symbol" => {
410            app.state.symbol_search.open();
411            app.state.status = "Symbol search".to_string();
412        }
413        "/new" => {
414            // Create a fresh session so the old one is preserved on disk.
415            match Session::new().await {
416                Ok(mut new_session) => {
417                    // Save the old session first — abort if persistence fails to
418                    // avoid silently discarding the user's conversation.
419                    if let Err(error) = session.save().await {
420                        tracing::warn!(error = %error, "Failed to save current session before /new");
421                        app.state.status = format!(
422                            "Failed to save current session before creating new session: {error}"
423                        );
424                        return;
425                    }
426
427                    // Carry over user preferences into the new session.
428                    new_session.metadata.auto_apply_edits = app.state.auto_apply_edits;
429                    new_session.metadata.allow_network = app.state.allow_network;
430                    new_session.metadata.slash_autocomplete = app.state.slash_autocomplete;
431                    new_session.metadata.use_worktree = app.state.use_worktree;
432                    new_session.metadata.model = session.metadata.model.clone();
433
434                    *session = new_session;
435                    session.attach_global_bus_if_missing();
436                    if let Err(error) = session.save().await {
437                        tracing::warn!(error = %error, "Failed to save new session");
438                        app.state.status =
439                            format!("New chat session created, but failed to persist: {error}");
440                    } else {
441                        app.state.status = "New chat session".to_string();
442                    }
443                    app.state.session_id = Some(session.id.clone());
444                    app.state.messages.clear();
445                    app.state.streaming_text.clear();
446                    app.state.processing = false;
447                    app.state.clear_request_timing();
448                    app.state.scroll_to_bottom();
449                    app.state.set_view_mode(ViewMode::Chat);
450                    refresh_sessions(app, cwd).await;
451                }
452                Err(err) => {
453                    app.state.status = format!("Failed to create new session: {err}");
454                }
455            }
456        }
457        "/undo" => {
458            // Remove from TUI messages: walk backwards removing everything
459            // until we've removed the last user message (inclusive)
460            let mut found_user = false;
461            while let Some(msg) = app.state.messages.last() {
462                if matches!(msg.message_type, MessageType::User) {
463                    if found_user {
464                        break; // hit the previous user turn, stop
465                    }
466                    found_user = true;
467                }
468                // Stop if we hit a system message before finding a user message
469                if matches!(msg.message_type, MessageType::System) && !found_user {
470                    break;
471                }
472                app.state.messages.pop();
473            }
474
475            if !found_user {
476                push_system_message(app, "Nothing to undo.");
477                return;
478            }
479
480            // Remove from session: walk backwards removing the last user message
481            // and all assistant/tool messages after it
482            let mut found_session_user = false;
483            while let Some(msg) = session.messages.last() {
484                if msg.role == crate::provider::Role::User {
485                    if found_session_user {
486                        break;
487                    }
488                    found_session_user = true;
489                }
490                if msg.role == crate::provider::Role::System && !found_session_user {
491                    break;
492                }
493                session.messages.pop();
494            }
495            if let Err(error) = session.save().await {
496                tracing::warn!(error = %error, "Failed to save session after undo");
497            }
498
499            push_system_message(app, "Undid last message and response.");
500        }
501        "/keys" => {
502            app.state.status =
503                "Protocol-first commands: /protocol /bus /file /autoapply /network /autocomplete /mcp /model /sessions /import-codex /swarm /ralph /latency /symbols /settings /lsp /rlm /chat /new /undo /spawn /kill /agents /agent\nEasy aliases: /add /talk /list /remove /focus /home /say /ls /rm /main"
504                    .to_string();
505        }
506        _ => {}
507    }
508
509    // --- commands with rest arguments handled below the simple match ---
510
511    if let Some(rest) = command_with_optional_args(&normalized, "/spawn") {
512        handle_spawn_command(app, rest).await;
513        return;
514    }
515
516    if let Some(rest) = command_with_optional_args(&normalized, "/kill") {
517        handle_kill_command(app, rest);
518        return;
519    }
520
521    if command_with_optional_args(&normalized, "/agents").is_some() {
522        handle_agents_command(app);
523        return;
524    }
525
526    if let Some(rest) = command_with_optional_args(&normalized, "/autochat") {
527        handle_autochat_command(app, rest);
528        return;
529    }
530
531    // If we get here, none of the handlers above matched.
532    // Easy-mode aliases are already normalized before reaching this point,
533    if !matches!(
534        normalized.as_str(),
535        "/help"
536            | "/sessions"
537            | "/import-codex"
538            | "/session"
539            | "/swarm"
540            | "/ralph"
541            | "/bus"
542            | "/protocol"
543            | "/model"
544            | "/settings"
545            | "/lsp"
546            | "/rlm"
547            | "/latency"
548            | "/chat"
549            | "/home"
550            | "/main"
551            | "/symbols"
552            | "/symbol"
553            | "/new"
554            | "/undo"
555            | "/keys"
556            | "/file"
557            | "/image"
558            | "/autoapply"
559            | "/network"
560            | "/autocomplete"
561            | "/mcp"
562            | "/spawn"
563            | "/kill"
564            | "/agents"
565            | "/agent"
566            | "/autochat"
567    ) {
568        app.state.status = format!("Unknown command: {normalized}");
569    }
570}
571
572async fn handle_spawn_command(app: &mut App, rest: &str) {
573    let rest = rest.trim();
574    if rest.is_empty() {
575        app.state.status = "Usage: /spawn <name> [instructions]".to_string();
576        return;
577    }
578
579    let mut parts = rest.splitn(2, char::is_whitespace);
580    let Some(name) = parts.next().filter(|s| !s.is_empty()) else {
581        app.state.status = "Usage: /spawn <name> [instructions]".to_string();
582        return;
583    };
584
585    if app.state.spawned_agents.contains_key(name) {
586        app.state.status = format!("Agent '{name}' already exists. Use /kill {name} first.");
587        push_system_message(app, format!("Agent '{name}' already exists."));
588        return;
589    }
590
591    let instructions = parts.next().unwrap_or("").trim().to_string();
592    let profile = agent_profile(name);
593
594    let system_prompt = if instructions.is_empty() {
595        format!(
596            "You are an AI assistant codenamed '{}' ({}) working as a sub-agent.
597             Personality: {}
598             Collaboration style: {}
599             Signature move: {}",
600            profile.codename,
601            profile.profile,
602            profile.personality,
603            profile.collaboration_style,
604            profile.signature_move,
605        )
606    } else {
607        instructions.clone()
608    };
609
610    match Session::new().await {
611        Ok(mut agent_session) => {
612            agent_session.agent = format!("spawned:{}", name);
613            agent_session.messages.push(crate::provider::Message {
614                role: crate::provider::Role::System,
615                content: vec![crate::provider::ContentPart::Text {
616                    text: system_prompt,
617                }],
618            });
619
620            // Persist the agent session to disk so it can be recovered if
621            // the TUI crashes before the agent sends its first prompt.
622            if let Err(e) = agent_session.save().await {
623                tracing::warn!(error = %e, "Failed to save spawned agent session");
624            }
625
626            let display_name = if instructions.is_empty() {
627                format!("{} [{}]", name, profile.codename)
628            } else {
629                name.to_string()
630            };
631
632            app.state.spawned_agents.insert(
633                name.to_string(),
634                SpawnedAgent {
635                    name: display_name.clone(),
636                    instructions,
637                    session: agent_session,
638                    is_processing: false,
639                },
640            );
641
642            app.state.status = format!("Spawned agent: {display_name}");
643            push_system_message(
644                app,
645                format!(
646                    "Spawned agent '{}' [{}] — ready for messages.",
647                    name, profile.codename
648                ),
649            );
650        }
651        Err(error) => {
652            app.state.status = format!("Failed to create agent session: {error}");
653            push_system_message(app, format!("Failed to spawn agent '{name}': {error}"));
654        }
655    }
656}
657
658fn handle_kill_command(app: &mut App, rest: &str) {
659    let name = rest.trim();
660    if name.is_empty() {
661        app.state.status = "Usage: /kill <name>".to_string();
662        return;
663    }
664
665    if app.state.spawned_agents.remove(name).is_some() {
666        if app.state.active_spawned_agent.as_deref() == Some(name) {
667            app.state.active_spawned_agent = None;
668        }
669        app.state.streaming_agent_texts.remove(name);
670        app.state.status = format!("Agent '{name}' removed.");
671        push_system_message(app, format!("Agent '{name}' has been shut down."));
672    } else {
673        app.state.status = format!("Agent '{name}' not found.");
674    }
675}
676
677fn handle_agents_command(app: &mut App) {
678    if app.state.spawned_agents.is_empty() {
679        app.state.status = "No spawned agents.".to_string();
680        push_system_message(app, "No spawned agents. Use /spawn <name> to create one.");
681    } else {
682        let count = app.state.spawned_agents.len();
683        let lines: Vec<String> = app
684            .state
685            .spawned_agents
686            .iter()
687            .map(|(key, agent)| {
688                let msg_count = agent.session.messages.len();
689                let model = agent.session.metadata.model.as_deref().unwrap_or("default");
690                let active = if app.state.active_spawned_agent.as_deref() == Some(key) {
691                    " [active]"
692                } else {
693                    ""
694                };
695                format!(
696                    "  {}{} — {} messages — model: {}",
697                    agent.name, active, msg_count, model
698                )
699            })
700            .collect();
701
702        let body = lines.join(
703            "
704",
705        );
706        app.state.status = format!("{count} spawned agent(s)");
707        push_system_message(
708            app,
709            format!(
710                "Spawned agents ({count}):
711{body}"
712            ),
713        );
714    }
715}
716async fn handle_go_command(
717    app: &mut App,
718    session: &mut Session,
719    _registry: Option<&Arc<ProviderRegistry>>,
720    rest: &str,
721) {
722    use crate::tui::app::okr_gate::{PendingOkrApproval, ensure_okr_repository, next_go_model};
723    use crate::tui::constants::AUTOCHAT_MAX_AGENTS;
724
725    let task = rest.trim();
726    if task.is_empty() {
727        app.state.status = "Usage: /go <task description>".to_string();
728        return;
729    }
730
731    // Rotate model for /go
732    let current_model = session.metadata.model.as_deref();
733    let model = next_go_model(current_model);
734    session.metadata.model = Some(model.clone());
735    if let Err(error) = session.save().await {
736        tracing::warn!(error = %error, "Failed to save session after model swap");
737    }
738
739    // Initialize OKR repository if needed
740    ensure_okr_repository(&mut app.state.okr_repository).await;
741
742    // Draft OKR and present for approval
743    let pending = PendingOkrApproval::propose(task.to_string(), AUTOCHAT_MAX_AGENTS, model).await;
744
745    push_system_message(app, pending.approval_prompt());
746
747    app.state.pending_okr_approval = Some(pending);
748    app.state.status = "OKR draft awaiting approval \u{2014} [A]pprove or [D]eny".to_string();
749}
750
751fn handle_autochat_command(app: &mut App, rest: &str) {
752    let task = rest.trim().to_string();
753    if task.is_empty() {
754        app.state.status = "Usage: /autochat <task description>".to_string();
755        return;
756    }
757    if app.state.autochat.running {
758        app.state.status = "Autochat relay already running.".to_string();
759        return;
760    }
761    let model = app.state.last_completion_model.clone().unwrap_or_default();
762    let rx = super::autochat::worker::start_autochat_relay(task, model);
763    app.state.autochat.running = true;
764    app.state.autochat.rx = Some(rx);
765    app.state.status = "Autochat relay started.".to_string();
766}