use crate::constants::{DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE};
use crate::models::{ChatMessage, MessageRole};
use crate::prompts::get_system_prompt;
use super::COMMAND_REGISTRY;
use super::cmd::{ChatRequest, Cmd};
use super::compaction::{
CompactionArchive, CompactionRequest, CompactionResult, CompactionTrigger, compaction_receipt,
};
use super::ids::TurnId;
use super::msg::{KeyCode, KeyMods, Msg, Paste, SlashCmd};
use super::state::{
GenPhase, McpServerEntry, McpServerStatus, State, StatusKind, StatusLine, TokenUsageTotals,
ToolOutcome, TurnState, UiMode,
};
use super::transition::{
action_display_for, commit_assistant_message, fill_outcome, start_generating,
tool_result_messages, try_complete_outcomes,
};
const MAX_PENDING_DRAIN: usize = 16;
pub fn update(mut state: State, msg: Msg) -> (State, Vec<Cmd>) {
let (new_state, mut cmds) = update_step(state, msg);
state = new_state;
let mut depth = 0usize;
while let Some(follow) = state.ui.pending_msgs.pop_front() {
if depth >= MAX_PENDING_DRAIN {
tracing::warn!(
max = MAX_PENDING_DRAIN,
remaining = state.ui.pending_msgs.len(),
"reducer: pending_msgs drain cap hit — follow-ups dropped this tick"
);
state.ui.pending_msgs.clear();
break;
}
let (s, c) = update_step(state, follow);
state = s;
cmds.extend(c);
depth += 1;
}
(state, cmds)
}
pub fn update_step(mut state: State, msg: Msg) -> (State, Vec<Cmd>) {
let mut cmds = Vec::new();
if let Some(event_turn) = msg.turn_id()
&& !state.turn.accepts(event_turn)
{
tracing::trace!(
event_turn = %event_turn,
active_turn = ?state.turn.id(),
kind = ?msg.kind(),
"reducer: dropped stale message"
);
return (state, cmds);
}
match msg {
Msg::Key(key) => {
handle_key(&mut state, &mut cmds, key.code, key.modifiers);
},
Msg::Paste(paste) => {
handle_paste(&mut state, &mut cmds, paste);
},
Msg::SubmitPrompt {
text,
attachment_ids,
} => {
handle_submit_prompt(&mut state, &mut cmds, text, &attachment_ids);
},
Msg::Slash(cmd) => {
handle_slash(&mut state, &mut cmds, cmd);
},
Msg::CancelTurn => {
handle_cancel_turn(&mut state, &mut cmds);
},
Msg::ConfirmAccepted => {
handle_confirm_accepted(&mut state, &mut cmds);
},
Msg::ConfirmDeclined => {
state.confirm = None;
},
Msg::Quit => {
request_exit(&mut state, &mut cmds);
},
Msg::RuntimeSignal(signal) => {
state.runtime.record_signal(signal);
request_exit(&mut state, &mut cmds);
},
Msg::StreamText { turn, chunk } => {
if let TurnState::Generating {
id,
partial_text,
phase,
tokens,
..
} = &mut state.turn
&& *id == turn
{
partial_text.push_str(&chunk);
*phase = GenPhase::Streaming;
*tokens = partial_text.len() / 4;
}
},
Msg::StreamReasoning { turn, chunk } => {
if let TurnState::Generating {
id,
partial_reasoning,
phase,
thinking_signature,
..
} = &mut state.turn
&& *id == turn
{
partial_reasoning.push_str(&chunk.text);
*phase = GenPhase::Thinking;
if let Some(sig) = chunk.signature {
*thinking_signature = Some(sig);
}
}
},
Msg::StreamToolCall { turn, call } => {
handle_stream_tool_call(&mut state, turn, call);
},
Msg::ContextUsageEstimated { turn, snapshot } => {
if state.turn.accepts(turn) {
state.session.context_usage = Some(snapshot);
}
},
Msg::CompactionFinished { turn, result } => {
handle_compaction_finished(&mut state, &mut cmds, turn, result);
},
Msg::CompactionFailed {
turn,
trigger,
message,
kind,
} => {
handle_compaction_failed(&mut state, turn, trigger, message, kind);
},
Msg::StreamDone {
turn,
usage,
thinking_signature,
} => {
handle_stream_done(&mut state, &mut cmds, turn, usage, thinking_signature);
},
Msg::UpstreamError { turn, error } => {
handle_upstream_error(&mut state, turn, error);
},
Msg::TurnCancelled(turn) => {
handle_turn_cancelled(&mut state, turn);
},
Msg::ToolStarted {
turn: _,
call_id: _,
} => {
},
Msg::ToolProgress {
turn,
call_id: _,
event,
} => {
handle_tool_progress(&mut state, &mut cmds, turn, event);
},
Msg::ToolFinished {
turn,
call_id,
outcome,
} => {
handle_tool_finished(&mut state, &mut cmds, turn, call_id, outcome);
},
Msg::McpServerReady { name, tools } => {
state
.mcp
.servers
.entry(name)
.and_modify(|e| {
e.status = McpServerStatus::Ready;
e.tools = tools.clone();
})
.or_insert_with(|| McpServerEntry {
config: crate::app::McpServerConfig {
command: String::new(),
args: Vec::new(),
env: std::collections::HashMap::new(),
},
status: McpServerStatus::Ready,
tools,
});
},
Msg::McpServerErrored { name, reason } => {
let status = McpServerStatus::Errored {
reason: reason.clone(),
};
state
.mcp
.servers
.entry(name.clone())
.and_modify(|e| e.status = status.clone())
.or_insert_with(|| McpServerEntry {
config: crate::app::McpServerConfig {
command: String::new(),
args: Vec::new(),
env: std::collections::HashMap::new(),
},
status,
tools: Vec::new(),
});
state.status = Some(StatusLine {
text: format!("MCP server {} errored: {}", name, reason),
kind: StatusKind::Error,
shown_at: std::time::SystemTime::now(),
});
},
Msg::McpServerStopped { name } => {
if let Some(entry) = state.mcp.servers.get_mut(&name) {
entry.status = McpServerStatus::Stopped;
}
},
Msg::InstructionsChanged(loaded) => {
state.instructions = loaded;
},
Msg::SessionSaved => {
},
Msg::ConversationLoaded(history) => {
state.session.conversation = history;
state.turn = TurnState::Idle;
state.ui.mode = UiMode::EditingInput;
emit_title_if_changed(&mut state, &mut cmds);
},
Msg::ConversationsListed(candidates) => {
if let UiMode::ConversationList { cursor, .. } = state.ui.mode {
state.ui.mode = UiMode::ConversationList {
candidates,
cursor: cursor.min(0),
};
}
},
Msg::ModelPullFinished { model } => {
state.status = Some(StatusLine {
text: format!("Pulled {}", model),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter { ms: 2_000 });
},
Msg::ModelPullProgress(line) => {
state.status = Some(StatusLine {
text: format!("ollama: {}", line),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
},
Msg::Tick => {
},
Msg::StatusDismiss => {
state.status = None;
},
Msg::Resize { .. } => {
},
Msg::MouseScroll { delta } => {
state.ui.mouse_scroll_accum = state.ui.mouse_scroll_accum.saturating_add(delta as i32);
},
Msg::TransientStatus {
text,
kind,
dismiss_ms,
} => {
state.status = Some(StatusLine {
text,
kind,
shown_at: std::time::SystemTime::now(),
});
if dismiss_ms > 0 {
cmds.push(Cmd::DismissStatusAfter { ms: dismiss_ms });
}
},
Msg::OpenImageAt {
message_index,
image_index,
} => {
handle_open_image_at(&mut state, &mut cmds, message_index, image_index);
},
}
(state, cmds)
}
fn emit_title_if_changed(state: &mut State, cmds: &mut Vec<Cmd>) {
let current = state.session.conversation.title.clone();
if state.ui.last_title_dispatched.as_deref() != Some(current.as_str()) {
cmds.push(Cmd::SetTerminalTitle(format!("mermaid - {}", current)));
state.ui.last_title_dispatched = Some(current);
}
}
fn handle_key(state: &mut State, cmds: &mut Vec<Cmd>, code: KeyCode, mods: KeyMods) {
if mods.ctrl && code == KeyCode::Char('c') {
request_exit(state, cmds);
return;
}
if mods.is_empty() && code == KeyCode::Escape && state.is_busy() {
if matches!(state.turn, TurnState::Cancelling { .. }) {
request_exit(state, cmds);
} else {
handle_cancel_turn(state, cmds);
}
return;
}
if mods.ctrl && code == KeyCode::Char('d') && state.ui.input_buffer.is_empty() {
request_exit(state, cmds);
return;
}
if mods.ctrl
&& code == KeyCode::Char('v')
&& matches!(state.ui.mode, UiMode::EditingInput)
&& state.confirm.is_none()
{
cmds.push(Cmd::ReadClipboard);
return;
}
if mods.alt && code == KeyCode::Char('t') {
let next = cycle_reasoning(state.session.reasoning);
state.session.reasoning = next;
cmds.push(Cmd::PersistReasoningFor {
model_id: state.session.model_id.clone(),
level: next,
});
state.status = Some(StatusLine {
text: format!("Reasoning: {}", next.as_str()),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter { ms: 2_000 });
return;
}
if matches!(state.ui.mode, UiMode::ConversationList { .. }) {
handle_conversation_list_key(state, cmds, code);
return;
}
if state.ui.attachment_focused {
handle_attachment_key(state, code);
return;
}
if state.ui.input_buffer.starts_with('/') {
use crate::domain::slash_commands::filter_by_prefix;
let typed = state
.ui
.input_buffer
.trim_start_matches('/')
.split_whitespace()
.next()
.unwrap_or("");
let candidates = filter_by_prefix(typed);
match code {
KeyCode::Up => {
let cur = state.ui.palette_cursor.unwrap_or(0);
state.ui.palette_cursor = Some(cur.saturating_sub(1));
return;
},
KeyCode::Down => {
let max = candidates.len().saturating_sub(1);
let cur = state.ui.palette_cursor.unwrap_or(0);
state.ui.palette_cursor = Some((cur + 1).min(max));
return;
},
KeyCode::Tab => {
let sel = state.ui.palette_cursor.unwrap_or(0);
if let Some(cmd) = candidates.get(sel) {
state.ui.input_buffer = format!("/{} ", cmd.name);
state.ui.input_cursor = state.ui.input_buffer.len();
state.ui.palette_cursor = Some(0);
}
return;
},
KeyCode::Escape => {
state.ui.input_buffer.clear();
state.ui.input_cursor = 0;
state.ui.palette_cursor = None;
return;
},
KeyCode::Enter if !mods.shift => {
let sel = state.ui.palette_cursor.unwrap_or(0);
if let Some(cmd) = candidates.get(sel) {
let raw = state.ui.input_buffer.clone();
let after_slash = raw.trim_start_matches('/');
let rest = match after_slash.find(char::is_whitespace) {
Some(idx) => &after_slash[idx..],
None => "",
};
state.ui.input_buffer = format!("/{}{}", cmd.name, rest);
state.ui.input_cursor = state.ui.input_buffer.len();
}
},
_ => {
},
}
}
if code == KeyCode::Enter && !mods.shift {
let buf = state.ui.input_buffer.trim().to_string();
if buf.is_empty() {
return;
}
if let Some(rest) = buf.strip_prefix('/') {
let slash = crate::app::event_source::parse_slash_command(rest);
state.ui.input_buffer.clear();
state.ui.input_cursor = 0;
state.ui.palette_cursor = None;
state.ui.pending_msgs.push_back(Msg::Slash(slash));
} else {
let text = std::mem::take(&mut state.ui.input_buffer);
state.ui.input_cursor = 0;
let attachment_ids: Vec<u64> = state.ui.attachments.iter().map(|a| a.id).collect();
state.ui.pending_msgs.push_back(Msg::SubmitPrompt {
text,
attachment_ids,
});
}
return;
}
if mods.is_empty() || mods.shift {
match code {
KeyCode::Char(c) => {
state.ui.input_history_cursor = None;
state.ui.history_draft.clear();
let pos = clamp_cursor(&state.ui.input_buffer, state.ui.input_cursor);
state.ui.input_buffer.insert(pos, c);
state.ui.input_cursor = clamp_cursor(&state.ui.input_buffer, pos + c.len_utf8());
if state.ui.input_buffer.starts_with('/') {
state.ui.palette_cursor = Some(0);
}
},
KeyCode::Backspace => {
state.ui.input_history_cursor = None;
state.ui.history_draft.clear();
let pos = clamp_cursor(&state.ui.input_buffer, state.ui.input_cursor);
if pos > 0 {
let new_pos = state.ui.input_buffer.floor_char_boundary(pos - 1);
state.ui.input_buffer.drain(new_pos..pos);
state.ui.input_cursor = new_pos;
}
if state.ui.input_buffer.starts_with('/') {
state.ui.palette_cursor = Some(0);
} else {
state.ui.palette_cursor = None;
}
},
KeyCode::Delete => {
state.ui.input_history_cursor = None;
state.ui.history_draft.clear();
let pos = clamp_cursor(&state.ui.input_buffer, state.ui.input_cursor);
if pos < state.ui.input_buffer.len() {
let next = state.ui.input_buffer.ceil_char_boundary(pos + 1);
state.ui.input_buffer.drain(pos..next);
}
if state.ui.input_buffer.starts_with('/') {
state.ui.palette_cursor = Some(0);
} else {
state.ui.palette_cursor = None;
}
},
KeyCode::Left => {
let pos = clamp_cursor(&state.ui.input_buffer, state.ui.input_cursor);
if pos > 0 {
state.ui.input_cursor = state.ui.input_buffer.floor_char_boundary(pos - 1);
}
},
KeyCode::Right => {
let pos = clamp_cursor(&state.ui.input_buffer, state.ui.input_cursor);
if pos < state.ui.input_buffer.len() {
state.ui.input_cursor = state.ui.input_buffer.ceil_char_boundary(pos + 1);
}
},
KeyCode::Home => state.ui.input_cursor = 0,
KeyCode::End => state.ui.input_cursor = state.ui.input_buffer.len(),
KeyCode::Up => {
if state.ui.input_buffer.is_empty() && !state.ui.attachments.is_empty() {
state.ui.attachment_focused = true;
state.ui.attachment_selected = state
.ui
.attachment_selected
.min(state.ui.attachments.len() - 1);
} else {
history_nav_back(state);
}
},
KeyCode::Down => {
history_nav_forward(state);
},
KeyCode::Escape => {
state.ui.attachment_focused = false;
state.ui.input_history_cursor = None;
state.ui.history_draft.clear();
},
_ => {},
}
}
}
fn handle_conversation_list_key(state: &mut State, cmds: &mut Vec<Cmd>, code: KeyCode) {
let UiMode::ConversationList {
ref candidates,
ref mut cursor,
} = state.ui.mode
else {
return;
};
match code {
KeyCode::Up => {
*cursor = cursor.saturating_sub(1);
},
KeyCode::Down => {
let max = candidates.len().saturating_sub(1);
if *cursor < max {
*cursor += 1;
}
},
KeyCode::Enter => {
if let Some(summary) = candidates.get(*cursor) {
cmds.push(Cmd::LoadConversation(summary.id.clone()));
}
},
KeyCode::Escape => {
state.ui.mode = UiMode::EditingInput;
},
_ => {},
}
}
fn handle_attachment_key(state: &mut State, code: KeyCode) {
match code {
KeyCode::Escape | KeyCode::Down => {
state.ui.attachment_focused = false;
},
KeyCode::Left => {
if !state.ui.attachments.is_empty() {
state.ui.attachment_selected = state
.ui
.attachment_selected
.checked_sub(1)
.unwrap_or(state.ui.attachments.len() - 1);
}
},
KeyCode::Right => {
if !state.ui.attachments.is_empty() {
state.ui.attachment_selected =
(state.ui.attachment_selected + 1) % state.ui.attachments.len();
}
},
KeyCode::Delete | KeyCode::Backspace => {
let idx = state.ui.attachment_selected;
if idx < state.ui.attachments.len() {
state.ui.attachments.remove(idx);
}
if state.ui.attachments.is_empty() {
state.ui.attachment_focused = false;
state.ui.attachment_selected = 0;
} else if state.ui.attachment_selected >= state.ui.attachments.len() {
state.ui.attachment_selected = state.ui.attachments.len() - 1;
}
},
_ => {},
}
}
fn clamp_cursor(s: &str, pos: usize) -> usize {
let capped = pos.min(s.len());
s.floor_char_boundary(capped)
}
fn history_nav_back(state: &mut State) {
let history = &state.session.conversation.input_history;
if history.is_empty() {
return;
}
let next_cursor = match state.ui.input_history_cursor {
None => {
state.ui.history_draft = state.ui.input_buffer.clone();
0
},
Some(i) => (i + 1).min(history.len() - 1),
};
state.ui.input_history_cursor = Some(next_cursor);
let historical = history
.iter()
.rev()
.nth(next_cursor)
.cloned()
.unwrap_or_default();
state.ui.input_buffer = historical;
state.ui.input_cursor = state.ui.input_buffer.len();
}
fn history_nav_forward(state: &mut State) {
let Some(cursor) = state.ui.input_history_cursor else {
return;
};
if cursor == 0 {
state.ui.input_buffer = std::mem::take(&mut state.ui.history_draft);
state.ui.input_cursor = state.ui.input_buffer.len();
state.ui.input_history_cursor = None;
return;
}
let new_cursor = cursor - 1;
state.ui.input_history_cursor = Some(new_cursor);
let historical = state
.session
.conversation
.input_history
.iter()
.rev()
.nth(new_cursor)
.cloned()
.unwrap_or_default();
state.ui.input_buffer = historical;
state.ui.input_cursor = state.ui.input_buffer.len();
}
fn cycle_reasoning(current: crate::models::ReasoningLevel) -> crate::models::ReasoningLevel {
use crate::models::ReasoningLevel as R;
match current {
R::None => R::Minimal,
R::Minimal => R::Low,
R::Low => R::Medium,
R::Medium => R::High,
R::High => R::XHigh,
R::XHigh => R::Max,
R::Max => R::None,
}
}
fn handle_paste(state: &mut State, cmds: &mut Vec<Cmd>, paste: Paste) {
match paste {
Paste::Text(t) => state.ui.input_buffer.push_str(&t),
Paste::Image { bytes, format } => {
let id = state.ids.tool_call.next();
let temp_path = std::env::temp_dir().join(format!("mermaid-img-{}.{}", id, format));
state.ui.attachments.push(super::state::Attachment {
id,
base64_data: base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bytes,
),
temp_path: temp_path.clone(),
size_bytes: bytes.len(),
format: format.clone(),
});
cmds.push(Cmd::WriteImageToTemp {
path: temp_path,
bytes,
format,
});
},
}
}
fn handle_submit_prompt(
state: &mut State,
cmds: &mut Vec<Cmd>,
text: String,
attachment_ids: &[u64],
) {
if text.trim().is_empty() {
return;
}
if !matches!(state.turn, TurnState::Idle) {
state.ui.queued_messages.push_back(text);
return;
}
let mut images: Vec<String> = Vec::new();
state.ui.attachments.retain(|a| {
if attachment_ids.contains(&a.id) {
images.push(a.base64_data.clone());
false
} else {
true
}
});
let mut user_msg = ChatMessage::user(text.clone());
if !images.is_empty() {
user_msg = user_msg.with_images(images);
}
state.session.append(user_msg);
state.session.conversation.add_to_input_history(text);
state.ui.input_buffer.clear();
emit_title_if_changed(state, cmds);
let (refreshed, _outcome) =
crate::app::instructions::refresh(state.instructions.take(), &state.cwd);
state.instructions = refreshed;
let turn = state.ids.fresh_turn();
state.turn = start_generating(turn);
cmds.push(Cmd::CallModel {
turn,
request: build_chat_request(state),
});
}
fn handle_slash(state: &mut State, cmds: &mut Vec<Cmd>, cmd: SlashCmd) {
match cmd {
SlashCmd::Model(None) => {
state.status = Some(StatusLine {
text: format!("Current model: {}", state.session.model_id),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter { ms: 3_000 });
},
SlashCmd::Model(Some(new_model)) => {
let pull_target = ollama_pull_target(&new_model);
state.session.model_id = new_model.clone();
state.runtime.set_model(&new_model);
state.status = Some(StatusLine {
text: format!("Model: {}", new_model),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::PersistLastModel(new_model));
if let Some(model) = pull_target {
cmds.push(Cmd::PullOllamaModel { model });
}
cmds.push(Cmd::DismissStatusAfter { ms: 3_000 });
},
SlashCmd::Reasoning(None) => {
state.status = Some(StatusLine {
text: format!("Reasoning: {}", state.session.reasoning.as_str()),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter { ms: 3_000 });
},
SlashCmd::Reasoning(Some(level)) => {
state.session.reasoning = level;
cmds.push(Cmd::PersistReasoningFor {
model_id: state.session.model_id.clone(),
level,
});
},
SlashCmd::Clear => {
state.confirm = Some(super::state::Confirmation {
prompt: "Clear conversation history?".to_string(),
accept_msg_token: super::state::ConfirmationTarget::ClearConversation,
});
},
SlashCmd::Save(_name) => {
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
SlashCmd::Load(Some(id)) => {
cmds.push(Cmd::LoadConversation(id));
},
SlashCmd::Load(None) | SlashCmd::List => {
state.ui.mode = UiMode::ConversationList {
candidates: Vec::new(),
cursor: 0,
};
cmds.push(Cmd::ListConversations);
},
SlashCmd::Usage => {
state.session.append(ChatMessage::system(usage_text(state)));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
SlashCmd::Context => {
state
.session
.append(ChatMessage::system(context_text(state)));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
SlashCmd::Compact(instructions) => {
handle_manual_compact(state, cmds, instructions);
},
SlashCmd::CloudSetup => {
state.status = Some(StatusLine {
text: "Run `mermaid cloud-setup` from your shell, then restart mermaid."
.to_string(),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter { ms: 5_000 });
},
SlashCmd::Help => {
state.session.append(ChatMessage::system(help_text()));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
SlashCmd::Quit => {
request_exit(state, cmds);
},
SlashCmd::Unknown(name) => {
state.status = Some(StatusLine {
text: format!("Unknown command: /{}", name),
kind: StatusKind::Warn,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter { ms: 2_500 });
},
}
}
fn ollama_pull_target(model_id: &str) -> Option<String> {
let model_id = model_id.trim();
if model_id.is_empty() {
return None;
}
let (provider, model) = match model_id.split_once('/') {
Some((provider, model)) => (provider, model),
None => ("ollama", model_id),
};
if !provider.eq_ignore_ascii_case("ollama") {
return None;
}
let model = model.trim();
if model.is_empty() || model.ends_with(":cloud") {
None
} else {
Some(model.to_string())
}
}
fn handle_manual_compact(state: &mut State, cmds: &mut Vec<Cmd>, instructions: Option<String>) {
if !matches!(state.turn, TurnState::Idle) {
state.status = Some(StatusLine {
text: "Cannot compact while a turn is active.".to_string(),
kind: StatusKind::Warn,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter { ms: 3_000 });
return;
}
if state.session.messages().len() < 3 {
state.status = Some(StatusLine {
text: "Not enough conversation history to compact.".to_string(),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter { ms: 3_000 });
return;
}
let (refreshed, _outcome) =
crate::app::instructions::refresh(state.instructions.take(), &state.cwd);
state.instructions = refreshed;
let turn = state.ids.fresh_turn();
state.turn = TurnState::Compacting {
id: turn,
started: std::time::SystemTime::now(),
trigger: CompactionTrigger::Manual,
};
state.status = Some(StatusLine {
text: "Compacting context...".to_string(),
kind: StatusKind::Persistent,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::CompactConversation {
turn,
request: CompactionRequest::manual(build_chat_request(state), instructions),
});
}
fn help_text() -> String {
let mut lines = Vec::with_capacity(COMMAND_REGISTRY.len() + 1);
lines.push("Available commands:".to_string());
for command in COMMAND_REGISTRY {
let hint = command.arg_hint.unwrap_or("");
let aliases = if command.aliases.is_empty() {
String::new()
} else {
format!(" ({})", command.aliases.join(", "))
};
let suffix = if hint.is_empty() {
String::new()
} else {
format!(" {}", hint)
};
lines.push(format!(
"/{}{}{} - {}",
command.name, suffix, aliases, command.description
));
}
lines.join("\n")
}
fn usage_text(state: &State) -> String {
let mut lines = Vec::new();
lines.push("Usage".to_string());
lines.push(format!("Model: {}", state.session.model_id));
lines.push(String::new());
match &state.session.context_usage {
Some(context) => {
let source = if context.is_estimate() {
"estimated"
} else {
"provider-reported"
};
lines.push(format!(
"Current context: {}{}{}",
format_compact_count(context.used_tokens),
context
.max_tokens
.map(|max| format!(" / {}", format_compact_count(max)))
.unwrap_or_else(|| " / unknown".to_string()),
context
.used_percent
.map(|p| format!(" ({}%, {})", p, source))
.unwrap_or_else(|| format!(" ({})", source))
));
},
None => lines.push("Current context: n/a".to_string()),
}
match state.session.last_token_usage {
Some(last) => lines.push(format!("Last API request: {}", usage_totals_line(last))),
None => lines.push("Last API request: n/a".to_string()),
}
lines.push(format!(
"Session processed: {}",
usage_totals_line(state.session.cumulative_token_usage)
));
lines.join("\n")
}
fn context_text(state: &State) -> String {
let mut lines = Vec::new();
lines.push("Context".to_string());
lines.push(format!("Model: {}", state.session.model_id));
lines.push(format!(
"Provider: {}",
state.runtime.provider_capabilities.provider
));
lines.push(String::new());
if let Some(context) = &state.session.context_usage {
let source = if context.is_estimate() {
"estimated"
} else {
"provider-reported"
};
lines.push(format!(
"Used: {}{} ({})",
format_compact_count(context.used_tokens),
context
.max_tokens
.map(|max| format!(" / {}", format_compact_count(max)))
.unwrap_or_else(|| " / unknown".to_string()),
source
));
if let Some(remaining) = context.remaining_tokens {
lines.push(format!("Remaining: {}", format_compact_count(remaining)));
}
if let Some(breakdown) = &context.breakdown {
lines.push(String::new());
lines.push("Prompt budget estimate:".to_string());
lines.push(format!(
"- system prompt: {}",
format_compact_count(breakdown.system_tokens)
));
lines.push(format!(
"- MERMAID.md: {}",
format_compact_count(breakdown.instructions_tokens)
));
lines.push(format!(
"- messages ({}): {}",
breakdown.message_count,
format_compact_count(breakdown.message_tokens)
));
lines.push(format!(
"- tool schemas ({}): {}",
breakdown.tool_count,
format_compact_count(breakdown.tool_schema_tokens)
));
if breakdown.image_count > 0 {
lines.push(format!("- images: {}", breakdown.image_count));
}
}
} else {
let request = build_chat_request(state);
let snapshot = super::state::estimate_context_usage_for_request(
&request,
state.runtime.provider_capabilities.max_context_tokens,
);
lines.push(format!(
"Estimated current request: {}{}",
format_compact_count(snapshot.used_tokens),
snapshot
.max_tokens
.map(|max| format!(" / {}", format_compact_count(max)))
.unwrap_or_else(|| " / unknown".to_string())
));
if let Some(breakdown) = snapshot.breakdown {
lines.push(String::new());
lines.push("Prompt budget estimate:".to_string());
lines.push(format!(
"- system prompt: {}",
format_compact_count(breakdown.system_tokens)
));
lines.push(format!(
"- MERMAID.md: {}",
format_compact_count(breakdown.instructions_tokens)
));
lines.push(format!(
"- messages ({}): {}",
breakdown.message_count,
format_compact_count(breakdown.message_tokens)
));
lines.push(format!(
"- MCP tool schemas ({}): {}",
breakdown.tool_count,
format_compact_count(breakdown.tool_schema_tokens)
));
lines.push("Built-in tool schemas are measured on the next model call after dispatch enrichment.".to_string());
}
}
if let Some(last) = state.session.conversation.compactions.last() {
lines.push(String::new());
lines.push("Last compaction:".to_string());
lines.push(format!("- trigger: {}", last.trigger.label()));
lines.push(format!(
"- context: {} -> {} tokens",
format_compact_count(last.before_tokens),
format_compact_count(last.after_tokens)
));
lines.push(format!(
"- archived: {} messages",
last.archived_message_count
));
lines.push(format!(
"- preserved: {} messages",
last.preserved_message_count
));
if let Some(path) = &last.archive_path {
lines.push(format!("- archive: {}", path));
}
}
lines.join("\n")
}
fn usage_totals_line(usage: TokenUsageTotals) -> String {
let mut parts = vec![
format!("total {}", format_compact_count(usage.total_tokens)),
format!("input {}", format_compact_count(usage.input_total_tokens())),
format!(
"output {}",
format_compact_count(usage.output_total_tokens())
),
];
if usage.cached_input_tokens > 0 {
parts.push(format!(
"cache read {}",
format_compact_count(usage.cached_input_tokens)
));
}
if usage.cache_creation_input_tokens > 0 {
parts.push(format!(
"cache write {}",
format_compact_count(usage.cache_creation_input_tokens)
));
}
if usage.reasoning_output_tokens > 0 {
parts.push(format!(
"reasoning {}",
format_compact_count(usage.reasoning_output_tokens)
));
}
parts.join(", ")
}
fn format_compact_count(value: usize) -> String {
if value >= 1_000_000 {
format_scaled(value, 1_000_000, "m")
} else if value >= 10_000 {
format_scaled(value, 1_000, "k")
} else {
value.to_string()
}
}
fn format_scaled(value: usize, divisor: usize, suffix: &str) -> String {
let whole = value / divisor;
let decimal = ((value % divisor) * 10) / divisor;
if decimal == 0 {
format!("{}{}", whole, suffix)
} else {
format!("{}.{}{}", whole, decimal, suffix)
}
}
fn handle_cancel_turn(state: &mut State, cmds: &mut Vec<Cmd>) {
let Some(id) = state.turn.id() else {
return;
};
if matches!(state.turn, TurnState::Cancelling { .. }) {
return;
}
cmds.push(Cmd::CancelScope(id));
state.turn = TurnState::Cancelling {
id,
since: std::time::SystemTime::now(),
};
}
fn request_exit(state: &mut State, cmds: &mut Vec<Cmd>) {
if state.should_exit {
return;
}
if let Some(id) = state.turn.id() {
cmds.push(Cmd::CancelScope(id));
}
state.should_exit = true;
state.ui.pending_msgs.clear();
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
cmds.push(Cmd::Exit);
}
fn handle_confirm_accepted(state: &mut State, cmds: &mut Vec<Cmd>) {
let Some(confirm) = state.confirm.take() else {
return;
};
match confirm.accept_msg_token {
super::state::ConfirmationTarget::ClearConversation => {
let project_path = state.session.conversation.project_path.clone();
let model_name = state.session.conversation.model_name.clone();
state.session.conversation =
crate::session::ConversationHistory::new(project_path, model_name);
state.session.cumulative_tokens = 0;
state.session.last_token_usage = None;
state.session.cumulative_token_usage = TokenUsageTotals::default();
state.session.context_usage = None;
emit_title_if_changed(state, cmds);
},
}
}
fn handle_compaction_finished(
state: &mut State,
cmds: &mut Vec<Cmd>,
turn: TurnId,
result: CompactionResult,
) {
let manual = match state.turn {
TurnState::Compacting { id, .. } if id == turn => true,
TurnState::Generating { id, .. } if id == turn => false,
_ => return,
};
let conversation_id = state.session.conversation.id.clone();
let mut record = result.record;
record.archive_path = Some(format!(
".mermaid/compactions/{}/{}.json",
conversation_id, record.id
));
let archive = CompactionArchive {
id: record.id.clone(),
conversation_id,
created_at: record.created_at,
messages: result.archived_messages,
};
state
.session
.conversation
.replace_messages(result.replacement_messages);
state.session.conversation.add_compaction(record.clone());
state.session.context_usage = Some(result.after_snapshot);
if let Some(usage) = result.usage {
let totals = TokenUsageTotals::from_usage(&usage);
state.session.last_token_usage = Some(totals);
state.session.cumulative_token_usage.add_assign(totals);
state.session.cumulative_tokens = state
.session
.cumulative_tokens
.saturating_add(usage.total_tokens);
}
if manual {
state.turn = TurnState::Idle;
}
state.status = Some(StatusLine {
text: compaction_receipt(&record),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::SaveCompactionArchive(archive));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
cmds.push(Cmd::DismissStatusAfter { ms: 5_000 });
}
fn handle_compaction_failed(
state: &mut State,
turn: TurnId,
trigger: CompactionTrigger,
message: String,
kind: StatusKind,
) {
match state.turn {
TurnState::Compacting { id, .. } if id == turn => {
state.turn = TurnState::Idle;
},
TurnState::Generating { id, .. } if id == turn => {},
_ => return,
}
let prefix = match trigger {
CompactionTrigger::Manual => "Compaction failed",
CompactionTrigger::AutoThreshold => "Auto-compaction skipped",
CompactionTrigger::ContextLimitRetry => "Context-limit compaction failed",
};
state.status = Some(StatusLine {
text: format!("{}: {}", prefix, message),
kind,
shown_at: std::time::SystemTime::now(),
});
}
fn handle_stream_tool_call(
state: &mut State,
turn: TurnId,
call: crate::models::tool_call::ToolCall,
) {
if let TurnState::Generating {
id,
pending_tool_calls,
..
} = &mut state.turn
&& *id == turn
{
pending_tool_calls.push(call);
}
}
fn handle_stream_done(
state: &mut State,
cmds: &mut Vec<Cmd>,
turn: TurnId,
usage: Option<crate::models::TokenUsage>,
thinking_signature: Option<String>,
) {
let generating = match std::mem::replace(&mut state.turn, TurnState::Idle) {
TurnState::Generating {
id,
partial_text,
partial_reasoning,
thinking_signature: accumulated_sig,
pending_tool_calls,
..
} if id == turn => (
partial_text,
partial_reasoning,
accumulated_sig,
pending_tool_calls,
),
other => {
state.turn = other;
return;
},
};
let (partial_text, partial_reasoning, accumulated_sig, tool_calls) = generating;
let final_sig = thinking_signature.or(accumulated_sig);
let msg = commit_assistant_message(
partial_text,
partial_reasoning,
tool_calls.clone(),
final_sig,
);
state.session.append(msg);
if let Some(u) = usage {
let totals = TokenUsageTotals::from_usage(&u);
state.session.last_token_usage = Some(totals);
state.session.cumulative_token_usage.add_assign(totals);
state.session.cumulative_tokens = state
.session
.cumulative_tokens
.saturating_add(u.total_tokens);
let max_context = state
.session
.context_usage
.as_ref()
.and_then(|snapshot| snapshot.max_tokens)
.or(state.runtime.provider_capabilities.max_context_tokens);
let mut context = super::state::ContextUsageSnapshot::from_usage(&u, max_context);
if let Some(prev) = state.session.context_usage.as_ref()
&& context.breakdown.is_none()
{
context.breakdown = prev.breakdown.clone();
}
state.session.context_usage = Some(context);
} else {
state.session.last_token_usage = None;
}
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
if !tool_calls.is_empty() {
let pending: Vec<super::state::PendingToolCall> = tool_calls
.into_iter()
.map(|source| super::state::PendingToolCall {
call_id: state.ids.fresh_tool_call(),
source,
})
.collect();
for call in &pending {
cmds.push(Cmd::ExecuteTool {
turn,
call_id: call.call_id,
source: call.source.clone(),
model_id: state.session.model_id.clone(),
});
}
state.turn = super::transition::start_executing_tools(turn, pending);
return;
}
if let Some(next) = state.ui.queued_messages.pop_front() {
let attachment_ids: Vec<u64> = state.ui.attachments.iter().map(|a| a.id).collect();
state.ui.pending_msgs.push_back(Msg::SubmitPrompt {
text: next,
attachment_ids,
});
}
}
fn handle_open_image_at(
state: &mut State,
cmds: &mut Vec<Cmd>,
message_index: usize,
image_index: usize,
) {
let msg = match state.session.messages().get(message_index) {
Some(m) => m,
None => return,
};
let Some(images) = msg.images.as_ref() else {
return;
};
let Some(b64) = images.get(image_index) else {
return;
};
use base64::{Engine, engine::general_purpose};
let Ok(bytes) = general_purpose::STANDARD.decode(b64) else {
return;
};
let id = state.ids.tool_call.next();
let temp_path = std::env::temp_dir().join(format!("mermaid-img-{}.png", id));
cmds.push(Cmd::WriteImageToTemp {
path: temp_path.clone(),
bytes,
format: "png".to_string(),
});
cmds.push(Cmd::OpenInSystem(temp_path));
}
fn handle_turn_cancelled(state: &mut State, turn: TurnId) {
match state.turn {
TurnState::Cancelling { id, .. } if id == turn => {
state.turn = TurnState::Idle;
if let Some(next) = state.ui.queued_messages.pop_front() {
let attachment_ids: Vec<u64> = state.ui.attachments.iter().map(|a| a.id).collect();
state.ui.pending_msgs.push_back(Msg::SubmitPrompt {
text: next,
attachment_ids,
});
}
},
_ => {
},
}
}
fn handle_upstream_error(state: &mut State, turn: TurnId, error: crate::models::UserFacingError) {
if state.turn.id() != Some(turn) {
return;
}
state.turn = TurnState::Idle;
let msg = ChatMessage {
role: MessageRole::Assistant,
content: String::new(),
timestamp: chrono::Local::now(),
kind: crate::models::ChatMessageKind::Normal,
metadata: None,
actions: vec![super::action::ActionDisplay {
action_type: "Error".to_string(),
target: error.summary.clone(),
result: super::action::ActionResult::Error {
error: error.message.clone(),
},
details: super::action::ActionDetails::Simple,
duration_seconds: None,
metadata: None,
}],
thinking: None,
images: None,
tool_calls: None,
tool_call_id: None,
tool_name: None,
thinking_signature: None,
};
state.session.append(msg);
}
fn handle_tool_progress(
state: &mut State,
cmds: &mut Vec<Cmd>,
_turn: TurnId,
event: crate::providers::ProgressEvent,
) {
use crate::providers::{ProgressEvent, SubagentPhase};
use base64::{Engine as _, engine::general_purpose};
const PROGRESS_DISMISS_MS: u64 = 2_000;
let set_status = |state: &mut State, text: String, cmds: &mut Vec<Cmd>| {
if !text.trim().is_empty() {
state.status = Some(StatusLine {
text,
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter {
ms: PROGRESS_DISMISS_MS,
});
}
};
match event {
ProgressEvent::Output(s) | ProgressEvent::Status(s) | ProgressEvent::SubagentText(s) => {
set_status(state, s, cmds);
},
ProgressEvent::Bytes { done, total } => {
let text = match total {
Some(t) => format!("{} / {} bytes", done, t),
None => format!("{} bytes", done),
};
set_status(state, text, cmds);
},
ProgressEvent::Artifact {
mime,
data,
caption,
} => {
if mime.starts_with("image/")
&& matches!(
state.turn,
TurnState::ExecutingTools { .. } | TurnState::Generating { .. }
)
&& let Some(last) = state.session.conversation.messages.last_mut()
&& last.role == MessageRole::Assistant
{
let encoded = general_purpose::STANDARD.encode(&data);
last.images.get_or_insert_with(Vec::new).push(encoded);
}
let label = caption.unwrap_or_else(|| format!("{} ({}b)", mime, data.len()));
set_status(state, label, cmds);
},
ProgressEvent::SubagentToolCall {
tool_name, phase, ..
} => {
let text = match phase {
SubagentPhase::Started => format!(" ⎿ subagent: {} …", tool_name),
SubagentPhase::Finished => format!(" ⎿ subagent: {} ✓", tool_name),
SubagentPhase::Errored => format!(" ⎿ subagent: {} ✗", tool_name),
};
set_status(state, text, cmds);
},
}
}
fn handle_tool_finished(
state: &mut State,
cmds: &mut Vec<Cmd>,
turn: TurnId,
call_id: super::ids::ToolCallId,
outcome: ToolOutcome,
) {
let completed = match &mut state.turn {
TurnState::ExecutingTools {
id,
calls,
outcomes,
} if *id == turn => {
if !fill_outcome(calls, outcomes, call_id, outcome.clone()) {
return;
}
if let Some(call) = calls.iter().find(|c| c.call_id == call_id) {
let action = action_display_for(call, &outcome);
if let Some(process) = action
.metadata
.as_ref()
.and_then(|metadata| metadata.process.clone())
{
state.runtime.register_process(process);
}
if let Some(last) = state.session.conversation.messages.last_mut()
&& last.role == MessageRole::Assistant
{
last.actions.push(action);
}
}
try_complete_outcomes(outcomes)
},
_ => None,
};
if let Some(completed_outcomes) = completed
&& let TurnState::ExecutingTools { id, calls, .. } =
std::mem::replace(&mut state.turn, TurnState::Idle)
&& id == turn
{
let tool_msgs = tool_result_messages(&calls, completed_outcomes);
for m in tool_msgs {
state.session.append(m);
}
let next_turn = state.ids.fresh_turn();
state.turn = start_generating(next_turn);
cmds.push(Cmd::CallModel {
turn: next_turn,
request: build_chat_request(state),
});
}
}
pub fn build_chat_request(state: &State) -> ChatRequest {
let instructions = state.instructions.as_ref().map(|i| i.content.clone());
let settings = &state.settings.default_model;
let temperature = if settings.temperature > 0.0 {
settings.temperature
} else {
DEFAULT_TEMPERATURE
};
let max_tokens = if settings.max_tokens > 0 {
settings.max_tokens
} else {
DEFAULT_MAX_TOKENS
};
let mcp_tools: Vec<crate::domain::ToolDefinition> = state
.mcp
.servers
.iter()
.filter(|(_, entry)| matches!(entry.status, crate::domain::McpServerStatus::Ready))
.flat_map(|(server_name, entry)| {
entry
.tools
.iter()
.map(move |tool| crate::domain::ToolDefinition {
name: format!("mcp__{}__{}", server_name, tool.name),
description: tool.description.clone(),
input_schema: tool.input_schema.clone(),
})
})
.collect();
ChatRequest {
model_id: state.session.model_id.clone(),
messages: evict_stale_screenshots(state.session.messages().to_vec()),
system_prompt: system_prompt_for_state(state),
instructions,
reasoning: state.session.reasoning,
temperature,
max_tokens,
tools: mcp_tools,
}
}
fn system_prompt_for_state(state: &State) -> String {
format!(
"{}\n\n## Current Session\nCurrent working directory: {}\nTreat this as the project root unless the user specifies a different path.",
get_system_prompt(),
state.cwd.display()
)
}
fn evict_stale_screenshots(mut messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
use crate::constants::MAX_RETAINED_SCREENSHOTS;
let mut seen = 0usize;
for msg in messages.iter_mut().rev() {
let Some(imgs) = msg.images.as_ref() else {
continue;
};
if imgs.is_empty() {
continue;
}
if seen < MAX_RETAINED_SCREENSHOTS {
seen += imgs.len();
continue;
}
let elided_count = imgs.len();
msg.images = None;
let marker = if elided_count == 1 {
"\n[Image elided — superseded by newer screenshot]"
} else {
"\n[Images elided — superseded by newer screenshots]"
};
if !msg.content.ends_with(marker) {
msg.content.push_str(marker);
}
}
messages
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::Config;
use crate::domain::msg::{Key, KeyCode, KeyMods};
use crate::domain::state::{McpServerEntry, McpState, PendingToolCall, UiState};
use crate::domain::transition::start_executing_tools;
use std::path::PathBuf;
fn fresh_state() -> State {
State::new(
Config::default(),
PathBuf::from("/tmp/project"),
"ollama/test".to_string(),
)
}
#[test]
fn evict_stale_screenshots_retains_most_recent_and_elides_rest() {
use crate::constants::MAX_RETAINED_SCREENSHOTS;
let mut msgs = Vec::new();
for i in 0..(MAX_RETAINED_SCREENSHOTS + 3) {
msgs.push(ChatMessage {
role: MessageRole::Assistant,
content: format!("turn {}", i),
timestamp: chrono::Local::now(),
kind: crate::models::ChatMessageKind::Normal,
metadata: None,
actions: vec![],
thinking: None,
images: Some(vec![format!("png-base64-{}", i)]),
tool_calls: None,
tool_call_id: None,
tool_name: None,
thinking_signature: None,
});
}
let out = super::evict_stale_screenshots(msgs);
for m in out.iter().rev().take(MAX_RETAINED_SCREENSHOTS) {
assert!(m.images.is_some(), "most-recent images must survive");
}
for m in out.iter().rev().skip(MAX_RETAINED_SCREENSHOTS) {
assert!(m.images.is_none(), "older images must be elided");
assert!(
m.content.contains("elided"),
"elision marker must land in content"
);
}
}
#[test]
fn evict_stale_screenshots_preserves_messages_without_images() {
use crate::constants::MAX_RETAINED_SCREENSHOTS;
let mut msgs = Vec::new();
for i in 0..5 {
msgs.push(ChatMessage {
role: MessageRole::User,
content: format!("text only {}", i),
timestamp: chrono::Local::now(),
kind: crate::models::ChatMessageKind::Normal,
metadata: None,
actions: vec![],
thinking: None,
images: None,
tool_calls: None,
tool_call_id: None,
tool_name: None,
thinking_signature: None,
});
}
for i in 0..2 {
msgs.push(ChatMessage {
role: MessageRole::Assistant,
content: format!("with image {}", i),
timestamp: chrono::Local::now(),
kind: crate::models::ChatMessageKind::Normal,
metadata: None,
actions: vec![],
thinking: None,
images: Some(vec![format!("png-{}", i)]),
tool_calls: None,
tool_call_id: None,
tool_name: None,
thinking_signature: None,
});
}
const { assert!(2 < MAX_RETAINED_SCREENSHOTS, "test premise") };
let out = super::evict_stale_screenshots(msgs);
let with_images = out.iter().filter(|m| m.images.is_some()).count();
assert_eq!(with_images, 2);
assert!(!out.iter().any(|m| m.content.contains("elided")));
}
#[test]
fn quit_sets_exit_flag_and_emits_save_and_exit() {
let state = fresh_state();
let (state, cmds) = update(state, Msg::Quit);
assert!(state.should_exit);
assert_eq!(cmds.len(), 2);
assert!(matches!(cmds[0], Cmd::SaveConversation(_)));
assert!(matches!(cmds[1], Cmd::Exit));
}
#[test]
fn ctrl_c_on_idle_empty_input_exits() {
let state = fresh_state();
let msg = Msg::Key(Key {
code: KeyCode::Char('c'),
modifiers: KeyMods::ctrl(),
});
let (state, cmds) = update(state, msg);
assert!(state.should_exit);
assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
}
#[test]
fn ctrl_c_on_idle_with_input_exits() {
let mut state = fresh_state();
state.ui.input_buffer = "partial".to_string();
let msg = Msg::Key(Key {
code: KeyCode::Char('c'),
modifiers: KeyMods::ctrl(),
});
let (state, cmds) = update(state, msg);
assert!(state.should_exit);
assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
}
#[test]
fn tool_progress_schedules_dismiss_on_banner_update() {
use crate::providers::ProgressEvent;
let mut state = fresh_state();
state.turn = start_generating(TurnId(1));
let turn = state.current_turn_id().unwrap();
let (state, cmds) = update(
state,
Msg::ToolProgress {
turn,
call_id: super::super::ids::ToolCallId(1),
event: ProgressEvent::Output(
"drwxrwxr-x 3 nsabaj nsabaj 4096 Mar 30 14:02 .mermaid".to_string(),
),
},
);
assert!(state.status.is_some(), "status should be set from output");
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::DismissStatusAfter { .. })),
"tool progress must schedule a dismiss so the banner clears"
);
}
#[test]
fn ctrl_v_in_editing_input_emits_read_clipboard() {
let state = fresh_state();
assert!(matches!(state.ui.mode, UiMode::EditingInput));
let (_, cmds) = update(
state,
Msg::Key(Key {
code: KeyCode::Char('v'),
modifiers: KeyMods::ctrl(),
}),
);
assert!(
cmds.iter().any(|c| matches!(c, Cmd::ReadClipboard)),
"Ctrl+V should dispatch Cmd::ReadClipboard; got tags: {:?}",
cmds.iter().map(|c| c.tag()).collect::<Vec<_>>(),
);
}
#[test]
fn ctrl_v_with_confirm_modal_open_is_noop() {
let mut state = fresh_state();
state.confirm = Some(super::super::state::Confirmation {
prompt: "Clear conversation history?".to_string(),
accept_msg_token: super::super::state::ConfirmationTarget::ClearConversation,
});
let (_, cmds) = update(
state,
Msg::Key(Key {
code: KeyCode::Char('v'),
modifiers: KeyMods::ctrl(),
}),
);
assert!(!cmds.iter().any(|c| matches!(c, Cmd::ReadClipboard)));
}
#[test]
fn ctrl_v_in_conversation_list_mode_is_noop() {
let mut state = fresh_state();
state.ui.mode = UiMode::ConversationList {
candidates: Vec::new(),
cursor: 0,
};
let (_, cmds) = update(
state,
Msg::Key(Key {
code: KeyCode::Char('v'),
modifiers: KeyMods::ctrl(),
}),
);
assert!(!cmds.iter().any(|c| matches!(c, Cmd::ReadClipboard)));
}
#[test]
fn transient_status_sets_banner_and_schedules_dismiss() {
let state = fresh_state();
let (state, cmds) = update(
state,
Msg::TransientStatus {
text: "Clipboard is empty".to_string(),
kind: StatusKind::Info,
dismiss_ms: 2_000,
},
);
let s = state.status.expect("status set");
assert_eq!(s.text, "Clipboard is empty");
assert_eq!(s.kind, StatusKind::Info);
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::DismissStatusAfter { ms: 2_000 }))
);
}
#[test]
fn paste_image_creates_attachment_and_writes_temp() {
let state = fresh_state();
let (state, cmds) = update(
state,
Msg::Paste(super::super::msg::Paste::Image {
bytes: vec![0x89, 0x50, 0x4E, 0x47], format: "png".to_string(),
}),
);
assert_eq!(state.ui.attachments.len(), 1);
let att = &state.ui.attachments[0];
assert_eq!(att.format, "png");
assert_eq!(att.size_bytes, 4);
assert!(cmds.iter().any(|c| {
matches!(c, Cmd::WriteImageToTemp { path, .. } if path == &att.temp_path)
}));
}
#[test]
fn open_image_writes_and_opens_the_same_temp_path() {
let mut state = fresh_state();
let image =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b"image bytes");
state
.session
.append(ChatMessage::assistant("image").with_images(vec![image]));
let (_, cmds) = update(
state,
Msg::OpenImageAt {
message_index: 0,
image_index: 0,
},
);
let write_path = cmds.iter().find_map(|cmd| match cmd {
Cmd::WriteImageToTemp { path, .. } => Some(path.clone()),
_ => None,
});
let open_path = cmds.iter().find_map(|cmd| match cmd {
Cmd::OpenInSystem(path) => Some(path.clone()),
_ => None,
});
assert_eq!(write_path, open_path);
}
#[test]
fn ctrl_c_during_turn_exits_and_cancels_scope() {
let mut state = fresh_state();
state.turn = start_generating(TurnId(5));
let msg = Msg::Key(Key {
code: KeyCode::Char('c'),
modifiers: KeyMods::ctrl(),
});
let (state, cmds) = update(state, msg);
assert!(state.should_exit);
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::CancelScope(TurnId(5))))
);
assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
}
#[test]
fn runtime_signal_exits_and_records_timeline() {
let state = fresh_state();
let (state, cmds) = update(
state,
Msg::RuntimeSignal(super::super::runtime::RuntimeSignal::Terminate),
);
assert!(state.should_exit);
assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
assert!(
state
.runtime
.timeline
.iter()
.any(|event| event.message.contains("terminate"))
);
}
#[test]
fn model_switch_updates_provider_capability_snapshot() {
let state = fresh_state();
let (state, cmds) = update(
state,
Msg::Slash(SlashCmd::Model(Some(
"anthropic/claude-opus-4-7".to_string(),
))),
);
assert_eq!(state.runtime.provider_capabilities.provider, "anthropic");
assert!(state.runtime.provider_capabilities.supports_vision);
assert!(cmds.iter().any(|c| matches!(c, Cmd::PersistLastModel(_))));
}
#[test]
fn build_chat_request_includes_current_working_directory() {
let state = fresh_state();
let request = build_chat_request(&state);
assert!(request.system_prompt.contains("Current Session"));
assert!(
request
.system_prompt
.contains("Current working directory: /tmp/project")
);
assert!(
request
.system_prompt
.contains("Treat this as the project root")
);
}
#[test]
fn esc_during_turn_transitions_to_cancelling() {
let mut state = fresh_state();
state.turn = start_generating(TurnId(5));
let msg = Msg::Key(Key {
code: KeyCode::Escape,
modifiers: KeyMods::default(),
});
let (state, cmds) = update(state, msg);
assert!(matches!(
state.turn,
TurnState::Cancelling { id: TurnId(5), .. }
));
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::CancelScope(TurnId(5))))
);
}
#[test]
fn esc_while_already_cancelling_forces_exit() {
let mut state = fresh_state();
state.turn = TurnState::Cancelling {
id: TurnId(5),
since: std::time::SystemTime::now(),
};
let msg = Msg::Key(Key {
code: KeyCode::Escape,
modifiers: KeyMods::default(),
});
let (state, cmds) = update(state, msg);
assert!(state.should_exit);
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::CancelScope(TurnId(5))))
);
assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
}
#[test]
fn double_cancel_does_not_emit_twice() {
let mut state = fresh_state();
state.turn = TurnState::Cancelling {
id: TurnId(1),
since: std::time::SystemTime::now(),
};
let (_state, cmds) = update(state, Msg::CancelTurn);
assert!(cmds.is_empty());
}
#[test]
fn submit_prompt_on_idle_transitions_to_generating() {
let state = fresh_state();
let msg = Msg::SubmitPrompt {
text: "hi there".to_string(),
attachment_ids: vec![],
};
let (state, cmds) = update(state, msg);
assert!(matches!(state.turn, TurnState::Generating { .. }));
assert!(cmds.iter().any(|c| matches!(c, Cmd::CallModel { .. })));
assert!(
!cmds.iter().any(|c| matches!(c, Cmd::RefreshInstructions)),
"F8: refresh happens inline; no longer queued as a Cmd",
);
assert_eq!(state.session.messages().len(), 1);
assert_eq!(state.session.messages()[0].content, "hi there");
}
#[test]
fn submit_prompt_when_busy_is_dropped() {
let mut state = fresh_state();
state.turn = start_generating(TurnId(1));
let msg = Msg::SubmitPrompt {
text: "ignored".to_string(),
attachment_ids: vec![],
};
let (state, cmds) = update(state, msg);
assert!(matches!(
state.turn,
TurnState::Generating { id: TurnId(1), .. }
));
assert!(cmds.is_empty());
assert!(state.session.messages().is_empty());
}
#[test]
fn submit_prompt_trims_empty_input() {
let state = fresh_state();
let msg = Msg::SubmitPrompt {
text: " \n\t".to_string(),
attachment_ids: vec![],
};
let (state, cmds) = update(state, msg);
assert!(matches!(state.turn, TurnState::Idle));
assert!(cmds.is_empty());
}
#[test]
fn stale_stream_text_dropped_silently() {
let mut state = fresh_state();
state.turn = start_generating(TurnId(5));
let msg = Msg::StreamText {
turn: TurnId(4), chunk: "should be dropped".to_string(),
};
let (state, _cmds) = update(state, msg);
if let TurnState::Generating { partial_text, .. } = &state.turn {
assert!(partial_text.is_empty());
} else {
panic!("expected Generating");
}
}
#[test]
fn current_turn_stream_text_accumulates() {
let mut state = fresh_state();
state.turn = start_generating(TurnId(5));
let (state, _) = update(
state,
Msg::StreamText {
turn: TurnId(5),
chunk: "hello ".to_string(),
},
);
let (state, _) = update(
state,
Msg::StreamText {
turn: TurnId(5),
chunk: "world".to_string(),
},
);
if let TurnState::Generating {
partial_text,
phase,
..
} = &state.turn
{
assert_eq!(partial_text, "hello world");
assert_eq!(*phase, GenPhase::Streaming);
} else {
panic!("expected Generating");
}
}
#[test]
fn reasoning_chunk_transitions_phase_to_thinking() {
let mut state = fresh_state();
state.turn = start_generating(TurnId(5));
let (state, _) = update(
state,
Msg::StreamReasoning {
turn: TurnId(5),
chunk: crate::models::ReasoningChunk {
text: "weighing...".to_string(),
signature: None,
},
},
);
if let TurnState::Generating {
phase,
partial_reasoning,
..
} = &state.turn
{
assert_eq!(*phase, GenPhase::Thinking);
assert_eq!(partial_reasoning, "weighing...");
} else {
panic!("expected Generating");
}
}
#[test]
fn stream_done_commits_assistant_message_and_returns_to_idle() {
let mut state = fresh_state();
state.turn = TurnState::Generating {
id: TurnId(5),
started: std::time::SystemTime::now(),
partial_text: "final answer".to_string(),
partial_reasoning: String::new(),
tokens: 0,
phase: GenPhase::Streaming,
thinking_signature: None,
pending_tool_calls: Vec::new(),
};
let (state, cmds) = update(
state,
Msg::StreamDone {
turn: TurnId(5),
usage: None,
thinking_signature: None,
},
);
assert!(matches!(state.turn, TurnState::Idle));
assert_eq!(state.session.messages().len(), 1);
assert_eq!(state.session.messages()[0].content, "final answer");
assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
}
#[test]
fn stream_done_tracks_last_and_cumulative_token_usage() {
let mut state = fresh_state();
state.turn = TurnState::Generating {
id: TurnId(5),
started: std::time::SystemTime::now(),
partial_text: "final answer".to_string(),
partial_reasoning: String::new(),
tokens: 0,
phase: GenPhase::Streaming,
thinking_signature: None,
pending_tool_calls: Vec::new(),
};
let (state, _) = update(
state,
Msg::StreamDone {
turn: TurnId(5),
usage: Some(crate::models::TokenUsage::provider(120, 30, 150)),
thinking_signature: None,
},
);
assert_eq!(state.session.last_token_usage.unwrap().prompt_tokens, 120);
assert_eq!(state.session.cumulative_token_usage.total_tokens, 150);
assert_eq!(state.session.cumulative_tokens, 150);
assert_eq!(
state.session.context_usage.as_ref().unwrap().used_tokens,
150
);
}
#[test]
fn context_usage_estimate_is_stored_during_generation() {
let mut state = fresh_state();
state.turn = TurnState::Generating {
id: TurnId(5),
started: std::time::SystemTime::now(),
partial_text: String::new(),
partial_reasoning: String::new(),
tokens: 0,
phase: GenPhase::Thinking,
thinking_signature: None,
pending_tool_calls: Vec::new(),
};
let snapshot = crate::domain::state::ContextUsageSnapshot::from_estimate(
crate::domain::state::PromptTokenBreakdown {
system_tokens: 10,
instructions_tokens: 0,
message_tokens: 20,
tool_schema_tokens: 30,
image_count: 0,
message_count: 1,
tool_count: 2,
},
Some(1_000),
);
let (state, _) = update(
state,
Msg::ContextUsageEstimated {
turn: TurnId(5),
snapshot,
},
);
let context = state.session.context_usage.expect("context usage");
assert!(context.is_estimate());
assert_eq!(context.used_tokens, 60);
assert_eq!(context.used_percent, Some(6));
}
#[test]
fn handle_upstream_error_refuses_mismatched_turn_id() {
let mut state = fresh_state();
state.turn = start_generating(TurnId(5));
let err = crate::models::UserFacingError {
summary: "Stale".to_string(),
message: "wrong turn".to_string(),
suggestion: String::new(),
category: crate::models::ErrorCategory::Temporary,
recoverable: true,
};
super::handle_upstream_error(&mut state, TurnId(999), err);
assert!(matches!(
state.turn,
TurnState::Generating { id: TurnId(5), .. }
));
assert!(state.session.messages().is_empty());
}
#[test]
fn upstream_error_ends_turn_and_records_line() {
let mut state = fresh_state();
state.turn = start_generating(TurnId(1));
let err = crate::models::UserFacingError {
summary: "Server error".to_string(),
message: "500 internal".to_string(),
suggestion: "retry".to_string(),
category: crate::models::ErrorCategory::Temporary,
recoverable: true,
};
let (state, _) = update(
state,
Msg::UpstreamError {
turn: TurnId(1),
error: err,
},
);
assert!(matches!(state.turn, TurnState::Idle));
assert_eq!(state.session.messages().len(), 1);
let m = &state.session.messages()[0];
assert_eq!(m.content, "");
assert_eq!(m.actions.len(), 1);
assert_eq!(m.actions[0].target, "Server error");
}
#[test]
fn slash_model_with_arg_persists_and_updates_session() {
let state = fresh_state();
let (state, cmds) = update(
state,
Msg::Slash(SlashCmd::Model(Some("anthropic/opus".to_string()))),
);
assert_eq!(state.session.model_id, "anthropic/opus");
assert!(cmds.iter().any(|c| matches!(c, Cmd::PersistLastModel(_))));
assert!(
!cmds
.iter()
.any(|c| matches!(c, Cmd::PullOllamaModel { .. }))
);
}
#[test]
fn slash_model_local_ollama_auto_pulls() {
let state = fresh_state();
let (state, cmds) = update(
state,
Msg::Slash(SlashCmd::Model(Some("ollama/qwen3:8b".to_string()))),
);
assert_eq!(state.session.model_id, "ollama/qwen3:8b");
assert!(
cmds.iter()
.any(|c| { matches!(c, Cmd::PullOllamaModel { model } if model == "qwen3:8b") }),
"local Ollama model should dispatch pull: {:?}",
cmds
);
}
#[test]
fn slash_model_bare_name_auto_pulls_as_ollama() {
let state = fresh_state();
let (_, cmds) = update(
state,
Msg::Slash(SlashCmd::Model(Some("qwen3-coder:30b".to_string()))),
);
assert!(
cmds.iter().any(|c| {
matches!(c, Cmd::PullOllamaModel { model } if model == "qwen3-coder:30b")
}),
"bare model names should dispatch an Ollama pull: {:?}",
cmds
);
}
#[test]
fn slash_model_ollama_cloud_skips_local_pull() {
let state = fresh_state();
let (_, cmds) = update(
state,
Msg::Slash(SlashCmd::Model(Some("ollama/gpt-oss:cloud".to_string()))),
);
assert!(
!cmds
.iter()
.any(|c| matches!(c, Cmd::PullOllamaModel { .. }))
);
}
#[test]
fn slash_help_appends_system_help_and_persists() {
let state = fresh_state();
let (state, cmds) = update(state, Msg::Slash(SlashCmd::Help));
let msg = state.session.messages().last().expect("help message");
assert_eq!(msg.role, MessageRole::System);
assert!(msg.content.contains("/model"));
assert!(msg.content.contains("/help"));
assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
}
#[test]
fn slash_reasoning_persists_per_model() {
let state = fresh_state();
let (state, cmds) = update(
state,
Msg::Slash(SlashCmd::Reasoning(Some(
crate::models::ReasoningLevel::High,
))),
);
assert_eq!(state.session.reasoning, crate::models::ReasoningLevel::High);
let emitted = cmds
.iter()
.find_map(|c| match c {
Cmd::PersistReasoningFor { model_id, level } => Some((model_id.clone(), *level)),
_ => None,
})
.expect("persist cmd emitted");
assert_eq!(emitted.0, "ollama/test");
assert_eq!(emitted.1, crate::models::ReasoningLevel::High);
}
#[test]
fn slash_clear_raises_confirmation() {
let state = fresh_state();
let (state, _) = update(state, Msg::Slash(SlashCmd::Clear));
assert!(state.confirm.is_some());
}
#[test]
fn confirm_accepted_for_clear_wipes_messages() {
let mut state = fresh_state();
state.session.append(ChatMessage::user("one"));
state.session.append(ChatMessage::assistant("two"));
state.confirm = Some(super::super::state::Confirmation {
prompt: "Clear conversation history?".to_string(),
accept_msg_token: super::super::state::ConfirmationTarget::ClearConversation,
});
let (state, _) = update(state, Msg::ConfirmAccepted);
assert!(state.session.messages().is_empty());
assert!(state.confirm.is_none());
}
#[test]
fn confirm_declined_clears_without_action() {
let mut state = fresh_state();
state.session.append(ChatMessage::user("kept"));
state.confirm = Some(super::super::state::Confirmation {
prompt: "Clear conversation history?".to_string(),
accept_msg_token: super::super::state::ConfirmationTarget::ClearConversation,
});
let (state, _) = update(state, Msg::ConfirmDeclined);
assert_eq!(state.session.messages().len(), 1);
assert!(state.confirm.is_none());
}
#[test]
fn mcp_server_ready_updates_entry_status() {
let mut state = fresh_state();
state.mcp = McpState::default();
state.mcp.servers.insert(
"s1".to_string(),
McpServerEntry {
config: crate::app::McpServerConfig {
command: "echo".to_string(),
args: vec![],
env: std::collections::HashMap::new(),
},
status: McpServerStatus::Starting,
tools: vec![],
},
);
let (state, _) = update(
state,
Msg::McpServerReady {
name: "s1".to_string(),
tools: vec![],
},
);
assert_eq!(state.mcp.servers["s1"].status, McpServerStatus::Ready);
}
#[test]
fn mcp_server_errored_sets_status_and_emits_status_line() {
let mut state = fresh_state();
state.mcp.servers.insert(
"s1".to_string(),
McpServerEntry {
config: crate::app::McpServerConfig {
command: "echo".to_string(),
args: vec![],
env: std::collections::HashMap::new(),
},
status: McpServerStatus::Starting,
tools: vec![],
},
);
let (state, _) = update(
state,
Msg::McpServerErrored {
name: "s1".to_string(),
reason: "exit 1".to_string(),
},
);
match &state.mcp.servers["s1"].status {
McpServerStatus::Errored { reason } => assert_eq!(reason, "exit 1"),
_ => panic!("expected Errored"),
}
assert!(state.status.is_some());
}
#[test]
fn status_dismiss_clears_status_line() {
let mut state = fresh_state();
state.status = Some(StatusLine {
text: "info".to_string(),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
let (state, _) = update(state, Msg::StatusDismiss);
assert!(state.status.is_none());
}
#[test]
fn tool_finished_with_all_outcomes_triggers_follow_up_call_model() {
let mut state = fresh_state();
let call = PendingToolCall {
call_id: super::super::ids::ToolCallId(1),
source: crate::models::tool_call::ToolCall {
id: Some("c1".to_string()),
function: crate::models::tool_call::FunctionCall {
name: "read_file".to_string(),
arguments: serde_json::json!({"path": "foo"}),
},
},
};
state.turn = start_executing_tools(TurnId(3), vec![call]);
state.session.append(ChatMessage::assistant("tools follow"));
let (state, cmds) = update(
state,
Msg::ToolFinished {
turn: TurnId(3),
call_id: super::super::ids::ToolCallId(1),
outcome: ToolOutcome::success("file contents", "file contents", 0.05),
},
);
assert!(matches!(state.turn, TurnState::Generating { .. }));
assert!(cmds.iter().any(|c| matches!(c, Cmd::CallModel { .. })));
let last = state.session.messages().last().unwrap();
assert_eq!(last.role, MessageRole::Tool);
}
#[test]
fn background_command_tool_finish_registers_process() {
let mut state = fresh_state();
let call = PendingToolCall {
call_id: super::super::ids::ToolCallId(1),
source: crate::models::tool_call::ToolCall {
id: Some("c1".to_string()),
function: crate::models::tool_call::FunctionCall {
name: "execute_command".to_string(),
arguments: serde_json::json!({
"command": "npm run dev",
"mode": "background",
"working_dir": "/tmp/project",
}),
},
},
};
state.turn = start_executing_tools(TurnId(3), vec![call]);
state.session.append(ChatMessage::assistant("tools follow"));
let (state, _) = update(
state,
Msg::ToolFinished {
turn: TurnId(3),
call_id: super::super::ids::ToolCallId(1),
outcome: ToolOutcome::success(
"Background command started.\nPID: 123\nLog: /tmp/mermaid-bg.log\nReady: matched pattern \"Local:\"\nDetected URL: http://127.0.0.1:5173\n",
"background process started",
0.2,
)
.with_metadata(crate::domain::ToolRunMetadata {
process: Some(crate::domain::ManagedProcess {
id: "bg-123".to_string(),
pid: 123,
command: "npm run dev".to_string(),
cwd: Some("/tmp/project".to_string()),
log_path: "/tmp/mermaid-bg.log".to_string(),
detected_url: Some("http://127.0.0.1:5173".to_string()),
status: crate::domain::ManagedProcessStatus::Running,
}),
..crate::domain::ToolRunMetadata::default()
}),
},
);
assert_eq!(state.runtime.processes.len(), 1);
let process = &state.runtime.processes[0];
assert_eq!(process.pid, 123);
assert_eq!(process.command, "npm run dev");
assert_eq!(process.cwd.as_deref(), Some("/tmp/project"));
assert_eq!(
process.detected_url.as_deref(),
Some("http://127.0.0.1:5173")
);
}
#[test]
fn tool_finished_partial_stays_in_executing() {
let mut state = fresh_state();
let calls = vec![
PendingToolCall {
call_id: super::super::ids::ToolCallId(1),
source: crate::models::tool_call::ToolCall {
id: Some("c1".to_string()),
function: crate::models::tool_call::FunctionCall {
name: "read_file".to_string(),
arguments: serde_json::json!({}),
},
},
},
PendingToolCall {
call_id: super::super::ids::ToolCallId(2),
source: crate::models::tool_call::ToolCall {
id: Some("c2".to_string()),
function: crate::models::tool_call::FunctionCall {
name: "write_file".to_string(),
arguments: serde_json::json!({}),
},
},
},
];
state.turn = start_executing_tools(TurnId(3), calls);
state.session.append(ChatMessage::assistant("tools follow"));
let (state, cmds) = update(
state,
Msg::ToolFinished {
turn: TurnId(3),
call_id: super::super::ids::ToolCallId(1),
outcome: ToolOutcome::cancelled(),
},
);
match &state.turn {
TurnState::ExecutingTools { outcomes, .. } => {
assert_eq!(outcomes.len(), 2);
assert!(outcomes[0].is_some());
assert!(outcomes[1].is_none());
},
_ => panic!("should still be ExecutingTools"),
}
assert!(cmds.is_empty());
}
#[test]
fn stale_tool_finished_dropped_silently() {
let mut state = fresh_state();
state.turn = start_executing_tools(
TurnId(3),
vec![PendingToolCall {
call_id: super::super::ids::ToolCallId(1),
source: crate::models::tool_call::ToolCall {
id: None,
function: crate::models::tool_call::FunctionCall {
name: "x".to_string(),
arguments: serde_json::json!({}),
},
},
}],
);
let (state, cmds) = update(
state,
Msg::ToolFinished {
turn: TurnId(999),
call_id: super::super::ids::ToolCallId(1),
outcome: ToolOutcome::cancelled(),
},
);
match &state.turn {
TurnState::ExecutingTools { outcomes, .. } => {
assert!(outcomes[0].is_none());
},
_ => panic!("unchanged state expected"),
}
assert!(cmds.is_empty());
}
#[test]
fn tick_is_noop() {
let before = fresh_state();
let (after, cmds) = update(before.clone(), Msg::Tick);
assert!(cmds.is_empty());
assert!(matches!(after.turn, TurnState::Idle));
}
#[test]
fn resize_is_noop() {
let (state, cmds) = update(
fresh_state(),
Msg::Resize {
width: 80,
height: 24,
},
);
assert!(cmds.is_empty());
assert!(matches!(state.turn, TurnState::Idle));
}
#[test]
fn ui_state_default_is_empty() {
let s = UiState::default();
assert!(s.input_buffer.is_empty());
assert_eq!(s.chat_scroll, 0);
assert!(matches!(s.mode, UiMode::EditingInput));
}
}