use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io::Stdout;
use std::sync::{Arc, Mutex};
use crate::commands::{self, CommandResult};
use crate::config::ResolvedModel;
use crate::db::Db;
use crate::permissions::PermissionResponse;
use crate::query::{Engine, SteeringQueue, StreamEvent};
use crate::theme::{Theme, ThemeName};
use super::screen::Action;
use super::ui;
#[derive(Debug, Clone)]
pub enum ChatMessage {
Text { role: String, content: String },
Tool {
name: String,
summary: String,
status: ToolStatus,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum ToolStatus {
Running,
Success,
Error,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Mode {
Input,
Streaming,
Permission,
}
pub struct ChatApp {
pub messages: Vec<ChatMessage>,
pub input: String,
pub cursor: usize,
pub scroll: u16,
pub manual_scroll: bool,
pub mode: Mode,
pub stream_buffer: String,
pub status: String,
pub permission_prompt: Option<String>,
pub permission_details: Option<Vec<String>>,
pub permission_always_is_command: bool,
pub should_exit: bool,
pub should_go_home: bool,
pub model: String,
pub total_lines: u16,
pub thinking: bool,
pub theme: Theme,
pub steer_buf: Arc<Mutex<String>>,
pub ctrl_c: crate::utils::CtrlCArm,
pub messages_rev: u64,
pub history_cache: Option<super::ui::HistoryCache>,
pub version: String,
}
impl ChatApp {
pub fn new(model: &str, theme: Theme) -> Self {
Self {
messages: Vec::new(),
input: String::new(),
cursor: 0,
scroll: 0,
manual_scroll: false,
mode: Mode::Input,
stream_buffer: String::new(),
status: String::new(),
permission_prompt: None,
permission_details: None,
permission_always_is_command: false,
should_exit: false,
should_go_home: false,
model: model.to_string(),
total_lines: 0,
thinking: false,
theme,
steer_buf: Arc::new(Mutex::new(String::new())),
ctrl_c: crate::utils::CtrlCArm::default(),
messages_rev: 0,
history_cache: None,
version: env!("CARGO_PKG_VERSION").to_string(),
}
}
pub fn add_message(&mut self, role: &str, content: &str) {
self.messages_rev += 1;
self.messages.push(ChatMessage::Text {
role: crate::utils::sanitize_terminal_text(role),
content: crate::utils::sanitize_terminal_text(content),
});
}
pub fn add_tool(&mut self, name: &str, summary: &str, status: ToolStatus) {
self.thinking = false;
self.messages_rev += 1;
self.messages.push(ChatMessage::Tool {
name: crate::utils::sanitize_terminal_text(name),
summary: crate::utils::sanitize_terminal_text(summary),
status,
});
}
#[cfg(test)]
pub fn update_last_tool_status(&mut self, new_status: ToolStatus) {
self.messages_rev += 1;
if let Some(ChatMessage::Tool { status, .. }) = self.messages.last_mut() {
*status = new_status;
}
}
pub fn set_tool_status_at(&mut self, idx: usize, new_status: ToolStatus) {
self.messages_rev += 1;
if let Some(ChatMessage::Tool { status, .. }) = self.messages.get_mut(idx) {
*status = new_status;
}
}
pub fn set_theme(&mut self, theme_name: ThemeName) {
self.theme = Theme::from_name(theme_name);
self.messages_rev += 1;
}
pub fn handle_key(&mut self, key: KeyEvent) {
match self.mode {
Mode::Input => self.handle_input_key(key),
Mode::Permission | Mode::Streaming => {}
}
}
fn handle_input_key(&mut self, key: KeyEvent) {
let is_ctrl_c = key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c');
if !is_ctrl_c && self.ctrl_c.is_armed() {
self.ctrl_c.disarm();
self.status = format!("{} | /help for commands", self.model);
}
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => {
if self.ctrl_c.press() {
self.should_exit = true;
} else {
self.status = "Press Ctrl+C again to exit".to_string();
}
}
(KeyModifiers::CONTROL, KeyCode::Char('d')) => {
self.should_exit = true;
}
(_, KeyCode::Enter) => {
}
(_, KeyCode::Backspace) if self.cursor > 0 => {
super::input::backspace(&mut self.input, &mut self.cursor);
}
(_, KeyCode::Delete) if self.cursor < super::input::char_count(&self.input) => {
super::input::delete(&mut self.input, self.cursor);
}
(_, KeyCode::Left) if self.cursor > 0 => {
self.cursor -= 1;
}
(_, KeyCode::Right) if self.cursor < super::input::char_count(&self.input) => {
self.cursor += 1;
}
(_, KeyCode::Home) | (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
self.cursor = 0;
}
(_, KeyCode::End) | (KeyModifiers::CONTROL, KeyCode::Char('e')) => {
self.cursor = super::input::char_count(&self.input);
}
(KeyModifiers::CONTROL, KeyCode::Char('u')) => {
self.input.clear();
self.cursor = 0;
}
(_, KeyCode::Up) => {
self.scroll = self.scroll.saturating_add(3);
self.manual_scroll = true;
}
(_, KeyCode::Down) => {
self.scroll = self.scroll.saturating_sub(3);
if self.scroll == 0 {
self.manual_scroll = false;
}
}
(_, KeyCode::Char(c)) => {
super::input::insert(&mut self.input, &mut self.cursor, c);
}
_ => {}
}
}
pub fn take_input(&mut self) -> Option<String> {
if self.input.trim().is_empty() {
return None;
}
let input = self.input.clone();
self.input.clear();
self.cursor = 0;
Some(input)
}
}
pub async fn run(
engine: &mut Engine,
session_id: &str,
db: &Db,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
theme: Theme,
models: &[ResolvedModel],
) -> Result<Action> {
let existing_messages = crate::session::repair_history(db.get_messages(session_id)?);
engine.set_messages(existing_messages.clone());
let mut app = ChatApp::new(engine.model(), theme);
app.status = format!("{} | /help for commands", engine.model());
for msg in &existing_messages {
let role = &msg.role;
let content = match &msg.content {
crate::api::types::MessageContent::Text(t) => t.clone(),
crate::api::types::MessageContent::Blocks(blocks) => blocks
.iter()
.filter_map(|b| match b {
crate::api::ContentBlock::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
};
app.add_message(role, &content);
}
let mut needs_redraw = true;
let mut pending_submit: Option<String> = None;
loop {
if needs_redraw {
terminal.draw(|f| ui::draw_chat(f, &mut app))?;
needs_redraw = false;
}
if let Some(input) = pending_submit.take() {
let trimmed = input.trim().to_string();
if trimmed == "/home" {
return Ok(Action::Home);
}
if let Some(result) = commands::parse_command(&trimmed) {
match result {
CommandResult::Text(ref text) if text == "__cost__" => {
app.add_message("system", &commands::format_cost(engine));
}
CommandResult::Text(text) => {
app.add_message("system", &text);
}
CommandResult::Exit => {
return Ok(Action::Home);
}
CommandResult::Async(async_cmd) => match async_cmd {
commands::AsyncCommand::Model(selector) => match selector {
Some(selector) => {
db.replace_messages(session_id, engine.messages())?;
return Ok(Action::SwitchModel {
session_id: session_id.to_string(),
selector,
});
}
None => app.add_message(
"system",
&commands::format_model_choices(engine.model_binding(), models),
),
},
commands::AsyncCommand::Theme(theme_name) => match theme_name {
Some(name) => {
let theme = match name.to_lowercase().as_str() {
"dark" => ThemeName::Dark,
"light" => ThemeName::Light,
"ansi" => ThemeName::Ansi,
"dracula" => ThemeName::Dracula,
"nord" => ThemeName::Nord,
"catppuccin" => ThemeName::Catppuccin,
_ => {
app.add_message("error", &format!(
"Unknown theme: {name}. Available: dark, light, ansi, dracula, nord, catppuccin"
));
continue;
}
};
app.set_theme(theme);
app.add_message("system", &format!("Theme set to: {name}"));
}
None => {
app.add_message("system",
"Available themes: dark, light, ansi, dracula, nord, catppuccin\n\
Use /theme <name> to switch.");
}
},
_ => {
match commands::execute_async(async_cmd, engine).await {
Ok(output) => app.add_message("system", &output),
Err(e) => app.add_message("error", &format!("Error: {e}")),
}
let _ = db.replace_messages(session_id, engine.messages());
if let Some(binding) = engine.model_binding() {
let _ = db.update_session_binding(session_id, binding);
}
}
},
}
app.scroll = 0;
app.manual_scroll = false;
needs_redraw = true;
continue;
}
app.add_message("user", &trimmed);
app.mode = Mode::Streaming;
app.stream_buffer.clear();
app.thinking = true;
app.scroll = 0;
app.manual_scroll = false;
app.status = format!("{} | thinking...", app.model);
let submit_result =
drive_streaming(engine, &trimmed, &mut app, terminal, &mut CrosstermKeys).await;
if let Err(e) = submit_result {
app.add_message("error", &format!("Error: {e}"));
}
if let Err(e) = db.replace_messages(session_id, engine.messages()) {
tracing::warn!("Failed to save session: {e}");
}
app.mode = Mode::Input;
app.status = format!("{} | {}", engine.model(), engine.cost.format_summary());
app.scroll = 0;
app.manual_scroll = false;
needs_redraw = true;
continue;
}
if event::poll(std::time::Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Enter && app.mode == Mode::Input {
if let Some(input) = app.take_input() {
pending_submit = Some(input);
}
} else {
app.handle_key(key);
}
needs_redraw = true;
}
}
if app.should_exit {
return Ok(Action::Quit);
}
if app.should_go_home {
return Ok(Action::Home);
}
}
}
async fn drive_streaming<B: ratatui::backend::Backend>(
engine: &mut Engine,
input: &str,
app: &mut ChatApp,
terminal: &mut Terminal<B>,
keys: &mut dyn KeySource,
) -> Result<()> {
let steering = engine.steering_queue();
let steer_buf = app.steer_buf.clone();
let cancel = tokio_util::sync::CancellationToken::new();
let (tx, mut rx) = tokio::sync::mpsc::channel::<StreamEvent>(256);
let mut submit_result: Option<Result<()>> = None;
{
let submit_fut = engine.submit_streaming(input, tx, cancel.clone());
tokio::pin!(submit_fut);
let mut running_tools: std::collections::VecDeque<usize> =
std::collections::VecDeque::new();
let mut tick = tokio::time::interval_at(
tokio::time::Instant::now() + std::time::Duration::from_millis(50),
std::time::Duration::from_millis(50),
);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
res = &mut submit_fut, if submit_result.is_none() => {
submit_result = Some(res);
}
event = rx.recv() => {
let Some(event) = event else {
break; };
match event {
StreamEvent::Text(t) => {
app.stream_buffer
.push_str(&crate::utils::sanitize_terminal_text(&t));
app.thinking = false;
}
StreamEvent::Retry(n) => {
app.stream_buffer.clear();
app.thinking = true;
app.add_message("system", &n);
}
StreamEvent::Notice(n) => {
flush_stream_buffer(app);
app.add_message("system", &n);
}
StreamEvent::SteeringSent(t) => {
flush_stream_buffer(app);
app.add_message("user", &t);
}
StreamEvent::ToolStart { name, summary, .. } => {
flush_stream_buffer(app);
app.add_tool(&name, &summary, ToolStatus::Running);
running_tools.push_back(app.messages.len() - 1);
terminal.draw(|f| ui::draw_chat(f, app))?;
}
StreamEvent::ToolResult { is_error, .. } => {
if let Some(idx) = running_tools.pop_front() {
app.set_tool_status_at(
idx,
if is_error { ToolStatus::Error } else { ToolStatus::Success },
);
}
terminal.draw(|f| ui::draw_chat(f, app))?;
}
StreamEvent::PermissionRequest { tool_name, summary, input, respond }
| StreamEvent::PermissionRequestWithDiff { tool_name, summary, input, respond, .. } => {
let response = prompt_permission_tui(
app,
terminal,
keys,
&tool_name,
&summary,
&input,
&steering,
)
.await?;
let end_turn = matches!(response, PermissionResponse::Deny);
let _ = respond.send(response);
if end_turn {
cancel.cancel();
}
}
StreamEvent::Interrupted => {
flush_stream_buffer(app);
while let Some(idx) = running_tools.pop_front() {
app.set_tool_status_at(idx, ToolStatus::Error);
}
app.add_message("system", "Interrupted by user.");
}
StreamEvent::Error(_) => {
}
StreamEvent::Done => {
flush_stream_buffer(app);
}
}
}
_ = tick.tick() => {
if poll_stream_key(keys, &steer_buf, &steering)? {
cancel.cancel();
}
terminal.draw(|f| ui::draw_chat(f, app))?;
}
}
}
}
submit_result.unwrap_or(Ok(()))
}
fn flush_stream_buffer(app: &mut ChatApp) {
if !app.stream_buffer.is_empty() {
let content = app.stream_buffer.clone();
app.stream_buffer.clear();
app.add_message("assistant", &content);
}
}
async fn prompt_permission_tui<B: ratatui::backend::Backend>(
app: &mut ChatApp,
terminal: &mut Terminal<B>,
keys: &mut dyn KeySource,
tool_name: &str,
summary: &str,
input: &serde_json::Value,
steering: &SteeringQueue,
) -> Result<PermissionResponse> {
app.permission_prompt = Some(crate::utils::sanitize_terminal_text(summary));
app.permission_details = Some(
format_permission_details(tool_name, input)
.into_iter()
.map(|line| crate::utils::sanitize_terminal_text(&line))
.collect(),
);
app.permission_always_is_command = tool_name == "Bash";
app.mode = Mode::Permission;
terminal.draw(|f| ui::draw_chat(f, app))?;
let mut perm_input = String::new();
let response = loop {
match keys.poll_key()? {
None => {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
Some(key) => {
match key.code {
KeyCode::Char('y') | KeyCode::Enter if perm_input.is_empty() => {
break PermissionResponse::Allow;
}
KeyCode::Char('a') if perm_input.is_empty() => {
break PermissionResponse::always_allow_for(tool_name, input);
}
KeyCode::Char('n') if perm_input.is_empty() => {
break PermissionResponse::Deny;
}
KeyCode::Esc if perm_input.is_empty() => {
break PermissionResponse::Deny;
}
KeyCode::Enter if !perm_input.is_empty() => {
steering
.lock()
.expect("steering queue poisoned")
.push_back(perm_input.clone());
break PermissionResponse::DenyAndCancel;
}
KeyCode::Backspace if !perm_input.is_empty() => {
perm_input.pop();
}
KeyCode::Char(c) => {
perm_input.push(c);
}
_ => {}
}
app.status = format!("{} | deny and message: {perm_input}", app.model);
terminal.draw(|f| ui::draw_chat(f, app))?;
}
}
};
app.permission_prompt = None;
app.permission_details = None;
app.permission_always_is_command = false;
app.mode = Mode::Streaming;
app.status = app.model.clone();
Ok(response)
}
pub trait KeySource {
fn poll_key(&mut self) -> Result<Option<KeyEvent>>;
}
pub struct CrosstermKeys;
impl KeySource for CrosstermKeys {
fn poll_key(&mut self) -> Result<Option<KeyEvent>> {
if event::poll(std::time::Duration::from_millis(0))? {
if let Event::Key(key) = event::read()? {
return Ok(Some(key));
}
}
Ok(None)
}
}
fn poll_stream_key(
keys: &mut dyn KeySource,
steer_buf: &Arc<Mutex<String>>,
steering: &SteeringQueue,
) -> Result<bool> {
{
if let Some(key) = keys.poll_key()? {
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => return Ok(true),
(_, KeyCode::Enter) => {
let text = {
let mut buf = steer_buf.lock().expect("steer buffer poisoned");
std::mem::take(&mut *buf)
};
let text = text.trim().to_string();
if !text.is_empty() {
steering
.lock()
.expect("steering queue poisoned")
.push_back(text);
}
}
(_, KeyCode::Backspace) => {
steer_buf.lock().expect("steer buffer poisoned").pop();
}
(m, KeyCode::Char(c)) if m.is_empty() || m == KeyModifiers::SHIFT => {
steer_buf.lock().expect("steer buffer poisoned").push(c);
}
_ => {}
}
}
}
Ok(false)
}
fn format_permission_details(tool_name: &str, input: &serde_json::Value) -> Vec<String> {
let mut lines = Vec::new();
match tool_name {
"Bash" => {
if let Some(cmd) = input["command"].as_str() {
lines.push("Command:".to_string());
for line in cmd.lines() {
lines.push(format!(" {line}"));
}
}
}
"Write" => {
if let Some(path) = input["file_path"].as_str() {
lines.push(format!("File: {path}"));
}
if let Some(content) = input["content"].as_str() {
let preview: Vec<&str> = content.lines().take(10).collect();
lines.push("Content:".to_string());
for line in &preview {
lines.push(format!(" {line}"));
}
let total = content.lines().count();
if total > 10 {
lines.push(format!(" ... ({} more lines)", total - 10));
}
}
}
"Edit" => {
if let Some(path) = input["file_path"].as_str() {
lines.push(format!("File: {path}"));
}
if let Some(old) = input["old_string"].as_str() {
lines.push("Replace:".to_string());
for line in old.lines().take(5) {
lines.push(format!(" - {line}"));
}
}
if let Some(new) = input["new_string"].as_str() {
lines.push("With:".to_string());
for line in new.lines().take(5) {
lines.push(format!(" + {line}"));
}
}
}
"Agent" => {
if let Some(prompt) = input["prompt"].as_str() {
lines.push("Task:".to_string());
for line in prompt.lines().take(5) {
lines.push(format!(" {line}"));
}
}
}
"Read" => {
if let Some(path) = input["file_path"].as_str() {
lines.push(format!("File: {path}"));
}
}
"Grep" => {
if let Some(pattern) = input["pattern"].as_str() {
lines.push(format!("Pattern: {pattern}"));
}
if let Some(path) = input["path"].as_str() {
lines.push(format!("In: {path}"));
}
}
"WebFetch" => {
if let Some(url) = input["url"].as_str() {
lines.push(format!("URL: {url}"));
}
}
_ => {
let json_str = serde_json::to_string_pretty(input).unwrap_or_default();
for line in json_str.lines().take(8) {
lines.push(format!(" {line}"));
}
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
fn test_app() -> ChatApp {
ChatApp::new("test-model", Theme::dark())
}
fn ctrl_c_key() -> KeyEvent {
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
}
#[test]
fn single_ctrl_c_warns_double_exits() {
let mut app = test_app();
app.handle_key(ctrl_c_key());
assert!(!app.should_exit, "first Ctrl+C must not exit");
assert!(app.status.contains("again"), "status should show the hint");
app.handle_key(ctrl_c_key());
assert!(app.should_exit, "second Ctrl+C must exit");
}
#[test]
fn typing_disarms_pending_ctrl_c() {
let mut app = test_app();
app.handle_key(ctrl_c_key());
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(!app.status.contains("again"), "hint should clear");
app.handle_key(ctrl_c_key());
assert!(
!app.should_exit,
"Ctrl+C after typing re-arms instead of exiting"
);
}
#[test]
fn ctrl_d_still_exits_immediately() {
let mut app = test_app();
app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert!(app.should_exit);
}
#[test]
fn input_editing_handles_multibyte_characters() {
let mut app = test_app();
for character in ['a', 'é', '界'] {
app.handle_key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
}
assert_eq!(app.input, "aé界");
assert_eq!(app.cursor, 3);
app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(app.input, "a界");
assert_eq!(app.cursor, 1);
app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
assert_eq!(app.input, "a");
}
#[test]
fn displayed_messages_strip_terminal_controls() {
let mut app = test_app();
app.add_message("assistant", "hello\x1b]52;c;secret\x07 world");
app.add_tool("Bash\x1b[2J", "echo safe\rspoof", ToolStatus::Running);
assert!(matches!(
&app.messages[0],
ChatMessage::Text { content, .. }
if content == "hello]52;c;secret world" && !content.contains('\x1b')
));
assert!(matches!(
&app.messages[1],
ChatMessage::Tool { name, summary, .. }
if name == "Bash[2J" && summary == "echo safespoof"
));
}
#[test]
fn add_message_creates_text_variant() {
let mut app = test_app();
app.add_message("user", "hello");
assert_eq!(app.messages.len(), 1);
match &app.messages[0] {
ChatMessage::Text { role, content } => {
assert_eq!(role, "user");
assert_eq!(content, "hello");
}
_ => panic!("expected Text variant"),
}
}
#[test]
fn add_tool_creates_tool_variant() {
let mut app = test_app();
app.add_tool("Bash", "cargo build", ToolStatus::Running);
assert_eq!(app.messages.len(), 1);
match &app.messages[0] {
ChatMessage::Tool {
name,
summary,
status,
} => {
assert_eq!(name, "Bash");
assert_eq!(summary, "cargo build");
assert_eq!(*status, ToolStatus::Running);
}
_ => panic!("expected Tool variant"),
}
}
#[test]
fn update_last_tool_status_changes_running_to_success() {
let mut app = test_app();
app.add_tool("Read", "/some/file", ToolStatus::Running);
app.update_last_tool_status(ToolStatus::Success);
match &app.messages[0] {
ChatMessage::Tool { status, .. } => assert_eq!(*status, ToolStatus::Success),
_ => panic!("expected Tool variant"),
}
}
#[test]
fn update_last_tool_status_changes_running_to_error() {
let mut app = test_app();
app.add_tool("Bash", "failing command", ToolStatus::Running);
app.update_last_tool_status(ToolStatus::Error);
match &app.messages[0] {
ChatMessage::Tool { status, .. } => assert_eq!(*status, ToolStatus::Error),
_ => panic!("expected Tool variant"),
}
}
#[test]
fn update_last_tool_status_ignores_text_messages() {
let mut app = test_app();
app.add_message("assistant", "some text");
app.update_last_tool_status(ToolStatus::Success);
match &app.messages[0] {
ChatMessage::Text { content, .. } => assert_eq!(content, "some text"),
_ => panic!("expected Text variant"),
}
}
#[test]
fn update_last_tool_status_targets_last_message_only() {
let mut app = test_app();
app.add_tool("Read", "first tool", ToolStatus::Success);
app.add_tool("Bash", "second tool", ToolStatus::Running);
app.update_last_tool_status(ToolStatus::Error);
match &app.messages[0] {
ChatMessage::Tool { status, .. } => assert_eq!(*status, ToolStatus::Success),
_ => panic!("expected Tool variant"),
}
match &app.messages[1] {
ChatMessage::Tool { status, .. } => assert_eq!(*status, ToolStatus::Error),
_ => panic!("expected Tool variant"),
}
}
#[test]
fn mixed_messages_preserve_order() {
let mut app = test_app();
app.add_message("user", "do something");
app.add_tool("Bash", "ls", ToolStatus::Running);
app.update_last_tool_status(ToolStatus::Success);
app.add_message("assistant", "done");
assert_eq!(app.messages.len(), 3);
assert!(matches!(&app.messages[0], ChatMessage::Text { role, .. } if role == "user"));
assert!(matches!(
&app.messages[1],
ChatMessage::Tool {
status: ToolStatus::Success,
..
}
));
assert!(matches!(&app.messages[2], ChatMessage::Text { role, .. } if role == "assistant"));
}
}
#[cfg(test)]
mod turn_tests {
use super::*;
use crate::permissions::PermissionMode;
use crate::test_support::{scripted_engine, tool_use};
use ratatui::backend::TestBackend;
struct ScriptedKeys(std::collections::VecDeque<KeyEvent>);
impl KeySource for ScriptedKeys {
fn poll_key(&mut self) -> Result<Option<KeyEvent>> {
Ok(self.0.pop_front())
}
}
fn ch(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn enter() -> KeyEvent {
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
}
fn ctrl_c() -> KeyEvent {
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
}
fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
}
async fn run_turn(
engine: &mut Engine,
keystrokes: Vec<KeyEvent>,
) -> (ChatApp, Terminal<TestBackend>) {
let mut app = ChatApp::new("test-model", Theme::dark());
app.mode = Mode::Streaming;
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut keys = ScriptedKeys(keystrokes.into());
drive_streaming(engine, "go", &mut app, &mut terminal, &mut keys)
.await
.unwrap();
app.mode = Mode::Input;
terminal.draw(|f| ui::draw_chat(f, &mut app)).unwrap();
(app, terminal)
}
fn tool_statuses(app: &ChatApp) -> Vec<ToolStatus> {
app.messages
.iter()
.filter_map(|m| match m {
ChatMessage::Tool { status, .. } => Some(status.clone()),
_ => None,
})
.collect()
}
#[tokio::test]
async fn turn_renders_text_and_tool_result() {
let mut engine = scripted_engine(
vec![tool_use(
"tu_1",
"Glob",
serde_json::json!({"pattern": "*.zz"}),
)],
None,
PermissionMode::Bypass,
);
let (app, terminal) = run_turn(&mut engine, vec![]).await;
assert_eq!(tool_statuses(&app), vec![ToolStatus::Success]);
let screen = buffer_text(&terminal);
assert!(screen.contains("working on it"), "assistant text rendered");
assert!(screen.contains("Glob"), "tool bubble rendered");
assert_eq!(engine.messages().len(), 3);
}
#[tokio::test]
async fn permission_y_allows_the_tool() {
let mut engine = scripted_engine(
vec![tool_use(
"tu_1",
"Bash",
serde_json::json!({"command": "echo approved-ok"}),
)],
None,
PermissionMode::Default, );
let (app, _terminal) = run_turn(&mut engine, vec![ch('y')]).await;
assert_eq!(tool_statuses(&app), vec![ToolStatus::Success]);
let crate::api::MessageContent::Blocks(blocks) = &engine.messages()[2].content else {
panic!("expected tool results");
};
let crate::api::ContentBlock::ToolResult { content, .. } = &blocks[0] else {
panic!("expected ToolResult");
};
assert!(
content.contains("approved-ok"),
"tool actually ran: {content}"
);
}
#[tokio::test]
async fn permission_always_is_command_specific_for_bash() {
let mut app = ChatApp::new("test-model", Theme::dark());
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut keys = ScriptedKeys(vec![ch('a')].into());
let steering = SteeringQueue::default();
let command = format!("echo {}", "x".repeat(200));
let input = serde_json::json!({"command": command});
let response = prompt_permission_tui(
&mut app,
&mut terminal,
&mut keys,
"Bash",
"bash: truncated display",
&input,
&steering,
)
.await
.unwrap();
assert_eq!(
response,
PermissionResponse::AlwaysAllowCommand(command),
"the grant must use the complete raw command"
);
}
#[tokio::test]
async fn permission_n_denies_and_ends_turn() {
let mut engine = scripted_engine(
vec![tool_use(
"tu_1",
"Bash",
serde_json::json!({"command": "echo never-runs"}),
)],
None,
PermissionMode::Default,
);
let (app, _terminal) = run_turn(&mut engine, vec![ch('n')]).await;
assert!(
app.messages.iter().any(|m| matches!(
m,
ChatMessage::Text { role, content } if role == "system" && content.contains("Interrupted")
)),
"denying ends the turn: {:?}",
app.messages
);
let crate::api::MessageContent::Blocks(blocks) = &engine.messages()[2].content else {
panic!("expected tool results");
};
let crate::api::ContentBlock::ToolResult { content, .. } = &blocks[0] else {
panic!("expected ToolResult");
};
assert!(content.contains("denied"), "tool denied: {content}");
}
#[tokio::test]
async fn permission_typed_message_becomes_steering() {
let mut engine = scripted_engine(
vec![tool_use(
"tu_1",
"Bash",
serde_json::json!({"command": "echo never-runs"}),
)],
None,
PermissionMode::Default,
);
let (app, _terminal) =
run_turn(&mut engine, vec![ch('f'), ch('i'), ch('x'), enter()]).await;
let steer_delivered = engine.messages().iter().any(|m| {
matches!(&m.content, crate::api::MessageContent::Text(t) if t == "fix")
&& m.role == "user"
});
assert!(
steer_delivered,
"typed message injected: {:?}",
engine.messages()
);
assert!(app.messages.iter().any(|m| matches!(
m,
ChatMessage::Text { role, content } if role == "user" && content == "fix"
)));
}
#[tokio::test]
async fn ctrl_c_cancels_a_running_tool() {
let mut engine = scripted_engine(
vec![tool_use(
"tu_1",
"Bash",
serde_json::json!({"command": "sleep 5"}),
)],
None,
PermissionMode::Bypass,
);
let start = std::time::Instant::now();
let (app, _terminal) = run_turn(&mut engine, vec![ctrl_c()]).await;
assert!(
start.elapsed() < std::time::Duration::from_secs(3),
"Ctrl+C should cut the tool short (took {:?})",
start.elapsed()
);
assert!(app.messages.iter().any(|m| matches!(
m,
ChatMessage::Text { role, content } if role == "system" && content.contains("Interrupted")
)));
}
#[tokio::test]
async fn typing_mid_tool_steers_and_preempts() {
let mut engine = scripted_engine(
vec![tool_use(
"tu_1",
"Bash",
serde_json::json!({"command": "sleep 5"}),
)],
None,
PermissionMode::Bypass,
);
let start = std::time::Instant::now();
let (_app, _terminal) = run_turn(&mut engine, vec![ch('n'), ch('o'), enter()]).await;
assert!(
start.elapsed() < std::time::Duration::from_secs(3),
"steering should preempt the tool (took {:?})",
start.elapsed()
);
let steer_delivered = engine.messages().iter().any(|m| {
matches!(&m.content, crate::api::MessageContent::Text(t) if t == "no")
&& m.role == "user"
});
assert!(
steer_delivered,
"steering delivered: {:?}",
engine.messages()
);
}
}
#[cfg(test)]
mod tuishot_shots {
use super::*;
use tuishot::Tuishot;
const SNAPSHOT_VERSION: &str = "TEST";
fn sample_conversation() -> ChatApp {
let theme = crate::theme::Theme::dark();
let mut app = ChatApp::new("claude-sonnet-4-20250514", theme);
app.version = SNAPSHOT_VERSION.to_string();
app.add_message("user", "Can you read src/main.rs and explain what it does?");
app.add_tool("Read", "src/main.rs (42 lines)", ToolStatus::Success);
app.add_message(
"assistant",
"This is the entry point for **claux**. It parses CLI arguments via `clap`, \
loads configuration from `~/.config/claux/config.toml`, and dispatches to \
either the REPL or one-shot mode depending on the flags.\n\n\
Key things:\n\
- `--tui` launches the full-screen Ratatui interface\n\
- `--resume <id>` reloads a previous session\n\
- `-p <prompt>` runs a single query and exits",
);
app.status = "1.2k tokens".to_string();
app
}
#[derive(Tuishot)]
enum ChatShot {
#[tuishot(
name = "chat-conversation",
description = "Mid-conversation with tool use and markdown"
)]
Conversation,
#[tuishot(
name = "chat-streaming",
description = "Assistant mid-response with streaming cursor"
)]
Streaming,
#[tuishot(
name = "chat-permission",
description = "Prompting for Bash permission"
)]
Permission,
#[tuishot(name = "chat-empty", description = "Fresh chat, no messages")]
Empty,
}
impl ChatShotRender for ChatShot {
fn render(&self, buf: &mut ratatui::buffer::Buffer, area: ratatui::layout::Rect) {
let theme = crate::theme::Theme::dark();
let mut app = match self {
ChatShot::Conversation => sample_conversation(),
ChatShot::Streaming => {
let mut app = sample_conversation();
app.mode = Mode::Streaming;
app.stream_buffer = "Sure, let me look at the configuration handling next. \
The config module uses `toml` for parsing and supports both global \
and per-project overrides"
.to_string();
app.thinking = false;
app
}
ChatShot::Permission => {
let mut app = sample_conversation();
app.mode = Mode::Permission;
app.permission_prompt = Some("Allow Bash command?".to_string());
app.permission_details = Some(vec![
"Command:".to_string(),
" cargo test --lib".to_string(),
"".to_string(),
"Working directory: /home/user/dev/claux".to_string(),
]);
app
}
ChatShot::Empty => {
let mut app = ChatApp::new("claude-sonnet-4-20250514", theme);
app.version = SNAPSHOT_VERSION.to_string();
app
}
};
let rendered = tuishot::render_to_buffer(area.width, area.height, |f| {
ui::draw_chat(f, &mut app);
});
buf.clone_from(&rendered);
}
}
#[test]
fn capture_chat_screens() {
ChatShot::check_all().expect("chat screen capture");
}
}