Files
cassady/src/app.rs
T
2026-06-24 00:27:30 -05:00

2146 lines
79 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings};
use crate::cli::{self, Command};
use crate::config::{self, Config, ModelDefinition, ReasoningEffort};
use crate::conversation::{self, Conversation, Record};
use crate::prompt;
use crate::ui::autofill::{AutoFillItem, AutoFillMenu};
use crate::ui::events::poll_event;
use crate::ui::render::{self, TranscriptBlock, TranscriptKind};
use crate::ui::terminal;
use anyhow::{Context, Result};
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
const TURN_CANCELLED_MESSAGE: &str = "Turn cancelled by user.";
const TOOL_CANCELLED_MESSAGE: &str = "Tool execution cancelled by user.";
pub async fn run() -> Result<()> {
let mut cli = cli::parse();
if matches!(cli.command, Some(Command::Check)) {
let report = crate::check::run(&cli)?;
print!("{}", report.render());
if report.has_errors() {
std::process::exit(1);
}
return Ok(());
}
if matches!(cli.command, Some(Command::Setup)) {
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Explicit).await?;
if !outcome.start_session {
return Ok(());
}
cli.command = None;
}
if cli.command.is_none()
&& cli.resume.is_none()
&& crate::setup::needs_initial_setup(&config::cass_root())
{
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Auto).await?;
if !outcome.start_session {
return Ok(());
}
}
let mut config = match Config::load(&cli) {
Ok(config) => config,
Err(err) => {
eprintln!("Cassady is not ready to start: {err:#}\n");
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Auto).await?;
if !outcome.start_session {
return Ok(());
}
Config::load(&cli)?
}
};
let cwd = resolve_cwd(cli.cwd.clone())?;
if matches!(cli.resume, Some(None)) {
list_chats(&config, &cwd)?;
return Ok(());
}
if config.resolved_api_key().is_err() {
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Auto).await?;
if !outcome.start_session {
return Ok(());
}
config = Config::load(&cli)?;
}
let (conversation, warning) = if let Some(Some(id)) = cli.resume.clone() {
Conversation::load(&config.conversations_dir(), &id)?
} else {
(create_new_conversation(&config, &cwd)?, None)
};
run_tui(config, cwd, conversation, warning).await
}
fn resolve_cwd(cwd: Option<PathBuf>) -> Result<PathBuf> {
let cwd = cwd.unwrap_or(std::env::current_dir()?);
let cwd = cwd
.canonicalize()
.with_context(|| format!("resolving cwd {}", cwd.display()))?;
std::env::set_current_dir(&cwd)?;
Ok(cwd)
}
fn create_new_conversation(config: &Config, cwd: &Path) -> Result<Conversation> {
let global = fs::read_to_string(config.global_path()).ok();
let base = prompt::build_base_system_prompt(global.as_deref());
Conversation::create(&config.conversations_dir(), &config.model, cwd, base)
}
fn list_chats(config: &Config, cwd: &std::path::Path) -> Result<()> {
let chats = conversation::list_chats(&config.conversations_dir(), cwd)?;
if chats.is_empty() {
println!("No chats found for {}", cwd.display());
} else {
for c in chats {
println!(
"{} {} {} {}",
c.id, c.created_at, c.model, c.first_user_preview
);
}
}
Ok(())
}
fn finalize_cancelled_turn(
config: &Config,
chat_id: &str,
turn_start_len: Option<usize>,
turn_message: Option<&str>,
) -> Result<Conversation> {
let (mut conversation, _) = Conversation::load(&config.conversations_dir(), chat_id)?;
if let (Some(start_len), Some(message)) = (turn_start_len, turn_message) {
if conversation.records.len() <= start_len {
conversation.append(Record::User {
content: message.to_string(),
ts: conversation::now_ts(),
})?;
}
}
for (id, name) in pending_tool_calls(&conversation.records) {
conversation.append(Record::Tool {
tool_call_id: id,
name,
ok: false,
content: TOOL_CANCELLED_MESSAGE.to_string(),
ts: conversation::now_ts(),
})?;
}
if !matches!(
conversation.records.last(),
Some(Record::Assistant { content, tool_calls, .. })
if content == TURN_CANCELLED_MESSAGE && tool_calls.is_empty()
) {
conversation.append(Record::Assistant {
content: TURN_CANCELLED_MESSAGE.to_string(),
reasoning: String::new(),
reasoning_field: None,
tool_calls: Vec::new(),
ts: conversation::now_ts(),
})?;
}
Ok(conversation)
}
fn pending_tool_calls(records: &[Record]) -> Vec<(String, String)> {
let mut pending = Vec::new();
for record in records {
match record {
Record::Assistant { tool_calls, .. } => {
pending = tool_calls
.iter()
.map(|call| (call.id.clone(), call.name.clone()))
.collect();
}
Record::Tool { tool_call_id, .. } => {
pending.retain(|(id, _)| id != tool_call_id);
}
Record::User { .. } => pending.clear(),
_ => {}
}
}
pending
}
async fn run_tui(
mut config: Config,
cwd: PathBuf,
mut conversation: Conversation,
warning: Option<String>,
) -> Result<()> {
let mut terminal = terminal::enter()?;
let mut transcript = Vec::new();
if let Some(w) = warning {
transcript.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "warning".into(),
content: w,
});
}
transcript.extend(blocks_from_conversation(&conversation));
let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
let mut agent_command_tx: Option<mpsc::UnboundedSender<AgentCommand>> = None;
let mut input = String::new();
let mut mode = config.default_access_mode;
let mut status = String::new();
let mut show_full_tools = false;
let mut show_reasoning = config.show_reasoning;
let mut reasoning_effort = config.reasoning_effort;
let mut scroll: u16 = 0;
let mut last_ctrl_c: Option<Instant> = None;
let mut handle: Option<JoinHandle<Result<Conversation>>> = None;
let mut cancel_requested = false;
let mut current_turn_start_len: Option<usize> = None;
let mut current_turn_message: Option<String> = None;
let mut active_assistant: Option<usize> = None;
let mut active_reasoning: Option<usize> = None;
let mut active_tools: HashMap<String, usize> = HashMap::new();
let mut stick_to_bottom = true;
let mut chat_id = conversation.id.clone();
let mut autofill_selected = 0usize;
let mut pending_approval: Option<PendingApproval> = None;
loop {
drain_agent_events(
&mut rx,
&mut AgentEventContext {
terminal: &terminal,
input: &input,
transcript: &mut transcript,
active_assistant: &mut active_assistant,
active_reasoning: &mut active_reasoning,
active_tools: &mut active_tools,
pending_approval: &mut pending_approval,
status: &mut status,
stick_to_bottom,
show_full_tools,
show_reasoning,
scroll: &mut scroll,
},
)?;
if handle.as_ref().map(|h| h.is_finished()).unwrap_or(false) {
let h = handle.take().unwrap();
let result = h.await;
drain_agent_events(
&mut rx,
&mut AgentEventContext {
terminal: &terminal,
input: &input,
transcript: &mut transcript,
active_assistant: &mut active_assistant,
active_reasoning: &mut active_reasoning,
active_tools: &mut active_tools,
pending_approval: &mut pending_approval,
status: &mut status,
stick_to_bottom,
show_full_tools,
show_reasoning,
scroll: &mut scroll,
},
)?;
let mut finished_status = "idle".to_string();
match result {
Ok(Ok(updated)) => {
conversation = updated;
ensure_final_assistant_visible(&mut transcript, &conversation);
}
Ok(Err(err)) => transcript.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "agent error".into(),
content: err.to_string(),
}),
Err(err) if err.is_cancelled() && cancel_requested => {
mark_active_tool_blocks_cancelled(&mut transcript, &active_tools);
match finalize_cancelled_turn(
&config,
&chat_id,
current_turn_start_len,
current_turn_message.as_deref(),
) {
Ok(updated) => conversation = updated,
Err(err) => transcript.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "cancel".into(),
content: format!(
"turn cancelled, but updating the conversation failed: {err}"
),
}),
}
transcript.push(TranscriptBlock {
kind: TranscriptKind::Status,
title: "cancelled".into(),
content: TURN_CANCELLED_MESSAGE.to_string(),
});
finished_status = "turn cancelled".into();
}
Err(err) => transcript.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "agent task error".into(),
content: err.to_string(),
}),
}
active_assistant = None;
active_reasoning = None;
active_tools.clear();
cancel_requested = false;
current_turn_start_len = None;
current_turn_message = None;
agent_command_tx = None;
pending_approval = None;
status = finished_status;
}
let autofill = build_autofill(&input, autofill_selected, &config, &cwd)?;
autofill_selected = autofill.as_ref().map(|m| m.selected).unwrap_or(0);
scroll = if stick_to_bottom {
bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?
} else {
clamp_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
scroll,
)?
};
terminal.draw(|f| {
render::render(
f,
&render::RenderState {
app_name: "Cassady",
chat_id: &chat_id,
model: &config.model,
mode,
cwd: &cwd,
transcript: &transcript,
input: &input,
status: &status,
busy: handle.is_some(),
show_full_tools,
show_reasoning,
reasoning_effort,
scroll,
autofill: autofill.as_ref(),
},
)
})?;
if let Some(event) = poll_event(Duration::from_millis(40))? {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
let busy = handle.is_some();
if busy {
if let Some(pending) = pending_approval.clone() {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Some(tx) = &agent_command_tx {
let _ = tx.send(AgentCommand::ApprovalDecision {
request_id: pending.request_id,
approved: true,
});
}
hide_pending_approval(
&mut pending_approval,
&mut transcript,
&mut active_assistant,
&mut active_reasoning,
&mut active_tools,
);
status = "approval sent".into();
continue;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
if let Some(tx) = &agent_command_tx {
let _ = tx.send(AgentCommand::ApprovalDecision {
request_id: pending.request_id,
approved: false,
});
}
hide_pending_approval(
&mut pending_approval,
&mut transcript,
&mut active_assistant,
&mut active_reasoning,
&mut active_tools,
);
status = "approval denied".into();
continue;
}
_ => {}
}
}
}
match (key.code, key.modifiers) {
(KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => {
let now = Instant::now();
if last_ctrl_c
.map(|t| now.duration_since(t) <= Duration::from_millis(1500))
.unwrap_or(false)
{
if busy {
if let Some(handle) = &handle {
handle.abort();
}
if cancel_requested {
let _ = finalize_cancelled_turn(
&config,
&chat_id,
current_turn_start_len,
current_turn_message.as_deref(),
);
}
}
terminal::leave(terminal)?;
println!("Resume this chat with: cass --resume {}", chat_id);
return Ok(());
}
if busy {
if let Some(handle) = &handle {
handle.abort();
}
cancel_requested = true;
last_ctrl_c = Some(now);
status = "turn cancellation requested; press Ctrl-C again within 1.5s to exit".into();
} else {
input.clear();
autofill_selected = 0;
last_ctrl_c = Some(now);
status = "press Ctrl-C again within 1.5s to exit".into();
}
}
(KeyCode::Esc, _) if busy => {
if let Some(handle) = &handle {
handle.abort();
}
cancel_requested = true;
last_ctrl_c = None;
status = "turn cancellation requested".into();
}
(KeyCode::BackTab, _) => {
if busy {
status = "mode can be changed when idle".into();
} else {
mode = mode.next();
status = format!("mode: {mode}");
}
}
(KeyCode::Tab, _) => {
if busy {
status = "reasoning effort can be changed when idle".into();
} else {
let next =
reasoning_effort.next_for_model(config.model_metadata.as_ref());
if next == reasoning_effort
&& next == ReasoningEffort::Off
&& !config
.model_metadata
.as_ref()
.is_some_and(|model| model.reasoning.supported)
{
status = "reasoning unsupported for this model".into();
} else {
reasoning_effort = next;
let _ = crate::config::save_last_used(
&config.root,
&config.model,
reasoning_effort,
);
status.clear();
}
}
}
(KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => {
show_full_tools = !show_full_tools;
scroll = if stick_to_bottom {
bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?
} else {
clamp_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
scroll,
)?
};
status = if show_full_tools {
"showing full tool output"
} else {
"showing compact tool output"
}
.into();
}
(KeyCode::Char('R'), m) if m.contains(KeyModifiers::CONTROL) => {
show_reasoning = !show_reasoning;
scroll = if stick_to_bottom {
bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?
} else {
clamp_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
scroll,
)?
};
status = if show_reasoning {
"showing reasoning"
} else {
"hiding reasoning"
}
.into();
}
(KeyCode::Char('r'), m)
if m.contains(KeyModifiers::CONTROL)
&& m.contains(KeyModifiers::SHIFT) =>
{
show_reasoning = !show_reasoning;
scroll = if stick_to_bottom {
bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?
} else {
clamp_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
scroll,
)?
};
status = if show_reasoning {
"showing reasoning"
} else {
"hiding reasoning"
}
.into();
}
(KeyCode::Char('j'), m) if m.contains(KeyModifiers::CONTROL) => {
input.push('\n');
autofill_selected = 0;
}
(KeyCode::Enter, m) if m.contains(KeyModifiers::CONTROL) => {
input.push('\n');
autofill_selected = 0;
}
(KeyCode::Enter, _) => {
if let Some(menu) = &autofill {
if let Some(next_input) = menu.apply(&input) {
input = next_input;
autofill_selected = 0;
}
} else if input.trim().is_empty() {
input.clear();
} else if input.trim_start().starts_with('/') {
match parse_local_command(&input) {
Ok(LocalCommand::Status) => {
let content = chat_status(
&chat_id,
&config.model,
mode,
&cwd,
busy,
&status,
conversation.records.len(),
);
input.clear();
autofill_selected = 0;
transcript.push(TranscriptBlock {
kind: TranscriptKind::Status,
title: "status".into(),
content,
});
status = "status shown".into();
if stick_to_bottom {
scroll = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
}
}
Ok(LocalCommand::Model(model)) => {
if busy {
status = "model can be changed when idle".into();
} else {
config.model = model.clone();
config.model_metadata =
model_metadata_for(&config, &model)?;
reasoning_effort = ReasoningEffort::default_for_model(
config.model_metadata.as_ref(),
);
let _ = crate::config::save_last_used(
&config.root,
&config.model,
reasoning_effort,
);
input.clear();
autofill_selected = 0;
transcript.push(TranscriptBlock {
kind: TranscriptKind::Status,
title: "model".into(),
content: format!("model changed to {model}"),
});
status = format!("model: {model}");
if stick_to_bottom {
scroll = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
}
}
}
Ok(LocalCommand::New) => {
if busy {
status = "new chat can be created when idle".into();
} else {
match create_new_conversation(&config, &cwd) {
Ok(new_conversation) => {
conversation = new_conversation;
chat_id = conversation.id.clone();
transcript.clear();
input.clear();
autofill_selected = 0;
active_assistant = None;
active_reasoning = None;
active_tools.clear();
status = format!("new chat {chat_id}");
stick_to_bottom = true;
scroll = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
}
Err(err) => {
status = format!("new chat failed: {err}");
transcript.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "new".into(),
content: err.to_string(),
});
if stick_to_bottom {
scroll = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
}
}
}
}
}
Ok(LocalCommand::Resume(id)) => {
if busy {
status = "chat can be resumed when idle".into();
} else {
match Conversation::load(
&config.conversations_dir(),
&id,
) {
Ok((loaded, warning)) => {
conversation = loaded;
chat_id = conversation.id.clone();
transcript = transcript_from_loaded(
&conversation,
warning,
);
input.clear();
autofill_selected = 0;
active_assistant = None;
active_reasoning = None;
active_tools.clear();
status = format!("resumed chat {chat_id}");
stick_to_bottom = true;
scroll = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
}
Err(err) => {
status = format!("resume failed: {err}");
transcript.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "resume".into(),
content: err.to_string(),
});
if stick_to_bottom {
scroll = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
}
}
}
}
}
Err(err) => {
status = err;
}
}
} else if busy {
status = "agent is still running".into();
} else {
let msg = input.trim_end().to_string();
current_turn_start_len = Some(conversation.records.len());
current_turn_message = Some(msg.clone());
cancel_requested = false;
last_ctrl_c = None;
input.clear();
autofill_selected = 0;
transcript.push(TranscriptBlock {
kind: TranscriptKind::User,
title: "message".into(),
content: msg.clone(),
});
active_assistant = None;
active_reasoning = None;
active_tools.clear();
status = "running".into();
let settings = AgentSettings {
config: config.clone(),
cwd: cwd.clone(),
mode,
reasoning_effort,
};
let convo = conversation.clone();
let tx2 = tx.clone();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<AgentCommand>();
agent_command_tx = Some(cmd_tx);
handle = Some(tokio::spawn(async move {
agent::run_turn_with_commands(convo, msg, settings, tx2, cmd_rx)
.await
}));
stick_to_bottom = true;
scroll = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
}
}
(KeyCode::Backspace, _) => {
input.pop();
autofill_selected = 0;
}
(KeyCode::Up, _) => {
if let Some(menu) = &autofill {
autofill_selected = menu.previous_index();
} else {
scroll = scroll.saturating_sub(1);
stick_to_bottom = false;
}
}
(KeyCode::Down, _) => {
if let Some(menu) = &autofill {
autofill_selected = menu.next_index();
} else {
let max = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
scroll = scroll.saturating_add(1).min(max);
stick_to_bottom = scroll >= max;
}
}
(KeyCode::PageUp, _) => {
scroll = scroll.saturating_sub(10);
stick_to_bottom = false;
}
(KeyCode::PageDown, _) => {
let max = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
scroll = scroll.saturating_add(10).min(max);
stick_to_bottom = scroll >= max;
}
(KeyCode::Char(ch), m)
if !m.contains(KeyModifiers::CONTROL)
&& !m.contains(KeyModifiers::ALT) =>
{
input.push(ch);
autofill_selected = 0;
}
_ => {}
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => {
scroll = scroll.saturating_sub(3);
stick_to_bottom = false;
}
MouseEventKind::ScrollDown => {
let max = bottom_scroll(
&terminal,
&input,
&transcript,
show_full_tools,
show_reasoning,
)?;
scroll = scroll.saturating_add(3).min(max);
stick_to_bottom = scroll >= max;
}
_ => {}
},
_ => {}
}
}
if last_ctrl_c
.map(|t| t.elapsed() > Duration::from_millis(1500))
.unwrap_or(false)
{
last_ctrl_c = None;
}
}
}
#[derive(Debug, Clone)]
struct PendingApproval {
request_id: String,
block_index: usize,
}
struct AgentEventContext<'a> {
terminal: &'a terminal::CassTerminal,
input: &'a str,
transcript: &'a mut Vec<TranscriptBlock>,
active_assistant: &'a mut Option<usize>,
active_reasoning: &'a mut Option<usize>,
active_tools: &'a mut HashMap<String, usize>,
pending_approval: &'a mut Option<PendingApproval>,
status: &'a mut String,
stick_to_bottom: bool,
show_full_tools: bool,
show_reasoning: bool,
scroll: &'a mut u16,
}
fn drain_agent_events(
rx: &mut mpsc::UnboundedReceiver<AgentEvent>,
ctx: &mut AgentEventContext<'_>,
) -> Result<()> {
while let Ok(event) = rx.try_recv() {
apply_agent_event(event, ctx)?;
}
Ok(())
}
fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Result<()> {
match event {
AgentEvent::AssistantChunk(s) => {
if ctx.active_assistant.is_none() && s.trim().is_empty() {
return Ok(());
}
let idx = match *ctx.active_assistant {
Some(i) => i,
None => {
ctx.transcript.push(TranscriptBlock {
kind: TranscriptKind::Assistant,
title: "response".into(),
content: String::new(),
});
let i = ctx.transcript.len() - 1;
*ctx.active_assistant = Some(i);
i
}
};
ctx.transcript[idx].content.push_str(&s);
update_bottom_scroll(ctx)?;
}
AgentEvent::ReasoningChunk(s) => {
if ctx.active_reasoning.is_none() && s.trim().is_empty() {
return Ok(());
}
let idx = match *ctx.active_reasoning {
Some(i) => i,
None => {
ctx.transcript.push(TranscriptBlock {
kind: TranscriptKind::Reasoning,
title: "reasoning".into(),
content: String::new(),
});
let i = ctx.transcript.len() - 1;
*ctx.active_reasoning = Some(i);
i
}
};
ctx.transcript[idx].content.push_str(&s);
update_bottom_scroll(ctx)?;
}
AgentEvent::ToolCallStarted {
id,
name,
arguments,
} => {
*ctx.active_assistant = None;
*ctx.active_reasoning = None;
ctx.transcript.push(TranscriptBlock {
kind: TranscriptKind::Tool,
title: format!("{name} … ({})", short_call_id(&id)),
content: summarize_tool_arguments(&name, &arguments),
});
ctx.active_tools.insert(id, ctx.transcript.len() - 1);
update_bottom_scroll(ctx)?;
}
AgentEvent::ToolOutputChunk {
id,
name,
stream,
content,
} => {
*ctx.active_assistant = None;
*ctx.active_reasoning = None;
let idx = active_tool_block(ctx, &id, &name);
append_tool_output_chunk(&mut ctx.transcript[idx].content, &stream, &content);
update_bottom_scroll(ctx)?;
}
AgentEvent::ToolResult {
id,
name,
ok,
content,
} => {
if name == "shell" {
if let Some(idx) = ctx.active_tools.remove(&id) {
ctx.transcript[idx].kind = if ok {
TranscriptKind::Tool
} else {
TranscriptKind::Error
};
ctx.transcript[idx].title = format!(
"{name} {} ({})",
if ok { "✓" } else { "✗" },
short_call_id(&id)
);
ctx.transcript[idx].content = content;
update_bottom_scroll(ctx)?;
return Ok(());
}
}
if let Some(idx) = ctx.active_tools.remove(&id) {
remove_transcript_block(ctx, idx);
}
ctx.transcript.push(TranscriptBlock {
kind: if ok {
TranscriptKind::Tool
} else {
TranscriptKind::Error
},
title: format!(
"{name} {} ({})",
if ok { "✓" } else { "✗" },
short_call_id(&id)
),
content,
});
update_bottom_scroll(ctx)?;
}
AgentEvent::ApprovalRequested {
request_id,
tool_call_id,
name,
arguments,
reason,
} => {
*ctx.active_assistant = None;
*ctx.active_reasoning = None;
let args = summarize_tool_arguments(&name, &arguments);
let block_index = ctx.transcript.len();
*ctx.pending_approval = Some(PendingApproval {
request_id: request_id.clone(),
block_index,
});
ctx.transcript.push(TranscriptBlock {
kind: TranscriptKind::Status,
title: format!("approval required ({})", short_call_id(&tool_call_id)),
content: format!(
"{name} requires approval before execution.\n\nReason: {reason}\n\nArguments:\n{args}\n\nPress y to approve, n or Esc to deny, Ctrl-C to cancel the turn."
),
});
*ctx.status = "approval required: press y to approve, n to deny".into();
update_bottom_scroll(ctx)?;
}
AgentEvent::ApprovalResolved {
request_id,
approved,
} => {
if ctx
.pending_approval
.as_ref()
.is_some_and(|pending| pending.request_id == request_id)
{
hide_pending_approval(
ctx.pending_approval,
ctx.transcript,
ctx.active_assistant,
ctx.active_reasoning,
ctx.active_tools,
);
}
*ctx.status = if approved {
"approval accepted"
} else {
"approval denied"
}
.into();
}
AgentEvent::Status(s) => {
ctx.transcript.push(TranscriptBlock {
kind: TranscriptKind::Status,
title: "status".into(),
content: s,
});
update_bottom_scroll(ctx)?;
}
AgentEvent::TurnFinished => {
*ctx.active_assistant = None;
*ctx.active_reasoning = None;
ctx.active_tools.clear();
*ctx.status = "turn finished".into();
}
}
Ok(())
}
fn active_tool_block(ctx: &mut AgentEventContext<'_>, id: &str, name: &str) -> usize {
if let Some(idx) = ctx.active_tools.get(id).copied() {
return idx;
}
ctx.transcript.push(TranscriptBlock {
kind: TranscriptKind::Tool,
title: format!("{name} … ({})", short_call_id(id)),
content: String::new(),
});
let idx = ctx.transcript.len() - 1;
ctx.active_tools.insert(id.to_string(), idx);
idx
}
fn hide_pending_approval(
pending_approval: &mut Option<PendingApproval>,
transcript: &mut Vec<TranscriptBlock>,
active_assistant: &mut Option<usize>,
active_reasoning: &mut Option<usize>,
active_tools: &mut HashMap<String, usize>,
) {
let Some(pending) = pending_approval.take() else {
return;
};
let idx = pending.block_index;
if idx >= transcript.len() {
return;
}
transcript.remove(idx);
adjust_index_after_remove(active_assistant, idx);
adjust_index_after_remove(active_reasoning, idx);
adjust_active_tool_indices_after_remove(active_tools, idx);
}
fn remove_transcript_block(ctx: &mut AgentEventContext<'_>, idx: usize) {
if idx >= ctx.transcript.len() {
return;
}
ctx.transcript.remove(idx);
adjust_index_after_remove(ctx.active_assistant, idx);
adjust_index_after_remove(ctx.active_reasoning, idx);
adjust_pending_approval_after_remove(ctx.pending_approval, idx);
adjust_active_tool_indices_after_remove(ctx.active_tools, idx);
}
fn adjust_active_tool_indices_after_remove(
active_tools: &mut HashMap<String, usize>,
removed: usize,
) {
active_tools.retain(|_, tool_idx| {
if *tool_idx == removed {
false
} else {
if *tool_idx > removed {
*tool_idx -= 1;
}
true
}
});
}
fn adjust_pending_approval_after_remove(
pending_approval: &mut Option<PendingApproval>,
removed: usize,
) {
if let Some(pending) = pending_approval {
if pending.block_index == removed {
*pending_approval = None;
} else if pending.block_index > removed {
pending.block_index -= 1;
}
}
}
fn adjust_index_after_remove(index: &mut Option<usize>, removed: usize) {
if let Some(value) = index {
if *value == removed {
*index = None;
} else if *value > removed {
*value -= 1;
}
}
}
fn mark_active_tool_blocks_cancelled(
transcript: &mut [TranscriptBlock],
active_tools: &HashMap<String, usize>,
) {
for idx in active_tools.values().copied() {
let Some(block) = transcript.get_mut(idx) else {
continue;
};
block.kind = TranscriptKind::Error;
block.title = block.title.replace('…', "cancelled");
if !block.content.ends_with('\n') && !block.content.is_empty() {
block.content.push('\n');
}
block.content.push_str(TOOL_CANCELLED_MESSAGE);
}
}
fn append_tool_output_chunk(existing: &mut String, stream: &str, chunk: &str) {
if !existing.contains("streamed output:\n") {
if !existing.trim().is_empty() {
existing.push_str("\n\n");
}
existing.push_str("streamed output:\n");
}
if !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str(&format!("[{stream}] "));
existing.push_str(chunk);
if !chunk.ends_with('\n') {
existing.push('\n');
}
}
fn update_bottom_scroll(ctx: &mut AgentEventContext<'_>) -> Result<()> {
if ctx.stick_to_bottom {
*ctx.scroll = bottom_scroll(
ctx.terminal,
ctx.input,
ctx.transcript,
ctx.show_full_tools,
ctx.show_reasoning,
)?;
}
Ok(())
}
fn ensure_final_assistant_visible(
transcript: &mut Vec<TranscriptBlock>,
conversation: &Conversation,
) -> bool {
let Some(content) = conversation.records.last().and_then(|record| match record {
conversation::Record::Assistant {
content,
tool_calls,
..
} if tool_calls.is_empty() && !content.trim().is_empty() => Some(content),
_ => None,
}) else {
return false;
};
if let Some(last) = transcript.last_mut() {
if matches!(last.kind, TranscriptKind::Assistant) {
if assistant_content_matches(&last.content, content) {
return false;
}
if content.trim_start().starts_with(last.content.trim_start()) {
last.content = content.clone();
return true;
}
return false;
}
}
transcript.push(TranscriptBlock {
kind: TranscriptKind::Assistant,
title: "response".into(),
content: content.clone(),
});
true
}
fn assistant_content_matches(a: &str, b: &str) -> bool {
a == b || (!a.trim().is_empty() && a.trim() == b.trim())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum LocalCommand {
Model(String),
New,
Resume(String),
Status,
}
struct CommandSpec {
name: &'static str,
usage: &'static str,
description: &'static str,
takes_value: bool,
}
const COMMANDS: &[CommandSpec] = &[
CommandSpec {
name: "model",
usage: "/model <model>",
description: "switch the model for future turns",
takes_value: true,
},
CommandSpec {
name: "new",
usage: "/new",
description: "create a new chat",
takes_value: false,
},
CommandSpec {
name: "resume",
usage: "/resume <chat>",
description: "resume a chat from this directory",
takes_value: true,
},
CommandSpec {
name: "status",
usage: "/status",
description: "show chat status",
takes_value: false,
},
];
fn build_autofill(
input: &str,
selected: usize,
config: &Config,
cwd: &Path,
) -> Result<Option<AutoFillMenu>> {
if input.contains('\n') || !input.starts_with('/') {
return Ok(None);
}
if let Some(menu) = command_autofill(input, selected) {
return Ok(Some(menu));
}
if let Some(menu) = model_autofill(input, selected, config)? {
return Ok(Some(menu));
}
resume_chat_autofill(input, selected, config, cwd)
}
fn command_autofill(input: &str, selected: usize) -> Option<AutoFillMenu> {
if !input.starts_with('/') || input[1..].chars().any(char::is_whitespace) {
return None;
}
if COMMANDS
.iter()
.any(|spec| !spec.takes_value && input == format!("/{}", spec.name))
{
return None;
}
let query = input[1..].to_ascii_lowercase();
let mut items = Vec::new();
for spec in COMMANDS {
if spec.name.starts_with(&query) || spec.usage[1..].starts_with(&query) {
let insert = if spec.takes_value {
format!("/{} ", spec.name)
} else {
format!("/{}", spec.name)
};
items.push(
AutoFillItem::new(spec.usage, insert).with_detail(spec.description.to_string()),
);
}
}
if items.is_empty() {
None
} else {
Some(AutoFillMenu::new("Commands", 0, input.len(), items).with_selected(selected))
}
}
fn model_autofill(input: &str, selected: usize, config: &Config) -> Result<Option<AutoFillMenu>> {
let Some(rest) = input.strip_prefix("/model") else {
return Ok(None);
};
if rest.is_empty() || !rest.chars().next().is_some_and(|c| c.is_whitespace()) {
return Ok(None);
}
let arg = rest.trim_start_matches(char::is_whitespace);
if arg.chars().any(char::is_whitespace) {
return Ok(None);
}
let replacement_start = input.len() - arg.len();
let query = arg.to_ascii_lowercase();
let models = crate::config::load_or_create_default_model_registry(&config.root)?;
if !arg.is_empty() && models.models.iter().any(|model| model.id == arg) {
return Ok(None);
}
let mut items = Vec::new();
for model in models.models {
if model_matches(&model, &query) {
let id = model.id.clone();
let detail = model_detail(&model, &config.model);
items.push(AutoFillItem::new(id.clone(), id).with_detail(detail));
}
}
if items.is_empty() {
Ok(None)
} else {
Ok(Some(
AutoFillMenu::new("Models", replacement_start, input.len(), items)
.with_selected(selected),
))
}
}
fn model_metadata_for(config: &Config, model_id: &str) -> Result<Option<ModelDefinition>> {
let models = crate::config::load_or_create_default_model_registry(&config.root)?;
Ok(models
.models
.iter()
.find(|model| model.id == model_id && model.provider == config.provider_id)
.cloned()
.or_else(|| models.models.into_iter().find(|model| model.id == model_id)))
}
fn model_matches(model: &ModelDefinition, query: &str) -> bool {
if query.is_empty() {
return true;
}
model.id.to_ascii_lowercase().contains(query)
|| model.provider.to_ascii_lowercase().contains(query)
|| model
.display_name
.as_ref()
.is_some_and(|name| name.to_ascii_lowercase().contains(query))
}
fn model_detail(model: &ModelDefinition, current_model: &str) -> String {
let mut parts = Vec::new();
if model.id == current_model {
parts.push("current".to_string());
}
if let Some(name) = &model.display_name {
if !name.trim().is_empty() && name != &model.id {
parts.push(name.clone());
}
}
parts.push(format!("provider {}", model.provider));
if let Some(context_length) = model.context_length {
parts.push(format!("ctx {context_length}"));
}
if let Some(max_output_tokens) = model.max_output_tokens {
parts.push(format!("max {max_output_tokens}"));
}
if !model.supports_tools {
parts.push("no tools".to_string());
}
if !model.supports_streaming {
parts.push("no streaming".to_string());
}
if model.reasoning.supported {
let label = if model.reasoning.required {
format!("reasoning {} required", model.reasoning.default_effort)
} else {
format!("reasoning {}", model.reasoning.default_effort)
};
parts.push(label);
} else {
parts.push("no reasoning".to_string());
}
parts.join(" · ")
}
fn resume_chat_autofill(
input: &str,
selected: usize,
config: &Config,
cwd: &Path,
) -> Result<Option<AutoFillMenu>> {
let Some(rest) = input.strip_prefix("/resume") else {
return Ok(None);
};
if rest.is_empty() || !rest.chars().next().is_some_and(|c| c.is_whitespace()) {
return Ok(None);
}
let arg = rest.trim_start_matches(char::is_whitespace);
if arg.chars().any(char::is_whitespace) {
return Ok(None);
}
let replacement_start = input.len() - arg.len();
let query = arg.to_ascii_lowercase();
let chats = conversation::list_chats(&config.conversations_dir(), cwd)?;
if !arg.is_empty() && chats.iter().any(|chat| chat.id == arg) {
return Ok(None);
}
let mut items = Vec::new();
for chat in chats {
if chat_matches(&chat, &query) {
let detail = chat_detail(&chat);
items.push(AutoFillItem::new(chat.id.clone(), chat.id).with_detail(detail));
}
}
if items.is_empty() {
Ok(None)
} else {
Ok(Some(
AutoFillMenu::new("Chats", replacement_start, input.len(), items)
.with_selected(selected),
))
}
}
fn chat_matches(chat: &conversation::ChatSummary, query: &str) -> bool {
if query.is_empty() {
return true;
}
chat.id.to_ascii_lowercase().contains(query)
|| chat.created_at.to_ascii_lowercase().contains(query)
|| chat.model.to_ascii_lowercase().contains(query)
|| chat.first_user_preview.to_ascii_lowercase().contains(query)
}
fn chat_detail(chat: &conversation::ChatSummary) -> String {
let mut parts = vec![chat.created_at.clone(), short_model_name(&chat.model)];
if !chat.first_user_preview.is_empty() {
parts.push(chat.first_user_preview.clone());
}
parts.join(" · ")
}
fn short_model_name(model: &str) -> String {
model.rsplit('/').next().unwrap_or(model).to_string()
}
fn parse_local_command(input: &str) -> std::result::Result<LocalCommand, String> {
let trimmed = input.trim();
let mut parts = trimmed.split_whitespace();
let Some(command) = parts.next() else {
return Err("empty command".into());
};
match command {
"/model" => {
let Some(model) = parts.next() else {
return Err("usage: /model <model>".into());
};
if parts.next().is_some() {
return Err("usage: /model <model>".into());
}
Ok(LocalCommand::Model(model.to_string()))
}
"/resume" => {
let Some(chat) = parts.next() else {
return Err("usage: /resume <chat>".into());
};
if parts.next().is_some() {
return Err("usage: /resume <chat>".into());
}
Ok(LocalCommand::Resume(chat.to_string()))
}
"/new" => {
if parts.next().is_some() {
return Err("usage: /new".into());
}
Ok(LocalCommand::New)
}
"/status" => {
if parts.next().is_some() {
return Err("usage: /status".into());
}
Ok(LocalCommand::Status)
}
other => Err(format!("unknown command: {other}")),
}
}
fn chat_status(
chat_id: &str,
model: &str,
mode: crate::access::AccessMode,
cwd: &Path,
busy: bool,
status: &str,
record_count: usize,
) -> String {
format!(
"chat: {chat_id}\nstate: {}\nmodel: {model}\nmode: {mode}\ncwd: {}\nrecords: {record_count}\nstatus: {}",
if busy { "running" } else { "idle" },
cwd.display(),
if status.is_empty() { "idle" } else { status }
)
}
fn transcript_from_loaded(
conversation: &Conversation,
warning: Option<String>,
) -> Vec<TranscriptBlock> {
let mut blocks = Vec::new();
if let Some(w) = warning {
blocks.push(TranscriptBlock {
kind: TranscriptKind::Error,
title: "warning".into(),
content: w,
});
}
blocks.extend(blocks_from_conversation(conversation));
blocks
}
fn summarize_tool_arguments(name: &str, args: &serde_json::Value) -> String {
match name {
"read" => summarize_read_args(args),
"write" => summarize_write_args(args),
"edit" => summarize_edit_args(args),
"shell" => summarize_shell_args(args),
"grep" => summarize_grep_args(args),
"ls" => summarize_ls_args(args),
_ => pretty_json(args),
}
}
fn summarize_read_args(args: &serde_json::Value) -> String {
let Some(files) = args.get("files").and_then(|value| value.as_array()) else {
return pretty_json(args);
};
if files.len() == 1 {
let Some(file) = files.first() else {
return pretty_json(args);
};
let Some(path) = file.get("path").and_then(|value| value.as_str()) else {
return pretty_json(args);
};
let mut lines = vec![format!("file: {path}")];
if let Some(range) = file.get("lines").and_then(|value| value.as_str()) {
lines.push(format!("lines: {}", range.replace('-', "–")));
}
return lines.join("\n");
}
let mut lines = vec![format!("files: {}", files.len())];
for file in files.iter().take(4) {
if let Some(path) = file.get("path").and_then(|value| value.as_str()) {
lines.push(format!("- {path}"));
}
}
if files.len() > 4 {
lines.push(format!("… {} more", files.len() - 4));
}
lines.join("\n")
}
fn summarize_write_args(args: &serde_json::Value) -> String {
let Some(path) = args.get("path").and_then(|value| value.as_str()) else {
return pretty_json(args);
};
let mut lines = vec![format!("file: {path}")];
if let Some(content) = args.get("content").and_then(|value| value.as_str()) {
lines.push(format!("bytes: {}", human_bytes(content.len())));
}
lines.join("\n")
}
fn summarize_edit_args(args: &serde_json::Value) -> String {
let Some(path) = args.get("path").and_then(|value| value.as_str()) else {
return pretty_json(args);
};
let Some(edits) = args.get("edits").and_then(|value| value.as_array()) else {
return pretty_json(args);
};
format!("file: {path}\nedits: {}", edits.len())
}
fn summarize_shell_args(args: &serde_json::Value) -> String {
let Some(command) = args.get("command").and_then(|value| value.as_str()) else {
return pretty_json(args);
};
format!("command: {command}")
}
fn summarize_grep_args(args: &serde_json::Value) -> String {
let Some(query) = args.get("query").and_then(|value| value.as_str()) else {
return pretty_json(args);
};
let mut lines = vec![format!("query: {query}")];
if let Some(paths) = args.get("paths").and_then(|value| value.as_array()) {
if paths.len() == 1 {
if let Some(path) = paths.first().and_then(|value| value.as_str()) {
lines.push(format!("path: {path}"));
}
} else if !paths.is_empty() {
lines.push(format!("paths: {}", paths.len()));
}
}
if args.get("regex").and_then(|value| value.as_bool()) == Some(true) {
lines.push("regex: true".into());
}
lines.join("\n")
}
fn summarize_ls_args(args: &serde_json::Value) -> String {
let Some(path) = args.get("path").and_then(|value| value.as_str()) else {
return pretty_json(args);
};
format!("path: {path}")
}
fn pretty_json(value: &serde_json::Value) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
}
fn human_bytes(bytes: usize) -> String {
const KB: f64 = 1024.0;
const MB: f64 = 1024.0 * 1024.0;
let bytes_f = bytes as f64;
if bytes < 1024 {
format!("{bytes} B")
} else if bytes_f < MB {
format!("{:.1} KB", bytes_f / KB)
} else {
format!("{:.1} MB", bytes_f / MB)
}
}
fn short_call_id(id: &str) -> String {
if id.len() <= 12 {
id.to_string()
} else {
format!("{}…{}", &id[..6], &id[id.len() - 4..])
}
}
fn terminal_area(terminal: &terminal::CassTerminal) -> Result<ratatui::layout::Rect> {
let size = terminal.size()?;
Ok(ratatui::layout::Rect::new(0, 0, size.width, size.height))
}
fn bottom_scroll(
terminal: &terminal::CassTerminal,
input: &str,
transcript: &[TranscriptBlock],
show_full_tools: bool,
show_reasoning: bool,
) -> Result<u16> {
let area = render::transcript_area(terminal_area(terminal)?, input);
Ok(render::max_transcript_scroll(
transcript,
show_full_tools,
show_reasoning,
area,
))
}
fn clamp_scroll(
terminal: &terminal::CassTerminal,
input: &str,
transcript: &[TranscriptBlock],
show_full_tools: bool,
show_reasoning: bool,
scroll: u16,
) -> Result<u16> {
Ok(scroll.min(bottom_scroll(
terminal,
input,
transcript,
show_full_tools,
show_reasoning,
)?))
}
fn blocks_from_conversation(conversation: &Conversation) -> Vec<TranscriptBlock> {
let completed_tool_calls: HashSet<&str> = conversation
.records
.iter()
.filter_map(|record| match record {
conversation::Record::Tool { tool_call_id, .. } => Some(tool_call_id.as_str()),
_ => None,
})
.collect();
let mut blocks = Vec::new();
for r in &conversation.records {
match r {
conversation::Record::User { content, .. } => blocks.push(TranscriptBlock {
kind: TranscriptKind::User,
title: "message".into(),
content: content.clone(),
}),
conversation::Record::Assistant {
content,
reasoning,
tool_calls,
..
} => {
if !reasoning.trim().is_empty() {
blocks.push(TranscriptBlock {
kind: TranscriptKind::Reasoning,
title: "reasoning".into(),
content: reasoning.clone(),
});
}
if !content.trim().is_empty() {
blocks.push(TranscriptBlock {
kind: TranscriptKind::Assistant,
title: "response".into(),
content: content.clone(),
});
}
for call in tool_calls {
if completed_tool_calls.contains(call.id.as_str()) {
continue;
}
blocks.push(TranscriptBlock {
kind: TranscriptKind::Tool,
title: format!("{} … ({})", call.name, short_call_id(&call.id)),
content: summarize_tool_arguments(&call.name, &call.arguments),
});
}
}
conversation::Record::Tool {
tool_call_id,
name,
ok,
content,
..
} => blocks.push(TranscriptBlock {
kind: if *ok {
TranscriptKind::Tool
} else {
TranscriptKind::Error
},
title: format!(
"{name} {} ({})",
if *ok { "✓" } else { "✗" },
short_call_id(tool_call_id)
),
content: content.clone(),
}),
_ => {}
}
}
blocks
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn config_with_models(models_json: &str) -> (tempfile::TempDir, Config) {
let root = tempdir().unwrap();
std::fs::write(root.path().join("models.json"), models_json).unwrap();
let config = Config {
root: root.path().to_path_buf(),
model: "alpha-model".to_string(),
..Config::default()
};
(root, config)
}
#[test]
fn summarize_edit_args_uses_edits() {
let summary = summarize_tool_arguments(
"edit",
&serde_json::json!({"path":"src/ui/render.rs","edits":[{"old_text":"a","new_text":"b"},{"old_text":"c","new_text":"d"}]}),
);
assert_eq!(summary, "file: src/ui/render.rs\nedits: 2");
assert!(!summary.contains("replacements"));
}
#[test]
fn summarize_shell_args_uses_command() {
let summary =
summarize_tool_arguments("shell", &serde_json::json!({"command":"cargo test"}));
assert_eq!(summary, "command: cargo test");
}
#[test]
fn summarize_read_args_uses_file_and_lines() {
let summary = summarize_tool_arguments(
"read",
&serde_json::json!({"files":[{"path":"src/app.rs","lines":"1-20"}]}),
);
assert_eq!(summary, "file: src/app.rs\nlines: 1–20");
}
#[test]
fn summarize_unknown_tool_falls_back_to_json() {
let summary = summarize_tool_arguments("custom", &serde_json::json!({"alpha":1}));
assert!(summary.contains("\"alpha\": 1"));
}
#[test]
fn loaded_transcript_hides_completed_tool_call_invocations() {
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
let config = Config {
root: root.path().to_path_buf(),
model: "test-model".into(),
..Config::default()
};
let mut conversation = Conversation::create(
&config.conversations_dir(),
&config.model,
cwd.path(),
"base prompt".into(),
)
.unwrap();
conversation
.append(Record::Assistant {
content: String::new(),
reasoning: String::new(),
reasoning_field: None,
tool_calls: vec![conversation::StoredToolCall {
id: "call_done".into(),
name: "read".into(),
arguments: serde_json::json!({"files":[{"path":"src/app.rs"}]}),
}],
ts: conversation::now_ts(),
})
.unwrap();
conversation
.append(Record::Tool {
tool_call_id: "call_done".into(),
name: "read".into(),
ok: true,
content: "ok".into(),
ts: conversation::now_ts(),
})
.unwrap();
let blocks = blocks_from_conversation(&conversation);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].title, "read ✓ (call_done)");
}
#[test]
fn loaded_transcript_keeps_pending_tool_call_invocations() {
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
let config = Config {
root: root.path().to_path_buf(),
model: "test-model".into(),
..Config::default()
};
let mut conversation = Conversation::create(
&config.conversations_dir(),
&config.model,
cwd.path(),
"base prompt".into(),
)
.unwrap();
conversation
.append(Record::Assistant {
content: String::new(),
reasoning: String::new(),
reasoning_field: None,
tool_calls: vec![conversation::StoredToolCall {
id: "call_pending".into(),
name: "shell".into(),
arguments: serde_json::json!({"command":"sleep 60"}),
}],
ts: conversation::now_ts(),
})
.unwrap();
let blocks = blocks_from_conversation(&conversation);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].title, "shell … (call_pending)");
}
#[test]
fn command_autofill_lists_new_command_and_hides_exact_match() {
let menu = command_autofill("/n", 0).unwrap();
assert_eq!(menu.items.len(), 1);
assert_eq!(menu.items[0].label, "/new");
assert_eq!(menu.items[0].insert, "/new");
assert_eq!(menu.apply("/n").unwrap(), "/new");
assert!(command_autofill("/new", 0).is_none());
}
#[test]
fn parse_local_command_accepts_new_without_args() {
assert_eq!(parse_local_command("/new").unwrap(), LocalCommand::New);
assert_eq!(parse_local_command("/new extra"), Err("usage: /new".into()));
}
#[test]
fn cancelled_turn_repairs_missing_tool_results() {
let root = tempdir().unwrap();
let cwd = tempdir().unwrap();
let config = Config {
root: root.path().to_path_buf(),
model: "test-model".into(),
..Config::default()
};
let mut conversation = Conversation::create(
&config.conversations_dir(),
&config.model,
cwd.path(),
"base prompt".into(),
)
.unwrap();
let chat_id = conversation.id.clone();
let start_len = conversation.records.len();
conversation
.append(Record::User {
content: "run tools".into(),
ts: conversation::now_ts(),
})
.unwrap();
conversation
.append(Record::Assistant {
content: String::new(),
reasoning: String::new(),
reasoning_field: None,
tool_calls: vec![
conversation::StoredToolCall {
id: "call_done".into(),
name: "read".into(),
arguments: serde_json::json!({"path":"a"}),
},
conversation::StoredToolCall {
id: "call_pending".into(),
name: "shell".into(),
arguments: serde_json::json!({"command":"sleep 60"}),
},
],
ts: conversation::now_ts(),
})
.unwrap();
conversation
.append(Record::Tool {
tool_call_id: "call_done".into(),
name: "read".into(),
ok: true,
content: "ok".into(),
ts: conversation::now_ts(),
})
.unwrap();
let updated =
finalize_cancelled_turn(&config, &chat_id, Some(start_len), Some("run tools")).unwrap();
assert!(matches!(
updated.records.get(updated.records.len() - 2),
Some(Record::Tool { tool_call_id, name, ok, content, .. })
if tool_call_id == "call_pending"
&& name == "shell"
&& !ok
&& content == TOOL_CANCELLED_MESSAGE
));
assert!(matches!(
updated.records.last(),
Some(Record::Assistant { content, tool_calls, .. })
if content == TURN_CANCELLED_MESSAGE && tool_calls.is_empty()
));
}
#[test]
fn model_autofill_lists_models_from_models_json() {
let (_root, config) = config_with_models(
r#"{
"models": [
{
"id": "alpha-model",
"provider": "fireworks",
"display_name": "Alpha Model",
"context_length": 1000,
"max_output_tokens": 200
},
{
"id": "beta-model",
"provider": "other",
"display_name": "Beta Model"
}
]
}
"#,
);
let menu = model_autofill("/model ", 0, &config).unwrap().unwrap();
assert_eq!(menu.items.len(), 2);
assert_eq!(menu.items[0].label, "alpha-model");
assert_eq!(menu.items[0].insert, "alpha-model");
assert_eq!(menu.apply("/model ").unwrap(), "/model alpha-model");
let detail = menu.items[0].detail.as_deref().unwrap();
assert!(detail.contains("current"));
assert!(detail.contains("Alpha Model"));
assert!(detail.contains("provider fireworks"));
}
#[test]
fn model_autofill_filters_and_hides_exact_matches() {
let (_root, config) = config_with_models(
r#"{
"models": [
{ "id": "alpha-model", "provider": "fireworks" },
{ "id": "beta-model", "provider": "other", "display_name": "Beta Model" }
]
}
"#,
);
let menu = model_autofill("/model beta", 0, &config).unwrap().unwrap();
assert_eq!(menu.items.len(), 1);
assert_eq!(menu.items[0].insert, "beta-model");
assert_eq!(menu.apply("/model beta").unwrap(), "/model beta-model");
assert!(model_autofill("/model alpha-model", 0, &config)
.unwrap()
.is_none());
}
#[test]
fn ensure_final_assistant_visible_appends_missing_final() {
let conversation = Conversation {
id: "chat".into(),
path: PathBuf::new(),
records: vec![conversation::Record::Assistant {
content: "Done.".into(),
reasoning: String::new(),
reasoning_field: None,
tool_calls: Vec::new(),
ts: "now".into(),
}],
};
let mut transcript = vec![TranscriptBlock {
kind: TranscriptKind::Tool,
title: "result: read ✓ (call_1)".into(),
content: "ok".into(),
}];
assert!(ensure_final_assistant_visible(
&mut transcript,
&conversation
));
assert!(matches!(
transcript.last(),
Some(TranscriptBlock {
kind: TranscriptKind::Assistant,
content,
..
}) if content == "Done."
));
}
#[test]
fn ensure_final_assistant_visible_does_not_duplicate_streamed_final() {
let conversation = Conversation {
id: "chat".into(),
path: PathBuf::new(),
records: vec![conversation::Record::Assistant {
content: "\nDone.".into(),
reasoning: String::new(),
reasoning_field: None,
tool_calls: Vec::new(),
ts: "now".into(),
}],
};
let mut transcript = vec![TranscriptBlock {
kind: TranscriptKind::Assistant,
title: "response".into(),
content: "Done.".into(),
}];
assert!(!ensure_final_assistant_visible(
&mut transcript,
&conversation
));
assert_eq!(transcript.len(), 1);
}
}