use ratatui::text::Line;
use serde_json::Value;
use chrono::Local;
use synaps_cli::Session;
#[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 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) 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<(usize, Vec<Line<'static>>)>,
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: 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) 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();
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,
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,
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: 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,
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);
}
}
pub(crate) fn add_usage(
&mut self,
input_tokens: u64,
output_tokens: u64,
cache_read: u64,
cache_creation: 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;
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;
let (input_price, output_price) = match model {
m if m.contains("opus") => (5.0, 25.0),
m if m.contains("sonnet") => (3.0, 15.0),
m if m.contains("haiku") => (1.0, 5.0), _ => (3.0, 15.0), };
let cost = (input_tokens as f64 / 1_000_000.0) * input_price
+ (cache_read as f64 / 1_000_000.0) * input_price * 0.1
+ (cache_creation as f64 / 1_000_000.0) * input_price * 1.25
+ (output_tokens as f64 / 1_000_000.0) * output_price;
self.session_cost += cost;
}
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();
}
pub(crate) fn invalidate(&mut self) {
self.line_cache = None;
}
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 == "…",
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 {
self.tool_start_time.is_some()
&& idx == self.messages.len().saturating_sub(1)
&& matches!(self.messages.get(idx).map(|m| &m.msg), Some(ChatMessage::ToolResult { elapsed_ms: None, .. }))
}
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) {
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) {
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_msg(ChatMessage::ToolResult { tool_id, content: delta, elapsed_ms: 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_msg(ChatMessage::ToolResult { tool_id, content: result, elapsed_ms: 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 (_, ref all_lines) = self.line_cache.as_ref()?;
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) {
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();
}
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() {
existing.push_str(text);
} else {
self.push_msg(ChatMessage::Thinking(text.to_string()));
}
self.invalidate();
}
pub(crate) fn handle_theme_command(&mut self, arg: &str) {
let descriptions: &[(&str, &str)] = &[
("default", "cool teal on dark blue-gray"),
("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_else(super::theme::Theme::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 crate::chatui::theme::THEME;
use super::*;
fn test_app() -> App {
App::new(Session::new("test-model", "low", None))
}
#[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());
assert!(!app.is_active_tool_result(1), "completed historical result must render done while later tool runs");
assert!(app.is_active_tool_result(3), "latest incomplete result is the actively running tool result");
}
#[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((80, app.render_lines(80)));
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.line_cache = Some((80, app.render_lines(80)));
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 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_rule = |slice: &[ratatui::text::Line]| {
slice
.iter()
.any(|line| line.spans.iter().any(|span| span.content.contains("─ · ─")))
};
assert!(!has_rule(&lines[header_idx + 1..child_idx]));
assert!(!has_rule(&lines[child_idx + 1..grandchild_idx]));
}
#[test]
fn unrelated_consecutive_system_messages_still_get_a_rule() {
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 rule_idx = between
.iter()
.position(|line| line.spans.iter().any(|span| {
span.content.contains("─ · ─")
&& span.style.fg == Some(THEME.load().muted)
&& !span.style.add_modifier.contains(ratatui::style::Modifier::DIM)
}))
.expect("expected centered rule between consecutive system messages");
let is_blank = |line: &ratatui::text::Line| {
line.spans.is_empty() || line.spans.iter().all(|span| span.content.is_empty())
};
assert!(rule_idx > 0 && is_blank(&between[rule_idx - 1]), "expected blank line before centered rule; got {:?}", between);
assert!(rule_idx + 1 < between.len() && is_blank(&between[rule_idx + 1]), "expected blank line after centered rule; got {:?}", between);
}
#[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");
}
}