use super::*;
use crate::{cli::commands::config::ConfigCommand, mcp};
pub fn handle_command(app: &mut App, command: &str) -> Option<Msg> {
if command_contains_api_key_like_argument(command) {
app.transcript.push(Entry::Error {
text: String::from("slash commands do not accept API keys as arguments; use /login <provider>"),
});
app.input.clear();
return None;
}
if command == "history" {
return run_history_command(app);
}
if command == "tokens" {
app.transcript
.push(Entry::Status { text: app.token_accounting_status() });
app.input.clear();
return None;
}
if let Some(session_id) = command.strip_prefix("resume ") {
return resume_session_command(app, session_id.trim());
}
if command == "resume" {
app.transcript
.push(Entry::Error { text: String::from("usage: /resume <session-id>") });
return None;
}
if let Some(session_id) = command.strip_prefix("session ") {
return show_session_command(app, session_id.trim());
}
if command == "session" {
app.transcript
.push(Entry::Error { text: String::from("usage: /session <session-id>") });
return None;
}
if command == "debug log" {
return read_session_log_command(app, None);
}
if let Some(session_id) = command.strip_prefix("debug log ") {
return read_session_log_command(app, Some(session_id.trim()));
}
if command == "context" || command == "context show" {
app.open_context_surface();
return None;
}
if let Some(rest) = command.strip_prefix("context ") {
return super::context::handle_context_command(app, rest.trim());
}
if let Some((action, rest)) = command.split_once(' ')
&& matches!(action, "pin" | "drop" | "recover")
{
return super::context::handle_context_command(app, &format!("{action} {rest}"));
}
if matches!(command, "pin" | "drop" | "recover") {
return super::context::handle_context_command(app, command);
}
if command == "mcp" {
list_mcp_servers(app);
return None;
}
if command == "mcp tools" {
list_mcp_tools(app, "");
return None;
}
if let Some(name) = command.strip_prefix("mcp tools ") {
list_mcp_tools(app, name.trim());
return None;
}
if let Some(rest) = command.strip_prefix("login ") {
app.input.clear();
match parse_api_key_provider(rest.trim()) {
Some(provider) => {
app.first_run_recovery = Some(FirstRunRecovery::login(provider));
}
None => app.transcript.push(Entry::Error {
text: String::from("usage: /login <umans|opencode-go|opencode-zen|chatgpt-codex>"),
}),
}
return None;
}
if let Some(rest) = command.strip_prefix("logout ") {
app.input.clear();
match parse_api_key_provider(rest.trim()) {
Some(SetupProviderArg::ChatgptCodex) => {
app.transcript.push(Entry::Status {
text: String::from(
"ChatGPT Codex logout is CLI-only; run `thndrs logout chatgpt-codex` outside the TUI",
),
});
}
Some(provider) => {
app.first_run_recovery = Some(FirstRunRecovery::logout(provider));
}
None => app.transcript.push(Entry::Error {
text: String::from("usage: /logout <umans|opencode-go|opencode-zen|chatgpt-codex>"),
}),
}
return None;
}
if let Some(rest) = command.strip_prefix("bg cancel") {
return cancel_background_process(app, rest.trim());
}
match command {
"compact" => super::context::start_compaction(app, session::CompactionTrigger::Manual, None),
"clear" => {
app.transcript.clear();
app.input.clear();
app.queued_steering.clear();
app.queued_followups.clear();
Some(Msg::Clear)
}
"quit" | "exit" => {
app.input.clear();
app.quit = true;
Some(Msg::Quit)
}
"help" => {
app.prompt_accessory = PromptAccessory::Help;
None
}
"bg" => {
list_background_processes(app);
None
}
"model" => {
open_model_picker(app);
None
}
"reasoning" => {
open_reasoning_effort_picker(app);
None
}
"skills" => {
open_skill_picker(app);
None
}
"doctor" => {
super::context::run_doctor_slash(app);
app.input.clear();
None
}
"auth status" => {
run_auth_status_slash(app);
app.input.clear();
None
}
"config path" => {
run_config_slash(app, &crate::cli::commands::config::ConfigCommand::Path);
app.input.clear();
None
}
"config show" => {
run_config_slash(
app,
&crate::cli::commands::config::ConfigCommand::Show(crate::cli::commands::config::ConfigShowCommand {
redacted: true,
}),
);
app.input.clear();
None
}
"config edit" => {
app.transcript.push(Entry::Status {
text: String::from(
"config edit is CLI-only; run `thndrs config edit --global` or `thndrs config edit --project` outside the TUI",
),
});
app.input.clear();
None
}
"setup" => {
let provider = provider_for_model(&app.model);
app.first_run_recovery = Some(FirstRunRecovery::setup(provider));
app.input.clear();
None
}
"login" => {
app.transcript.push(Entry::Error {
text: String::from("usage: /login <umans|opencode-go|opencode-zen|chatgpt-codex>"),
});
app.input.clear();
None
}
"logout" => {
app.transcript.push(Entry::Error {
text: String::from("usage: /logout <umans|opencode-go|opencode-zen|chatgpt-codex>"),
});
app.input.clear();
None
}
_ => None,
}
}
pub fn command_suggestions_for_app(app: &App) -> Vec<(&'static str, &'static str)> {
let query = super::input::command_query(app);
let commands = [
("clear", "clear transcript"),
("quit", "exit app"),
("exit", "exit app"),
("help", "show help"),
("bg", "list background processes"),
("model", "switch model"),
("reasoning", "set reasoning effort"),
("skills", "browse loaded skills"),
("doctor", "show context health"),
("history", "list recent sessions"),
("resume", "resume a local session"),
("session", "show a local session summary"),
("tokens", "show current session token totals"),
("debug log", "read the current session log"),
("auth status", "show credential sources"),
("config path", "show config paths"),
("config show", "show redacted config"),
("setup", "open setup"),
("login", "provider login"),
("logout", "remove provider credential"),
];
commands
.into_iter()
.filter(|(cmd, _)| cmd.starts_with(&query))
.collect()
}
pub fn handle_running_command(app: &mut App, command: &str) -> Option<Msg> {
let is_read_only = matches!(command, "quit" | "exit" | "help" | "bg" | "bg cancel")
|| command.starts_with("bg cancel ")
|| matches!(command, "history" | "tokens" | "debug log")
|| matches!(command, "context" | "context show" | "doctor")
|| command.starts_with("context export ")
|| command.starts_with("session ")
|| command.starts_with("debug log ");
if is_read_only {
return handle_command(app, command);
}
app.transcript.push(Entry::Status {
text: format!("/{command} is not available while the agent is working; use //{command} to queue it as text"),
});
None
}
fn parse_api_key_provider(input: &str) -> Option<SetupProviderArg> {
match input {
"umans" => Some(SetupProviderArg::Umans),
"opencode-go" => Some(SetupProviderArg::OpencodeGo),
"opencode-zen" => Some(SetupProviderArg::OpencodeZen),
"chatgpt-codex" => Some(SetupProviderArg::ChatgptCodex),
_ => None,
}
}
fn command_contains_api_key_like_argument(command: &str) -> bool {
let mut parts = command.split_whitespace();
let Some(head) = parts.next() else {
return false;
};
let skip = match head {
"login" | "logout" => 1,
_ => 0,
};
parts.skip(skip).any(is_api_key_like)
}
fn is_api_key_like(value: &str) -> bool {
let value = value.trim_matches(|ch: char| ch == '"' || ch == '\'' || ch == '`' || ch == ',' || ch == ';');
let lower = value.to_ascii_lowercase();
if lower.starts_with("ctx_") {
return false;
}
value.starts_with("sk-")
|| lower.contains("api_key=")
|| lower.contains("apikey=")
|| lower.contains("opencode_go_key=")
|| lower.contains("opencode_zen_key=")
|| lower.contains("umans_api_key=")
|| lower.contains("access_token=")
|| lower.contains("refresh_token=")
|| lower.contains("device_auth_id=")
|| lower.contains("device_code=")
|| (value.len() >= 32
&& value.chars().any(|ch| ch.is_ascii_digit())
&& value.chars().any(|ch| ch.is_ascii_alphabetic()))
}
fn run_auth_status_slash(app: &mut App) {
let mut output = Vec::new();
let result = crate::cli::commands::auth::write_auth_status(&app.cwd, &mut output);
push_command_output(app, "auth status", &output, result);
}
fn run_config_slash(app: &mut App, command: &ConfigCommand) {
let mut output = Vec::new();
let result = crate::cli::commands::config::run_with_writer(&app.cli, command, &mut output);
push_command_output(app, "config", &output, result);
}
fn push_command_output(app: &mut App, label: &str, output: &[u8], result: std::io::Result<()>) {
let text = String::from_utf8_lossy(output).trim_end().to_string();
if !text.is_empty() {
app.transcript.push(Entry::Status { text });
}
if let Err(err) = result {
app.transcript
.push(Entry::Error { text: format!("{label} exited with {}: {err}", err.kind()) });
}
}
fn run_history_command(app: &mut App) -> Option<Msg> {
let dir = app.session_directory();
let files = session::list_session_files(&dir);
if files.is_empty() {
app.transcript
.push(Entry::Status { text: String::from("no sessions found") });
} else {
let rows = files
.into_iter()
.take(20)
.map(|path| {
let id = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or("session");
let summary = session::SessionReader::read_summary(&path);
format!(
"{id}\t{}\t{}\tin {} out {}",
summary.title, summary.model, summary.input_tokens, summary.output_tokens
)
})
.collect::<Vec<_>>();
app.transcript
.push(Entry::Status { text: format!("sessions:\n{}", rows.join("\n")) });
}
app.input.clear();
None
}
fn show_session_command(app: &mut App, session_id: &str) -> Option<Msg> {
let path = match session::resolve_session_file(&app.session_directory(), session_id) {
Ok(path) => path,
Err(error) => {
app.transcript.push(Entry::Error { text: error.to_string() });
return None;
}
};
let id = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or(session_id);
let summary = session::SessionReader::read_summary(&path);
app.transcript.push(Entry::Status {
text: format!(
"session: {id}\ntitle: {}\nmodel: {}\ntokens: in {} out {}\npath: {}",
summary.title,
summary.model,
summary.input_tokens,
summary.output_tokens,
path.display()
),
});
app.input.clear();
None
}
fn resume_session_command(app: &mut App, session_id: &str) -> Option<Msg> {
let path = match session::resolve_session_file(&app.session_directory(), session_id) {
Ok(path) => path,
Err(error) => {
app.transcript.push(Entry::Error { text: error.to_string() });
return None;
}
};
let id = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(session_id)
.to_string();
if id == app.session_id {
app.transcript
.push(Entry::Error { text: String::from("the current session is already active") });
return None;
}
let writer = match session::SessionWriter::resume(&path, &id) {
Ok(writer) => writer,
Err(error) => {
app.transcript
.push(Entry::Error { text: format!("cannot resume session `{id}`: {error}") });
return None;
}
};
let summary = session::SessionReader::read_summary(&path);
let transcript = session::SessionReader::read_transcript(&path);
let records = session::SessionReader::read_records(&path);
let turn_count = records
.iter()
.filter(|record| matches!(record, session::SessionRecord::User { .. }))
.count() as u64;
app.session_writer = Some(writer);
app.session_id = id.clone();
app.transcript = transcript;
app.restore_context_state(&records);
app.last_request_accounting = records.iter().rev().find_map(|record| match record {
session::SessionRecord::RequestAccounting { accounting, .. } => Some(accounting.clone()),
_ => None,
});
app.session_tokens_in = summary.input_tokens;
app.session_tokens_out = summary.output_tokens;
app.turn_count = turn_count;
app.last_input = None;
app.pending_manual_compaction = None;
app.queued_steering.clear();
app.queued_followups.clear();
app.pending_permission = None;
app.run_state = RunState::Idle;
app.input.clear();
app.history_cursor = None;
app.history_draft.clear();
app.transcript
.push(Entry::Status { text: format!("resumed session: {id}") });
None
}
fn read_session_log_command(app: &mut App, requested_session_id: Option<&str>) -> Option<Msg> {
let id = match requested_session_id {
Some(query) => match session::resolve_session_file(&app.session_directory(), query) {
Ok(path) => path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(query)
.to_string(),
Err(error) => {
app.transcript.push(Entry::Error { text: error.to_string() });
return None;
}
},
None => app.session_id.clone(),
};
let path = app
.cwd
.join(".thndrs")
.join("logs")
.join("sessions")
.join(format!("thndrs-{id}.log"));
let lines = session::read_redacted_log_tail(&path, 100);
if lines.is_empty() {
app.transcript
.push(Entry::Error { text: format!("debug log `{}` is empty or missing", path.display()) });
return None;
}
app.transcript
.push(Entry::Status { text: format!("debug log {id}:\n{}", lines.join("\n")) });
app.input.clear();
None
}
fn list_background_processes(app: &mut App) {
super::agent_lifecycle::drain_background_processes(app);
let bg_ids: Vec<u64> = app.process_registry.background_ids().collect();
if bg_ids.is_empty() {
app.transcript
.push(Entry::Status { text: String::from("no background processes") });
} else {
let lines: Vec<String> = bg_ids
.iter()
.filter_map(|id| {
app.process_registry.get(*id).map(|p| {
let elapsed = p.elapsed().as_secs();
let cmd = p.command.join(" ");
format!("[{id}] {cmd} cwd={} ({elapsed}s)", p.cwd.display())
})
})
.collect();
app.transcript
.push(Entry::Status { text: format!("background processes:\n{}", lines.join("\n")) });
}
}
fn cancel_background_process(app: &mut App, id_text: &str) -> Option<Msg> {
super::agent_lifecycle::drain_background_processes(app);
if id_text.is_empty() {
app.transcript
.push(Entry::Error { text: String::from("usage: :bg cancel <id>") });
return None;
}
let Ok(id) = id_text.parse::<u64>() else {
app.transcript
.push(Entry::Error { text: format!("invalid background process id: {id_text}") });
return None;
};
if app.process_registry.cancel(id) {
app.transcript
.push(Entry::Status { text: format!("cancellation requested for background process [{id}]") });
} else {
app.transcript
.push(Entry::Error { text: format!("background process [{id}] is not running") });
}
None
}
fn list_mcp_servers(app: &mut App) {
let env_vars: Vec<(String, String)> = std::env::vars().collect();
match mcp::config::load_effective_mcp(&app.cwd, &env_vars) {
Ok(effective) if effective.config.servers.is_empty() => {
app.transcript
.push(Entry::Status { text: String::from("no MCP servers configured") });
}
Ok(effective) => {
let mut lines = Vec::new();
for (name, server) in &effective.config.servers {
let status = if server.enabled { "enabled" } else { "disabled" };
lines.push(format!("{name}\t{status}\t{:?}", server.transport));
}
lines.extend(
effective
.diagnostics
.into_iter()
.map(|diagnostic| format!("diagnostic: {diagnostic}")),
);
app.transcript
.push(Entry::Status { text: format!("MCP servers:\n{}", lines.join("\n")) });
}
Err(err) => app
.transcript
.push(Entry::Error { text: format!("failed to load MCP config: {err}") }),
}
}
fn list_mcp_tools(app: &mut App, name: &str) {
if name.is_empty() {
app.transcript
.push(Entry::Error { text: String::from("usage: /mcp tools <name>") });
return;
}
let env_vars: Vec<(String, String)> = std::env::vars().collect();
let effective = match mcp::config::load_effective_mcp(&app.cwd, &env_vars) {
Ok(effective) => effective,
Err(err) => {
app.transcript
.push(Entry::Error { text: format!("failed to load MCP config: {err}") });
return;
}
};
let Some(server) = effective.config.servers.get(name) else {
app.transcript
.push(Entry::Error { text: format!("MCP server `{name}` is not configured") });
return;
};
if !server.enabled {
app.transcript
.push(Entry::Error { text: format!("MCP server `{name}` is disabled") });
return;
}
match mcp::manager::McpClient::connect(name.to_string(), server) {
Ok(client) => {
let lines: Vec<String> = client
.tool_definitions()
.into_iter()
.map(|tool| format!("{}\t{}", tool.name, tool.description))
.collect();
app.transcript.push(Entry::Status {
text: if lines.is_empty() {
format!("MCP server `{name}` exposes no tools")
} else {
format!("MCP tools for `{name}`:\n{}", lines.join("\n"))
},
});
}
Err(err) => app.transcript.push(Entry::Error { text: err.to_string() }),
}
}