use crate::constants::{DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE};
use crate::models::{ChatMessage, MessageRole};
use crate::prompts::get_system_prompt;
use crate::runtime::TaskStatus;
use super::cmd::{ChatRequest, Cmd};
use super::compaction::{
CompactionArchive, CompactionPolicy, CompactionRequest, CompactionResult, CompactionTrigger,
compaction_receipt, context_exceeds_hard_limit, should_auto_compact,
};
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,
};
use super::{COMMAND_GROUPS, COMMAND_REGISTRY};
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::ApprovalRequested {
turn,
call_id,
tool,
risk,
kind,
prompt,
allowlist_scope,
} => {
state
.pending_approval
.push_back(super::state::PendingApproval {
turn,
call_id,
tool,
risk,
kind,
prompt,
allowlist_scope,
selected_option: 0,
});
},
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::MemoryChanged(loaded) => {
state.memory = 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 { .. } = state.ui.mode {
state.ui.mode = UiMode::ConversationList {
candidates,
cursor: 0,
};
}
},
Msg::RuntimeTasksListed(tasks) => {
state
.session
.append(ChatMessage::system(tasks_text(&tasks)));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
Msg::RuntimeTaskLoaded { task, events } => {
state.session.append(ChatMessage::system(task_detail_text(
task.as_ref(),
&events,
)));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
Msg::RuntimeProcessesListed(processes) => {
state
.session
.append(ChatMessage::system(processes_text(&processes)));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
Msg::RuntimeText(text) => {
state.session.append(ChatMessage::system(text));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
Msg::RuntimeApprovalsListed(approvals) => {
state
.session
.append(ChatMessage::system(approvals_text(&approvals)));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
Msg::RuntimeCheckpointsListed(checkpoints) => {
state
.session
.append(ChatMessage::system(checkpoints_text(&checkpoints)));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
Msg::RuntimePluginsListed(plugins) => {
state
.session
.append(ChatMessage::system(plugins_text(&plugins)));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
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.ctrl && code == KeyCode::Char('b') {
if let TurnState::ExecutingTools { id, .. } = &state.turn {
cmds.push(Cmd::BackgroundScope(*id));
}
return;
}
if !state.pending_approval.is_empty() {
use crate::domain::ApprovalChoice;
const APPROVAL_OPTIONS: usize = 3;
let choice_for = |idx: usize| match idx {
0 => ApprovalChoice::Approve,
1 => ApprovalChoice::ApproveAlways,
_ => ApprovalChoice::Deny,
};
let selected = state
.pending_approval
.front()
.map(|i| i.selected_option)
.unwrap_or(0);
let choice = match code {
KeyCode::Char('1') | KeyCode::Char('y') | KeyCode::Char('Y') => {
Some(ApprovalChoice::Approve)
},
KeyCode::Char('2') | KeyCode::Char('a') | KeyCode::Char('A') => {
Some(ApprovalChoice::ApproveAlways)
},
KeyCode::Char('3') | KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Escape => {
Some(ApprovalChoice::Deny)
},
KeyCode::Enter => Some(choice_for(selected)),
KeyCode::Up => {
if let Some(front) = state.pending_approval.front_mut() {
front.selected_option = selected.saturating_sub(1);
}
None
},
KeyCode::Down => {
if let Some(front) = state.pending_approval.front_mut() {
front.selected_option = (selected + 1).min(APPROVAL_OPTIONS - 1);
}
None
},
_ => None,
};
if let Some(decision) = choice
&& let Some(call_id) = state.pending_approval.front().map(|i| i.call_id)
{
state.pending_approval.pop_front();
cmds.push(Cmd::ResolveApproval { call_id, decision });
}
return;
}
if state.confirm.is_some() {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
handle_confirm_accepted(state, cmds);
},
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Escape => {
state.confirm = None;
},
_ => {},
}
return;
}
if mods.is_empty() && code == KeyCode::Escape && state.is_busy() {
if !matches!(state.turn, TurnState::Cancelling { .. }) {
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 code == KeyCode::BackTab {
let next = cycle_safety(state.session.safety_mode);
state.session.safety_mode = next;
state.status = Some(StatusLine {
text: format!("Safety: {}", 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 cycle_safety(current: crate::runtime::SafetyMode) -> crate::runtime::SafetyMode {
use crate::runtime::SafetyMode as S;
match current {
S::ReadOnly => S::Ask,
S::Ask => S::Auto,
S::Auto => S::FullAccess,
S::FullAccess => S::ReadOnly,
}
}
fn handle_paste(state: &mut State, cmds: &mut Vec<Cmd>, paste: Paste) {
match paste {
Paste::Text(t) => {
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_str(pos, &t);
state.ui.input_cursor = clamp_cursor(&state.ui.input_buffer, pos + t.len());
if state.ui.input_buffer.starts_with('/') {
state.ui.palette_cursor = Some(0);
}
},
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 (refreshed_memory, _) =
crate::app::memory::refresh(state.memory.take(), &state.cwd, &state.settings.memory);
state.memory = refreshed_memory;
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::Safety(None) => {
state.status = Some(StatusLine {
text: format!(
"Safety: {} — options: read_only, ask, auto, full_access (Shift+Tab cycles)",
state.session.safety_mode.as_str()
),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter { ms: 4_000 });
},
SlashCmd::Safety(Some(mode)) => {
state.session.safety_mode = mode;
state.status = Some(StatusLine {
text: format!("Safety: {}", mode.as_str()),
kind: StatusKind::Info,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter { ms: 2_000 });
},
SlashCmd::VisibleReasoning(arg) => {
match visible_reasoning_value(arg.as_deref(), state.ui.show_reasoning) {
Ok(next) => {
state.ui.show_reasoning = next;
set_status(
state,
cmds,
if next {
"Visible reasoning: on"
} else {
"Visible reasoning: off"
},
StatusKind::Info,
3_000,
);
},
Err(usage) => {
set_status(state, cmds, usage, StatusKind::Warn, 4_000);
},
}
},
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::Memory => {
cmds.push(Cmd::ListMemory);
},
SlashCmd::Remember(Some(text)) => {
cmds.push(Cmd::RememberMemory { text });
},
SlashCmd::Remember(None) => {
set_status(
state,
cmds,
"Usage: /remember <fact to remember>",
StatusKind::Info,
3_000,
);
},
SlashCmd::Forget(Some(id)) => {
cmds.push(Cmd::ForgetMemory { id });
},
SlashCmd::Forget(None) => {
set_status(
state,
cmds,
"Usage: /forget <memory name> (see /memory for names)",
StatusKind::Info,
3_000,
);
},
SlashCmd::ConsolidateMemory => {
cmds.push(Cmd::ConsolidateMemory {
model_id: state.session.model_id.clone(),
});
},
SlashCmd::Doctor => {
state
.session
.append(ChatMessage::system(doctor_text(state)));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
SlashCmd::Tasks => {
cmds.push(Cmd::ListRuntimeTasks { limit: 10 });
},
SlashCmd::Task(Some(id)) => {
cmds.push(Cmd::LoadRuntimeTask { id });
},
SlashCmd::Task(None) => {
set_status(state, cmds, "Usage: /task <id>", StatusKind::Info, 3_000);
},
SlashCmd::Pause(Some(id)) => {
cmds.push(Cmd::UpdateRuntimeTaskStatus {
id,
status: TaskStatus::Blocked,
final_report: Some("Paused from TUI".to_string()),
});
},
SlashCmd::Pause(None) => {
set_status(
state,
cmds,
"Usage: /pause <task-id>",
StatusKind::Info,
3_000,
);
},
SlashCmd::Resume(Some(id)) => {
cmds.push(Cmd::UpdateRuntimeTaskStatus {
id,
status: TaskStatus::Running,
final_report: None,
});
},
SlashCmd::Resume(None) => {
set_status(
state,
cmds,
"Usage: /resume <task-id>",
StatusKind::Info,
3_000,
);
},
SlashCmd::Cancel(Some(id)) => {
cmds.push(Cmd::UpdateRuntimeTaskStatus {
id,
status: TaskStatus::Cancelled,
final_report: Some("Cancelled from TUI".to_string()),
});
},
SlashCmd::Cancel(None) => {
if matches!(state.turn, TurnState::Idle) {
set_status(
state,
cmds,
"No active turn to cancel.",
StatusKind::Info,
2_500,
);
} else {
handle_cancel_turn(state, cmds);
}
},
SlashCmd::Handoff(Some(id)) | SlashCmd::Report(Some(id)) => {
cmds.push(Cmd::LoadRuntimeTask { id });
},
SlashCmd::Handoff(None) => {
let text = format!(
"Handoff report\n\n{}\n\n{}",
context_text(state),
usage_text(state)
);
state.session.append(ChatMessage::system(text));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
SlashCmd::Report(None) => {
let text = format!(
"Runtime report\n\n{}\n\n{}",
context_text(state),
usage_text(state)
);
state.session.append(ChatMessage::system(text));
cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
},
SlashCmd::Processes => {
cmds.push(Cmd::ListRuntimeProcesses { limit: 10 });
},
SlashCmd::Logs(Some(id)) => {
cmds.push(Cmd::ShowRuntimeProcessLogs { id });
},
SlashCmd::Logs(None) => {
set_status(
state,
cmds,
"Usage: /logs <process-id>",
StatusKind::Info,
3_000,
);
},
SlashCmd::Stop(Some(id)) => {
cmds.push(Cmd::StopRuntimeProcess { id });
},
SlashCmd::Stop(None) => {
set_status(
state,
cmds,
"Usage: /stop <process-id>",
StatusKind::Info,
3_000,
);
},
SlashCmd::Restart(Some(id)) => {
cmds.push(Cmd::RestartRuntimeProcess { id });
},
SlashCmd::Restart(None) => {
set_status(
state,
cmds,
"Usage: /restart <process-id>",
StatusKind::Info,
3_000,
);
},
SlashCmd::Open(Some(target)) => {
cmds.push(Cmd::OpenRuntimeTarget { target });
},
SlashCmd::Open(None) => {
set_status(
state,
cmds,
"Usage: /open <url|path|process-id>",
StatusKind::Info,
3_000,
);
},
SlashCmd::Ports => {
cmds.push(Cmd::ShowRuntimePorts);
},
SlashCmd::Approvals => {
cmds.push(Cmd::ListRuntimeApprovals);
},
SlashCmd::Approve(Some(id)) => {
cmds.push(Cmd::DecideRuntimeApproval {
id,
decision: "approved".to_string(),
});
},
SlashCmd::Approve(None) => {
set_status(
state,
cmds,
"Usage: /approve <approval-id>",
StatusKind::Info,
3_000,
);
},
SlashCmd::Deny(Some(id)) => {
cmds.push(Cmd::DecideRuntimeApproval {
id,
decision: "denied".to_string(),
});
},
SlashCmd::Deny(None) => {
set_status(
state,
cmds,
"Usage: /deny <approval-id>",
StatusKind::Info,
3_000,
);
},
SlashCmd::Checkpoint(Some(paths)) => {
let paths = paths
.split_whitespace()
.map(std::path::PathBuf::from)
.collect::<Vec<_>>();
cmds.push(Cmd::CreateRuntimeCheckpoint { paths });
},
SlashCmd::Checkpoint(None) => {
set_status(
state,
cmds,
"Usage: /checkpoint <path...>",
StatusKind::Info,
3_000,
);
},
SlashCmd::Checkpoints => {
cmds.push(Cmd::ListRuntimeCheckpoints { limit: 10 });
},
SlashCmd::Restore(Some(id)) => {
cmds.push(Cmd::RestoreRuntimeCheckpoint { id });
},
SlashCmd::Restore(None) => {
set_status(
state,
cmds,
"Usage: /restore <checkpoint-id>",
StatusKind::Info,
3_000,
);
},
SlashCmd::Plugins => {
cmds.push(Cmd::ListRuntimePlugins);
},
SlashCmd::ModelInfo(Some(model)) => {
cmds.push(Cmd::ShowRuntimeModelInfo { model });
},
SlashCmd::ModelInfo(None) => {
set_status(
state,
cmds,
"Usage: /model-info <model>",
StatusKind::Info,
3_000,
);
},
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 visible_reasoning_value(arg: Option<&str>, current: bool) -> Result<bool, &'static str> {
match arg.map(str::trim).filter(|s| !s.is_empty()) {
None | Some("toggle") => Ok(!current),
Some("on") | Some("true") | Some("yes") | Some("show") => Ok(true),
Some("off") | Some("false") | Some("no") | Some("hide") => Ok(false),
Some(_) => Err("Usage: /visible-reasoning [on|off|toggle]"),
}
}
fn set_status(
state: &mut State,
cmds: &mut Vec<Cmd>,
text: impl Into<String>,
kind: StatusKind,
dismiss_ms: u64,
) {
state.status = Some(StatusLine {
text: text.into(),
kind,
shown_at: std::time::SystemTime::now(),
});
cmds.push(Cmd::DismissStatusAfter { ms: dismiss_ms });
}
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 (refreshed_memory, _) =
crate::app::memory::refresh(state.memory.take(), &state.cwd, &state.settings.memory);
state.memory = refreshed_memory;
let turn = state.ids.fresh_turn();
state.turn = TurnState::Compacting {
id: turn,
started: std::time::SystemTime::now(),
trigger: CompactionTrigger::Manual,
};
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() + COMMAND_GROUPS.len() + 2);
lines.push("Mermaid commands".to_string());
lines.push(
"Everyday commands are first; daemon/task/process commands are advanced runtime."
.to_string(),
);
for group in COMMAND_GROUPS {
lines.push(String::new());
lines.push(format!("{}:", group.title()));
for command in COMMAND_REGISTRY
.iter()
.filter(|command| command.group == *group)
{
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 doctor_text(state: &State) -> String {
let mut lines = Vec::new();
lines.push("Mermaid Doctor".to_string());
lines.push(format!("Project: {}", state.cwd.display()));
lines.push(format!("Active model: {}", state.session.model_id));
lines.push(format!("Reasoning: {}", state.session.reasoning.as_str()));
lines.push(format!(
"Provider: {} / {}",
state.runtime.provider_capabilities.provider, state.runtime.provider_capabilities.model
));
lines.push(format!(
"Model capabilities: tools={}, vision={}, reasoning={}, context={}",
state.runtime.provider_capabilities.supports_tools,
state.runtime.provider_capabilities.supports_vision,
state.runtime.provider_capabilities.reasoning,
state
.runtime
.provider_capabilities
.max_context_tokens
.map(|n| n.to_string())
.unwrap_or_else(|| "unknown".to_string())
));
lines.push(format!(
"Safety: mode={}, checkpoint_on_mutation={}",
safety_mode_name(state.settings.safety.mode),
state.settings.safety.checkpoint_on_mutation
));
lines.push(format!(
"Prompt: {}",
if state.settings.prompt.is_customized() {
"customized for this invocation"
} else {
"default"
}
));
match &state.instructions {
Some(instructions) => lines.push(format!(
"Project instructions: {} bytes from {} source(s){}",
instructions.byte_len,
instructions.sources.len(),
if instructions.truncated {
" (truncated)"
} else {
""
}
)),
None => lines.push("Project instructions: none loaded (AGENTS.md, MERMAID.md)".to_string()),
}
lines.push(format!(
"MCP servers: {} configured, {} ready",
state.mcp.servers.len(),
state
.mcp
.servers
.values()
.filter(|entry| matches!(entry.status, crate::domain::McpServerStatus::Ready))
.count()
));
lines.push(
"Useful next commands: /help, /context, /model-info <model>, /compact [focus]".to_string(),
);
lines.join("\n")
}
fn safety_mode_name(mode: crate::runtime::SafetyMode) -> &'static str {
mode.as_str()
}
fn latest_user_intent(session: &super::state::Session) -> Option<String> {
const MAX: usize = 2000;
session
.messages()
.iter()
.rev()
.find(|m| matches!(m.role, crate::models::MessageRole::User))
.map(|m| {
let c = m.content.trim();
if c.len() > MAX {
format!("{}…", &c[..c.floor_char_boundary(MAX)])
} else {
c.to_string()
}
})
}
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());
let request = build_chat_request(state);
let max_context = state
.session
.context_usage
.as_ref()
.and_then(|snapshot| snapshot.max_tokens)
.or(state.runtime.provider_capabilities.max_context_tokens);
let next_snapshot = super::state::estimate_context_usage_for_request(&request, max_context);
let policy = CompactionPolicy::default();
let response_reserve = policy.response_reserve(request.max_tokens);
let usage_summary = match (next_snapshot.used_percent, next_snapshot.max_tokens) {
(Some(percent), Some(_)) if percent >= policy.auto_threshold_percent => {
format!("high ({percent}% used)")
},
(Some(percent), Some(_)) if percent >= 70 => format!("getting full ({percent}% used)"),
(Some(percent), Some(_)) => format!("comfortable ({percent}% used)"),
_ => "unknown because provider context limit is unknown".to_string(),
};
lines.push(format!("Context fullness: {usage_summary}"));
lines.push(format!(
"Next request: {}{} (estimated)",
format_compact_count(next_snapshot.used_tokens),
next_snapshot
.max_tokens
.map(|max| format!(" / {}", format_compact_count(max)))
.unwrap_or_else(|| " / unknown".to_string())
));
if let Some(remaining) = next_snapshot.remaining_tokens {
lines.push(format!(
"Remaining after request: {}",
format_compact_count(remaining)
));
}
lines.push(format!(
"Response reserve: {}",
format_compact_count(response_reserve)
));
lines.push(format!(
"Auto compact threshold: {}%",
policy.auto_threshold_percent
));
let auto_status = match should_auto_compact(&next_snapshot, &request, policy) {
Ok(()) => "would run before the next model call".to_string(),
Err(reason) => format!("not needed ({reason})"),
};
lines.push(format!("Auto compact: {auto_status}"));
if auto_status.starts_with("would run") {
lines.push(
"Suggested action: continue normally; Mermaid will compact before the next model call."
.to_string(),
);
} else {
lines.push("Suggested action: no manual compaction needed unless you want a handoff checkpoint now.".to_string());
}
lines.push(format!(
"Hard limit risk: {}",
if context_exceeds_hard_limit(&next_snapshot, &request, policy) {
"yes"
} else {
"no"
}
));
if let Some(context) = &state.session.context_usage {
let source = if context.is_estimate() {
"estimated"
} else {
"provider-reported"
};
lines.push(format!(
"Last reported context: {}{} ({})",
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(breakdown) = &next_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!(
"- instructions: {}",
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)
));
if breakdown.image_count > 0 {
lines.push(format!("- images: {}", breakdown.image_count));
}
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
));
lines.push(format!(
"- verification: {}",
if last.verified {
"verified".to_string()
} else {
last.verification_error
.as_ref()
.map(|err| format!("draft fallback ({err})"))
.unwrap_or_else(|| "draft fallback".to_string())
}
));
if let Some(path) = &last.archive_path {
lines.push(format!("- archive: {}", path));
}
lines.push("- inspect: use the archive path above to review the raw messages Mermaid removed from context.".to_string());
} else {
lines.push(String::new());
lines.push("Last compaction: none yet.".to_string());
}
lines.join("\n")
}
fn tasks_text(tasks: &[crate::runtime::TaskRecord]) -> String {
let mut lines = vec!["Tasks".to_string()];
if tasks.is_empty() {
lines.push("No tasks recorded yet.".to_string());
return lines.join("\n");
}
for task in tasks {
lines.push(format!(
"- {} [{}] {} - {}",
task.id, task.status, task.priority, task.title
));
lines.push(format!(" project: {}", task.project_path));
lines.push(format!(" updated: {}", task.updated_at));
}
lines.join("\n")
}
fn task_detail_text(
task: Option<&crate::runtime::TaskRecord>,
events: &[crate::runtime::TaskTimelineEvent],
) -> String {
let Some(task) = task else {
return "Task not found.".to_string();
};
let mut lines = vec![
format!("Task {}", task.id),
format!("Title: {}", task.title),
format!("Status: {}", task.status),
format!("Priority: {}", task.priority),
format!("Project: {}", task.project_path),
format!("Model: {}", task.model_id),
format!("Created: {}", task.created_at),
format!("Updated: {}", task.updated_at),
];
if let Some(report) = &task.final_report {
lines.push(String::new());
lines.push("Final report:".to_string());
lines.push(report.clone());
}
if !events.is_empty() {
lines.push(String::new());
lines.push("Timeline:".to_string());
for event in events {
lines.push(format!(
"- {} {}: {}",
event.created_at, event.kind, event.message
));
}
}
lines.join("\n")
}
fn processes_text(processes: &[crate::runtime::ProcessRecord]) -> String {
let mut lines = vec!["Processes".to_string()];
if processes.is_empty() {
lines.push("No processes recorded yet.".to_string());
return lines.join("\n");
}
for process in processes {
lines.push(format!(
"- {} pid={} [{}] {}",
process.id,
process.pid,
process.status.as_str(),
process.command
));
if let Some(task_id) = &process.task_id {
lines.push(format!(" task: {}", task_id));
}
if let Some(url) = &process.detected_url {
lines.push(format!(" url: {}", url));
}
if let Some(log_path) = &process.log_path {
lines.push(format!(" log: {}", log_path));
}
}
lines.join("\n")
}
fn approvals_text(approvals: &[crate::runtime::ApprovalRecord]) -> String {
let mut lines = vec!["Approvals".to_string()];
if approvals.is_empty() {
lines.push("No pending approvals.".to_string());
return lines.join("\n");
}
for approval in approvals {
lines.push(format!(
"- {} [{}] {}",
approval.id, approval.risk_classification, approval.proposed_action
));
if let Some(checkpoint_id) = &approval.checkpoint_id {
lines.push(format!(" checkpoint: {}", checkpoint_id));
}
if approval.pending_action_json.is_some() {
lines.push(" pending action: recorded".to_string());
}
}
lines.join("\n")
}
fn checkpoints_text(checkpoints: &[crate::runtime::CheckpointRecord]) -> String {
let mut lines = vec!["Checkpoints".to_string()];
if checkpoints.is_empty() {
lines.push("No checkpoints recorded yet.".to_string());
return lines.join("\n");
}
for checkpoint in checkpoints {
lines.push(format!(
"- {} {} {}",
checkpoint.id, checkpoint.created_at, checkpoint.project_path
));
}
lines.join("\n")
}
fn plugins_text(plugins: &[crate::runtime::PluginInstallRecord]) -> String {
let mut lines = vec!["Plugins".to_string()];
if plugins.is_empty() {
lines.push("No plugins installed.".to_string());
return lines.join("\n");
}
for plugin in plugins {
lines.push(format!(
"- {} [{}] {}",
plugin.id,
if plugin.enabled {
"enabled"
} else {
"disabled"
},
plugin.source
));
}
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.pending_approval.clear();
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();
state.pending_approval.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,
record,
conversation: 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();
let intent = latest_user_intent(&state.session);
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(),
safety_mode: state.session.safety_mode,
intent: intent.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;
use base64::{Engine as _, engine::general_purpose};
if let ProgressEvent::Artifact { mime, data, .. } = event
&& 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);
}
}
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())
{
cmds.push(Cmd::SaveProcess(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 = match (
state.instructions.as_ref().map(|i| i.content.clone()),
state.memory.as_ref().map(|m| m.index.clone()),
) {
(Some(i), Some(m)) => Some(format!("{i}\n\n{m}")),
(Some(i), None) => Some(i),
(None, Some(m)) => Some(m),
(None, None) => None,
};
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 {
let base = state
.settings
.prompt
.render_system_prompt(&get_system_prompt());
format!(
"{}\n\n## Current Session\nCurrent working directory: {}\nSafety mode: {} (live — the user can switch it anytime with Shift+Tab or /safety; trust this over any earlier tool error, and attempt gated actions rather than assuming they will fail).\nTreat this as the project root unless the user specifies a different path.",
base,
state.cwd.display(),
state.session.safety_mode.as_str()
)
}
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_output_does_not_touch_status_banner() {
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_none(),
"tool stdout must not appear in the status banner"
);
}
#[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_injects_memory_index() {
let mut state = fresh_state();
assert!(
!build_chat_request(&state)
.instructions
.map(|i| i.contains("# Memory"))
.unwrap_or(false)
);
state.memory = Some(crate::app::memory::LoadedMemory {
entries: Vec::new(),
index: "# Memory\n\n## Global (all projects)\n- [pnpm] use pnpm — /m/pnpm.md\n"
.to_string(),
truncated: false,
});
let instr = build_chat_request(&state)
.instructions
.expect("memory index should populate the instructions suffix");
assert!(instr.contains("# Memory"));
assert!(instr.contains("[pnpm] use pnpm"));
}
#[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")
);
assert!(
request.system_prompt.contains("Safety mode: "),
"system prompt must surface the live safety mode"
);
}
#[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_does_not_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, "Esc must not exit mermaid");
assert!(
!cmds.iter().any(|c| matches!(c, Cmd::Exit)),
"Esc must not emit Cmd::Exit"
);
assert!(
matches!(state.turn, TurnState::Cancelling { id: TurnId(5), .. }),
"a second Esc mid-cancel leaves the turn cancelling, unchanged"
);
}
#[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 cancelled_turn_submits_oldest_queued_message() {
let mut state = fresh_state();
state.turn = TurnState::Cancelling {
id: TurnId(1),
since: std::time::SystemTime::now(),
};
state
.ui
.queued_messages
.push_back("first queued".to_string());
state
.ui
.queued_messages
.push_back("second queued".to_string());
let (state, cmds) = update(state, Msg::TurnCancelled(TurnId(1)));
assert!(matches!(state.turn, TurnState::Generating { .. }));
assert!(cmds.iter().any(|cmd| matches!(cmd, Cmd::CallModel { .. })));
assert_eq!(state.session.messages()[0].content, "first queued");
assert_eq!(state.ui.queued_messages.len(), 1);
assert_eq!(
state.ui.queued_messages.front().map(String::as_str),
Some("second queued")
);
}
#[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 context_text_explains_auto_compaction_policy() {
let mut state = fresh_state();
state.runtime.provider_capabilities.max_context_tokens = Some(8_000);
state.session.append(ChatMessage::user("hello"));
let text = context_text(&state);
assert!(text.contains("Next request:"));
assert!(text.contains("Response reserve:"));
assert!(text.contains("Auto compact threshold:"));
assert!(text.contains("Auto compact:"));
assert!(text.contains("Hard limit risk:"));
}
#[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("Everyday:"));
assert!(msg.content.contains("Advanced runtime:"));
assert!(msg.content.contains("/model"));
assert!(msg.content.contains("/help"));
assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
}
#[test]
fn slash_doctor_appends_session_readiness_report() {
let state = fresh_state();
let (state, cmds) = update(state, Msg::Slash(SlashCmd::Doctor));
let msg = state.session.messages().last().expect("doctor message");
assert_eq!(msg.role, MessageRole::System);
assert!(msg.content.contains("Mermaid Doctor"));
assert!(msg.content.contains("Active model:"));
assert!(msg.content.contains("Safety:"));
assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
}
#[test]
fn slash_memory_commands_dispatch_effects() {
let (_s, cmds) = update(fresh_state(), Msg::Slash(SlashCmd::Memory));
assert!(cmds.iter().any(|c| matches!(c, Cmd::ListMemory)));
let (_s, cmds) = update(
fresh_state(),
Msg::Slash(SlashCmd::Remember(Some("prefer ripgrep".to_string()))),
);
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::RememberMemory { text } if text == "prefer ripgrep"))
);
let (_s, cmds) = update(
fresh_state(),
Msg::Slash(SlashCmd::Forget(Some("prefer-rg".to_string()))),
);
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::ForgetMemory { id } if id == "prefer-rg"))
);
let (state, cmds) = update(fresh_state(), Msg::Slash(SlashCmd::Remember(None)));
assert!(!cmds.iter().any(|c| matches!(c, Cmd::RememberMemory { .. })));
assert!(state.status.is_some());
let (_s, cmds) = update(fresh_state(), Msg::Slash(SlashCmd::ConsolidateMemory));
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::ConsolidateMemory { .. }))
);
}
#[test]
fn chat_request_uses_runtime_prompt_customization() {
let mut state = fresh_state();
state.settings.prompt.system_prompt = Some("replacement prompt".to_string());
state
.settings
.prompt
.append_system_prompt
.push("extra runtime rule".to_string());
let request = build_chat_request(&state);
assert!(request.system_prompt.contains("replacement prompt"));
assert!(request.system_prompt.contains("extra runtime rule"));
assert!(!request.system_prompt.contains("Core Loop"));
assert!(request.system_prompt.contains("Current working directory"));
}
#[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_visible_reasoning_toggles_runtime_ui_state() {
let state = fresh_state();
let (state, _) = update(state, Msg::Slash(SlashCmd::VisibleReasoning(None)));
assert!(state.ui.show_reasoning);
let (state, _) = update(
state,
Msg::Slash(SlashCmd::VisibleReasoning(Some("off".to_string()))),
);
assert!(!state.ui.show_reasoning);
}
#[test]
fn cycle_safety_walks_by_permissiveness() {
use crate::runtime::SafetyMode as S;
assert_eq!(cycle_safety(S::ReadOnly), S::Ask);
assert_eq!(cycle_safety(S::Ask), S::Auto);
assert_eq!(cycle_safety(S::Auto), S::FullAccess);
assert_eq!(cycle_safety(S::FullAccess), S::ReadOnly);
}
#[test]
fn shift_tab_cycles_safety_mode() {
let state = fresh_state();
let start = state.session.safety_mode;
let (state, _) = update(
state,
Msg::Key(Key {
code: KeyCode::BackTab,
modifiers: KeyMods::NONE,
}),
);
assert_eq!(state.session.safety_mode, cycle_safety(start));
}
#[test]
fn slash_safety_sets_session_mode() {
let state = fresh_state();
let (state, _) = update(
state,
Msg::Slash(SlashCmd::Safety(Some(crate::runtime::SafetyMode::Auto))),
);
assert_eq!(state.session.safety_mode, crate::runtime::SafetyMode::Auto);
}
fn pending_approval_state() -> State {
let mut state = fresh_state();
state.turn = start_generating(TurnId(1));
let (state, _) = update(
state,
Msg::ApprovalRequested {
turn: TurnId(1),
call_id: super::super::ids::ToolCallId(5),
tool: "execute_command".to_string(),
risk: "shell_mutation".to_string(),
kind: crate::domain::ApprovalKind::Shell,
prompt: "$ npm test".to_string(),
allowlist_scope: "execute_command:npm".to_string(),
},
);
state
}
fn key(code: KeyCode) -> Msg {
Msg::Key(Key {
code,
modifiers: KeyMods::NONE,
})
}
#[test]
fn ctrl_b_backgrounds_running_tool() {
let ctrl_b = Msg::Key(Key {
code: KeyCode::Char('b'),
modifiers: KeyMods {
ctrl: true,
..KeyMods::NONE
},
});
let mut state = fresh_state();
state.turn = start_executing_tools(TurnId(9), Vec::new());
let (_s, cmds) = update(state, ctrl_b.clone());
assert!(
cmds.iter()
.any(|c| matches!(c, Cmd::BackgroundScope(t) if *t == TurnId(9))),
"Ctrl+B during tool execution should background the scope"
);
let (_s, cmds) = update(fresh_state(), ctrl_b);
assert!(!cmds.iter().any(|c| matches!(c, Cmd::BackgroundScope(_))));
}
#[test]
fn paste_interleaved_with_keys_preserves_order() {
let mut state = fresh_state();
for msg in [
key(KeyCode::Char('R')),
Msg::Paste(Paste::Text("eview the ".to_string())),
key(KeyCode::Char('D')),
Msg::Paste(Paste::Text("ocs".to_string())),
] {
let (next, _) = update(state, msg);
state = next;
}
assert_eq!(state.ui.input_buffer, "Review the Docs");
assert_eq!(state.ui.input_cursor, state.ui.input_buffer.len());
}
#[test]
fn paste_inserts_at_cursor_not_end() {
let mut state = fresh_state();
for msg in [
key(KeyCode::Char('a')),
key(KeyCode::Char('c')),
key(KeyCode::Left),
Msg::Paste(Paste::Text("b".to_string())),
] {
let (next, _) = update(state, msg);
state = next;
}
assert_eq!(state.ui.input_buffer, "abc");
}
#[test]
fn approval_requested_enqueues_modal() {
let state = pending_approval_state();
assert_eq!(state.pending_approval.len(), 1);
assert_eq!(
state.pending_approval.front().unwrap().tool,
"execute_command"
);
}
#[test]
fn approval_keys_emit_the_right_decision() {
use crate::domain::ApprovalChoice as A;
for (code, expected) in [
(KeyCode::Char('1'), A::Approve),
(KeyCode::Char('y'), A::Approve),
(KeyCode::Enter, A::Approve),
(KeyCode::Char('2'), A::ApproveAlways),
(KeyCode::Char('a'), A::ApproveAlways),
(KeyCode::Char('3'), A::Deny),
(KeyCode::Char('n'), A::Deny),
(KeyCode::Escape, A::Deny),
] {
let (state, cmds) = update(pending_approval_state(), key(code));
assert!(
state.pending_approval.is_empty(),
"{code:?} should pop the modal"
);
assert!(
cmds.iter().any(
|c| matches!(c, Cmd::ResolveApproval { decision, .. } if *decision == expected)
),
"{code:?} should resolve {expected:?}; got {cmds:?}",
);
if code == KeyCode::Escape {
assert!(
!cmds.iter().any(|c| matches!(c, Cmd::CancelScope(_))),
"Esc on an approval must deny, not cancel the turn",
);
}
}
}
#[test]
fn approval_modal_swallows_unrelated_keys() {
let (state, cmds) = update(pending_approval_state(), key(KeyCode::Char('x')));
assert_eq!(
state.pending_approval.len(),
1,
"unrelated key must not pop the modal"
);
assert!(cmds.is_empty());
}
#[test]
fn approval_arrows_move_highlight_without_resolving() {
let (state, cmds) = update(pending_approval_state(), key(KeyCode::Down));
assert_eq!(state.pending_approval.front().unwrap().selected_option, 1);
assert!(cmds.is_empty() && state.pending_approval.len() == 1);
let (state, _) = update(state, key(KeyCode::Down));
assert_eq!(state.pending_approval.front().unwrap().selected_option, 2);
let (state, _) = update(state, key(KeyCode::Down)); assert_eq!(state.pending_approval.front().unwrap().selected_option, 2);
let (state, _) = update(state, key(KeyCode::Up));
assert_eq!(state.pending_approval.front().unwrap().selected_option, 1);
}
#[test]
fn approval_enter_resolves_the_highlighted_option() {
use crate::domain::ApprovalChoice as A;
let (state, _) = update(pending_approval_state(), key(KeyCode::Down));
let (state, _) = update(state, key(KeyCode::Down));
let (state, cmds) = update(state, key(KeyCode::Enter));
assert!(
state.pending_approval.is_empty(),
"Enter should pop the modal"
);
assert!(
cmds.iter().any(
|c| matches!(c, Cmd::ResolveApproval { decision, .. } if *decision == A::Deny)
),
"Enter on the highlighted 'No' must deny; got {cmds:?}"
);
}
#[test]
fn approval_fifo_shows_one_at_a_time() {
let state = pending_approval_state();
let (state, _) = update(
state,
Msg::ApprovalRequested {
turn: TurnId(1),
call_id: super::super::ids::ToolCallId(6),
tool: "write_file".to_string(),
risk: "file_mutation".to_string(),
kind: crate::domain::ApprovalKind::FileMutation,
prompt: "src/x.rs".to_string(),
allowlist_scope: "write_file".to_string(),
},
);
assert_eq!(state.pending_approval.len(), 2);
let (state, _) = update(state, key(KeyCode::Char('1')));
assert_eq!(state.pending_approval.len(), 1);
assert_eq!(state.pending_approval.front().unwrap().tool, "write_file");
}
#[test]
fn clear_confirm_now_accepts_via_keypress() {
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 (state, _) = update(state, key(KeyCode::Char('y')));
assert!(
state.confirm.is_none(),
"y should accept and clear the confirm modal"
);
}
#[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));
}
}