use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
use tokio::sync::mpsc;
use crate::channels::{AgentUpdate, UserCommand};
use crate::config::Provider;
use crate::credentials::CredentialStore;
pub struct SlashCommand {
pub name: &'static str,
pub description: &'static str,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AutocompleteItem {
pub value: String,
pub description: String,
}
const SLASH_COMMANDS: &[SlashCommand] = &[
SlashCommand {
name: "/help",
description: "Show this help",
},
SlashCommand {
name: "/clear",
description: "Clear chat history",
},
SlashCommand {
name: "/status",
description: "Show connection status",
},
SlashCommand {
name: "/project",
description: "Show project info",
},
SlashCommand {
name: "/explain",
description: "Toggle explain mode",
},
SlashCommand {
name: "/network",
description: "Switch network (local/testnet/mainnet)",
},
SlashCommand {
name: "/model",
description: "Show model status and suggestions",
},
SlashCommand {
name: "/model set",
description: "Switch provider and model",
},
SlashCommand {
name: "/model provider",
description: "Switch provider only",
},
SlashCommand {
name: "/model model",
description: "Switch model only",
},
SlashCommand {
name: "/login",
description: "Save an API key for a provider",
},
SlashCommand {
name: "/logout",
description: "Remove a stored API key",
},
SlashCommand {
name: "/providers",
description: "Show credential status for every provider",
},
SlashCommand {
name: "/install-stellar-build",
description: "Install the Stellar Build persona pack (third-party)",
},
];
#[derive(Clone, Debug)]
pub enum ChatMessage {
User(String),
Agent(String),
System(String),
}
#[derive(Clone, Debug, PartialEq)]
pub enum AppStatus {
Ready,
Working,
NeedsCredential,
}
pub fn status_label(status: &AppStatus) -> &'static str {
match status {
AppStatus::Ready => "Ready",
AppStatus::Working => "Working...",
AppStatus::NeedsCredential => "No API key",
}
}
pub fn context_meter(used: usize, window: usize) -> String {
fn thousands(n: usize) -> String {
if n < 1_000 {
return n.to_string();
}
let k = n as f64 / 1_000.0;
if k < 10.0 {
format!("{:.1}k", k)
} else {
format!("{:.0}k", k)
}
}
format!("{}/{}", thousands(used), thousands(window))
}
pub fn activity_label(activity: &str) -> &'static str {
let lower = activity.to_lowercase();
if lower.contains("thinking") {
"Thinking"
} else if lower.contains("caatinga_build") || lower.contains("building") {
"Building contract"
} else if lower.contains("caatinga_deploy") || lower.contains("deploying") {
"Deploying"
} else if lower.contains("caatinga_doctor") || lower.contains("doctor") {
"Checking env"
} else if lower.contains("stellar_invoke")
|| lower.contains("caatinga_invoke")
|| lower.contains("invok")
{
"Invoking"
} else if lower.contains("run_tests") || lower.contains("test") {
"Testing"
} else if lower.contains("raven") {
"Searching Stellar Docs"
} else if lower.contains("search") || lower.contains("grep") || lower.contains("glob") {
"Searching"
} else if lower.contains("using tool") {
"Executing"
} else {
"Working..."
}
}
fn home_relative_cwd() -> String {
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(_) => return ".".to_string(),
};
let home = std::env::var_os("HOME").map(PathBuf::from);
match home {
Some(home) => match cwd.strip_prefix(&home) {
Ok(rest) if rest.as_os_str().is_empty() => "~".to_string(),
Ok(rest) => format!("~/{}", rest.display()),
Err(_) => cwd.display().to_string(),
},
None => cwd.display().to_string(),
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ExecutionStep {
pub label: String,
pub state: ExecutionStepState,
pub after: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ExecutionStepState {
Running,
Waiting,
Done,
Failed,
}
impl ExecutionStepState {
fn is_open(&self) -> bool {
matches!(self, Self::Running | Self::Waiting)
}
}
pub struct AppState {
pub messages: Vec<ChatMessage>,
pub input: String,
pub input_cursor: usize,
pub status: AppStatus,
pub chat_scroll: usize,
chat_follow: bool,
pub project_name: String,
pub cwd_label: String,
pub active_network: String,
pub active_account: String,
pub active_contract: Option<String>,
pub active_provider: String,
pub active_model: String,
pub mcp_servers: Vec<crate::channels::McpServerStatus>,
pub palette_open: bool,
pub palette_input: String,
pub palette_cursor: usize,
pub palette_matches: Vec<AutocompleteItem>,
pub palette_selected: usize,
agent_streaming: bool,
explain_mode: bool,
has_credential: bool,
credentials_path: Option<PathBuf>,
pub autocomplete_active: bool,
pub autocomplete_matches: Vec<AutocompleteItem>,
pub autocomplete_selected: usize,
pub autocomplete_prefix: String,
pub current_activity: Option<String>,
pub spinner_frame: usize,
command_history: Vec<String>,
history_cursor: Option<usize>,
history_draft: String,
pub execution_steps: Vec<ExecutionStep>,
pub execution_failed: bool,
pub cancel: crate::channels::CancelFlag,
pub pending_approval: Option<crate::channels::ApprovalRequest>,
pub approval_tx: Option<mpsc::UnboundedSender<crate::channels::ApprovalDecision>>,
pub mainnet_allowed: bool,
pub local_models: Vec<String>,
pub context_usage: Option<(usize, usize)>,
}
impl AppState {
pub fn new() -> Self {
Self {
messages: Vec::new(),
input: String::new(),
input_cursor: 0,
status: AppStatus::Ready,
chat_scroll: 0,
chat_follow: true,
project_name: "No project".to_string(),
cwd_label: home_relative_cwd(),
active_network: "testnet".to_string(),
active_account: "None".to_string(),
active_contract: None,
active_provider: "anthropic".to_string(),
active_model: "claude-sonnet-5".to_string(),
mcp_servers: Vec::new(),
palette_open: false,
palette_input: String::new(),
palette_cursor: 0,
palette_matches: Vec::new(),
palette_selected: 0,
agent_streaming: false,
explain_mode: false,
has_credential: true,
credentials_path: None,
autocomplete_active: false,
autocomplete_matches: Vec::new(),
autocomplete_selected: 0,
autocomplete_prefix: String::new(),
current_activity: None,
spinner_frame: 0,
command_history: Vec::new(),
history_cursor: None,
history_draft: String::new(),
execution_steps: Vec::new(),
execution_failed: false,
cancel: crate::channels::CancelFlag::default(),
pending_approval: None,
approval_tx: None,
mainnet_allowed: false,
local_models: Vec::new(),
context_usage: None,
}
}
pub fn tick(&mut self) {
self.spinner_frame = self.spinner_frame.wrapping_add(1);
}
fn push_history(&mut self, line: String) {
if self.command_history.last() != Some(&line) {
self.command_history.push(line);
}
self.history_cursor = None;
}
fn recall_history(&mut self, delta: isize) {
if self.command_history.is_empty() {
return;
}
let next = match self.history_cursor {
None if delta < 0 => {
self.history_draft = self.input.clone();
self.command_history.len() - 1
}
None => return,
Some(i) => {
let next = i as isize + delta;
if next < 0 {
return;
}
if next as usize >= self.command_history.len() {
self.input = std::mem::take(&mut self.history_draft);
self.input_cursor = self.input_char_count();
self.history_cursor = None;
return;
}
next as usize
}
};
self.input = self.command_history[next].clone();
self.input_cursor = self.input_char_count();
self.history_cursor = Some(next);
}
fn cursor_byte_offset(&self) -> usize {
self.input
.char_indices()
.nth(self.input_cursor)
.map(|(i, _)| i)
.unwrap_or(self.input.len())
}
fn input_char_count(&self) -> usize {
self.input.chars().count()
}
fn sync_autocomplete(&mut self) {
if !self.input.starts_with('/') {
self.autocomplete_active = false;
self.autocomplete_matches.clear();
return;
}
self.autocomplete_matches = self.compute_autocomplete_matches();
self.autocomplete_selected = 0;
self.autocomplete_active = !self.autocomplete_matches.is_empty();
}
fn compute_autocomplete_matches(&self) -> Vec<AutocompleteItem> {
if !self.input.contains(' ') {
return Self::filter_candidates(
SLASH_COMMANDS
.iter()
.map(|c| (c.name.to_string(), c.description.to_string())),
&self.input,
);
}
let tokens: Vec<&str> = self.input.split_whitespace().collect();
let (fixed, partial) = if self.input.ends_with(' ') {
(tokens.as_slice(), "")
} else {
(&tokens[..tokens.len() - 1], *tokens.last().unwrap())
};
let fixed: Vec<String> = fixed.iter().map(|t| t.to_lowercase()).collect();
let fixed: Vec<&str> = fixed.iter().map(String::as_str).collect();
let model_names = |provider: &str| -> Vec<(String, String)> {
let local: Option<Provider> = provider.parse().ok().filter(|p: &Provider| p.is_local());
if local.is_some() && !self.local_models.is_empty() {
return self
.local_models
.iter()
.map(|m| (m.clone(), String::new()))
.collect();
}
provider
.parse::<Provider>()
.ok()
.into_iter()
.flat_map(|p| p.suggested_models())
.map(|m| (m.to_string(), "suggestion".to_string()))
.collect()
};
let candidates: Vec<(String, String)> = match fixed.as_slice() {
["/model"] => vec![
(
"status".to_string(),
"Show model status and suggestions".to_string(),
),
("set".to_string(), "Switch provider and model".to_string()),
("provider".to_string(), "Switch provider only".to_string()),
("model".to_string(), "Switch model only".to_string()),
],
["/model", "provider"] | ["/model", "set"] | ["/login"] | ["/logout"] => {
Self::provider_candidates()
}
["/model", "model"] => model_names(&self.active_provider),
["/model", "set", provider] => model_names(provider),
["/network"] => vec![
("local".to_string(), String::new()),
("testnet".to_string(), String::new()),
(
"mainnet".to_string(),
"Real funds — asks to confirm".to_string(),
),
],
["/network", "mainnet"] => vec![(
"confirm".to_string(),
"Actually switch to the public network".to_string(),
)],
["/install-stellar-build"] => vec![(
"confirm".to_string(),
"Actually run the third-party installer".to_string(),
)],
_ => Vec::new(),
};
Self::filter_candidates(candidates.into_iter(), partial)
}
fn provider_candidates() -> Vec<(String, String)> {
Provider::ALL
.iter()
.map(|p| {
let hint = if p.is_local() {
"local, no credential needed"
} else {
""
};
(p.to_string(), hint.to_string())
})
.collect()
}
fn filter_candidates(
candidates: impl Iterator<Item = (String, String)>,
partial: &str,
) -> Vec<AutocompleteItem> {
let partial = partial.to_lowercase();
candidates
.filter(|(value, _)| value.to_lowercase().starts_with(&partial))
.map(|(value, description)| AutocompleteItem { value, description })
.collect()
}
fn move_autocomplete_selection(&mut self, delta: isize) {
let len = self.autocomplete_matches.len();
if len == 0 {
return;
}
let len_i = len as isize;
let next = (self.autocomplete_selected as isize + delta).rem_euclid(len_i);
self.autocomplete_selected = next as usize;
}
fn accept_autocomplete(&mut self) {
if let Some(item) = self.autocomplete_matches.get(self.autocomplete_selected) {
let value = item.value.clone();
if self.input.contains(' ') {
let base = self.input.rfind(' ').map(|i| i + 1).unwrap_or(0);
self.input.truncate(base);
self.input.push_str(&value);
self.input.push(' ');
} else {
self.input = value;
}
self.input_cursor = self.input.chars().count();
}
self.autocomplete_active = false;
self.autocomplete_matches.clear();
}
fn cancel_autocomplete(&mut self) {
self.input = self.autocomplete_prefix.clone();
self.input_cursor = self.input.chars().count();
self.autocomplete_active = false;
self.autocomplete_matches.clear();
}
#[cfg(test)]
pub fn slash_commands() -> &'static [SlashCommand] {
SLASH_COMMANDS
}
pub fn scroll_back(&mut self, lines: usize) {
self.chat_follow = false;
self.chat_scroll = self.chat_scroll.saturating_sub(lines);
}
pub fn scroll_forward(&mut self, lines: usize) {
self.chat_scroll = self.chat_scroll.saturating_add(lines);
}
pub fn resolve_scroll(&mut self, max_scroll: usize) -> usize {
if self.chat_follow || self.chat_scroll >= max_scroll {
self.chat_follow = true;
self.chat_scroll = max_scroll;
}
self.chat_scroll
}
pub fn is_following_chat(&self) -> bool {
self.chat_follow
}
pub fn is_explaining(&self) -> bool {
self.explain_mode
}
fn palette_sync(&mut self) {
let query = self.palette_input.trim().to_lowercase();
let mut items: Vec<AutocompleteItem> = Vec::new();
for cmd in SLASH_COMMANDS {
if query.is_empty()
|| cmd.name.to_lowercase().contains(&query)
|| cmd.description.to_lowercase().contains(&query)
{
items.push(AutocompleteItem {
value: cmd.name.to_string(),
description: cmd.description.to_string(),
});
}
}
let actions = [
("/build", "Build the project (Ctrl+B)"),
("/test", "Run tests (Ctrl+T)"),
("/deploy", "Deploy contract (Ctrl+D)"),
("/doctor", "Check environment / Caatinga doctor"),
];
for (name, desc) in actions {
if query.is_empty() || name.contains(&query) || desc.to_lowercase().contains(&query) {
items.push(AutocompleteItem {
value: name.to_string(),
description: desc.to_string(),
});
}
}
self.palette_matches = items;
if self.palette_selected >= self.palette_matches.len() {
self.palette_selected = 0;
}
}
pub fn palette_open(&mut self) {
self.palette_open = true;
self.palette_input.clear();
self.palette_cursor = 0;
self.palette_selected = 0;
self.palette_sync();
}
pub fn palette_close(&mut self) {
self.palette_open = false;
self.palette_input.clear();
self.palette_matches.clear();
self.palette_selected = 0;
}
fn palette_cursor_byte(&self) -> usize {
self.palette_input
.char_indices()
.nth(self.palette_cursor)
.map(|(i, _)| i)
.unwrap_or(self.palette_input.len())
}
fn palette_handle_key(
&mut self,
key: crossterm::event::KeyEvent,
user_tx: &mpsc::UnboundedSender<UserCommand>,
) -> bool {
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => return true,
(KeyModifiers::NONE, KeyCode::Esc) => {
self.palette_close();
}
(KeyModifiers::NONE, KeyCode::Enter) => {
if let Some(item) = self.palette_matches.get(self.palette_selected).cloned() {
self.palette_close();
if item.value.starts_with('/') {
match item.value.as_str() {
"/build" | "/test" | "/deploy" | "/doctor" => {
self.run_quick_action(&item.value, user_tx)
}
_ => self.handle_command(&item.value, user_tx),
}
}
} else {
self.palette_close();
}
}
(KeyModifiers::NONE, KeyCode::Up) => {
if !self.palette_matches.is_empty() {
let len = self.palette_matches.len() as isize;
self.palette_selected =
(self.palette_selected as isize - 1).rem_euclid(len) as usize;
}
}
(KeyModifiers::NONE, KeyCode::Down) | (KeyModifiers::NONE, KeyCode::Tab) => {
if !self.palette_matches.is_empty() {
let len = self.palette_matches.len() as isize;
self.palette_selected =
(self.palette_selected as isize + 1).rem_euclid(len) as usize;
}
}
(KeyModifiers::NONE, KeyCode::BackTab) | (KeyModifiers::SHIFT, KeyCode::BackTab) => {
if !self.palette_matches.is_empty() {
let len = self.palette_matches.len() as isize;
self.palette_selected =
(self.palette_selected as isize - 1).rem_euclid(len) as usize;
}
}
(KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => {
let at = self.palette_cursor_byte();
self.palette_input.insert(at, c);
self.palette_cursor += 1;
self.palette_sync();
}
(KeyModifiers::NONE, KeyCode::Backspace) => {
if self.palette_cursor > 0 {
self.palette_cursor -= 1;
let at = self.palette_cursor_byte();
self.palette_input.remove(at);
self.palette_sync();
}
}
(KeyModifiers::NONE, KeyCode::Delete) => {
if self.palette_cursor < self.palette_input.chars().count() {
let at = self.palette_cursor_byte();
self.palette_input.remove(at);
self.palette_sync();
}
}
(KeyModifiers::NONE, KeyCode::Left) => {
if self.palette_cursor > 0 {
self.palette_cursor -= 1;
}
}
(KeyModifiers::NONE, KeyCode::Right) => {
if self.palette_cursor < self.palette_input.chars().count() {
self.palette_cursor += 1;
}
}
(KeyModifiers::NONE, KeyCode::Home) | (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
self.palette_cursor = 0;
}
(KeyModifiers::NONE, KeyCode::End) | (KeyModifiers::CONTROL, KeyCode::Char('e')) => {
self.palette_cursor = self.palette_input.chars().count();
}
_ => {}
}
false
}
fn approval_handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
use crate::channels::ApprovalDecision;
let decision = match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => return true,
(_, KeyCode::Char('y')) | (_, KeyCode::Enter) => ApprovalDecision::Once,
(_, KeyCode::Char('a')) => ApprovalDecision::Always,
(_, KeyCode::Char('n')) | (_, KeyCode::Esc) => ApprovalDecision::Deny,
_ => return false,
};
let request = self.pending_approval.take();
let tool = request.as_ref().map(|r| r.tool.clone()).unwrap_or_default();
let scope = request
.as_ref()
.map(|r| r.scope.clone())
.unwrap_or_else(|| tool.clone());
let what = request.map(|r| r.detail).unwrap_or_else(|| tool.clone());
self.messages.push(ChatMessage::System(match decision {
ApprovalDecision::Once => format!("Allowed {}", what),
ApprovalDecision::Always => {
format!(
"Allowed {} — and {} for the rest of this session",
what, scope
)
}
ApprovalDecision::Deny => format!("Declined {}", what),
}));
if let Some(tx) = &self.approval_tx {
let _ = tx.send(decision);
}
false
}
fn submit_line(&mut self, user_tx: &mpsc::UnboundedSender<UserCommand>) {
let msg = self.input.trim().to_string();
if msg.is_empty() {
return;
}
self.push_history(msg.clone());
self.clear_execution();
self.messages.push(ChatMessage::User(Self::echo_of(&msg)));
if msg.starts_with('/') {
self.handle_command(&msg, user_tx);
} else {
let _ = user_tx.send(UserCommand::SendPrompt(msg));
}
self.input.clear();
self.input_cursor = 0;
self.autocomplete_active = false;
self.autocomplete_matches.clear();
}
fn echo_of(submitted: &str) -> String {
let mut parts = submitted.split_whitespace();
if parts.next() != Some("/login") {
return submitted.to_string();
}
match parts.next() {
Some(provider) => format!("/login {} ••••••", provider),
None => "/login".to_string(),
}
}
fn run_quick_action(&mut self, action: &str, user_tx: &mpsc::UnboundedSender<UserCommand>) {
let (name, input, label) = match action {
"/build" => ("caatinga_build", serde_json::json!({}), "Building project"),
"/test" => ("run_tests", serde_json::json!({}), "Running tests"),
"/deploy" => (
"caatinga_deploy",
serde_json::json!({ "network": self.active_network }),
"Deploying contract",
),
"/doctor" => (
"caatinga_doctor",
serde_json::json!({}),
"Checking environment",
),
_ => return,
};
self.clear_execution();
let _ = user_tx.send(UserCommand::RunTool {
name: name.to_string(),
input,
label: label.to_string(),
});
}
pub fn handle_key(
&mut self,
key: crossterm::event::KeyEvent,
user_tx: &mpsc::UnboundedSender<UserCommand>,
) -> bool {
if key.kind != KeyEventKind::Press {
return false;
}
if self.pending_approval.is_some() {
return self.approval_handle_key(key);
}
if self.palette_open {
return self.palette_handle_key(key, user_tx);
}
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => return true,
(KeyModifiers::CONTROL, KeyCode::Char('k')) => {
self.palette_open();
}
(KeyModifiers::CONTROL, KeyCode::Char('d')) => {
self.run_quick_action("/deploy", user_tx);
}
(KeyModifiers::CONTROL, KeyCode::Char('t')) => {
self.run_quick_action("/test", user_tx);
}
(KeyModifiers::CONTROL, KeyCode::Char('b')) => {
self.run_quick_action("/build", user_tx);
}
(KeyModifiers::NONE, KeyCode::Tab | KeyCode::Down) if self.autocomplete_active => {
self.move_autocomplete_selection(1);
}
(KeyModifiers::NONE, KeyCode::BackTab | KeyCode::Up)
| (KeyModifiers::SHIFT, KeyCode::BackTab)
if self.autocomplete_active =>
{
self.move_autocomplete_selection(-1);
}
(KeyModifiers::NONE, KeyCode::Enter) if self.autocomplete_active => {
let already_typed = self
.autocomplete_matches
.get(self.autocomplete_selected)
.is_some_and(|item| {
let current_token = if self.input.ends_with(' ') {
""
} else {
self.input.rsplit(' ').next().unwrap_or(&self.input)
};
current_token.eq_ignore_ascii_case(&item.value)
});
if already_typed {
self.submit_line(user_tx);
} else {
self.accept_autocomplete();
}
}
(KeyModifiers::NONE, KeyCode::Esc) if self.autocomplete_active => {
self.cancel_autocomplete();
}
(KeyModifiers::NONE, KeyCode::Esc) if matches!(self.status, AppStatus::Working) => {
self.cancel.raise();
self.current_activity = Some("Interrupting...".to_string());
}
(KeyModifiers::NONE, KeyCode::Enter) => {
self.submit_line(user_tx);
}
(KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char('?'))
if self.input.is_empty() =>
{
self.handle_command("/help", user_tx);
}
(KeyModifiers::ALT, KeyCode::Up) => self.recall_history(-1),
(KeyModifiers::ALT, KeyCode::Down) => self.recall_history(1),
(KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => {
self.history_cursor = None;
let at = self.cursor_byte_offset();
self.input.insert(at, c);
self.input_cursor += 1;
if c == '/' && self.input_cursor == 1 {
self.autocomplete_prefix = self.input.clone();
}
self.sync_autocomplete();
}
(KeyModifiers::NONE, KeyCode::Backspace) => {
if self.input_cursor > 0 {
self.history_cursor = None;
self.input_cursor -= 1;
let at = self.cursor_byte_offset();
self.input.remove(at);
self.sync_autocomplete();
}
}
(KeyModifiers::NONE, KeyCode::Delete) => {
if self.input_cursor < self.input_char_count() {
self.history_cursor = None;
let at = self.cursor_byte_offset();
self.input.remove(at);
self.sync_autocomplete();
}
}
(KeyModifiers::NONE, KeyCode::Left) => {
if self.input_cursor > 0 {
self.input_cursor -= 1;
}
}
(KeyModifiers::NONE, KeyCode::Right) => {
if self.input_cursor < self.input_char_count() {
self.input_cursor += 1;
}
}
(KeyModifiers::NONE, KeyCode::Home) | (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
self.input_cursor = 0;
}
(KeyModifiers::NONE, KeyCode::End) | (KeyModifiers::CONTROL, KeyCode::Char('e')) => {
self.input_cursor = self.input_char_count();
}
(KeyModifiers::NONE, KeyCode::Up) => self.scroll_back(1),
(KeyModifiers::NONE, KeyCode::Down) => self.scroll_forward(1),
(KeyModifiers::NONE, KeyCode::PageUp) => self.scroll_back(10),
(KeyModifiers::NONE, KeyCode::PageDown) => self.scroll_forward(10),
_ => {}
}
false
}
fn request_switch(
&mut self,
user_tx: &mpsc::UnboundedSender<UserCommand>,
provider: Provider,
model: String,
) {
if user_tx
.send(UserCommand::SwitchModel { provider, model })
.is_err()
{
self.messages.push(ChatMessage::System(
"The agent is no longer running, so the model cannot be switched. Restart Procyon."
.to_string(),
));
}
}
fn open_credential_store(&self) -> color_eyre::Result<CredentialStore> {
match &self.credentials_path {
Some(path) => CredentialStore::load(path.clone()),
None => CredentialStore::load_default(),
}
}
fn handle_command(&mut self, cmd: &str, user_tx: &mpsc::UnboundedSender<UserCommand>) {
let parts: Vec<&str> = cmd.split_whitespace().collect();
let command = parts[0];
match command {
"/help" => {
self.messages.push(ChatMessage::System(
"Available commands:\n\
/help - Show this help\n\
/clear - Clear chat history\n\
/status - Show connection status\n\
/project - Show project info\n\
/network <net> - Switch network (local/testnet/mainnet)\n\
/explain - Toggle explain mode\n\
/model - Show model status and suggestions\n\
/model set <prov> <mdl> - Switch provider and model\n\
/model provider <name> - Switch provider only\n\
/model model <name> - Switch model only\n\
/login <prov> <key> - Save an API key for a provider\n\
/logout <prov> - Remove a stored API key\n\
/providers - Show credential status for every provider\n\
/install-stellar-build - Install the Stellar Build persona pack \
(third-party)\n\
\n\
Quick actions:\n\
Ctrl+B - Build project\n\
Ctrl+T - Run tests\n\
Ctrl+D - Deploy contract\n\
\n\
Keyboard shortcuts:\n\
Ctrl+K - Command palette\n\
Esc - Stop the turn in flight\n\
Ctrl+C - Quit\n\
? - This help (on an empty prompt)\n\
Up/Down - Scroll chat\n\
Alt+Up/Down - Command history"
.to_string(),
));
}
"/clear" => {
self.messages.clear();
self.clear_execution();
self.messages
.push(ChatMessage::System("Chat cleared.".to_string()));
}
"/status" => {
let mut out = format!(
"Status: {}\nProject: {}\nNetwork: {}\nAccount: {}\nContract: {}\n\
Provider: {} / {}\nExplain: {}",
status_label(&self.status),
self.project_name,
if self.active_network == "mainnet" && !self.mainnet_allowed {
"mainnet (signing disabled)".to_string()
} else {
self.active_network.clone()
},
self.active_account,
self.active_contract.as_deref().unwrap_or("—"),
self.active_provider,
self.active_model,
if self.is_explaining() { "on" } else { "off" },
);
out.push_str(&match self.context_usage {
Some((used, window)) => {
format!("\nContext: {}", context_meter(used, window))
}
None => "\nContext: not measured yet".to_string(),
});
let mcp = |srv: &crate::channels::McpServerStatus| {
format!(
"{} {} {}",
if srv.connected { "●" } else { "○" },
srv.name,
srv.detail
)
};
match self.mcp_servers.as_slice() {
[] => out.push_str("\nMCP: none"),
[only] => out.push_str(&format!("\nMCP: {}", mcp(only))),
many => {
let connected = many.iter().filter(|s| s.connected).count();
out.push_str(&format!(
"\nMCP: {} servers, {} connected",
many.len(),
connected
));
for srv in many {
out.push_str(&format!("\n {}", mcp(srv)));
}
}
}
self.messages.push(ChatMessage::System(out));
}
"/project" => {
self.messages.push(ChatMessage::System(format!(
"Project: {}\nNetwork: {}",
self.project_name, self.active_network
)));
}
"/explain" => {
self.explain_mode = !self.explain_mode;
let _ = user_tx.send(UserCommand::SetExplain(self.explain_mode));
self.messages.push(ChatMessage::System(
if self.explain_mode {
"Explain mode on: the agent will narrate each step it takes."
} else {
"Explain mode off."
}
.to_string(),
));
}
"/network" => {
if let Some(network) = parts.get(1) {
match *network {
"mainnet" if parts.get(2).copied() != Some("confirm") => {
self.messages.push(ChatMessage::System(
if self.mainnet_allowed {
"Mainnet is the public network: operations there spend real \
funds and cannot be undone. Signing is enabled on this \
machine.\n\nRun `/network mainnet confirm` to switch."
} else {
"Mainnet is the public network: operations there spend real \
funds and cannot be undone. Signing is currently disabled, so \
deploys and invokes would be refused — reading is \
unaffected.\n\nRun `/network mainnet confirm` to switch \
anyway."
}
.to_string(),
));
}
"local" | "testnet" | "mainnet" => {
self.active_network = network.to_string();
let note = match (*network == "mainnet", self.mainnet_allowed) {
(true, true) => " — signing enabled, operations spend real funds",
(true, false) => {
" — signing disabled, so deploys and invokes will \
be refused"
}
_ => "",
};
self.messages.push(ChatMessage::System(format!(
"Network switched to {}{}",
network, note
)));
}
_ => {
self.messages.push(ChatMessage::System(
"Invalid network. Use: local, testnet, or mainnet".to_string(),
));
}
}
} else {
self.messages.push(ChatMessage::System(format!(
"Current network: {}",
self.active_network
)));
}
}
"/model" => {
let sub = parts.get(1).copied();
match sub {
None | Some("status") => {
let provider: Provider =
self.active_provider.parse().unwrap_or(Provider::Anthropic);
let (heading, models) = if self.local_models.is_empty() {
(
"Suggested models:",
provider
.suggested_models()
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>(),
)
} else {
("Installed models:", self.local_models.clone())
};
let mut msg = format!(
"Provider: {}\nModel: {}\n\n{}",
self.active_provider, self.active_model, heading
);
for model in models {
let active = crate::llm::is_installed(
std::slice::from_ref(&model),
&self.active_model,
);
msg.push_str(&format!(
"\n {}{}",
model,
if active { " (in use)" } else { "" }
));
}
self.messages.push(ChatMessage::System(msg));
}
Some("set") => {
let provider_str = parts.get(2);
let model_str = parts.get(3);
match (provider_str, model_str) {
(Some(p), Some(m)) => match p.parse::<Provider>() {
Ok(provider) => {
self.request_switch(user_tx, provider, m.to_string());
}
Err(e) => {
self.messages.push(ChatMessage::System(e));
}
},
_ => {
self.messages.push(ChatMessage::System(
"Usage: /model set <provider> <model>".to_string(),
));
}
}
}
Some("provider") => match parts.get(2) {
Some(p) => match p.parse::<Provider>() {
Ok(provider) => {
let model = if provider
.suggested_models()
.contains(&self.active_model.as_str())
{
self.active_model.clone()
} else {
provider.default_model().to_string()
};
if model.is_empty() {
self.messages.push(ChatMessage::System(format!(
"{} serves no model this build can name. Use `/model set \
{} <model>`.",
p, p
)));
} else {
self.request_switch(user_tx, provider, model);
}
}
Err(e) => {
self.messages.push(ChatMessage::System(e));
}
},
None => {
self.messages.push(ChatMessage::System(
"Usage: /model provider <name>".to_string(),
));
}
},
Some("model") => match parts.get(2) {
Some(m) => {
let provider =
self.active_provider.parse().unwrap_or(Provider::Anthropic);
self.request_switch(user_tx, provider, m.to_string());
}
None => {
self.messages.push(ChatMessage::System(
"Usage: /model model <name>".to_string(),
));
}
},
Some(unknown) => {
self.messages.push(ChatMessage::System(format!(
"Unknown subcommand: {}. Use: status, set, provider, model",
unknown
)));
}
}
}
"/login" => {
match (parts.get(1), parts.get(2)) {
(Some(p), Some(_)) => match p.parse::<Provider>() {
Ok(provider) => match self.open_credential_store() {
Ok(mut store) => {
let key = parts[2..].join(" ");
match store.set(&provider.to_string(), key) {
Ok(()) => {
self.messages.push(ChatMessage::System(format!(
"Saved credential for {}.",
provider
)));
if provider.to_string() == self.active_provider {
self.request_switch(
user_tx,
provider,
self.active_model.clone(),
);
} else {
self.messages.push(ChatMessage::System(format!(
"Run `/model provider {}` to switch to it.",
provider
)));
}
}
Err(e) => {
self.messages.push(ChatMessage::System(format!(
"Failed to save credential: {}",
e
)));
}
}
}
Err(e) => {
self.messages.push(ChatMessage::System(format!(
"Failed to open credential store: {}",
e
)));
}
},
Err(e) => {
self.messages.push(ChatMessage::System(e));
}
},
_ => {
self.messages.push(ChatMessage::System(
"Usage: /login <provider> <key>".to_string(),
));
}
}
}
"/logout" => match parts.get(1) {
Some(p) => match p.parse::<Provider>() {
Ok(provider) => match self.open_credential_store() {
Ok(mut store) => match store.remove(&provider.to_string()) {
Ok(true) => {
self.messages.push(ChatMessage::System(format!(
"Removed stored credential for {}.",
provider
)));
}
Ok(false) => {
self.messages.push(ChatMessage::System(format!(
"No stored credential for {}.",
provider
)));
}
Err(e) => {
self.messages.push(ChatMessage::System(format!(
"Failed to remove credential: {}",
e
)));
}
},
Err(e) => {
self.messages.push(ChatMessage::System(format!(
"Failed to open credential store: {}",
e
)));
}
},
Err(e) => {
self.messages.push(ChatMessage::System(e));
}
},
None => {
self.messages
.push(ChatMessage::System("Usage: /logout <provider>".to_string()));
}
},
"/providers" => {
let store = self.open_credential_store().ok();
let with_keys: std::collections::HashSet<&str> = store
.as_ref()
.map(|s| s.providers_with_keys().collect())
.unwrap_or_default();
let mut msg = String::from("Provider credentials:");
for provider in Provider::ALL {
let name = provider.to_string();
let stored = with_keys.contains(name.as_str());
let has_env = std::env::var(provider.default_key_env())
.ok()
.filter(|k| !k.is_empty())
.is_some();
let status = if provider.is_local() {
"local, no credential needed"
} else if stored {
"stored"
} else if has_env {
"env var set"
} else {
"missing"
};
msg.push_str(&format!("\n {:<12} {}", name, status));
}
self.messages.push(ChatMessage::System(msg));
}
"/install-stellar-build" => {
if parts.get(1).copied() == Some("confirm") {
if user_tx.send(UserCommand::InstallStellarBuild).is_err() {
self.messages.push(ChatMessage::System(
"The agent is no longer running, so Stellar Build cannot be \
installed. Restart Procyon."
.to_string(),
));
}
} else {
self.messages.push(ChatMessage::System(format!(
"This downloads and runs a shell script from a third party (not \
maintained by Procyon):\n {}\n\nIt installs the Stellar Build persona \
pack (Justin, Nicole, Kaan, Tyler, Elliot, Bri) that `talk_to` and \
`party_mode` use. Unix/macOS only.\n\nRun `/install-stellar-build \
confirm` to proceed.",
crate::channels::STELLAR_BUILD_INSTALL_URL
)));
}
}
_ => {
self.messages.push(ChatMessage::System(format!(
"Unknown command: {}. Type /help for available commands.",
command
)));
}
}
}
fn is_tool_step(label: &str) -> bool {
label.to_lowercase().starts_with("using tool:")
}
fn close_phase(steps: &mut [ExecutionStep]) {
if let Some(last) = steps.last_mut() {
if last.state == ExecutionStepState::Running && !Self::is_tool_step(&last.label) {
last.state = ExecutionStepState::Done;
}
}
}
fn open_step_for(&mut self, tool: &str) -> Option<&mut ExecutionStep> {
let needle = format!("using tool: {}", tool.to_lowercase());
self.execution_steps
.iter_mut()
.rev()
.find(|step| step.state.is_open() && step.label.to_lowercase().starts_with(&needle))
}
pub fn handle_agent_update(&mut self, update: AgentUpdate) {
match update {
AgentUpdate::ResponseChunk(text) => {
if !self.agent_streaming {
self.messages.push(ChatMessage::Agent(String::new()));
self.agent_streaming = true;
}
if let Some(ChatMessage::Agent(buf)) = self.messages.last_mut() {
buf.push_str(&text);
}
self.current_activity = None;
Self::close_phase(&mut self.execution_steps);
self.status = AppStatus::Working;
}
AgentUpdate::ResponseEnd => {
self.end_stream();
for step in &mut self.execution_steps {
if step.state.is_open() {
step.state = if self.execution_failed {
ExecutionStepState::Failed
} else {
ExecutionStepState::Done
};
}
}
self.settle();
}
AgentUpdate::Status(text) => {
self.end_stream();
self.current_activity = Some(text.clone());
Self::close_phase(&mut self.execution_steps);
self.execution_steps.push(ExecutionStep {
label: text,
state: ExecutionStepState::Running,
after: self.messages.len(),
});
self.status = AppStatus::Working;
}
AgentUpdate::Notice(text) => {
self.messages.push(ChatMessage::System(text));
}
AgentUpdate::Error(text) => {
self.end_stream();
self.execution_failed = true;
for step in &mut self.execution_steps {
if step.state.is_open() {
step.state = ExecutionStepState::Failed;
}
}
self.messages
.push(ChatMessage::System(format!("Error: {}", text)));
self.settle();
}
AgentUpdate::Ready {
provider,
model,
credential,
} => {
self.active_provider = provider;
self.active_model = model;
self.has_credential = credential;
self.settle();
}
AgentUpdate::Workspace(snap) => {
self.project_name = snap.project_name;
self.mainnet_allowed = snap.mainnet_allowed;
self.active_network = snap.network;
self.active_account = snap.account;
self.active_contract = snap.contract_name;
if !snap.mcp_servers.is_empty() {
self.mcp_servers = snap.mcp_servers;
}
}
AgentUpdate::McpStatus(servers) => {
self.mcp_servers = servers;
}
AgentUpdate::Approval(request) => {
if let Some(step) = self.open_step_for(&request.tool) {
step.state = ExecutionStepState::Waiting;
}
self.pending_approval = Some(request);
}
AgentUpdate::RetractResponse => {
self.end_stream();
if matches!(self.messages.last(), Some(ChatMessage::Agent(_))) {
self.messages.pop();
}
}
AgentUpdate::History(entries) => {
use crate::channels::TranscriptEntry as Entry;
self.messages.clear();
self.execution_steps.clear();
self.execution_failed = false;
for entry in entries {
match entry {
Entry::User(text) => self.messages.push(ChatMessage::User(text)),
Entry::Agent(text) => self.messages.push(ChatMessage::Agent(text)),
Entry::Tool { name, ok } => self.execution_steps.push(ExecutionStep {
label: format!("Using tool: {}", name),
state: if ok {
ExecutionStepState::Done
} else {
ExecutionStepState::Failed
},
after: self.messages.len(),
}),
Entry::Compacted => self.messages.push(ChatMessage::System(
"— earlier messages compacted to fit the context window —".to_string(),
)),
}
}
self.chat_follow = true;
}
AgentUpdate::LocalModels(models) => {
self.local_models = models;
}
AgentUpdate::Context { used, window } => {
self.context_usage = Some((used, window));
}
AgentUpdate::ToolFinished { name, ok } => {
if let Some(step) = self.open_step_for(&name) {
step.state = if ok {
ExecutionStepState::Done
} else {
ExecutionStepState::Failed
};
}
self.current_activity = None;
}
}
}
fn settle(&mut self) {
self.current_activity = None;
self.status = if self.has_credential {
AppStatus::Ready
} else {
AppStatus::NeedsCredential
};
}
pub fn clear_execution(&mut self) {
self.execution_steps.clear();
self.execution_failed = false;
}
fn end_stream(&mut self) {
if !self.agent_streaming {
return;
}
self.agent_streaming = false;
if matches!(self.messages.last(), Some(ChatMessage::Agent(t)) if t.is_empty()) {
self.messages.pop();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
fn press(state: &mut AppState, code: KeyCode, modifiers: KeyModifiers) {
let (tx, _rx) = mpsc::unbounded_channel();
state.handle_key(KeyEvent::new(code, modifiers), &tx);
}
fn type_str(state: &mut AppState, text: &str) {
for c in text.chars() {
let modifiers = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
press(state, KeyCode::Char(c), modifiers);
}
}
#[test]
fn types_multibyte_text_without_panicking() {
let mut state = AppState::new();
type_str(&mut state, "ação corrigida");
assert_eq!(state.input, "ação corrigida");
assert_eq!(state.input_cursor, 14);
}
#[test]
fn types_uppercase_characters() {
let mut state = AppState::new();
type_str(&mut state, "Deploy");
assert_eq!(state.input, "Deploy");
}
#[test]
fn backspace_removes_whole_multibyte_char() {
let mut state = AppState::new();
type_str(&mut state, "ação");
press(&mut state, KeyCode::Backspace, KeyModifiers::NONE);
assert_eq!(state.input, "açã");
assert_eq!(state.input_cursor, 3);
press(&mut state, KeyCode::Backspace, KeyModifiers::NONE);
assert_eq!(state.input, "aç");
assert_eq!(state.input_cursor, 2);
}
#[test]
fn inserts_at_cursor_inside_multibyte_text() {
let mut state = AppState::new();
type_str(&mut state, "ção");
press(&mut state, KeyCode::Home, KeyModifiers::NONE);
type_str(&mut state, "a");
assert_eq!(state.input, "ação");
}
#[test]
fn delete_at_end_of_multibyte_text_is_noop() {
let mut state = AppState::new();
type_str(&mut state, "ç");
press(&mut state, KeyCode::Delete, KeyModifiers::NONE);
assert_eq!(state.input, "ç");
}
#[test]
fn streaming_chunks_accumulate_into_one_message() {
let mut state = AppState::new();
let before = state.messages.len();
for chunk in ["Olá", ", ", "mundo"] {
state.handle_agent_update(AgentUpdate::ResponseChunk(chunk.to_string()));
}
state.handle_agent_update(AgentUpdate::ResponseEnd);
assert_eq!(state.messages.len(), before + 1);
assert!(
matches!(state.messages.last(), Some(ChatMessage::Agent(t)) if t == "Olá, mundo"),
"got {:?}",
state.messages.last()
);
}
#[test]
fn status_between_chunks_splits_agent_messages() {
let mut state = AppState::new();
state.messages.clear();
state.handle_agent_update(AgentUpdate::ResponseChunk("antes".to_string()));
state.handle_agent_update(AgentUpdate::Status("Using tool: build".to_string()));
state.handle_agent_update(AgentUpdate::ResponseChunk("depois".to_string()));
state.handle_agent_update(AgentUpdate::ResponseEnd);
let rendered: Vec<_> = state
.messages
.iter()
.map(|m| match m {
ChatMessage::User(t) | ChatMessage::Agent(t) | ChatMessage::System(t) => t.as_str(),
})
.collect();
assert_eq!(rendered, vec!["antes", "depois"]);
assert_eq!(state.execution_steps.len(), 1);
assert_eq!(state.execution_steps[0].after, 1);
}
#[test]
fn stream_with_no_text_leaves_no_empty_message() {
let mut state = AppState::new();
let before = state.messages.len();
state.handle_agent_update(AgentUpdate::ResponseEnd);
assert_eq!(state.messages.len(), before);
}
#[test]
fn a_status_update_becomes_a_step_and_sets_current_activity() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Status("Thinking...".to_string()));
assert_eq!(state.execution_steps.len(), 1);
assert_eq!(state.execution_steps[0].label, "Thinking...");
assert!(state.messages.is_empty(), "got {:?}", state.messages);
assert_eq!(state.current_activity.as_deref(), Some("Thinking..."));
assert_eq!(state.status, AppStatus::Working);
}
#[test]
fn current_activity_clears_once_text_starts_streaming() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Status("Using tool: build".to_string()));
state.handle_agent_update(AgentUpdate::ResponseChunk("hi".to_string()));
assert_eq!(state.current_activity, None);
}
#[test]
fn current_activity_clears_when_the_turn_settles() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Status("Thinking...".to_string()));
state.handle_agent_update(AgentUpdate::ResponseEnd);
assert_eq!(state.current_activity, None);
assert_eq!(state.status, AppStatus::Ready);
}
#[test]
fn current_activity_clears_on_error_too() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Status("Thinking...".to_string()));
state.handle_agent_update(AgentUpdate::Error("boom".to_string()));
assert_eq!(state.current_activity, None);
}
#[test]
fn tick_advances_the_spinner_frame() {
let mut state = AppState::new();
let before = state.spinner_frame;
state.tick();
assert_eq!(state.spinner_frame, before + 1);
}
fn submit(state: &mut AppState, text: &str) -> mpsc::UnboundedReceiver<UserCommand> {
let (tx, rx) = mpsc::unbounded_channel();
for c in text.chars() {
state.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE), &tx);
}
state.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &tx);
rx
}
fn last_system_message(state: &AppState) -> String {
match state.messages.last() {
Some(ChatMessage::System(t)) => t.clone(),
other => panic!("expected a system message, got {:?}", other),
}
}
#[test]
fn alt_up_recalls_the_previous_submission() {
let mut state = AppState::new();
submit(&mut state, "first prompt");
submit(&mut state, "second prompt");
press(&mut state, KeyCode::Up, KeyModifiers::ALT);
assert_eq!(state.input, "second prompt");
press(&mut state, KeyCode::Up, KeyModifiers::ALT);
assert_eq!(state.input, "first prompt");
}
#[test]
fn alt_up_stops_at_the_oldest_entry() {
let mut state = AppState::new();
submit(&mut state, "only prompt");
press(&mut state, KeyCode::Up, KeyModifiers::ALT);
press(&mut state, KeyCode::Up, KeyModifiers::ALT);
assert_eq!(state.input, "only prompt");
}
#[test]
fn alt_down_past_the_newest_entry_restores_the_in_progress_draft() {
let mut state = AppState::new();
submit(&mut state, "old prompt");
type_str(&mut state, "still typing");
press(&mut state, KeyCode::Up, KeyModifiers::ALT);
assert_eq!(state.input, "old prompt");
press(&mut state, KeyCode::Down, KeyModifiers::ALT);
assert_eq!(state.input, "still typing");
}
#[test]
fn typing_during_recall_resets_history_navigation() {
let mut state = AppState::new();
submit(&mut state, "first");
submit(&mut state, "second");
press(&mut state, KeyCode::Up, KeyModifiers::ALT);
assert_eq!(state.input, "second");
type_str(&mut state, "!");
assert_eq!(state.input, "second!");
press(&mut state, KeyCode::Up, KeyModifiers::ALT);
assert_eq!(state.input, "second");
}
#[test]
fn history_skips_immediate_duplicates() {
let mut state = AppState::new();
submit(&mut state, "repeat me");
submit(&mut state, "repeat me");
press(&mut state, KeyCode::Up, KeyModifiers::ALT);
assert_eq!(state.input, "repeat me");
press(&mut state, KeyCode::Up, KeyModifiers::ALT);
assert_eq!(
state.input, "repeat me",
"a second, distinct entry should not exist to recall into"
);
}
#[test]
fn explain_is_not_an_unknown_command() {
let mut state = AppState::new();
submit(&mut state, "/explain");
let msg = last_system_message(&state);
assert!(
!msg.contains("Unknown command"),
"/explain is advertised in /help but was rejected: {}",
msg
);
}
#[test]
fn explain_toggles_and_reports_both_directions() {
let mut state = AppState::new();
assert!(!state.is_explaining());
submit(&mut state, "/explain");
assert!(state.is_explaining());
assert!(last_system_message(&state).contains("on"));
submit(&mut state, "/explain");
assert!(!state.is_explaining());
assert!(last_system_message(&state).contains("off"));
}
#[test]
fn explain_tells_the_agent_task() {
let mut state = AppState::new();
let mut rx = submit(&mut state, "/explain");
match rx.try_recv() {
Ok(UserCommand::SetExplain(true)) => {}
other => panic!("expected SetExplain(true), got {:?}", other),
}
let mut rx = submit(&mut state, "/explain");
match rx.try_recv() {
Ok(UserCommand::SetExplain(false)) => {}
other => panic!("expected SetExplain(false), got {:?}", other),
}
}
#[test]
fn every_command_in_help_is_handled() {
let mut state = AppState::new();
submit(&mut state, "/help");
let help = last_system_message(&state);
let advertised: Vec<String> = help
.lines()
.filter_map(|line| line.split_whitespace().next())
.filter(|word| word.starts_with('/'))
.map(|word| word.to_string())
.collect();
assert!(advertised.len() >= 6, "parsed too few: {:?}", advertised);
for command in advertised {
let mut probe = AppState::new();
submit(&mut probe, &command);
let reply = last_system_message(&probe);
assert!(
!reply.contains("Unknown command"),
"{} is listed in /help but not handled",
command
);
}
}
#[test]
fn model_without_args_shows_current() {
let mut state = AppState::new();
submit(&mut state, "/model");
let msg = last_system_message(&state);
assert!(msg.contains("Provider: anthropic"), "got: {}", msg);
assert!(msg.contains("Model: claude-sonnet-5"), "got: {}", msg);
assert!(msg.contains("Suggested models:"), "got: {}", msg);
}
#[test]
fn model_status_shows_current() {
let mut state = AppState::new();
submit(&mut state, "/model status");
let msg = last_system_message(&state);
assert!(msg.contains("Provider: anthropic"), "got: {}", msg);
assert!(msg.contains("Model: claude-sonnet-5"), "got: {}", msg);
}
#[test]
fn model_set_asks_for_both() {
let mut state = AppState::new();
let mut rx = submit(&mut state, "/model set ollama llama3.2");
assert_eq!(
state.active_provider, "anthropic",
"not applied optimistically"
);
match rx.try_recv() {
Ok(UserCommand::SwitchModel { provider, model }) => {
assert_eq!(provider, Provider::Ollama);
assert_eq!(model, "llama3.2");
}
other => panic!("expected SwitchModel, got {:?}", other),
}
}
#[test]
fn model_rejects_bad_input_with_a_usage_or_rejection_message() {
let cases = [
("/model set ollama", "Usage: /model set"),
("/model provider", "Usage: /model provider"),
("/model model", "Usage: /model model"),
("/model foobar", "Unknown subcommand"),
("/model set fakeprovider gpt-4o", "Unknown provider"),
];
for (command, expected) in cases {
let mut state = AppState::new();
submit(&mut state, command);
let msg = last_system_message(&state);
assert!(msg.contains(expected), "{}: got {}", command, msg);
}
}
#[test]
fn switching_provider_carries_a_model_that_provider_serves() {
let mut state = AppState::new();
let mut rx = submit(&mut state, "/model provider deepseek");
match rx.try_recv() {
Ok(UserCommand::SwitchModel { provider, model }) => {
assert_eq!(provider, Provider::Deepseek);
assert_eq!(model, Provider::Deepseek.default_model());
assert_ne!(model, "claude-sonnet-5");
}
other => panic!("expected SwitchModel, got {:?}", other),
}
}
#[test]
fn switching_provider_keeps_a_model_the_target_still_serves() {
let mut state = AppState::new();
state.active_model = "claude-haiku".to_string();
let mut rx = submit(&mut state, "/model provider anthropic");
match rx.try_recv() {
Ok(UserCommand::SwitchModel { model, .. }) => assert_eq!(model, "claude-haiku"),
other => panic!("expected SwitchModel, got {:?}", other),
}
}
#[test]
fn model_model_asks_for_the_model() {
let mut state = AppState::new();
let mut rx = submit(&mut state, "/model model gpt-4o");
assert_eq!(
state.active_model, "claude-sonnet-5",
"not applied optimistically"
);
match rx.try_recv() {
Ok(UserCommand::SwitchModel { provider, model }) => {
assert_eq!(provider, Provider::Anthropic);
assert_eq!(model, "gpt-4o");
}
other => panic!("expected SwitchModel, got {:?}", other),
}
}
#[test]
fn ready_is_what_moves_the_pair() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Ready {
provider: "ollama".to_string(),
model: "llama3.2".to_string(),
credential: true,
});
assert_eq!(state.active_provider, "ollama");
assert_eq!(state.active_model, "llama3.2");
assert_eq!(state.status, AppStatus::Ready);
}
#[test]
fn a_missing_credential_is_reported_as_such_and_is_recoverable() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Ready {
provider: "anthropic".to_string(),
model: "claude-sonnet-5".to_string(),
credential: false,
});
assert_eq!(state.status, AppStatus::NeedsCredential);
state.handle_agent_update(AgentUpdate::Ready {
provider: "ollama".to_string(),
model: "llama3.2".to_string(),
credential: true,
});
assert_eq!(state.status, AppStatus::Ready);
}
#[test]
fn an_error_does_not_latch_the_status() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Error("the tool blew up".to_string()));
assert_eq!(state.status, AppStatus::Ready);
assert!(last_system_message(&state).contains("blew up"));
}
#[test]
fn a_switch_with_no_agent_listening_says_so() {
let mut state = AppState::new();
let (tx, rx) = mpsc::unbounded_channel();
drop(rx);
state.handle_command("/model provider ollama", &tx);
let msg = last_system_message(&state);
assert!(msg.contains("no longer running"), "got: {}", msg);
assert_eq!(state.active_provider, "anthropic");
}
#[test]
fn typing_slash_activates_autocomplete() {
let mut state = AppState::new();
let (_tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
assert!(state.autocomplete_active);
assert!(!state.autocomplete_matches.is_empty());
}
#[test]
fn autocomplete_filters_by_prefix() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "he", &tx);
let matching: Vec<_> = state
.autocomplete_matches
.iter()
.map(|item| item.value.as_str())
.collect();
assert_eq!(matching, vec!["/help"]);
}
#[test]
fn autocomplete_filters_model_subcommands() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "model", &tx);
let matching: Vec<_> = state
.autocomplete_matches
.iter()
.map(|item| item.value.as_str())
.collect();
assert!(
matching.contains(&"/model"),
"expected /model in matches, got: {:?}",
matching
);
assert!(
matching.contains(&"/model set"),
"expected /model set in matches, got: {:?}",
matching
);
}
#[test]
fn a_trailing_space_suggests_the_next_argument_instead_of_closing() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
type_str_with_tx(&mut state, "/model ", &tx);
assert!(state.autocomplete_active);
let matching: Vec<_> = state
.autocomplete_matches
.iter()
.map(|item| item.value.as_str())
.collect();
assert_eq!(matching, vec!["status", "set", "provider", "model"]);
}
#[test]
fn model_provider_suggests_provider_names() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
type_str_with_tx(&mut state, "/model provider anth", &tx);
let matching: Vec<_> = state
.autocomplete_matches
.iter()
.map(|item| item.value.as_str())
.collect();
assert_eq!(matching, vec!["anthropic"]);
}
#[test]
fn login_suggests_provider_names_with_local_ones_flagged() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
type_str_with_tx(&mut state, "/login oll", &tx);
let item = &state.autocomplete_matches[0];
assert_eq!(item.value, "ollama");
assert_eq!(item.description, "local, no credential needed");
}
#[test]
fn logout_suggests_provider_names() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
type_str_with_tx(&mut state, "/logout xa", &tx);
let matching: Vec<_> = state
.autocomplete_matches
.iter()
.map(|item| item.value.as_str())
.collect();
assert_eq!(matching, vec!["xai"]);
}
#[test]
fn model_model_suggests_models_for_the_active_provider() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
type_str_with_tx(&mut state, "/model model claude-", &tx);
let matching: Vec<_> = state
.autocomplete_matches
.iter()
.map(|item| item.value.as_str())
.collect();
let expected: Vec<_> = Provider::Anthropic
.suggested_models()
.into_iter()
.filter(|m| m.starts_with("claude-"))
.collect();
assert_eq!(matching, expected);
}
#[test]
fn model_set_suggests_models_once_a_provider_is_typed() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
type_str_with_tx(&mut state, "/model set groq ", &tx);
let matching: Vec<_> = state
.autocomplete_matches
.iter()
.map(|item| item.value.as_str())
.collect();
assert_eq!(matching, Provider::Groq.suggested_models());
}
#[test]
fn login_offers_no_suggestions_for_the_key_itself() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
type_str_with_tx(&mut state, "/login anthropic ", &tx);
assert!(!state.autocomplete_active);
assert!(state.autocomplete_matches.is_empty());
}
#[test]
fn enter_submits_once_the_typed_argument_exactly_matches_the_suggestion() {
let mut state = AppState::new();
let mut rx = submit(&mut state, "/model provider ollama");
assert!(
state.input.is_empty(),
"Enter should have submitted, not just accepted in place"
);
match rx.try_recv() {
Ok(UserCommand::SwitchModel { provider, .. }) => {
assert_eq!(provider, Provider::Ollama);
}
other => panic!("expected the command to actually run, got {:?}", other),
}
}
#[test]
fn backspacing_out_of_a_dead_end_revives_suggestions() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
type_str_with_tx(&mut state, "/login anthropic k", &tx);
assert!(!state.autocomplete_active);
press(&mut state, KeyCode::Backspace, KeyModifiers::NONE);
press(&mut state, KeyCode::Backspace, KeyModifiers::NONE);
assert_eq!(state.input, "/login anthropic");
assert!(state.autocomplete_active);
let matching: Vec<_> = state
.autocomplete_matches
.iter()
.map(|item| item.value.as_str())
.collect();
assert_eq!(matching, vec!["anthropic"]);
}
#[test]
fn tab_cycles_through_matches() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "m", &tx);
assert_eq!(state.autocomplete_selected, 0);
press(&mut state, KeyCode::Tab, KeyModifiers::NONE);
assert_eq!(state.autocomplete_selected, 1);
press(&mut state, KeyCode::Tab, KeyModifiers::NONE);
assert_eq!(state.autocomplete_selected, 2);
}
#[test]
fn tab_wraps_around() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "m", &tx);
let count = state.autocomplete_matches.len();
for _ in 0..count {
press(&mut state, KeyCode::Tab, KeyModifiers::NONE);
}
assert_eq!(state.autocomplete_selected, 0);
}
#[test]
fn shift_tab_cycles_backwards() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "m", &tx);
let count = state.autocomplete_matches.len();
press(&mut state, KeyCode::BackTab, KeyModifiers::SHIFT);
assert_eq!(state.autocomplete_selected, count - 1);
}
#[test]
fn arrow_keys_navigate_the_popup() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "m", &tx);
let count = state.autocomplete_matches.len();
assert!(count > 1);
press(&mut state, KeyCode::Down, KeyModifiers::NONE);
assert_eq!(state.autocomplete_selected, 1);
press(&mut state, KeyCode::Up, KeyModifiers::NONE);
assert_eq!(state.autocomplete_selected, 0);
press(&mut state, KeyCode::Up, KeyModifiers::NONE);
assert_eq!(state.autocomplete_selected, count - 1);
}
#[test]
fn arrows_scroll_the_chat_once_the_popup_is_closed() {
let mut state = AppState::new();
state.scroll_forward(5);
press(&mut state, KeyCode::Up, KeyModifiers::NONE);
assert_eq!(state.chat_scroll, 4);
}
#[test]
fn enter_accepts_autocomplete() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "he", &tx);
press(&mut state, KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(state.input, "/help");
assert!(!state.autocomplete_active);
}
#[test]
fn a_slash_command_appears_above_its_own_answer() {
let mut state = AppState::new();
submit(&mut state, "/status");
assert!(
matches!(&state.messages[0], ChatMessage::User(t) if t == "/status"),
"got {:?}",
state.messages
);
assert!(state.messages.len() > 1, "the reply is still there");
}
#[test]
fn the_echo_hides_a_credential() {
let mut state = AppState::new();
state.credentials_path = Some(tempfile::tempdir().unwrap().keep().join("credentials.toml"));
submit(&mut state, "/login groq super-secret-key");
for message in &state.messages {
let text = match message {
ChatMessage::User(t) | ChatMessage::Agent(t) | ChatMessage::System(t) => t,
};
assert!(!text.contains("super-secret-key"), "leaked: {}", text);
}
assert!(
matches!(&state.messages[0], ChatMessage::User(t) if t.starts_with("/login groq")),
"got {:?}",
state.messages
);
}
#[test]
fn submitting_from_the_popup_does_what_submitting_normally_does() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Status("Using tool: grep".to_string()));
let (tx, _rx) = mpsc::unbounded_channel();
type_str_with_tx(&mut state, "/status", &tx);
assert!(state.autocomplete_active, "the popup should be open");
press(&mut state, KeyCode::Enter, KeyModifiers::NONE);
assert!(
matches!(&state.messages[0], ChatMessage::User(t) if t == "/status"),
"no echo: {:?}",
state.messages
);
assert!(state.execution_steps.is_empty(), "the old trace survived");
press(&mut state, KeyCode::Up, KeyModifiers::ALT);
assert_eq!(state.input, "/status");
}
#[test]
fn a_login_with_no_arguments_has_nothing_to_hide() {
assert_eq!(AppState::echo_of("/login"), "/login");
assert_eq!(AppState::echo_of("/status"), "/status");
assert_eq!(AppState::echo_of("build the counter"), "build the counter");
}
fn with_local_models(models: &[&str]) -> AppState {
let mut state = AppState::new();
state.active_provider = "ollama".to_string();
state.active_model = "qwen3:4b".to_string();
state.handle_agent_update(AgentUpdate::LocalModels(
models.iter().map(|m| m.to_string()).collect(),
));
state
}
fn last_message(state: &AppState) -> String {
format!("{:?}", state.messages.last().unwrap())
}
#[test]
fn a_local_provider_lists_what_the_server_has() {
let mut state = with_local_models(&["qwen3:4b", "llama3.1:8b"]);
let (tx, _rx) = mpsc::unbounded_channel();
state.handle_command("/model", &tx);
let msg = last_message(&state);
assert!(msg.contains("Installed models:"), "got {}", msg);
assert!(msg.contains("qwen3:4b"), "got {}", msg);
assert!(msg.contains("llama3.1:8b"), "got {}", msg);
assert!(!msg.contains("codellama"), "got {}", msg);
}
#[test]
fn the_model_in_use_is_marked_in_the_list() {
let mut state = with_local_models(&["qwen3:4b", "llama3.1:8b"]);
let (tx, _rx) = mpsc::unbounded_channel();
state.handle_command("/model", &tx);
let msg = last_message(&state);
assert!(msg.contains("qwen3:4b (in use)"), "got {}", msg);
}
#[test]
fn the_implicit_latest_tag_still_marks_the_model_in_use() {
let mut state = with_local_models(&["llama3.2:latest"]);
state.active_model = "llama3.2".to_string();
let (tx, _rx) = mpsc::unbounded_channel();
state.handle_command("/model", &tx);
assert!(
last_message(&state).contains("(in use)"),
"{}",
last_message(&state)
);
}
#[test]
fn with_nothing_to_ask_the_suggestions_are_labelled_as_suggestions() {
let mut state = AppState::new();
state.active_provider = "ollama".to_string();
let (tx, _rx) = mpsc::unbounded_channel();
state.handle_command("/model", &tx);
assert!(last_message(&state).contains("Suggested models:"));
}
#[test]
fn autocomplete_offers_installed_models_rather_than_suggestions() {
let mut state = with_local_models(&["qwen3:4b", "llama3.1:8b"]);
let (tx, _rx) = mpsc::unbounded_channel();
type_str_with_tx(&mut state, "/model model ", &tx);
let offered: Vec<&str> = state
.autocomplete_matches
.iter()
.map(|item| item.value.as_str())
.collect();
assert!(offered.contains(&"qwen3:4b"), "got {:?}", offered);
assert!(!offered.contains(&"codellama"), "got {:?}", offered);
}
fn on_network(command: &str, allowed: bool) -> AppState {
let mut state = AppState::new();
state.mainnet_allowed = allowed;
let (tx, _rx) = mpsc::unbounded_channel();
state.handle_command(command, &tx);
state
}
#[test]
fn the_quiet_networks_switch_without_ceremony() {
for network in ["local", "testnet"] {
let state = on_network(&format!("/network {}", network), false);
assert_eq!(state.active_network, network);
}
}
#[test]
fn mainnet_is_not_entered_on_one_word() {
let state = on_network("/network mainnet", true);
assert_eq!(
state.active_network, "testnet",
"switched without confirming"
);
let said = format!("{:?}", state.messages.last().unwrap());
assert!(said.contains("real funds"), "got {}", said);
assert!(said.contains("confirm"), "got {}", said);
}
#[test]
fn confirming_switches() {
let state = on_network("/network mainnet confirm", true);
assert_eq!(state.active_network, "mainnet");
}
#[test]
fn a_mainnet_that_cannot_sign_says_so_everywhere_it_is_shown() {
let mut state = on_network("/network mainnet confirm", false);
let switch = format!("{:?}", state.messages.last().unwrap());
assert!(switch.contains("signing disabled"), "got {}", switch);
let (tx, _rx) = mpsc::unbounded_channel();
state.handle_command("/status", &tx);
let status = format!("{:?}", state.messages.last().unwrap());
assert!(
status.contains("mainnet (signing disabled)"),
"got {}",
status
);
}
#[test]
fn a_mainnet_that_can_sign_says_that_instead() {
let state = on_network("/network mainnet confirm", true);
let said = format!("{:?}", state.messages.last().unwrap());
assert!(said.contains("signing enabled"), "got {}", said);
assert!(!said.contains("signing disabled"), "got {}", said);
}
#[test]
fn the_agent_is_what_tells_the_ui_whether_signing_is_allowed() {
let mut state = AppState::new();
assert!(!state.mainnet_allowed);
state.handle_agent_update(AgentUpdate::Workspace(crate::channels::WorkspaceSnapshot {
project_name: "demo".to_string(),
contract_name: None,
network: "testnet".to_string(),
account: "None".to_string(),
mcp_servers: Vec::new(),
mainnet_allowed: true,
}));
assert!(state.mainnet_allowed);
}
fn quick_action(key: KeyCode) -> UserCommand {
let mut state = AppState::new();
state.active_network = "testnet".to_string();
let (tx, mut rx) = mpsc::unbounded_channel();
state.handle_key(KeyEvent::new(key, KeyModifiers::CONTROL), &tx);
rx.try_recv().expect("a quick action must send something")
}
#[test]
fn the_shortcuts_run_the_tool_they_are_named_after() {
for (key, tool) in [
(KeyCode::Char('t'), "run_tests"),
(KeyCode::Char('b'), "caatinga_build"),
(KeyCode::Char('d'), "caatinga_deploy"),
] {
match quick_action(key) {
UserCommand::RunTool { name, .. } => assert_eq!(name, tool, "key {:?}", key),
other => panic!("expected a tool call for {:?}, got {:?}", key, other),
}
}
}
#[test]
fn deploy_names_the_network_the_session_is_on() {
match quick_action(KeyCode::Char('d')) {
UserCommand::RunTool { input, .. } => {
assert_eq!(
input.get("network").and_then(|v| v.as_str()),
Some("testnet")
);
}
other => panic!("got {:?}", other),
}
}
#[test]
fn a_quick_action_starts_a_fresh_execution_story() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Status("Using tool: grep".to_string()));
let (tx, _rx) = mpsc::unbounded_channel();
state.handle_key(
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL),
&tx,
);
assert!(state.execution_steps.is_empty());
}
#[test]
fn retracting_takes_the_streamed_json_off_the_screen() {
let mut state = AppState::new();
state.messages.push(ChatMessage::User("liste".to_string()));
state.handle_agent_update(AgentUpdate::ResponseChunk(
r#"{"name": "list_dir", "parameters": {}}"#.to_string(),
));
assert_eq!(state.messages.len(), 2);
state.handle_agent_update(AgentUpdate::RetractResponse);
assert_eq!(state.messages.len(), 1, "got {:?}", state.messages);
assert!(matches!(&state.messages[0], ChatMessage::User(t) if t == "liste"));
}
#[test]
fn retracting_never_reaches_past_the_reply_into_the_conversation() {
let mut state = AppState::new();
state.messages.push(ChatMessage::User("liste".to_string()));
state.handle_agent_update(AgentUpdate::RetractResponse);
assert_eq!(state.messages.len(), 1);
}
#[test]
fn a_resumed_session_is_put_back_on_screen() {
use crate::channels::TranscriptEntry as Entry;
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::History(vec![
Entry::User("liste".to_string()),
Entry::Tool {
name: "list_dir".to_string(),
ok: true,
},
Entry::Agent("sao estes".to_string()),
]));
assert_eq!(state.messages.len(), 2);
assert!(matches!(&state.messages[0], ChatMessage::User(t) if t == "liste"));
assert!(matches!(&state.messages[1], ChatMessage::Agent(t) if t == "sao estes"));
assert_eq!(state.execution_steps.len(), 1);
assert_eq!(state.execution_steps[0].state, ExecutionStepState::Done);
assert_eq!(state.execution_steps[0].after, 1);
}
#[test]
fn a_restored_failure_is_still_a_failure() {
use crate::channels::TranscriptEntry as Entry;
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::History(vec![Entry::Tool {
name: "caatinga_deploy".to_string(),
ok: false,
}]));
assert_eq!(state.execution_steps[0].state, ExecutionStepState::Failed);
assert!(!state.execution_failed);
}
#[test]
fn compaction_is_marked_where_it_cut() {
use crate::channels::TranscriptEntry as Entry;
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::History(vec![
Entry::Compacted,
Entry::User("depois".to_string()),
]));
let first = format!("{:?}", state.messages[0]);
assert!(first.contains("compacted"), "got {}", first);
}
#[test]
fn restoring_twice_does_not_double_the_transcript() {
use crate::channels::TranscriptEntry as Entry;
let mut state = AppState::new();
let history = vec![Entry::User("oi".to_string())];
state.handle_agent_update(AgentUpdate::History(history.clone()));
state.handle_agent_update(AgentUpdate::History(history));
assert_eq!(state.messages.len(), 1);
}
fn awaiting_approval(
tool: &str,
) -> (
AppState,
mpsc::UnboundedReceiver<crate::channels::ApprovalDecision>,
) {
let mut state = AppState::new();
let (tx, rx) = mpsc::unbounded_channel();
state.approval_tx = Some(tx);
state.handle_agent_update(AgentUpdate::Approval(crate::channels::ApprovalRequest {
tool: tool.to_string(),
detail: format!("{} → src/lib.rs", tool),
scope: format!("{}:src/lib.rs", tool),
}));
(state, rx)
}
#[test]
fn a_request_parks_the_prompt_without_writing_anything_down_yet() {
let (state, _rx) = awaiting_approval("write_file");
assert!(state.pending_approval.is_some());
assert!(state.messages.is_empty(), "got {:?}", state.messages);
}
#[test]
fn the_decision_records_what_was_allowed_not_just_which_tool() {
for (key, expected) in [
(KeyCode::Char('y'), "Allowed write_file → src/lib.rs"),
(KeyCode::Char('n'), "Declined write_file → src/lib.rs"),
] {
let (mut state, _rx) = awaiting_approval("write_file");
press(&mut state, key, KeyModifiers::NONE);
let said = format!("{:?}", state.messages.last().unwrap());
assert!(said.contains(expected), "got {}", said);
}
}
#[test]
fn y_allows_once_and_a_allows_for_the_session() {
for (key, expected) in [
(KeyCode::Char('y'), crate::channels::ApprovalDecision::Once),
(KeyCode::Enter, crate::channels::ApprovalDecision::Once),
(
KeyCode::Char('a'),
crate::channels::ApprovalDecision::Always,
),
(KeyCode::Char('n'), crate::channels::ApprovalDecision::Deny),
(KeyCode::Esc, crate::channels::ApprovalDecision::Deny),
] {
let (mut state, mut rx) = awaiting_approval("write_file");
press(&mut state, key, KeyModifiers::NONE);
assert_eq!(rx.try_recv().ok(), Some(expected), "key {:?}", key);
assert!(state.pending_approval.is_none(), "key {:?}", key);
}
}
#[test]
fn an_unrecognised_key_answers_nothing() {
let (mut state, mut rx) = awaiting_approval("write_file");
for key in [KeyCode::Char('z'), KeyCode::Tab, KeyCode::Up] {
press(&mut state, key, KeyModifiers::NONE);
}
assert!(rx.try_recv().is_err(), "a stray key decided something");
assert!(state.pending_approval.is_some());
}
#[test]
fn quitting_still_works_while_a_question_is_on_screen() {
let (mut state, _rx) = awaiting_approval("caatinga_deploy");
let (tx, _user_rx) = mpsc::unbounded_channel();
assert!(state.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&tx
));
}
#[test]
fn the_question_outranks_the_palette() {
let (mut state, mut rx) = awaiting_approval("write_file");
state.palette_open = true;
press(&mut state, KeyCode::Char('n'), KeyModifiers::NONE);
assert_eq!(
rx.try_recv().ok(),
Some(crate::channels::ApprovalDecision::Deny),
"the palette swallowed an answer the agent is blocked on"
);
}
#[test]
fn the_decision_is_written_into_the_transcript() {
let (mut state, _rx) = awaiting_approval("write_file");
press(&mut state, KeyCode::Char('a'), KeyModifiers::NONE);
let last = format!("{:?}", state.messages.last().unwrap());
assert!(last.contains("rest of this session"), "got {}", last);
}
#[test]
fn escape_stops_a_turn_in_flight() {
let mut state = AppState::new();
state.status = AppStatus::Working;
press(&mut state, KeyCode::Esc, KeyModifiers::NONE);
assert!(state.cancel.is_raised());
assert_eq!(state.current_activity.as_deref(), Some("Interrupting..."));
}
#[test]
fn escape_while_idle_stops_nothing() {
let mut state = AppState::new();
state.status = AppStatus::Ready;
press(&mut state, KeyCode::Esc, KeyModifiers::NONE);
assert!(
!state.cancel.is_raised(),
"a stale flag would cancel the next turn the moment it was sent"
);
}
#[test]
fn escape_closes_the_popup_before_it_stops_the_turn() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel();
state.status = AppStatus::Working;
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "he", &tx);
press(&mut state, KeyCode::Esc, KeyModifiers::NONE);
assert!(!state.autocomplete_active);
assert!(!state.cancel.is_raised());
press(&mut state, KeyCode::Esc, KeyModifiers::NONE);
assert!(state.cancel.is_raised());
}
#[test]
fn escape_cancels_autocomplete() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "he", &tx);
press(&mut state, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(state.input, "/");
assert!(!state.autocomplete_active);
}
#[test]
fn enter_submits_full_command_even_with_autocomplete_active() {
let mut state = AppState::new();
let (_tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
submit(&mut state, "/help");
let msg = last_system_message(&state);
assert!(msg.contains("Available commands"), "got: {}", msg);
}
#[test]
fn backspace_deactivates_autocomplete_when_not_slash_prefix() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "h", &tx);
assert!(state.autocomplete_active);
press(&mut state, KeyCode::Backspace, KeyModifiers::NONE);
assert!(state.autocomplete_active);
assert_eq!(state.input, "/");
}
#[test]
fn space_deactivates_autocomplete() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "help", &tx);
assert!(state.autocomplete_active);
press(&mut state, KeyCode::Char(' '), KeyModifiers::NONE);
assert!(!state.autocomplete_active);
}
#[test]
fn no_matches_deactivates_autocomplete() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel();
press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE);
type_str_with_tx(&mut state, "xyz", &tx);
assert!(!state.autocomplete_active);
assert!(state.autocomplete_matches.is_empty());
}
#[test]
fn enter_without_autocomplete_submits_command() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
type_str_with_tx(&mut state, "/status", &tx);
press(&mut state, KeyCode::Enter, KeyModifiers::NONE);
let msg = last_system_message(&state);
assert!(msg.contains("Status: Ready"), "got: {}", msg);
}
fn status_of(servers: Vec<crate::channels::McpServerStatus>) -> String {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
state.mcp_servers = servers;
state.handle_command("/status", &tx);
last_system_message(&state)
}
fn server(name: &str, connected: bool) -> crate::channels::McpServerStatus {
crate::channels::McpServerStatus {
name: name.to_string(),
connected,
detail: format!("https://{}.example/mcp", name),
}
}
#[test]
fn ctrl_c_quits_even_with_the_palette_open() {
let mut state = AppState::new();
let (tx, _rx) = mpsc::unbounded_channel::<UserCommand>();
state.palette_open();
assert!(state.palette_open);
assert!(
state.handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&tx
),
"ctrl+c must quit from inside the palette"
);
}
#[test]
fn status_says_none_when_no_mcp_server_is_configured() {
assert!(status_of(Vec::new()).contains("MCP: none"));
}
#[test]
fn status_puts_a_single_mcp_server_inline() {
let msg = status_of(vec![server("raven", true)]);
assert!(
msg.contains("MCP: ● raven https://raven.example/mcp"),
"got: {}",
msg
);
assert!(!msg.contains("MCP:\n"), "label left dangling: {}", msg);
}
#[test]
fn status_counts_mcp_servers_before_listing_them() {
let msg = status_of(vec![
server("raven", true),
server("other", false),
server("third", true),
]);
assert!(msg.contains("MCP: 3 servers, 2 connected"), "got: {}", msg);
assert!(msg.contains("\n ○ other"), "got: {}", msg);
}
fn type_str_with_tx(state: &mut AppState, text: &str, tx: &mpsc::UnboundedSender<UserCommand>) {
for c in text.chars() {
let modifiers = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
state.handle_key(KeyEvent::new(KeyCode::Char(c), modifiers), tx);
}
}
fn state_with_tempdir() -> (AppState, tempfile::TempDir) {
let temp = tempfile::tempdir().unwrap();
let mut state = AppState::new();
state.credentials_path = Some(temp.path().join("credentials.toml"));
(state, temp)
}
#[test]
fn login_stores_a_key_retrievable_afterward() {
let (mut state, temp) = state_with_tempdir();
submit(&mut state, "/login groq gsk-secret");
let store = crate::credentials::CredentialStore::load(temp.path().join("credentials.toml"))
.unwrap();
assert_eq!(store.get("groq"), Some("gsk-secret"));
}
#[test]
fn login_keeps_a_key_containing_internal_whitespace() {
let (mut state, temp) = state_with_tempdir();
submit(&mut state, "/login groq gsk part-two");
let store = crate::credentials::CredentialStore::load(temp.path().join("credentials.toml"))
.unwrap();
assert_eq!(store.get("groq"), Some("gsk part-two"));
}
#[test]
fn login_overwriting_the_active_provider_triggers_a_live_switch() {
let (mut state, _temp) = state_with_tempdir();
let active_model = state.active_model.clone();
let mut rx = submit(&mut state, "/login anthropic sk-ant-secret");
match rx.try_recv() {
Ok(UserCommand::SwitchModel { provider, model }) => {
assert_eq!(provider, Provider::Anthropic);
assert_eq!(model, active_model);
}
other => panic!("expected SwitchModel, got {:?}", other),
}
}
#[test]
fn login_for_an_inactive_provider_just_confirms_and_suggests_the_switch() {
let (mut state, _temp) = state_with_tempdir();
let mut rx = submit(&mut state, "/login groq gsk-secret");
assert!(rx.try_recv().is_err(), "should not have asked to switch");
let msg = last_system_message(&state);
assert!(msg.contains("/model provider groq"), "got: {}", msg);
}
#[test]
fn login_with_an_invalid_provider_reports_an_error_and_does_not_crash() {
let (mut state, _temp) = state_with_tempdir();
submit(&mut state, "/login fakeprovider somekey");
let msg = last_system_message(&state);
assert!(msg.contains("Unknown provider"), "got: {}", msg);
}
#[test]
fn login_never_leaks_the_raw_key_into_messages() {
let (mut state, _temp) = state_with_tempdir();
submit(&mut state, "/login groq super-secret-key");
for message in &state.messages {
let text = match message {
ChatMessage::User(t) | ChatMessage::Agent(t) | ChatMessage::System(t) => t,
};
assert!(
!text.contains("super-secret-key"),
"the raw key leaked into a message: {}",
text
);
}
}
#[test]
fn login_requires_both_a_provider_and_a_key() {
let (mut state, _temp) = state_with_tempdir();
submit(&mut state, "/login groq");
let msg = last_system_message(&state);
assert!(msg.contains("Usage: /login"), "got: {}", msg);
}
#[test]
fn logout_removes_a_known_providers_credential() {
let (mut state, temp) = state_with_tempdir();
submit(&mut state, "/login groq gsk-1");
submit(&mut state, "/logout groq");
let msg = last_system_message(&state);
assert!(msg.contains("Removed stored credential"), "got: {}", msg);
let store = crate::credentials::CredentialStore::load(temp.path().join("credentials.toml"))
.unwrap();
assert_eq!(store.get("groq"), None);
}
#[test]
fn logout_reports_when_there_is_nothing_to_remove() {
let (mut state, _temp) = state_with_tempdir();
submit(&mut state, "/logout groq");
let msg = last_system_message(&state);
assert!(msg.contains("No stored credential"), "got: {}", msg);
}
#[test]
fn providers_lists_every_named_provider_and_flags_local_ones() {
let (mut state, _temp) = state_with_tempdir();
submit(&mut state, "/providers");
let msg = last_system_message(&state);
for provider in Provider::ALL {
assert!(
msg.contains(&provider.to_string()),
"{} missing from: {}",
provider,
msg
);
}
assert!(msg.contains("local, no credential needed"), "got: {}", msg);
}
#[test]
fn providers_reflects_a_credential_saved_through_login() {
let (mut state, _temp) = state_with_tempdir();
submit(&mut state, "/login groq gsk-1");
submit(&mut state, "/providers");
let msg = last_system_message(&state);
let line = msg
.lines()
.find(|line| line.trim_start().starts_with("groq"))
.unwrap_or_else(|| panic!("no groq line in: {}", msg));
assert!(line.contains("stored"), "got: {}", line);
}
#[test]
fn install_stellar_build_without_confirm_explains_but_does_not_run_anything() {
let mut state = AppState::new();
let mut rx = submit(&mut state, "/install-stellar-build");
let msg = last_system_message(&state);
assert!(
msg.contains(crate::channels::STELLAR_BUILD_INSTALL_URL),
"got: {}",
msg
);
assert!(msg.contains("confirm"), "got: {}", msg);
assert!(rx.try_recv().is_err(), "should not have sent anything yet");
}
#[test]
fn install_stellar_build_confirm_sends_the_install_command() {
let mut state = AppState::new();
let mut rx = submit(&mut state, "/install-stellar-build confirm");
match rx.try_recv() {
Ok(UserCommand::InstallStellarBuild) => {}
other => panic!("expected InstallStellarBuild, got {:?}", other),
}
}
#[test]
fn activity_label_maps_status_to_semantic_phase() {
assert_eq!(activity_label("Thinking..."), "Thinking");
assert_eq!(
activity_label("Using tool: caatinga_build"),
"Building contract"
);
assert_eq!(activity_label("Using tool: caatinga_deploy"), "Deploying");
assert_eq!(
activity_label("Using tool: raven__search"),
"Searching Stellar Docs"
);
assert_eq!(activity_label("Using tool: grep"), "Searching");
}
#[test]
fn execution_trace_groups_tool_status_into_steps() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Status("Thinking...".to_string()));
state.handle_agent_update(AgentUpdate::Status(
"Using tool: caatinga_build".to_string(),
));
state.handle_agent_update(AgentUpdate::Status(
"Using tool: caatinga_deploy".to_string(),
));
assert_eq!(state.execution_steps.len(), 3);
assert_eq!(state.execution_steps[0].state, ExecutionStepState::Done);
assert_eq!(state.execution_steps[2].state, ExecutionStepState::Running);
state.handle_agent_update(AgentUpdate::ResponseChunk("done".to_string()));
assert_eq!(state.execution_steps[2].state, ExecutionStepState::Running);
state.handle_agent_update(AgentUpdate::ToolFinished {
name: "caatinga_deploy".to_string(),
ok: true,
});
assert_eq!(state.execution_steps[2].state, ExecutionStepState::Done);
}
#[test]
fn execution_trace_marks_failed_on_error() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Status(
"Using tool: caatinga_build".to_string(),
));
state.handle_agent_update(AgentUpdate::Error("boom".to_string()));
assert_eq!(state.execution_steps[0].state, ExecutionStepState::Failed);
assert!(state.execution_failed);
}
#[test]
fn new_user_prompt_clears_execution_trace() {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Status(
"Using tool: caatinga_build".to_string(),
));
submit(&mut state, "hello");
assert!(state.execution_steps.is_empty());
}
fn running_tool(tool: &str) -> AppState {
let mut state = AppState::new();
state.handle_agent_update(AgentUpdate::Status(format!("Using tool: {}", tool)));
state
}
#[test]
fn a_tool_that_succeeds_settles_as_done() {
let mut state = running_tool("read_file");
assert_eq!(state.execution_steps[0].state, ExecutionStepState::Running);
state.handle_agent_update(AgentUpdate::ToolFinished {
name: "read_file".to_string(),
ok: true,
});
assert_eq!(state.execution_steps[0].state, ExecutionStepState::Done);
}
#[test]
fn a_tool_that_fails_settles_as_failed() {
let mut state = running_tool("caatinga_deploy");
state.handle_agent_update(AgentUpdate::ToolFinished {
name: "caatinga_deploy".to_string(),
ok: false,
});
assert_eq!(state.execution_steps[0].state, ExecutionStepState::Failed);
}
#[test]
fn a_failed_tool_does_not_condemn_the_turn() {
let mut state = running_tool("read_file");
state.handle_agent_update(AgentUpdate::ToolFinished {
name: "read_file".to_string(),
ok: false,
});
assert!(!state.execution_failed);
state.handle_agent_update(AgentUpdate::ResponseEnd);
assert_eq!(state.execution_steps[0].state, ExecutionStepState::Failed);
}
#[test]
fn an_outcome_settles_its_own_step_not_whichever_is_last() {
let mut state = running_tool("grep");
state.handle_agent_update(AgentUpdate::Status("Using tool: read_file".to_string()));
state.handle_agent_update(AgentUpdate::ToolFinished {
name: "grep".to_string(),
ok: false,
});
assert_eq!(state.execution_steps[0].state, ExecutionStepState::Failed);
assert_eq!(state.execution_steps[1].state, ExecutionStepState::Running);
}
#[test]
fn a_step_waiting_on_approval_says_so_rather_than_claiming_to_work() {
let mut state = running_tool("write_file");
state.handle_agent_update(AgentUpdate::Approval(crate::channels::ApprovalRequest {
tool: "write_file".to_string(),
detail: "write_file → src/lib.rs".to_string(),
scope: "write_file:src/lib.rs".to_string(),
}));
assert_eq!(state.execution_steps[0].state, ExecutionStepState::Waiting);
}
#[test]
fn a_waiting_step_is_settled_when_the_turn_ends() {
let mut state = running_tool("write_file");
state.handle_agent_update(AgentUpdate::Approval(crate::channels::ApprovalRequest {
tool: "write_file".to_string(),
detail: "write_file → src/lib.rs".to_string(),
scope: "write_file:src/lib.rs".to_string(),
}));
state.handle_agent_update(AgentUpdate::ResponseEnd);
assert!(
!state.execution_steps[0].state.is_open(),
"left open: {:?}",
state.execution_steps[0].state
);
}
#[test]
fn clearing_the_chat_clears_the_trace_under_it() {
let mut state = running_tool("caatinga_build");
state.handle_agent_update(AgentUpdate::ToolFinished {
name: "caatinga_build".to_string(),
ok: true,
});
let (tx, _rx) = mpsc::unbounded_channel();
state.handle_command("/clear", &tx);
assert!(
state.execution_steps.is_empty(),
"the last turn's steps outlived the words 'Chat cleared.'"
);
}
}