use anyhow::Result;
use futures::StreamExt;
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io;
use crate::app::{load_config, persist_last_model, persist_reasoning_for_model};
use crate::models::{MessageRole, ModelFactory, ReasoningLevel};
use crate::ollama;
use crate::tui::App;
use crate::utils::drain_complete_lines;
use super::render::render_ui;
type TuiTerminal = Terminal<CrosstermBackend<io::Stdout>>;
pub async fn handle_command(
app: &mut App,
terminal: &mut TuiTerminal,
command: &str,
) -> Result<()> {
let parts: Vec<&str> = command.split_whitespace().collect();
match parts.first().copied() {
Some("quit") | Some("q") => handle_quit(app),
Some("clear") => handle_clear(app),
Some("model") => handle_model(app, terminal, parts.get(1).copied()).await,
Some("reasoning") => handle_reasoning(app, parts.get(1).copied()),
Some("save") => handle_save(app, parts.get(1).copied()),
Some("load") => handle_load(app, parts.get(1).copied()),
Some("list") => handle_list(app),
Some("cloud-setup") => handle_cloud_setup(app),
Some("help") | Some("h") => handle_help(app),
_ => {
app.set_status(format!("Unknown command: {}", command));
},
}
Ok(())
}
fn handle_reasoning(app: &mut App, level_arg: Option<&str>) {
let Some(level_str) = level_arg else {
app.set_status(format!(
"Current reasoning level: {}",
app.model_state.base_config.reasoning.as_str()
));
return;
};
let parsed = match level_str.to_lowercase().as_str() {
"none" => Some(ReasoningLevel::None),
"minimal" => Some(ReasoningLevel::Minimal),
"low" => Some(ReasoningLevel::Low),
"medium" => Some(ReasoningLevel::Medium),
"high" => Some(ReasoningLevel::High),
"max" => Some(ReasoningLevel::Max),
"xhigh" => Some(ReasoningLevel::XHigh),
_ => None,
};
let Some(level) = parsed else {
app.set_status(format!(
"Unknown reasoning level: '{}' (valid: none, minimal, low, medium, high, max, xhigh)",
level_str
));
return;
};
app.model_state.set_reasoning(level);
match persist_reasoning_for_model(&app.model_state.model_id, level) {
Ok(()) => app.set_status(format!(
"Reasoning set to: {} for {}",
level.as_str(),
app.model_state.model_id
)),
Err(e) => app.set_status(format!(
"Reasoning set to: {} (failed to persist: {})",
level.as_str(),
e
)),
}
}
fn handle_quit(app: &mut App) {
app.auto_save_conversation();
app.quit();
}
fn handle_clear(app: &mut App) {
app.session_state.messages.clear();
app.ui_state.markdown_cache.clear();
app.set_status("Chat cleared");
}
async fn handle_model(app: &mut App, terminal: &mut TuiTerminal, model_name: Option<&str>) {
if let Some(model_name) = model_name {
let model_id = if model_name.contains('/') {
model_name.to_string()
} else {
format!("ollama/{}", model_name)
};
if ollama::is_cloud_model(&model_id) && !ollama::is_cloud_configured() {
app.add_message(
MessageRole::System,
"Cloud model requested but Ollama Cloud is not configured.\n\n\
To use cloud models:\n\
1. Get an API key from https://ollama.com/cloud\n\
2. Run /cloud-setup to configure interactively\n\
OR\n\
3. Set environment variable: export OLLAMA_API_KEY=your_key\n\
OR\n\
4. Add to config: ~/.config/mermaid/config.toml\n\
[ollama]\n\
cloud_api_key = \"your_key\"\n\n\
Available cloud models:\n\
- kimi-k2-thinking:cloud\n\
- qwen3-coder:480b-cloud\n\
- deepseek-v3.1:671b-cloud\n\
- gpt-oss:120b-cloud"
.to_string(),
);
return;
}
let config = match load_config() {
Ok(cfg) => cfg,
Err(e) => {
app.set_status(format!("Failed to load config: {}", e));
return;
},
};
let bare_model = model_id.strip_prefix("ollama/").unwrap_or(&model_id);
if (model_id.starts_with("ollama/") || !model_id.contains('/'))
&& let Ok(models) = ModelFactory::from_config(&config)
.list_models("ollama")
.await
{
let model_exists = models.iter().any(|m| {
m == bare_model
|| (!bare_model.contains(':') && *m == format!("{}:latest", bare_model))
});
if !model_exists {
app.set_status(format!("Pulling model: {}...", bare_model));
app.add_message(
MessageRole::System,
format!(
"Model '{}' not found locally. Pulling from registry...",
bare_model
),
);
match pull_model_http(
app,
terminal,
bare_model,
&config.ollama.host,
config.ollama.port,
)
.await
{
Ok(()) => {
app.add_message(
MessageRole::System,
format!("Model '{}' pulled successfully.", bare_model),
);
},
Err(e) => {
app.set_status(format!("Failed to pull model: {}", e));
app.add_message(
MessageRole::System,
format!("Failed to pull model '{}': {}", bare_model, e),
);
return;
},
}
}
}
app.set_status(format!("Switching to model: {}...", model_id));
let new_model = ModelFactory::create(&model_id, Some(&config)).await;
match new_model {
Ok(model) => {
let new_supported_reasoning = model.capabilities().supports_reasoning.clone();
*app.model_state.model.write().await = model;
app.model_state.model_name = model_id.clone();
app.model_state.model_id = model_id.clone();
app.model_state.supported_reasoning = new_supported_reasoning;
app.model_state.vision_supported = None;
let new_reasoning = config
.reasoning_per_model
.get(&model_id)
.copied()
.unwrap_or(config.default_model.reasoning);
app.model_state.set_reasoning(new_reasoning);
if let Err(e) = persist_last_model(&model_id) {
app.set_status(format!("Switched to {} (failed to save: {})", model_id, e));
} else {
app.set_status(format!("Switched to model: {}", model_id));
}
},
Err(e) => {
app.set_status(format!("Failed to switch model: {}", e));
},
}
} else {
app.set_status(format!("Current model: {}", app.model_state.model_name));
}
}
fn handle_save(app: &mut App, name: Option<&str>) {
if let Err(e) = app.save_conversation() {
app.set_status(format!("Failed to save: {}", e));
} else {
app.set_status(if let Some(name) = name {
format!("Conversation saved as: {}", name)
} else {
"Conversation saved".to_string()
});
}
}
fn handle_load(app: &mut App, name: Option<&str>) {
if let Some(ref manager) = app.session_state.conversation_manager {
if let Some(name) = name {
match manager.load_conversation(name) {
Ok(conv) => {
app.load_conversation(conv);
},
Err(e) => {
app.set_status(format!("Failed to load: {}", e));
},
}
} else {
match manager.list_conversations() {
Ok(conversations) => {
if conversations.is_empty() {
app.set_status("No saved conversations found");
} else {
let list = conversations
.iter()
.map(|c| c.summary())
.collect::<Vec<_>>()
.join("\n");
app.add_message(
MessageRole::System,
format!(
"Available conversations:\n{}\n\nUse /load <id> to load a specific conversation",
list
),
);
}
},
Err(e) => {
app.set_status(format!("Failed to list conversations: {}", e));
},
}
}
}
}
fn handle_list(app: &mut App) {
if let Some(ref manager) = app.session_state.conversation_manager {
match manager.list_conversations() {
Ok(conversations) => {
if conversations.is_empty() {
app.set_status("No saved conversations in this directory");
} else {
let list = conversations
.iter()
.map(|c| c.summary())
.collect::<Vec<_>>()
.join("\n");
app.add_message(
MessageRole::System,
format!("Saved conversations:\n{}", list),
);
}
},
Err(e) => {
app.set_status(format!("Failed to list conversations: {}", e));
},
}
}
}
fn handle_cloud_setup(app: &mut App) {
app.add_message(
MessageRole::System,
"Ollama Cloud Setup\n\n\
Option 1 \u{2014} environment variable (session-scoped):\n\
export OLLAMA_API_KEY=your_key_here\n\
(set before launching Mermaid; takes effect on next start)\n\n\
Option 2 \u{2014} config file (persisted):\n\
Edit ~/.config/mermaid/config.toml and add:\n\
[ollama]\n\
cloud_api_key = \"your_key_here\"\n\n\
Get an API key at: https://ollama.com/cloud\n\n\
After setup, switch to a cloud model with /model <name>:cloud\n\
(e.g., /model kimi-k2-thinking:cloud, /model qwen3-coder:480b-cloud,\n\
/model deepseek-v3.1:671b-cloud)."
.to_string(),
);
}
async fn pull_model_http(
app: &mut App,
terminal: &mut TuiTerminal,
model_name: &str,
host: &str,
port: u16,
) -> anyhow::Result<()> {
let url = format!("http://{}:{}/api/pull", host, port);
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.build()?;
let response = client
.post(&url)
.json(&serde_json::json!({
"model": model_name,
"stream": true
}))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("HTTP {}: {}", status, body);
}
let mut stream = response.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result?;
buf.extend_from_slice(&chunk);
for line in drain_complete_lines(&mut buf) {
if line.trim().is_empty() {
continue;
}
let parsed: serde_json::Value = serde_json::from_str(&line)
.map_err(|e| anyhow::anyhow!("invalid pull progress JSON: {} ({})", e, line))?;
if let Some(err) = parsed.get("error").and_then(|v| v.as_str()) {
anyhow::bail!("{}", err);
}
let status = parsed
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("pulling");
let display = match (
parsed.get("completed").and_then(|v| v.as_u64()),
parsed.get("total").and_then(|v| v.as_u64()),
) {
(Some(done), Some(total)) if total > 0 => {
let pct = (done as f64 / total as f64 * 100.0) as u64;
format!(
"Pulling {}: {} {}% ({} / {})",
model_name,
status,
pct,
format_bytes(done),
format_bytes(total),
)
},
_ => format!("Pulling {}: {}", model_name, status),
};
app.set_status(display);
terminal.draw(|f| render_ui(f, app))?;
}
}
Ok(())
}
fn format_bytes(n: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if n >= GB {
format!("{:.1} GB", n as f64 / GB as f64)
} else if n >= MB {
format!("{:.1} MB", n as f64 / MB as f64)
} else if n >= KB {
format!("{:.1} KB", n as f64 / KB as f64)
} else {
format!("{} B", n)
}
}
fn handle_help(app: &mut App) {
let mut output = String::from("COMMANDS:\n");
for cmd in crate::tui::slash_commands::COMMAND_REGISTRY {
let name_with_args = match cmd.arg_hint {
Some(hint) => format!("/{} {}", cmd.name, hint),
None => format!("/{}", cmd.name),
};
let alias_suffix = if cmd.aliases.is_empty() {
String::new()
} else {
let aliases: Vec<String> = cmd.aliases.iter().map(|a| format!("/{}", a)).collect();
format!(" (alias: {})", aliases.join(", "))
};
output.push_str(&format!(
" {} - {}{}\n",
name_with_args, cmd.description, alias_suffix
));
}
output.push_str(
"\nKEYBOARD:\n\
Enter - Send message\n\
Esc - Stop generation / clear input\n\
Ctrl+C - Quit\n\
Alt+T - Cycle reasoning level (none → low → medium → high → max → none)\n\
Ctrl+V - Paste image or text from clipboard\n\
Ctrl+O - Preview attached image\n\
Ctrl+Click - Open image from chat history\n\
Up/Down - Navigate input history or scroll chat\n\
Page Up/Down - Scroll chat\n\
Mouse Wheel - Scroll chat\n\
Left/Right - Move cursor in input\n\
Home/End - Jump to start/end of input",
);
app.add_message(MessageRole::System, output);
}
#[cfg(test)]
mod tests {
use super::format_bytes;
#[test]
fn format_bytes_scales_correctly() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(512), "512 B");
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1536), "1.5 KB");
assert_eq!(format_bytes(2 * 1024 * 1024), "2.0 MB");
assert_eq!(
format_bytes(3 * 1024 * 1024 * 1024 + 512 * 1024 * 1024),
"3.5 GB"
);
}
}