Skip to main content

thndrs_lib/cli/app/
commands.rs

1//! Slash-command routing and command output projection.
2//!
3//! This module handles text entered after `/` or `:`.
4//!
5//! It dispatches session, context, setup/auth, model, skill, MCP,
6//! background-process, and quit commands, and appends their redacted
7//! status  or error output to the transcript.
8//!
9//! Commands that need another agent turn return a [`Msg`].
10
11use super::*;
12use crate::{cli::commands::config::ConfigCommand, mcp};
13
14/// Route a slash command (the part after `/` or the text after `:`).
15pub fn handle_command(app: &mut App, command: &str) -> Option<Msg> {
16    if command_contains_api_key_like_argument(command) {
17        app.transcript.push(Entry::Error {
18            text: String::from("slash commands do not accept API keys as arguments; use /login <provider>"),
19        });
20        app.input.clear();
21        return None;
22    }
23
24    if command == "history" {
25        return run_history_command(app);
26    }
27    if command == "tokens" {
28        app.transcript
29            .push(Entry::Status { text: app.token_accounting_status() });
30        app.input.clear();
31        return None;
32    }
33    if let Some(session_id) = command.strip_prefix("resume ") {
34        return resume_session_command(app, session_id.trim());
35    }
36    if command == "resume" {
37        app.transcript
38            .push(Entry::Error { text: String::from("usage: /resume <session-id>") });
39        return None;
40    }
41    if let Some(session_id) = command.strip_prefix("session ") {
42        return show_session_command(app, session_id.trim());
43    }
44    if command == "session" {
45        app.transcript
46            .push(Entry::Error { text: String::from("usage: /session <session-id>") });
47        return None;
48    }
49    if command == "debug log" {
50        return read_session_log_command(app, None);
51    }
52    if let Some(session_id) = command.strip_prefix("debug log ") {
53        return read_session_log_command(app, Some(session_id.trim()));
54    }
55
56    if command == "context" || command == "context show" {
57        app.open_context_surface();
58        return None;
59    }
60    if let Some(rest) = command.strip_prefix("context ") {
61        return super::context::handle_context_command(app, rest.trim());
62    }
63    if let Some((action, rest)) = command.split_once(' ')
64        && matches!(action, "pin" | "drop" | "recover")
65    {
66        return super::context::handle_context_command(app, &format!("{action} {rest}"));
67    }
68    if matches!(command, "pin" | "drop" | "recover") {
69        return super::context::handle_context_command(app, command);
70    }
71
72    if command == "mcp" {
73        list_mcp_servers(app);
74        return None;
75    }
76    if command == "mcp tools" {
77        list_mcp_tools(app, "");
78        return None;
79    }
80    if let Some(name) = command.strip_prefix("mcp tools ") {
81        list_mcp_tools(app, name.trim());
82        return None;
83    }
84    if let Some(rest) = command.strip_prefix("login ") {
85        app.input.clear();
86        match parse_api_key_provider(rest.trim()) {
87            Some(provider) => {
88                app.first_run_recovery = Some(FirstRunRecovery::login(provider));
89            }
90            None => app.transcript.push(Entry::Error {
91                text: String::from("usage: /login <umans|opencode-go|opencode-zen|chatgpt-codex>"),
92            }),
93        }
94        return None;
95    }
96    if let Some(rest) = command.strip_prefix("logout ") {
97        app.input.clear();
98        match parse_api_key_provider(rest.trim()) {
99            Some(SetupProviderArg::ChatgptCodex) => {
100                app.transcript.push(Entry::Status {
101                    text: String::from(
102                        "ChatGPT Codex logout is CLI-only; run `thndrs logout chatgpt-codex` outside the TUI",
103                    ),
104                });
105            }
106            Some(provider) => {
107                app.first_run_recovery = Some(FirstRunRecovery::logout(provider));
108            }
109            None => app.transcript.push(Entry::Error {
110                text: String::from("usage: /logout <umans|opencode-go|opencode-zen|chatgpt-codex>"),
111            }),
112        }
113        return None;
114    }
115
116    if let Some(rest) = command.strip_prefix("bg cancel") {
117        return cancel_background_process(app, rest.trim());
118    }
119
120    match command {
121        "compact" => super::context::start_compaction(app, session::CompactionTrigger::Manual, None),
122        "clear" => {
123            app.transcript.clear();
124            app.input.clear();
125            app.queued_steering.clear();
126            app.queued_followups.clear();
127            Some(Msg::Clear)
128        }
129        "quit" | "exit" => {
130            app.input.clear();
131            app.quit = true;
132            Some(Msg::Quit)
133        }
134        "help" => {
135            app.prompt_accessory = PromptAccessory::Help;
136            None
137        }
138        "bg" => {
139            list_background_processes(app);
140            None
141        }
142        "model" => {
143            open_model_picker(app);
144            None
145        }
146        "reasoning" => {
147            open_reasoning_effort_picker(app);
148            None
149        }
150        "skills" => {
151            open_skill_picker(app);
152            None
153        }
154        "doctor" => {
155            super::context::run_doctor_slash(app);
156            app.input.clear();
157            None
158        }
159        "auth status" => {
160            run_auth_status_slash(app);
161            app.input.clear();
162            None
163        }
164        "config path" => {
165            run_config_slash(app, &crate::cli::commands::config::ConfigCommand::Path);
166            app.input.clear();
167            None
168        }
169        "config show" => {
170            run_config_slash(
171                app,
172                &crate::cli::commands::config::ConfigCommand::Show(crate::cli::commands::config::ConfigShowCommand {
173                    redacted: true,
174                }),
175            );
176            app.input.clear();
177            None
178        }
179        "config edit" => {
180            app.transcript.push(Entry::Status {
181                text: String::from(
182                    "config edit is CLI-only; run `thndrs config edit --global` or `thndrs config edit --project` outside the TUI",
183                ),
184            });
185            app.input.clear();
186            None
187        }
188        "setup" => {
189            let provider = provider_for_model(&app.model);
190            app.first_run_recovery = Some(FirstRunRecovery::setup(provider));
191            app.input.clear();
192            None
193        }
194        "login" => {
195            app.transcript.push(Entry::Error {
196                text: String::from("usage: /login <umans|opencode-go|opencode-zen|chatgpt-codex>"),
197            });
198            app.input.clear();
199            None
200        }
201        "logout" => {
202            app.transcript.push(Entry::Error {
203                text: String::from("usage: /logout <umans|opencode-go|opencode-zen|chatgpt-codex>"),
204            });
205            app.input.clear();
206            None
207        }
208        _ => None,
209    }
210}
211
212pub fn command_suggestions_for_app(app: &App) -> Vec<(&'static str, &'static str)> {
213    let query = super::input::command_query(app);
214    let commands = [
215        ("clear", "clear transcript"),
216        ("quit", "exit app"),
217        ("exit", "exit app"),
218        ("help", "show help"),
219        ("bg", "list background processes"),
220        ("model", "switch model"),
221        ("reasoning", "set reasoning effort"),
222        ("skills", "browse loaded skills"),
223        ("doctor", "show context health"),
224        ("history", "list recent sessions"),
225        ("resume", "resume a local session"),
226        ("session", "show a local session summary"),
227        ("tokens", "show current session token totals"),
228        ("debug log", "read the current session log"),
229        ("auth status", "show credential sources"),
230        ("config path", "show config paths"),
231        ("config show", "show redacted config"),
232        ("setup", "open setup"),
233        ("login", "provider login"),
234        ("logout", "remove provider credential"),
235    ];
236    commands
237        .into_iter()
238        .filter(|(cmd, _)| cmd.starts_with(&query))
239        .collect()
240}
241
242/// Handle a slash command submitted while the agent is working.
243///
244/// Safe commands (`quit`, `exit`, `help`, `bg`) execute immediately.
245///
246/// Commands that mutate idle-only UI state are rejected instead of being queued as text.
247///
248/// Prefix with `//` to queue a literal slash-prefixed follow-up.
249pub fn handle_running_command(app: &mut App, command: &str) -> Option<Msg> {
250    let is_read_only = matches!(command, "quit" | "exit" | "help" | "bg" | "bg cancel")
251        || command.starts_with("bg cancel ")
252        || matches!(command, "history" | "tokens" | "debug log")
253        || matches!(command, "context" | "context show" | "doctor")
254        || command.starts_with("context export ")
255        || command.starts_with("session ")
256        || command.starts_with("debug log ");
257    if is_read_only {
258        return handle_command(app, command);
259    }
260    app.transcript.push(Entry::Status {
261        text: format!("/{command} is not available while the agent is working; use //{command} to queue it as text"),
262    });
263    None
264}
265
266fn parse_api_key_provider(input: &str) -> Option<SetupProviderArg> {
267    match input {
268        "umans" => Some(SetupProviderArg::Umans),
269        "opencode-go" => Some(SetupProviderArg::OpencodeGo),
270        "opencode-zen" => Some(SetupProviderArg::OpencodeZen),
271        "chatgpt-codex" => Some(SetupProviderArg::ChatgptCodex),
272        _ => None,
273    }
274}
275
276fn command_contains_api_key_like_argument(command: &str) -> bool {
277    let mut parts = command.split_whitespace();
278    let Some(head) = parts.next() else {
279        return false;
280    };
281    let skip = match head {
282        "login" | "logout" => 1,
283        _ => 0,
284    };
285    parts.skip(skip).any(is_api_key_like)
286}
287
288fn is_api_key_like(value: &str) -> bool {
289    let value = value.trim_matches(|ch: char| ch == '"' || ch == '\'' || ch == '`' || ch == ',' || ch == ';');
290    let lower = value.to_ascii_lowercase();
291    if lower.starts_with("ctx_") {
292        return false;
293    }
294    value.starts_with("sk-")
295        || lower.contains("api_key=")
296        || lower.contains("apikey=")
297        || lower.contains("opencode_go_key=")
298        || lower.contains("opencode_zen_key=")
299        || lower.contains("umans_api_key=")
300        || lower.contains("access_token=")
301        || lower.contains("refresh_token=")
302        || lower.contains("device_auth_id=")
303        || lower.contains("device_code=")
304        || (value.len() >= 32
305            && value.chars().any(|ch| ch.is_ascii_digit())
306            && value.chars().any(|ch| ch.is_ascii_alphabetic()))
307}
308
309fn run_auth_status_slash(app: &mut App) {
310    let mut output = Vec::new();
311    let result = crate::cli::commands::auth::write_auth_status(&app.cwd, &mut output);
312    push_command_output(app, "auth status", &output, result);
313}
314
315fn run_config_slash(app: &mut App, command: &ConfigCommand) {
316    let mut output = Vec::new();
317    let result = crate::cli::commands::config::run_with_writer(&app.cli, command, &mut output);
318    push_command_output(app, "config", &output, result);
319}
320
321fn push_command_output(app: &mut App, label: &str, output: &[u8], result: std::io::Result<()>) {
322    let text = String::from_utf8_lossy(output).trim_end().to_string();
323    if !text.is_empty() {
324        app.transcript.push(Entry::Status { text });
325    }
326    if let Err(err) = result {
327        app.transcript
328            .push(Entry::Error { text: format!("{label} exited with {}: {err}", err.kind()) });
329    }
330}
331
332fn run_history_command(app: &mut App) -> Option<Msg> {
333    let dir = app.session_directory();
334    let files = session::list_session_files(&dir);
335    if files.is_empty() {
336        app.transcript
337            .push(Entry::Status { text: String::from("no sessions found") });
338    } else {
339        let rows = files
340            .into_iter()
341            .take(20)
342            .map(|path| {
343                let id = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or("session");
344                let summary = session::SessionReader::read_summary(&path);
345                format!(
346                    "{id}\t{}\t{}\tin {} out {}",
347                    summary.title, summary.model, summary.input_tokens, summary.output_tokens
348                )
349            })
350            .collect::<Vec<_>>();
351        app.transcript
352            .push(Entry::Status { text: format!("sessions:\n{}", rows.join("\n")) });
353    }
354    app.input.clear();
355    None
356}
357
358fn show_session_command(app: &mut App, session_id: &str) -> Option<Msg> {
359    let path = match session::resolve_session_file(&app.session_directory(), session_id) {
360        Ok(path) => path,
361        Err(error) => {
362            app.transcript.push(Entry::Error { text: error.to_string() });
363            return None;
364        }
365    };
366    let id = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or(session_id);
367    let summary = session::SessionReader::read_summary(&path);
368    app.transcript.push(Entry::Status {
369        text: format!(
370            "session: {id}\ntitle: {}\nmodel: {}\ntokens: in {} out {}\npath: {}",
371            summary.title,
372            summary.model,
373            summary.input_tokens,
374            summary.output_tokens,
375            path.display()
376        ),
377    });
378    app.input.clear();
379    None
380}
381
382fn resume_session_command(app: &mut App, session_id: &str) -> Option<Msg> {
383    let path = match session::resolve_session_file(&app.session_directory(), session_id) {
384        Ok(path) => path,
385        Err(error) => {
386            app.transcript.push(Entry::Error { text: error.to_string() });
387            return None;
388        }
389    };
390    let id = path
391        .file_stem()
392        .and_then(|stem| stem.to_str())
393        .unwrap_or(session_id)
394        .to_string();
395    if id == app.session_id {
396        app.transcript
397            .push(Entry::Error { text: String::from("the current session is already active") });
398        return None;
399    }
400    let writer = match session::SessionWriter::resume(&path, &id) {
401        Ok(writer) => writer,
402        Err(error) => {
403            app.transcript
404                .push(Entry::Error { text: format!("cannot resume session `{id}`: {error}") });
405            return None;
406        }
407    };
408    let summary = session::SessionReader::read_summary(&path);
409    let transcript = session::SessionReader::read_transcript(&path);
410    let records = session::SessionReader::read_records(&path);
411    let turn_count = records
412        .iter()
413        .filter(|record| matches!(record, session::SessionRecord::User { .. }))
414        .count() as u64;
415
416    app.session_writer = Some(writer);
417    app.session_id = id.clone();
418    app.transcript = transcript;
419    app.restore_context_state(&records);
420    app.last_request_accounting = records.iter().rev().find_map(|record| match record {
421        session::SessionRecord::RequestAccounting { accounting, .. } => Some(accounting.clone()),
422        _ => None,
423    });
424    app.session_tokens_in = summary.input_tokens;
425    app.session_tokens_out = summary.output_tokens;
426    app.turn_count = turn_count;
427    app.last_input = None;
428    app.pending_manual_compaction = None;
429    app.queued_steering.clear();
430    app.queued_followups.clear();
431    app.pending_permission = None;
432    app.run_state = RunState::Idle;
433    app.input.clear();
434    app.history_cursor = None;
435    app.history_draft.clear();
436    app.transcript
437        .push(Entry::Status { text: format!("resumed session: {id}") });
438    None
439}
440
441fn read_session_log_command(app: &mut App, requested_session_id: Option<&str>) -> Option<Msg> {
442    let id = match requested_session_id {
443        Some(query) => match session::resolve_session_file(&app.session_directory(), query) {
444            Ok(path) => path
445                .file_stem()
446                .and_then(|stem| stem.to_str())
447                .unwrap_or(query)
448                .to_string(),
449            Err(error) => {
450                app.transcript.push(Entry::Error { text: error.to_string() });
451                return None;
452            }
453        },
454        None => app.session_id.clone(),
455    };
456    let path = app
457        .cwd
458        .join(".thndrs")
459        .join("logs")
460        .join("sessions")
461        .join(format!("thndrs-{id}.log"));
462    let lines = session::read_redacted_log_tail(&path, 100);
463    if lines.is_empty() {
464        app.transcript
465            .push(Entry::Error { text: format!("debug log `{}` is empty or missing", path.display()) });
466        return None;
467    }
468    app.transcript
469        .push(Entry::Status { text: format!("debug log {id}:\n{}", lines.join("\n")) });
470    app.input.clear();
471    None
472}
473
474/// List background processes in the transcript.
475fn list_background_processes(app: &mut App) {
476    super::agent_lifecycle::drain_background_processes(app);
477    let bg_ids: Vec<u64> = app.process_registry.background_ids().collect();
478    if bg_ids.is_empty() {
479        app.transcript
480            .push(Entry::Status { text: String::from("no background processes") });
481    } else {
482        let lines: Vec<String> = bg_ids
483            .iter()
484            .filter_map(|id| {
485                app.process_registry.get(*id).map(|p| {
486                    let elapsed = p.elapsed().as_secs();
487                    let cmd = p.command.join(" ");
488                    format!("[{id}] {cmd} cwd={} ({elapsed}s)", p.cwd.display())
489                })
490            })
491            .collect();
492        app.transcript
493            .push(Entry::Status { text: format!("background processes:\n{}", lines.join("\n")) });
494    }
495}
496
497fn cancel_background_process(app: &mut App, id_text: &str) -> Option<Msg> {
498    super::agent_lifecycle::drain_background_processes(app);
499    if id_text.is_empty() {
500        app.transcript
501            .push(Entry::Error { text: String::from("usage: :bg cancel <id>") });
502        return None;
503    }
504    let Ok(id) = id_text.parse::<u64>() else {
505        app.transcript
506            .push(Entry::Error { text: format!("invalid background process id: {id_text}") });
507        return None;
508    };
509    if app.process_registry.cancel(id) {
510        app.transcript
511            .push(Entry::Status { text: format!("cancellation requested for background process [{id}]") });
512    } else {
513        app.transcript
514            .push(Entry::Error { text: format!("background process [{id}] is not running") });
515    }
516    None
517}
518
519fn list_mcp_servers(app: &mut App) {
520    let env_vars: Vec<(String, String)> = std::env::vars().collect();
521    match mcp::config::load_effective_mcp(&app.cwd, &env_vars) {
522        Ok(effective) if effective.config.servers.is_empty() => {
523            app.transcript
524                .push(Entry::Status { text: String::from("no MCP servers configured") });
525        }
526        Ok(effective) => {
527            let mut lines = Vec::new();
528            for (name, server) in &effective.config.servers {
529                let status = if server.enabled { "enabled" } else { "disabled" };
530                lines.push(format!("{name}\t{status}\t{:?}", server.transport));
531            }
532            lines.extend(
533                effective
534                    .diagnostics
535                    .into_iter()
536                    .map(|diagnostic| format!("diagnostic: {diagnostic}")),
537            );
538            app.transcript
539                .push(Entry::Status { text: format!("MCP servers:\n{}", lines.join("\n")) });
540        }
541        Err(err) => app
542            .transcript
543            .push(Entry::Error { text: format!("failed to load MCP config: {err}") }),
544    }
545}
546
547fn list_mcp_tools(app: &mut App, name: &str) {
548    if name.is_empty() {
549        app.transcript
550            .push(Entry::Error { text: String::from("usage: /mcp tools <name>") });
551        return;
552    }
553
554    let env_vars: Vec<(String, String)> = std::env::vars().collect();
555    let effective = match mcp::config::load_effective_mcp(&app.cwd, &env_vars) {
556        Ok(effective) => effective,
557        Err(err) => {
558            app.transcript
559                .push(Entry::Error { text: format!("failed to load MCP config: {err}") });
560            return;
561        }
562    };
563    let Some(server) = effective.config.servers.get(name) else {
564        app.transcript
565            .push(Entry::Error { text: format!("MCP server `{name}` is not configured") });
566        return;
567    };
568    if !server.enabled {
569        app.transcript
570            .push(Entry::Error { text: format!("MCP server `{name}` is disabled") });
571        return;
572    }
573
574    match mcp::manager::McpClient::connect(name.to_string(), server) {
575        Ok(client) => {
576            let lines: Vec<String> = client
577                .tool_definitions()
578                .into_iter()
579                .map(|tool| format!("{}\t{}", tool.name, tool.description))
580                .collect();
581            app.transcript.push(Entry::Status {
582                text: if lines.is_empty() {
583                    format!("MCP server `{name}` exposes no tools")
584                } else {
585                    format!("MCP tools for `{name}`:\n{}", lines.join("\n"))
586                },
587            });
588        }
589        Err(err) => app.transcript.push(Entry::Error { text: err.to_string() }),
590    }
591}