use serde_json::Value;
use chrono::Local;
use synaps_cli::Session;
use synaps_cli::pricing::calculate_cost_optional_split;
pub(crate) const THINKING_PLACEHOLDER: &str = "\u{2026}\u{200B}";
#[derive(Clone)]
pub(crate) enum ChatMessage {
User(String),
Thinking(String),
Text(String),
ToolUseStart {
tool_id: String,
tool_name: String,
partial_input: String,
},
ToolUse {
tool_id: String,
tool_name: String,
input: String,
},
ToolResult {
tool_id: String,
content: String,
elapsed_ms: Option<u64>,
},
Error(String),
System(String),
Event { source: String, severity: String, text: String },
}
pub(crate) struct TimestampedMsg {
pub(crate) msg: ChatMessage,
pub(crate) time: String,
}
pub(crate) struct LineCache {
pub(crate) width: usize,
pub(crate) per_msg: Vec<Vec<ratatui::text::Line<'static>>>,
pub(crate) flat: Vec<ratatui::text::Line<'static>>,
}
pub(crate) struct App {
pub(crate) messages: Vec<TimestampedMsg>,
pub(crate) input: String,
pub(crate) cursor_pos: usize,
pub(crate) scroll_back: u16,
pub(crate) scroll_pinned: bool,
pub(crate) api_messages: Vec<Value>,
pub(crate) streaming: bool,
pub(crate) input_history: Vec<String>,
pub(crate) history_index: Option<usize>,
pub(crate) input_stash: String,
pub(crate) tab_cycle: Option<(String, usize, Vec<String>)>,
pub(crate) input_tokens: u64,
pub(crate) output_tokens: u64,
pub(crate) total_input_tokens: u64,
pub(crate) total_output_tokens: u64,
pub(crate) total_cache_read_tokens: u64,
pub(crate) total_cache_creation_tokens: u64,
pub(crate) total_cache_write_5m: u64,
pub(crate) total_cache_write_1h: u64,
pub(crate) last_turn_context: u64,
pub(crate) last_turn_context_window: u64,
pub(crate) api_call_count: u32,
pub(crate) session_cost: f64,
pub(crate) session: Session,
pub(crate) agent_name: String,
pub(crate) line_cache: Option<LineCache>,
pub(crate) dirty_from: Option<usize>,
pub(crate) needs_redraw: bool,
pub(crate) show_full_output: bool,
pub(crate) logo_dismiss_t: Option<f64>,
pub(crate) logo_build_t: Option<f64>,
pub(crate) last_line_count: usize,
pub(crate) subagents: Vec<SubagentState>,
pub(crate) tool_start_time: Option<std::time::Instant>,
pub(crate) tool_start_times: std::collections::HashMap<String, std::time::Instant>,
pub(crate) abort_context: Option<String>,
pub(crate) queued_message: Option<String>,
pub(crate) input_before_paste: Option<String>,
pub(crate) pasted_char_count: usize,
pub(crate) spinner_frame: usize,
pub(crate) status_text: Option<String>,
pub(crate) gamba_child: Option<std::process::Child>,
pub(crate) settings: Option<super::settings::SettingsState>,
pub(crate) plugins: Option<super::plugins::PluginsModalState>,
pub(crate) models: Option<super::models::ModelsModalState>,
pub(crate) help_find: Option<synaps_cli::help::HelpFindState>,
pub(crate) compact_task: Option<tokio::task::JoinHandle<Result<String, synaps_cli::error::RuntimeError>>>,
pub(crate) pending_events: Vec<String>,
pub(crate) model_health: std::collections::HashMap<String, (synaps_cli::runtime::openai::ping::PingStatus, u64)>,
pub(crate) ping_print: bool,
pub(crate) ping_pending: usize,
pub(crate) ping_tx: tokio::sync::mpsc::UnboundedSender<(String, synaps_cli::runtime::openai::ping::PingStatus, u64)>,
pub(crate) ping_rx: tokio::sync::mpsc::UnboundedReceiver<(String, synaps_cli::runtime::openai::ping::PingStatus, u64)>,
pub(crate) model_list_tx: tokio::sync::mpsc::UnboundedSender<(String, Result<Vec<super::models::ExpandedModelEntry>, String>)>,
pub(crate) model_list_rx: tokio::sync::mpsc::UnboundedReceiver<(String, Result<Vec<super::models::ExpandedModelEntry>, String>)>,
pub(crate) selection_anchor: Option<(u16, u16)>,
pub(crate) selection_end: Option<(u16, u16)>,
pub(crate) msg_area_rect: Option<ratatui::layout::Rect>,
pub(crate) visible_line_range: Option<(usize, usize)>,
pub(crate) suppress_paste_until: Option<std::time::Instant>,
pub(crate) sidecars: std::collections::HashMap<String, super::sidecar::SidecarUiState>,
pub(crate) active_tasks: std::sync::Arc<synaps_cli::extensions::active_tasks::ActiveTasks>,
pub(crate) toasts: super::toast::ToastProvider,
pub(crate) extension_loader_rx: tokio::sync::mpsc::UnboundedReceiver<synaps_cli::extensions::loader::ExtensionLoaderEvent>,
pub(crate) extension_loader_tx: tokio::sync::mpsc::UnboundedSender<synaps_cli::extensions::loader::ExtensionLoaderEvent>,
pub(crate) extension_loader_running: bool,
pub(crate) widget_rx: tokio::sync::mpsc::UnboundedReceiver<synaps_cli::extensions::widgets::ExtensionWidgetEvent>,
pub(crate) widget_tx: tokio::sync::mpsc::UnboundedSender<synaps_cli::extensions::widgets::ExtensionWidgetEvent>,
pub(crate) keybinds: Option<std::sync::Arc<std::sync::RwLock<synaps_cli::skills::keybinds::KeybindRegistry>>>,
}
pub(crate) const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
#[derive(Clone)]
pub(crate) struct SubagentState {
pub(crate) id: u64,
pub(crate) name: String,
pub(crate) status: String,
pub(crate) start_time: std::time::Instant,
pub(crate) done: bool,
pub(crate) duration_secs: Option<f64>,
}
impl App {
pub(crate) fn new(session: Session) -> Self {
let (ping_tx_init, ping_rx_init) = tokio::sync::mpsc::unbounded_channel();
let (model_list_tx_init, model_list_rx_init) = tokio::sync::mpsc::unbounded_channel();
let (extension_loader_tx_init, extension_loader_rx_init) = tokio::sync::mpsc::unbounded_channel();
let (widget_tx_init, widget_rx_init) = tokio::sync::mpsc::unbounded_channel();
Self {
messages: Vec::new(),
input: String::new(),
cursor_pos: 0,
scroll_back: 0,
scroll_pinned: true,
api_messages: Vec::new(),
streaming: false,
input_history: Vec::new(),
history_index: None,
input_stash: String::new(),
tab_cycle: None,
input_tokens: 0,
output_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_write_5m: 0,
total_cache_write_1h: 0,
last_turn_context: 0,
last_turn_context_window: synaps_cli::models::context_window_for_model(
synaps_cli::models::default_model(),
),
api_call_count: 0,
session_cost: 0.0,
session,
agent_name: synaps_cli::config::load_config()
.agent_name
.unwrap_or_else(|| "agent".to_string()),
line_cache: None,
dirty_from: None,
needs_redraw: true,
show_full_output: false,
logo_dismiss_t: None,
logo_build_t: Some(0.0),
last_line_count: 0,
subagents: Vec::new(),
tool_start_time: None,
tool_start_times: std::collections::HashMap::new(),
abort_context: None,
queued_message: None,
input_before_paste: None,
pasted_char_count: 0,
spinner_frame: 0,
status_text: None,
gamba_child: None,
settings: None,
plugins: None,
models: None,
help_find: None,
compact_task: None,
pending_events: Vec::new(),
model_health: std::collections::HashMap::new(),
ping_print: false,
ping_pending: 0,
ping_tx: ping_tx_init,
ping_rx: ping_rx_init,
model_list_tx: model_list_tx_init,
model_list_rx: model_list_rx_init,
selection_anchor: None,
selection_end: None,
msg_area_rect: None,
visible_line_range: None,
suppress_paste_until: None,
sidecars: std::collections::HashMap::new(),
active_tasks: std::sync::Arc::new(synaps_cli::extensions::active_tasks::ActiveTasks::new()),
toasts: super::toast::ToastProvider::new(),
extension_loader_rx: extension_loader_rx_init,
extension_loader_tx: extension_loader_tx_init,
extension_loader_running: false,
widget_rx: widget_rx_init,
widget_tx: widget_tx_init,
keybinds: None,
}
}
pub(crate) fn user_display_text_for_submission(&self, input: &str) -> String {
if self.pasted_char_count == 0 {
return input.to_string();
}
let before_paste = self.input_before_paste.as_deref().unwrap_or("");
let before_chars = before_paste.chars().count();
let total_chars = input.chars().count();
let paste_chars = self.pasted_char_count.min(total_chars.saturating_sub(before_chars));
let after_chars = total_chars.saturating_sub(before_chars + paste_chars);
let paste_byte_start = input
.char_indices()
.nth(before_chars)
.map(|(i, _)| i)
.unwrap_or(input.len());
let paste_byte_end = input
.char_indices()
.nth(before_chars + paste_chars)
.map(|(i, _)| i)
.unwrap_or(input.len());
let pasted = &input[paste_byte_start..paste_byte_end];
let after_paste = if after_chars == 0 {
""
} else {
&input[paste_byte_end..]
};
let line_count = pasted.lines().count();
let paste_label = if line_count > 1 {
format!("[Pasted {} lines]", line_count)
} else {
format!("[Pasted {} chars]", paste_chars)
};
match (before_paste.is_empty(), after_paste.is_empty()) {
(true, true) => paste_label,
(false, true) => format!("{} {}", before_paste.trim(), paste_label),
(true, false) => format!("{} {}", paste_label, after_paste.trim()),
(false, false) => format!(
"{} {} {}",
before_paste.trim(),
paste_label,
after_paste.trim()
),
}
}
pub(crate) fn cursor_byte_pos(&self) -> usize {
self.input.char_indices()
.nth(self.cursor_pos)
.map(|(i, _)| i)
.unwrap_or(self.input.len())
}
pub(crate) fn input_char_count(&self) -> usize {
self.input.chars().count()
}
pub(crate) fn input_wrap_info(&self, inner_width: u16) -> (u16, u16, u16) {
use unicode_width::UnicodeWidthChar;
let w = inner_width.max(1) as usize;
let prefix_width: usize = 2;
let mut row: u16 = 0;
let mut col: usize = prefix_width;
let mut cursor_row: u16 = 0;
let mut cursor_col: u16 = prefix_width as u16;
for (i, ch) in self.input.chars().enumerate() {
if i == self.cursor_pos {
cursor_row = row;
cursor_col = col as u16;
}
if ch == '\n' {
row += 1;
col = prefix_width; continue;
}
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if col + cw > w {
row += 1;
col = 0;
}
col += cw;
}
if self.cursor_pos == self.input_char_count() {
cursor_row = row;
cursor_col = col as u16;
if col >= w {
cursor_row += 1;
cursor_col = 0;
}
}
let total_lines = row + 1;
(total_lines, cursor_row, cursor_col)
}
pub(crate) async fn save_session(&mut self) {
if self.api_messages.is_empty() {
return;
}
self.session.api_messages = self.api_messages.clone();
self.session.total_input_tokens = self.total_input_tokens;
self.session.total_output_tokens = self.total_output_tokens;
self.session.session_cost = self.session_cost;
self.session.abort_context = self.abort_context.clone();
self.session.updated_at = chrono::Utc::now();
self.session.auto_title();
if let Err(e) = self.session.save().await {
eprintln!("\x1b[31m[ERROR] Failed to save session: {}\x1b[0m", e);
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn add_usage(
&mut self,
input_tokens: u64,
output_tokens: u64,
cache_read: u64,
cache_creation: u64,
cache_creation_5m: Option<u64>,
cache_creation_1h: Option<u64>,
model: &str,
context_window_override: Option<u64>,
) {
self.input_tokens = input_tokens;
self.output_tokens = output_tokens;
self.total_input_tokens += input_tokens;
self.total_output_tokens += output_tokens;
self.total_cache_read_tokens += cache_read;
self.total_cache_creation_tokens += cache_creation;
if let Some(c5) = cache_creation_5m { self.total_cache_write_5m += c5; }
if let Some(c1) = cache_creation_1h { self.total_cache_write_1h += c1; }
self.last_turn_context = input_tokens + cache_read + cache_creation;
self.last_turn_context_window = context_window_override
.unwrap_or_else(|| synaps_cli::models::context_window_for_model(model));
self.api_call_count += 1;
self.session_cost += calculate_cost_optional_split(
model, input_tokens, output_tokens, cache_read,
cache_creation, cache_creation_5m, cache_creation_1h,
);
}
pub(crate) fn push_msg(&mut self, msg: ChatMessage) {
self.messages.push(TimestampedMsg {
msg,
time: Local::now().format("%H:%M").to_string(),
});
if self.scroll_pinned {
self.scroll_back = 0;
}
self.invalidate_last();
}
pub(crate) fn push_tool_result(&mut self, tool_id: String, content: String, elapsed_ms: Option<u64>) {
let use_idx = if tool_id.is_empty() {
None
} else {
debug_assert!(
self.messages.iter().filter(|m| matches!(
&m.msg,
ChatMessage::ToolUse { tool_id: tid, .. }
| ChatMessage::ToolUseStart { tool_id: tid, .. }
if tid == &tool_id
)).count() <= 1,
"push_tool_result: duplicate ToolUse/ToolUseStart for tool_id={tool_id:?}"
);
self.messages.iter().position(|m| matches!(
&m.msg,
ChatMessage::ToolUse { tool_id: tid, .. }
| ChatMessage::ToolUseStart { tool_id: tid, .. }
if tid == &tool_id
))
};
let msg = ChatMessage::ToolResult { tool_id, content, elapsed_ms };
match use_idx {
Some(i) => {
let at = (i + 1).min(self.messages.len());
self.messages.insert(at, TimestampedMsg {
msg,
time: Local::now().format("%H:%M").to_string(),
});
if self.scroll_pinned {
self.scroll_back = 0;
}
self.invalidate_from(at);
}
None => self.push_msg(msg),
}
}
pub(crate) fn cap_resumed_display(&mut self, cap: usize) {
if self.messages.len() <= cap {
return;
}
let omitted = self.messages.len() - cap;
self.messages.drain(0..omitted);
self.messages.insert(
0,
TimestampedMsg {
msg: ChatMessage::System(format!(
"… {omitted} earlier message(s) hidden to speed resume — full history is still in the model's context"
)),
time: Local::now().format("%H:%M").to_string(),
},
);
}
pub(crate) fn invalidate(&mut self) {
self.line_cache = None;
self.dirty_from = None;
self.needs_redraw = true;
}
pub(crate) fn invalidate_from(&mut self, idx: usize) {
self.dirty_from = Some(match self.dirty_from {
Some(k) => k.min(idx),
None => idx,
});
self.needs_redraw = true;
}
pub(crate) fn invalidate_last(&mut self) {
self.invalidate_from(self.messages.len().saturating_sub(1));
}
pub(crate) fn request_redraw(&mut self) {
self.needs_redraw = true;
}
pub(crate) fn advance_animations(&mut self) -> bool {
self.spinner_frame = self.spinner_frame.wrapping_add(1);
if self.spinner_frame % 3 != 0 {
return false;
}
self.render_lines_uses_spinner()
}
fn render_lines_uses_spinner(&self) -> bool {
self.messages.iter().enumerate().any(|(idx, msg)| match &msg.msg {
ChatMessage::Thinking(text) => text == THINKING_PLACEHOLDER,
ChatMessage::ToolUseStart { .. } => true,
ChatMessage::ToolUse { .. } => {
idx == self.messages.len().saturating_sub(1) && self.tool_start_time.is_some()
}
ChatMessage::ToolResult { .. } => self.is_active_tool_result(idx),
_ => false,
})
}
pub(crate) fn needs_clear_for_animation_redraw(&self) -> bool {
false
}
pub(crate) fn is_active_tool_result(&self, idx: usize) -> bool {
if self.tool_start_time.is_none() {
return false;
}
match self.messages.get(idx).map(|m| &m.msg) {
Some(ChatMessage::ToolResult { tool_id, elapsed_ms: None, .. }) => {
self.tool_start_times.contains_key(tool_id)
}
_ => false,
}
}
pub(crate) fn find_preceding_read_extension(&self, idx: usize) -> String {
let target_id: Option<String> = match self.messages.get(idx).map(|m| &m.msg) {
Some(ChatMessage::ToolResult { tool_id, .. }) if !tool_id.is_empty() => Some(tool_id.clone()),
_ => None,
};
if let Some(id) = target_id {
for m in self.messages.iter() {
if let ChatMessage::ToolUse { tool_id, tool_name, input } = &m.msg {
if tool_id == &id && tool_name == "read" {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(input) {
if let Some(path) = parsed["path"].as_str() {
if let Some(ext) = std::path::Path::new(path).extension() {
return ext.to_string_lossy().to_string();
}
}
}
return String::new();
}
}
}
}
if idx == 0 { return String::new(); }
for i in (0..idx).rev() {
if let ChatMessage::ToolUse { ref tool_name, ref input, .. } = self.messages[i].msg {
if tool_name == "read" {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(input) {
if let Some(path) = parsed["path"].as_str() {
if let Some(ext) = std::path::Path::new(path).extension() {
return ext.to_string_lossy().to_string();
}
}
}
}
break; }
}
String::new()
}
pub(crate) fn find_preceding_tool_name(&self, idx: usize) -> Option<String> {
if let Some(ChatMessage::ToolResult { tool_id, .. }) = self.messages.get(idx).map(|m| &m.msg) {
if !tool_id.is_empty() {
for m in self.messages.iter() {
match &m.msg {
ChatMessage::ToolUse { tool_id: tid, tool_name, .. }
| ChatMessage::ToolUseStart { tool_id: tid, tool_name, .. }
if tid == tool_id =>
{
return Some(tool_name.clone());
}
_ => {}
}
}
}
}
if idx == 0 { return None; }
for i in (0..idx).rev() {
if let ChatMessage::ToolUse { ref tool_name, .. } = self.messages[i].msg {
return Some(tool_name.clone());
}
if let ChatMessage::ToolUseStart { ref tool_name, .. } = self.messages[i].msg {
return Some(tool_name.clone());
}
}
None
}
pub(crate) fn find_tool_use_start_idx(&self, tool_id: &str) -> Option<usize> {
self.messages.iter().rposition(|m| matches!(
&m.msg,
ChatMessage::ToolUseStart { tool_id: tid, .. } if tid == tool_id
))
}
pub(crate) fn find_tool_result_idx(&self, tool_id: &str) -> Option<usize> {
self.messages.iter().rposition(|m| matches!(
&m.msg,
ChatMessage::ToolResult { tool_id: tid, .. } if tid == tool_id
))
}
pub(crate) fn on_tool_use_start(&mut self, tool_id: String, tool_name: String) {
self.drop_empty_thinking();
let now = std::time::Instant::now();
self.tool_start_time = Some(now);
if !tool_id.is_empty() {
self.tool_start_times.insert(tool_id.clone(), now);
}
self.push_msg(ChatMessage::ToolUseStart {
tool_id,
tool_name,
partial_input: String::new(),
});
}
pub(crate) fn on_tool_use_delta(&mut self, tool_id: &str, delta: &str) {
let target_idx = if !tool_id.is_empty() {
self.find_tool_use_start_idx(tool_id)
} else {
self.messages.iter().rposition(|m| matches!(&m.msg, ChatMessage::ToolUseStart { .. }))
};
if let Some(idx) = target_idx {
if let ChatMessage::ToolUseStart { ref mut partial_input, .. } = self.messages[idx].msg {
partial_input.push_str(delta);
self.invalidate();
}
}
}
pub(crate) fn on_tool_use_finalized(&mut self, tool_id: String, tool_name: String, input_str: String) {
self.drop_empty_thinking();
if !tool_id.is_empty() {
self.tool_start_times.entry(tool_id.clone()).or_insert_with(std::time::Instant::now);
}
self.tool_start_time = Some(std::time::Instant::now());
if let Some(idx) = self.find_tool_use_start_idx(&tool_id) {
self.messages[idx].msg = ChatMessage::ToolUse { tool_id, tool_name, input: input_str };
self.invalidate();
return;
}
self.push_msg(ChatMessage::ToolUse { tool_id, tool_name, input: input_str });
}
pub(crate) fn on_tool_result_delta(&mut self, tool_id: String, delta: String) {
if let Some(idx) = self.find_tool_result_idx(&tool_id) {
if let ChatMessage::ToolResult { ref mut content, elapsed_ms, .. } = self.messages[idx].msg {
if elapsed_ms.is_none() {
content.push_str(&delta);
self.invalidate();
return;
}
}
}
self.push_tool_result(tool_id, delta, None);
}
pub(crate) fn on_tool_result(&mut self, tool_id: String, result: String) {
let elapsed = self
.tool_start_times
.remove(&tool_id)
.map(|t| t.elapsed().as_millis() as u64);
if self.tool_start_times.is_empty() {
self.tool_start_time = None;
}
if let Some(idx) = self.find_tool_result_idx(&tool_id) {
if let ChatMessage::ToolResult { ref mut content, elapsed_ms, .. } = self.messages[idx].msg {
if elapsed_ms.is_none() {
*content = result;
self.messages[idx].msg = ChatMessage::ToolResult {
tool_id,
content: std::mem::take(content),
elapsed_ms: elapsed,
};
self.invalidate();
return;
}
}
}
self.push_tool_result(tool_id, result, elapsed);
}
pub(crate) fn capture_abort_context(&mut self) {
let mut parts: Vec<String> = Vec::new();
for tmsg in self.messages.iter().rev() {
match &tmsg.msg {
ChatMessage::User(_) => break, ChatMessage::Thinking(t)
if !t.is_empty() => {
let preview: String = t.chars().take(500).collect();
parts.push(format!("[thinking]: {}", preview));
}
ChatMessage::Text(t)
if !t.is_empty() => {
parts.push(format!("[response]: {}", t));
}
ChatMessage::ToolUse { tool_name, input, .. } => {
let input_preview: String = input.chars().take(200).collect();
parts.push(format!("[tool_use]: {} — {}", tool_name, input_preview));
}
ChatMessage::ToolResult { content, .. }
if !content.is_empty() => {
let preview: String = content.chars().take(300).collect();
parts.push(format!("[tool_result]: {}", preview));
}
_ => {}
}
}
if parts.is_empty() {
self.abort_context = None;
return;
}
parts.reverse(); self.abort_context = Some(format!(
"[ABORT CONTEXT — your previous response was interrupted. Here's what you completed before the abort:]\n\n{}\n\n[END ABORT CONTEXT — continue from where you left off or adjust based on the user's new message]",
parts.join("\n")
));
}
pub(crate) fn history_up(&mut self) {
if self.input_history.is_empty() { return; }
match self.history_index {
None => {
self.input_stash = self.input.clone();
self.history_index = Some(self.input_history.len() - 1);
}
Some(i) if i > 0 => {
self.history_index = Some(i - 1);
}
_ => return,
}
if let Some(idx) = self.history_index {
self.input = self.input_history[idx].clone();
self.cursor_pos = self.input.chars().count();
}
}
pub(crate) fn history_down(&mut self) {
if let Some(i) = self.history_index {
if i + 1 < self.input_history.len() {
self.history_index = Some(i + 1);
self.input = self.input_history[i + 1].clone();
} else {
self.history_index = None;
self.input = self.input_stash.clone();
self.input_stash.clear();
}
self.cursor_pos = self.input.chars().count();
}
}
pub(crate) fn has_selection(&self) -> bool {
self.selection_anchor.is_some() && self.selection_end.is_some()
}
pub(crate) fn clear_selection(&mut self) {
self.selection_anchor = None;
self.selection_end = None;
}
pub(crate) fn selection_range(&self) -> Option<(u16, u16, u16, u16)> {
let (ac, ar) = self.selection_anchor?;
let (ec, er) = self.selection_end?;
if ar < er || (ar == er && ac <= ec) {
Some((ac, ar, ec, er))
} else {
Some((ec, er, ac, ar))
}
}
const MSG_LINE_INDENT: &'static str = " ";
pub(crate) fn selected_text(&self) -> Option<String> {
let (sc, sr, ec, er) = self.selection_range()?;
let rect = self.msg_area_rect?;
let (vis_start, vis_end) = self.visible_line_range?;
let all_lines = &self.line_cache.as_ref()?.flat;
let content_x = rect.x;
let content_y = rect.y;
let content_h = rect.height;
let mut result = String::new();
for term_y in sr..=er {
if term_y < content_y || term_y >= content_y + content_h {
continue;
}
let line_offset = (term_y - content_y) as usize;
let line_idx = vis_start + line_offset;
if line_idx >= vis_end || line_idx >= all_lines.len() {
continue;
}
let line = &all_lines[line_idx];
let full_text: String = line.spans.iter()
.map(|s| s.content.as_ref())
.collect();
let line_start_col = if term_y == sr {
(sc.saturating_sub(content_x)) as usize
} else {
0
};
let line_end_col = if term_y == er {
(ec.saturating_sub(content_x)) as usize
} else {
full_text.len()
};
let chars: Vec<char> = full_text.chars().collect();
let start = line_start_col.min(chars.len());
let end = line_end_col.min(chars.len());
if start < end {
let selected: String = chars[start..end].iter().collect();
let trimmed = selected.trim_end();
let trimmed = if result.is_empty() {
trimmed.trim_start()
} else {
trimmed.strip_prefix(Self::MSG_LINE_INDENT).unwrap_or(trimmed)
};
if !result.is_empty() {
result.push('\n');
}
result.push_str(trimmed);
}
}
if result.is_empty() { None } else { Some(result) }
}
pub(crate) fn append_or_update_text(&mut self, text: &str) {
self.drop_empty_thinking();
if let Some(TimestampedMsg { msg: ChatMessage::Text(ref mut existing), .. }) = self.messages.last_mut() {
existing.push_str(text);
} else {
self.push_msg(ChatMessage::Text(text.to_string()));
}
self.invalidate_last();
}
pub(crate) fn append_or_update_thinking(&mut self, text: &str) {
if let Some(TimestampedMsg { msg: ChatMessage::Thinking(ref mut existing), .. }) = self.messages.last_mut() {
if existing == THINKING_PLACEHOLDER {
*existing = text.to_string();
} else {
existing.push_str(text);
}
} else {
self.push_msg(ChatMessage::Thinking(text.to_string()));
}
self.invalidate_last();
}
pub(crate) fn drop_empty_thinking(&mut self) {
let candidate_idx = self.messages.iter().rposition(|m| {
!matches!(&m.msg, ChatMessage::System(_))
});
if let Some(idx) = candidate_idx {
if let ChatMessage::Thinking(t) = &self.messages[idx].msg {
if t == THINKING_PLACEHOLDER || t.is_empty() {
self.messages.remove(idx);
self.invalidate();
}
}
}
}
pub(crate) fn handle_theme_command(&mut self, arg: &str) {
let descriptions: &[(&str, &str)] = &[
("default", "cool teal on dark blue-gray"),
("night-city", "premium neon-noir — cyberpunk/blade runner"),
("neon-rain", "cyberpunk hot pink + cyan"),
("amber", "warm CRT retro terminal"),
("phosphor", "green monochrome CRT"),
("solarized-dark", "Ethan Schoonover's classic"),
("blood", "dark red, Doom/horror"),
("ocean", "deep sea bioluminescence"),
("rose-pine", "elegant muted purples/pinks"),
("nord", "arctic frost blues"),
("dracula", "purple/pink/cyan vibrant"),
("monokai", "classic orange/pink/green"),
("gruvbox", "warm earthy tones"),
("catppuccin", "soft pastels, cozy dark"),
("tokyo-night", "dark blue-purple, soft accents"),
("sunset", "warm oranges/pinks dusk"),
("ice", "frozen arctic pale blues"),
("forest", "deep greens and browns"),
("lavender", "rich purple/violet"),
];
if arg.is_empty() {
self.push_msg(ChatMessage::System("Available themes:".to_string()));
for (name, desc) in descriptions {
self.push_msg(ChatMessage::System(format!(" {:<15} — {}", name, desc)));
}
let themes_dir = synaps_cli::config::base_dir().join("themes");
if let Ok(entries) = std::fs::read_dir(&themes_dir) {
let mut custom: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| !descriptions.iter().any(|(d, _)| *d == n.as_str()))
.collect();
custom.sort();
for name in &custom {
self.push_msg(ChatMessage::System(format!(" {:<15} — custom", name)));
}
}
self.push_msg(ChatMessage::System(String::new()));
self.push_msg(ChatMessage::System("Usage: /theme <name> to set. Restart to apply.".to_string()));
} else {
let name = arg.trim();
let is_valid = descriptions.iter().any(|(n, _)| *n == name)
|| synaps_cli::config::base_dir().join("themes").join(name).exists();
if is_valid {
match synaps_cli::config::write_config_value("theme", name) {
Ok(_) => {
let new_theme = super::theme::load_theme_by_name(name)
.unwrap_or_default();
super::theme::set_theme(new_theme);
self.push_msg(ChatMessage::System(
format!("Theme applied: {}", name)
));
self.invalidate();
}
Err(e) => {
self.push_msg(ChatMessage::Error(
format!("failed to write config: {}", e)
));
}
}
} else {
self.push_msg(ChatMessage::Error(
format!("unknown theme: '{}'. Use /theme to list available themes.", name)
));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_app() -> App {
App::new(Session::new("test-model", "low", None))
}
#[test]
fn empty_thinking_placeholder_is_dropped_when_agent_moves_on() {
let mut app = test_app();
app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
assert!(app.render_lines_uses_spinner(), "placeholder should animate while present");
app.append_or_update_text("here is the answer");
assert!(
!matches!(app.messages.last().map(|m| &m.msg), Some(ChatMessage::Thinking(_))),
"empty thinking must be gone once text arrives"
);
assert!(!app.render_lines_uses_spinner(), "spinner must stop after agent moves on");
let mut app = test_app();
app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
app.on_tool_use_start("t1".to_string(), "bash".to_string());
let thinking_count = app
.messages
.iter()
.filter(|m| matches!(m.msg, ChatMessage::Thinking(_)))
.count();
assert_eq!(thinking_count, 0, "empty thinking must be gone once a tool runs");
let mut app = test_app();
app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
app.append_or_update_thinking("actually reasoning");
app.append_or_update_text("done");
assert!(
app.messages.iter().any(|m| matches!(&m.msg, ChatMessage::Thinking(t) if t == "actually reasoning")),
"non-empty thinking must survive and not keep the … prefix"
);
}
#[test]
fn parallel_tool_results_pair_with_their_inputs() {
let mut app = test_app();
for id in ["t1", "t2", "t3", "t4"] {
app.on_tool_use_finalized(id.to_string(), "bash".to_string(), "{}".to_string());
}
for id in ["t2", "t1", "t4", "t3"] {
app.on_tool_result(id.to_string(), format!("out-{id}"));
}
let seq: Vec<(String, bool)> = app
.messages
.iter()
.filter_map(|m| match &m.msg {
ChatMessage::ToolUse { tool_id, .. } => Some((tool_id.clone(), false)),
ChatMessage::ToolResult { tool_id, .. } => Some((tool_id.clone(), true)),
_ => None,
})
.collect();
let expected = vec![
("t1".to_string(), false), ("t1".to_string(), true),
("t2".to_string(), false), ("t2".to_string(), true),
("t3".to_string(), false), ("t3".to_string(), true),
("t4".to_string(), false), ("t4".to_string(), true),
];
assert_eq!(seq, expected, "parallel tool calls must render as input→output pairs");
}
#[test]
fn tool_block_has_gutter_and_background() {
let mut app = test_app();
app.on_tool_use_finalized("t1".to_string(), "bash".to_string(), "{}".to_string());
app.on_tool_result("t1".to_string(), "hello\nworld".to_string());
let lines = app.render_lines(80);
for l in &lines {
let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
!s.chars().any(|c| matches!(c,
'\u{256D}' | '\u{256E}' | '\u{2570}' | '\u{256F}' | '\u{2502}'
| '\u{2591}' | '\u{2592}' | '\u{2593}')),
"no borders or shade glyphs: {s:?}"
);
}
let panel_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains('\u{258E}')))
.expect("a panel line with a gutter");
assert!(
panel_line.spans.iter().any(|s| s.style.bg.is_some()),
"panel cells (incl. text) must share a background"
);
if let Some(first) = panel_line.spans.first() {
if !first.content.is_empty() && first.content.chars().all(|c| c == ' ') {
assert!(first.style.bg.is_none(), "left margin must stay transparent");
}
}
}
#[test]
fn active_tool_result_is_only_latest_incomplete_result() {
let mut app = test_app();
app.push_msg(ChatMessage::ToolUse {
tool_id: "call_1".to_string(),
tool_name: "bash".to_string(),
input: "{}".to_string(),
});
app.push_msg(ChatMessage::ToolResult {
tool_id: "call_1".to_string(),
content: "first output".to_string(),
elapsed_ms: None,
});
app.push_msg(ChatMessage::ToolUse {
tool_id: "call_2".to_string(),
tool_name: "bash".to_string(),
input: "{}".to_string(),
});
app.push_msg(ChatMessage::ToolResult {
tool_id: "call_2".to_string(),
content: "second output".to_string(),
elapsed_ms: None,
});
app.tool_start_time = Some(std::time::Instant::now());
app.tool_start_times.insert("call_2".to_string(), std::time::Instant::now());
assert!(!app.is_active_tool_result(1), "completed historical result (call_1, not in tool_start_times) must render done");
assert!(app.is_active_tool_result(3), "latest in-flight result (call_2, in tool_start_times) must be active");
let mut app2 = test_app();
app2.push_msg(ChatMessage::ToolUse {
tool_id: "p1".to_string(),
tool_name: "bash".to_string(),
input: "{}".to_string(),
});
app2.push_msg(ChatMessage::ToolResult {
tool_id: "p1".to_string(),
content: "".to_string(),
elapsed_ms: None,
});
app2.push_msg(ChatMessage::ToolUse {
tool_id: "p2".to_string(),
tool_name: "bash".to_string(),
input: "{}".to_string(),
});
app2.push_msg(ChatMessage::ToolResult {
tool_id: "p2".to_string(),
content: "".to_string(),
elapsed_ms: None,
});
app2.tool_start_time = Some(std::time::Instant::now());
app2.tool_start_times.insert("p1".to_string(), std::time::Instant::now());
app2.tool_start_times.insert("p2".to_string(), std::time::Instant::now());
assert!(app2.is_active_tool_result(1), "parallel in-flight p1 mid-vec must be active");
assert!(app2.is_active_tool_result(3), "parallel in-flight p2 last must be active");
}
#[test]
fn completed_latest_tool_result_is_not_active() {
let mut app = test_app();
app.push_msg(ChatMessage::ToolUse {
tool_id: "call_1".to_string(),
tool_name: "bash".to_string(),
input: "{}".to_string(),
});
app.push_msg(ChatMessage::ToolResult {
tool_id: "call_1".to_string(),
content: "done".to_string(),
elapsed_ms: Some(25),
});
app.tool_start_time = Some(std::time::Instant::now());
assert!(!app.is_active_tool_result(1));
}
#[test]
fn animation_tick_for_subagent_panel_does_not_invalidate_message_cache() {
let mut app = test_app();
app.push_msg(ChatMessage::System("stable transcript".to_string()));
app.line_cache = Some({
let w = 80;
let per_msg: Vec<Vec<ratatui::text::Line<'static>>> = (0..app.messages.len()).map(|i| app.render_message_lines(i, w)).collect();
let flat: Vec<ratatui::text::Line<'static>> = per_msg.iter().flatten().cloned().collect();
LineCache { width: w, per_msg, flat }
});
app.subagents.push(SubagentState {
id: 1,
name: "tester".to_string(),
status: "running".to_string(),
start_time: std::time::Instant::now(),
done: false,
duration_secs: None,
});
app.spinner_frame = 2;
let invalidate_messages = app.advance_animations();
assert!(!invalidate_messages, "subagent panel spinner redraw must not rebuild message cache");
assert!(!app.needs_clear_for_animation_redraw(), "subagent-only animation must not force terminal.clear flicker");
assert!(app.line_cache.is_some(), "message cache should remain valid for panel-only animation");
}
#[test]
fn animation_tick_for_active_bash_result_invalidates_message_cache() {
let mut app = test_app();
app.push_msg(ChatMessage::ToolUse {
tool_id: "call_1".to_string(),
tool_name: "bash".to_string(),
input: "{}".to_string(),
});
app.push_msg(ChatMessage::ToolResult {
tool_id: "call_1".to_string(),
content: String::new(),
elapsed_ms: None,
});
app.tool_start_time = Some(std::time::Instant::now());
app.tool_start_times.insert("call_1".to_string(), std::time::Instant::now());
app.line_cache = Some({
let w = 80;
let per_msg: Vec<Vec<ratatui::text::Line<'static>>> = (0..app.messages.len()).map(|i| app.render_message_lines(i, w)).collect();
let flat: Vec<ratatui::text::Line<'static>> = per_msg.iter().flatten().cloned().collect();
LineCache { width: w, per_msg, flat }
});
app.spinner_frame = 2;
let invalidate_messages = app.advance_animations();
assert!(invalidate_messages, "active message-area bash animation must rebuild message cache");
assert!(!app.needs_clear_for_animation_redraw(), "streaming animation must not force whole-terminal clear flicker");
}
#[test]
fn spinner_tick_with_thinking_placeholder_marks_only_tail_dirty() {
let mut app = test_app();
let w = 80;
app.push_msg(ChatMessage::User("question".to_string()));
app.push_msg(ChatMessage::Text("partial response".to_string()));
app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
app.line_cache = Some({
let per_msg: Vec<Vec<ratatui::text::Line<'static>>> = (0..app.messages.len()).map(|i| app.render_message_lines(i, w)).collect();
let flat: Vec<ratatui::text::Line<'static>> = per_msg.iter().flatten().cloned().collect();
LineCache { width: w, per_msg, flat }
});
app.dirty_from = None;
let last = app.messages.len() - 1;
let snapshot: Vec<Vec<String>> = app.line_cache.as_ref().unwrap().per_msg[..last]
.iter()
.map(|slot| slot.iter().map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()).collect())
.collect();
app.spinner_frame = 2; let needs_redraw = app.advance_animations();
assert!(needs_redraw, "spinner must signal redraw while THINKING_PLACEHOLDER present");
app.invalidate_last();
assert_eq!(app.dirty_from, Some(last), "dirty_from must point to tail message only");
{
let fresh: Vec<Vec<ratatui::text::Line<'static>>> = (last..app.messages.len())
.map(|i| app.render_message_lines(i, w))
.collect();
let cache = app.line_cache.as_mut().unwrap();
for (offset, rendered) in fresh.into_iter().enumerate() {
cache.per_msg[last + offset] = rendered;
}
let prefix_len: usize = cache.per_msg[..last].iter().map(|v| v.len()).sum();
cache.flat.truncate(prefix_len);
for slot in &cache.per_msg[last..] {
cache.flat.extend(slot.iter().cloned());
}
}
app.dirty_from = None;
let after: Vec<Vec<String>> = app.line_cache.as_ref().unwrap().per_msg[..last]
.iter()
.map(|slot| slot.iter().map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()).collect())
.collect();
assert_eq!(snapshot, after, "earlier per_msg slots must not change on spinner tick");
}
#[test]
fn grouped_system_output_does_not_insert_rules_between_indented_lines() {
let mut app = test_app();
app.push_msg(ChatMessage::System("Extensions (1):".to_string()));
app.push_msg(ChatMessage::System(" capture — ok".to_string()));
app.push_msg(ChatMessage::System(" tools: speak".to_string()));
let lines = app.render_lines(80);
let header_idx = lines
.iter()
.position(|line| line.spans.iter().any(|span| span.content.contains("Extensions (1):")))
.expect("header system message should render");
let child_idx = lines
.iter()
.position(|line| line.spans.iter().any(|span| span.content.contains("capture — ok")))
.expect("child system message should render");
let grandchild_idx = lines
.iter()
.position(|line| line.spans.iter().any(|span| span.content.contains("tools: speak")))
.expect("grandchild system message should render");
let has_separator = |slice: &[ratatui::text::Line]| {
slice.windows(2).any(|w| {
let blank = |l: &ratatui::text::Line| l.spans.is_empty() || l.spans.iter().all(|s| s.content.is_empty());
blank(&w[0]) && blank(&w[1])
})
};
assert!(!has_separator(&lines[header_idx + 1..child_idx]));
assert!(!has_separator(&lines[child_idx + 1..grandchild_idx]));
}
#[test]
fn unrelated_consecutive_system_messages_get_blank_line_separator() {
let mut app = test_app();
app.push_msg(ChatMessage::System("first".to_string()));
app.push_msg(ChatMessage::System("second".to_string()));
let lines = app.render_lines(80);
let first_idx = lines
.iter()
.position(|line| line.spans.iter().any(|span| span.content.contains("first")))
.expect("first system message should render");
let second_idx = lines
.iter()
.position(|line| line.spans.iter().any(|span| span.content.contains("second")))
.expect("second system message should render");
let between = &lines[first_idx + 1..second_idx];
let is_blank = |line: &ratatui::text::Line| {
line.spans.is_empty() || line.spans.iter().all(|span| span.content.is_empty())
};
assert!(between.iter().any(is_blank), "expected blank line between consecutive system messages");
}
#[test]
fn pasted_message_display_preserves_text_typed_after_paste() {
let mut app = test_app();
app.input_before_paste = Some("before".to_string());
app.pasted_char_count = "PASTED".chars().count();
let display = app.user_display_text_for_submission("beforePASTED after");
assert_eq!(display, "before [Pasted 6 chars] after");
}
fn last_tool_use(app: &App, tool_id: &str) -> Option<(String, String)> {
app.messages.iter().find_map(|m| match &m.msg {
ChatMessage::ToolUse { tool_id: tid, tool_name, input } if tid == tool_id => {
Some((tool_name.clone(), input.clone()))
}
_ => None,
})
}
fn tool_use_start_partial(app: &App, tool_id: &str) -> Option<String> {
app.messages.iter().find_map(|m| match &m.msg {
ChatMessage::ToolUseStart { tool_id: tid, partial_input, .. } if tid == tool_id => {
Some(partial_input.clone())
}
_ => None,
})
}
fn tool_result_content(app: &App, tool_id: &str) -> Option<String> {
app.messages.iter().find_map(|m| match &m.msg {
ChatMessage::ToolResult { tool_id: tid, content, .. } if tid == tool_id => {
Some(content.clone())
}
_ => None,
})
}
#[test]
fn parallel_tool_use_deltas_are_routed_by_tool_id() {
let mut app = test_app();
app.on_tool_use_start("call_a".to_string(), "bash".to_string());
app.on_tool_use_start("call_b".to_string(), "read".to_string());
app.on_tool_use_delta("call_b", "{\"path\":");
app.on_tool_use_delta("call_a", "{\"command\":");
app.on_tool_use_delta("call_a", "\"ls\"}");
app.on_tool_use_delta("call_b", "\"a\"}");
assert_eq!(
tool_use_start_partial(&app, "call_a").as_deref(),
Some(r#"{"command":"ls"}"#),
"call_a partial input must accumulate only call_a's deltas"
);
assert_eq!(
tool_use_start_partial(&app, "call_b").as_deref(),
Some(r#"{"path":"a"}"#),
"call_b partial input must accumulate only call_b's deltas"
);
}
#[test]
fn parallel_tool_use_finalize_collapses_matching_start() {
let mut app = test_app();
app.on_tool_use_start("call_a".to_string(), "bash".to_string());
app.on_tool_use_start("call_b".to_string(), "read".to_string());
app.on_tool_use_finalized(
"call_a".to_string(),
"bash".to_string(),
r#"{"command":"ls"}"#.to_string(),
);
app.on_tool_use_finalized(
"call_b".to_string(),
"read".to_string(),
r#"{"path":"a"}"#.to_string(),
);
let lingering_starts = app
.messages
.iter()
.filter(|m| matches!(&m.msg, ChatMessage::ToolUseStart { .. }))
.count();
assert_eq!(
lingering_starts, 0,
"every ToolUseStart must collapse on finalize — leftover starts cause perpetual bash-trace animations"
);
assert_eq!(
last_tool_use(&app, "call_a"),
Some(("bash".to_string(), r#"{"command":"ls"}"#.to_string()))
);
assert_eq!(
last_tool_use(&app, "call_b"),
Some(("read".to_string(), r#"{"path":"a"}"#.to_string()))
);
let positions: Vec<&str> = app
.messages
.iter()
.filter_map(|m| match &m.msg {
ChatMessage::ToolUse { tool_id, .. } => Some(tool_id.as_str()),
_ => None,
})
.collect();
assert_eq!(positions, vec!["call_a", "call_b"]);
}
#[test]
fn parallel_tool_results_do_not_overwrite_each_other() {
let mut app = test_app();
app.on_tool_use_start("call_a".to_string(), "bash".to_string());
app.on_tool_use_start("call_b".to_string(), "bash".to_string());
app.on_tool_use_finalized(
"call_a".to_string(),
"bash".to_string(),
"{}".to_string(),
);
app.on_tool_use_finalized(
"call_b".to_string(),
"bash".to_string(),
"{}".to_string(),
);
app.on_tool_result("call_a".to_string(), "first output".to_string());
app.on_tool_result("call_b".to_string(), "second output".to_string());
assert_eq!(
tool_result_content(&app, "call_a").as_deref(),
Some("first output"),
"call_a's result must survive — was overwritten by call_b in the buggy implementation"
);
assert_eq!(
tool_result_content(&app, "call_b").as_deref(),
Some("second output")
);
}
#[test]
fn tool_result_delta_streams_into_matching_block() {
let mut app = test_app();
app.on_tool_use_start("call_a".to_string(), "bash".to_string());
app.on_tool_use_start("call_b".to_string(), "bash".to_string());
app.on_tool_use_finalized(
"call_a".to_string(),
"bash".to_string(),
"{}".to_string(),
);
app.on_tool_use_finalized(
"call_b".to_string(),
"bash".to_string(),
"{}".to_string(),
);
app.on_tool_result_delta("call_a".to_string(), "alpha-".to_string());
app.on_tool_result_delta("call_b".to_string(), "beta-".to_string());
app.on_tool_result_delta("call_a".to_string(), "one".to_string());
app.on_tool_result_delta("call_b".to_string(), "two".to_string());
app.on_tool_result("call_a".to_string(), "alpha-one".to_string());
app.on_tool_result("call_b".to_string(), "beta-two".to_string());
assert_eq!(
tool_result_content(&app, "call_a").as_deref(),
Some("alpha-one")
);
assert_eq!(
tool_result_content(&app, "call_b").as_deref(),
Some("beta-two")
);
}
#[test]
fn parallel_tool_results_record_per_tool_elapsed_time() {
let mut app = test_app();
app.on_tool_use_start("call_a".to_string(), "bash".to_string());
app.on_tool_use_start("call_b".to_string(), "bash".to_string());
std::thread::sleep(std::time::Duration::from_millis(2));
app.on_tool_result("call_a".to_string(), "a".to_string());
std::thread::sleep(std::time::Duration::from_millis(2));
app.on_tool_result("call_b".to_string(), "b".to_string());
let a_elapsed = app.messages.iter().find_map(|m| match &m.msg {
ChatMessage::ToolResult { tool_id, elapsed_ms, .. } if tool_id == "call_a" => *elapsed_ms,
_ => None,
});
let b_elapsed = app.messages.iter().find_map(|m| match &m.msg {
ChatMessage::ToolResult { tool_id, elapsed_ms, .. } if tool_id == "call_b" => *elapsed_ms,
_ => None,
});
assert!(a_elapsed.is_some(), "call_a must record elapsed_ms from its own start_time");
assert!(b_elapsed.is_some(), "call_b must record elapsed_ms from its own start_time");
}
#[test]
fn real_ellipsis_thinking_content_is_not_dropped() {
let mut app = test_app();
app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
app.append_or_update_thinking("…");
app.append_or_update_text("answer");
assert!(
app.messages.iter().any(|m| matches!(&m.msg, ChatMessage::Thinking(t) if t == "…")),
"real ellipsis thinking content must survive — sentinel and real output are distinct"
);
}
#[test]
fn placeholder_stranded_by_system_message_is_dropped() {
let mut app = test_app();
app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
app.push_msg(ChatMessage::System("retrying…".to_string()));
app.drop_empty_thinking();
let has_placeholder = app.messages.iter().any(|m| matches!(
&m.msg, ChatMessage::Thinking(t) if t == THINKING_PLACEHOLDER
));
assert!(!has_placeholder, "placeholder stranded under a System message must be removed by drop_empty_thinking");
}
#[test]
fn abort_path_clears_thinking_placeholder() {
let mut app = test_app();
app.push_msg(ChatMessage::Thinking(THINKING_PLACEHOLDER.to_string()));
app.drop_empty_thinking();
app.push_msg(ChatMessage::Error("aborted".to_string()));
let has_placeholder = app.messages.iter().any(|m| matches!(
&m.msg, ChatMessage::Thinking(t) if t == THINKING_PLACEHOLDER
));
assert!(!has_placeholder, "abort must remove thinking placeholder so spinner doesn't freeze");
}
#[test]
fn render_lines_equals_concat_of_render_message_lines() {
let mut app = test_app();
app.push_msg(ChatMessage::User("hello world".to_string()));
app.push_msg(ChatMessage::Thinking("some reasoning".to_string()));
app.push_msg(ChatMessage::Text("here is the answer".to_string()));
app.push_msg(ChatMessage::ToolUse {
tool_id: "call_1".to_string(),
tool_name: "bash".to_string(),
input: r#"{"command":"ls"}"#.to_string(),
});
app.push_msg(ChatMessage::ToolResult {
tool_id: "call_1".to_string(),
content: "file1.txt\nfile2.txt".to_string(),
elapsed_ms: Some(42),
});
let w = 80;
let flat = app.render_lines(w);
let concat: Vec<ratatui::text::Line<'static>> = (0..app.messages.len())
.flat_map(|i| app.render_message_lines(i, w))
.collect();
let to_str = |lines: &[ratatui::text::Line<'static>]| -> Vec<String> {
lines.iter().map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()).collect()
};
assert_eq!(to_str(&flat), to_str(&concat),
"render_lines must equal concat of render_message_lines for each index");
}
#[test]
fn line_cache_build_produces_flat_equal_to_render_lines() {
let mut app = test_app();
app.push_msg(ChatMessage::User("hi".to_string()));
app.push_msg(ChatMessage::Text("hello back".to_string()));
app.push_msg(ChatMessage::ToolUse {
tool_id: "t1".to_string(),
tool_name: "bash".to_string(),
input: "{}".to_string(),
});
app.push_msg(ChatMessage::ToolResult {
tool_id: "t1".to_string(),
content: "output".to_string(),
elapsed_ms: Some(10),
});
let w = 80;
let expected_flat = app.render_lines(w);
let per_msg: Vec<Vec<ratatui::text::Line<'static>>> = (0..app.messages.len())
.map(|i| app.render_message_lines(i, w))
.collect();
let flat: Vec<ratatui::text::Line<'static>> = per_msg.iter().flatten().cloned().collect();
let cache = LineCache { width: w, per_msg, flat };
let to_str = |lines: &[ratatui::text::Line<'static>]| -> Vec<String> {
lines.iter().map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()).collect()
};
assert_eq!(to_str(&expected_flat), to_str(&cache.flat),
"LineCache.flat must equal render_lines output");
assert!(cache.width == w);
}
fn build_cache(app: &App, width: usize) -> LineCache {
let per_msg: Vec<Vec<ratatui::text::Line<'static>>> = (0..app.messages.len())
.map(|i| app.render_message_lines(i, width))
.collect();
let flat: Vec<ratatui::text::Line<'static>> = per_msg.iter().flatten().cloned().collect();
LineCache { width, per_msg, flat }
}
fn rebuild_incremental(app: &mut App, width: usize) {
match app.line_cache.take() {
Some(mut cache) if cache.width == width => {
if let Some(k) = app.dirty_from.take() {
if cache.per_msg.len() != app.messages.len() {
cache.per_msg.truncate(k);
for i in k..app.messages.len() {
cache.per_msg.push(app.render_message_lines(i, width));
}
} else {
for i in k..app.messages.len() {
cache.per_msg[i] = app.render_message_lines(i, width);
}
}
let prefix_len: usize = cache.per_msg[..k].iter().map(|v| v.len()).sum();
cache.flat.truncate(prefix_len);
for slot in &cache.per_msg[k..] {
cache.flat.extend(slot.iter().cloned());
}
}
app.line_cache = Some(cache);
}
_ => {
app.dirty_from = None;
app.line_cache = Some(build_cache(app, width));
}
}
}
#[test]
fn incremental_cache_does_not_re_render_unchanged_messages() {
let mut app = test_app();
let w = 80;
app.push_msg(ChatMessage::User("hello".to_string()));
app.push_msg(ChatMessage::Thinking("reasoning".to_string()));
app.push_msg(ChatMessage::Text("partial answer".to_string()));
app.line_cache = Some(build_cache(&app, w));
app.dirty_from = None;
let last = app.messages.len() - 1;
let snapshot: Vec<Vec<String>> = app.line_cache.as_ref().unwrap().per_msg[..last]
.iter()
.map(|slot| slot.iter().map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()).collect())
.collect();
if let Some(crate::tui::app::TimestampedMsg { msg: ChatMessage::Text(ref mut t), .. }) = app.messages.last_mut() {
t.push_str(" — more content appended");
}
app.dirty_from = Some(last);
rebuild_incremental(&mut app, w);
let cache = app.line_cache.as_ref().unwrap();
let after: Vec<Vec<String>> = cache.per_msg[..last]
.iter()
.map(|slot| slot.iter().map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()).collect())
.collect();
assert_eq!(snapshot, after, "per_msg[0..last] must not change on tail-only invalidation");
let last_strs: Vec<String> = cache.per_msg[last].iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>())
.collect();
let contains_new = last_strs.iter().any(|s| s.contains("more content appended"));
assert!(contains_new, "per_msg[last] must reflect the updated text");
let expected_flat = app.render_lines(w);
let to_str = |lines: &[ratatui::text::Line<'static>]| -> Vec<String> {
lines.iter().map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()).collect()
};
assert_eq!(to_str(&expected_flat), to_str(&cache.flat),
"flat must equal full render_lines after incremental rebuild");
}
#[test]
fn incremental_cache_handles_tool_result_insert() {
let mut app = test_app();
let w = 80;
app.push_msg(ChatMessage::User("run something".to_string()));
app.push_msg(ChatMessage::ToolUse {
tool_id: "t1".to_string(),
tool_name: "bash".to_string(),
input: r#"{"command":"ls"}"#.to_string(),
});
app.line_cache = Some(build_cache(&app, w));
app.dirty_from = None;
let at = 2;
app.messages.insert(at, crate::tui::app::TimestampedMsg {
msg: ChatMessage::ToolResult {
tool_id: "t1".to_string(),
content: "file.txt".to_string(),
elapsed_ms: Some(5),
},
time: "00:00".to_string(),
});
app.dirty_from = Some(at);
rebuild_incremental(&mut app, w);
let cache = app.line_cache.as_ref().unwrap();
assert_eq!(cache.per_msg.len(), app.messages.len(), "per_msg must track messages after insert");
let expected_flat = app.render_lines(w);
let to_str = |lines: &[ratatui::text::Line<'static>]| -> Vec<String> {
lines.iter().map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>()).collect()
};
assert_eq!(to_str(&expected_flat), to_str(&cache.flat),
"flat must equal render_lines after insert + incremental rebuild");
}
}