1use super::*;
12use crate::{cli::commands::config::ConfigCommand, mcp};
13
14pub 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
242pub 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
474fn 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}