use synaps_cli::Session;
use synaps_cli::pricing::calculate_cost_optional_split;
pub(crate) use super::transcript::{ChatMessage, TranscriptStore, THINKING_PLACEHOLDER};
#[allow(unused_imports)]
pub(crate) use super::transcript::{CacheState, LineCache, MsgSlot, RenderCtx};
#[allow(unused_imports)]
pub(crate) use super::transcript::TimestampedMsg;
pub(crate) struct App {
pub(crate) transcript: TranscriptStore,
pub(crate) input: String,
pub(crate) cursor_pos: usize,
pub(crate) api_messages: Vec<synaps_cli::SharedMessage>,
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,
api_call_count: u32, pub(crate) session_cost: f64,
pub(crate) session: Session,
pub(crate) agent_name: String,
pub(crate) needs_redraw: bool,
pub(crate) force_redraw: bool,
pub(crate) logo_dismiss_t: Option<f64>,
pub(crate) logo_build_t: Option<f64>,
pub(crate) subagents: Vec<SubagentState>,
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) modal_stack: super::focus::ModalStack,
pub(crate) secret_prompts: synaps_cli::tools::SecretPromptQueue,
pub(crate) compact_task: Option<tokio::task::JoinHandle<Result<String, synaps_cli::error::RuntimeError>>>,
pub(crate) pending_events: Vec<String>,
pub(crate) consecutive_auto_turns: u32,
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) 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) clock: super::clock::TuiClock,
}
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>,
pub(crate) done_at: Option<std::time::Instant>,
}
impl App {
#[cfg(test)]
pub(crate) fn new(session: Session) -> Self {
Self::new_with_clock(session, super::clock::TuiClock::real())
}
pub(crate) fn new_with_clock(session: Session, clock: super::clock::TuiClock) -> 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 {
transcript: TranscriptStore::new(clock.clone()),
input: String::new(),
cursor_pos: 0,
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()),
needs_redraw: true,
force_redraw: false,
logo_dismiss_t: None,
logo_build_t: Some(0.0),
subagents: Vec::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,
modal_stack: super::focus::ModalStack::new(),
secret_prompts: synaps_cli::tools::SecretPromptQueue::new(),
compact_task: None,
pending_events: Vec::new(),
consecutive_auto_turns: 0,
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,
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(clock.clone()),
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,
clock,
}
}
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) 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.transcript.push_msg(msg);
self.needs_redraw = true;
}
pub(crate) fn cap_resumed_display(&mut self, cap: usize) {
self.transcript.cap_resumed_display(cap);
}
pub(crate) fn invalidate(&mut self) {
self.transcript.invalidate();
self.needs_redraw = true;
}
pub(crate) fn invalidate_last(&mut self) {
self.transcript.invalidate_last();
self.needs_redraw = true;
}
pub(crate) fn request_redraw(&mut self) {
self.needs_redraw = true;
}
pub(crate) fn request_immediate_redraw(&mut self) {
self.needs_redraw = true;
self.force_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.transcript.uses_spinner()
}
pub(crate) fn needs_clear_for_animation_redraw(&self) -> bool {
false
}
pub(crate) fn on_tool_use_start(&mut self, tool_id: String, tool_name: String) {
self.transcript.on_tool_use_start(tool_id, tool_name);
self.needs_redraw = true;
}
pub(crate) fn on_tool_use_delta(&mut self, tool_id: &str, delta: &str) {
self.transcript.on_tool_use_delta(tool_id, delta);
self.needs_redraw = true;
}
pub(crate) fn on_tool_use_finalized(&mut self, tool_id: String, tool_name: String, input_str: String) {
self.transcript.on_tool_use_finalized(tool_id, tool_name, input_str);
self.needs_redraw = true;
}
pub(crate) fn on_tool_result_delta(&mut self, tool_id: String, delta: String) {
self.transcript.on_tool_result_delta(tool_id, delta);
self.needs_redraw = true;
}
pub(crate) fn on_tool_result(&mut self, tool_id: String, result: String) {
self.transcript.on_tool_result(tool_id, result);
self.needs_redraw = true;
}
pub(crate) fn capture_abort_context(&mut self) {
let mut parts: Vec<String> = Vec::new();
for tmsg in self.transcript.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 append_or_update_text(&mut self, text: &str) {
self.transcript.append_or_update_text(text);
self.needs_redraw = true;
}
pub(crate) fn append_or_update_thinking(&mut self, text: &str) {
self.transcript.append_or_update_thinking(text);
self.needs_redraw = true;
}
pub(crate) fn drop_empty_thinking(&mut self) {
self.transcript.drop_empty_thinking();
self.needs_redraw = true;
}
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)]
impl App {
pub(crate) fn render_ctx(&self) -> RenderCtx<'_> {
RenderCtx {
spinner_frame: self.spinner_frame,
streaming: self.streaming,
agent_name: &self.agent_name,
}
}
pub(crate) fn render_message_lines(&self, idx: usize, width: usize) -> MsgSlot {
self.transcript.render_message_lines(idx, width, &self.render_ctx())
}
pub(crate) fn render_lines(&self, width: usize) -> Vec<ratatui::text::Line<'static>> {
self.transcript.render_lines(width, &self.render_ctx())
}
}
#[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.transcript.uses_spinner(), "placeholder should animate while present");
app.append_or_update_text("here is the answer");
assert!(
!matches!(app.transcript.messages().last().map(|m| &m.msg), Some(ChatMessage::Thinking(_))),
"empty thinking must be gone once text arrives"
);
assert!(!app.transcript.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
.transcript.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.transcript.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
.transcript.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.transcript.test_set_tool_start_time(Some(app.clock.now()));
app.transcript.test_insert_tool_start_time("call_2".to_string(), app.clock.now());
assert!(!app.transcript.is_active_tool_result(1), "completed historical result (call_1, not in tool_start_times) must render done");
assert!(app.transcript.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.transcript.test_set_tool_start_time(Some(app2.clock.now()));
app2.transcript.test_insert_tool_start_time("p1".to_string(), app2.clock.now());
app2.transcript.test_insert_tool_start_time("p2".to_string(), app2.clock.now());
assert!(app2.transcript.is_active_tool_result(1), "parallel in-flight p1 mid-vec must be active");
assert!(app2.transcript.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.transcript.test_set_tool_start_time(Some(app.clock.now()));
assert!(!app.transcript.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()));
{
let w = 80;
let per_msg: Vec<MsgSlot> = (0..app.transcript.message_count()).map(|i| app.render_message_lines(i, w)).collect();
app.transcript.test_set_cache_clean(LineCache::new(w, per_msg));
}
app.subagents.push(SubagentState {
id: 1,
name: "tester".to_string(),
status: "running".to_string(),
start_time: app.clock.now(),
done: false,
duration_secs: None,
done_at: 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.transcript.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.transcript.test_set_tool_start_time(Some(app.clock.now()));
app.transcript.test_insert_tool_start_time("call_1".to_string(), app.clock.now());
{
let w = 80;
let per_msg: Vec<MsgSlot> = (0..app.transcript.message_count()).map(|i| app.render_message_lines(i, w)).collect();
app.transcript.test_set_cache_clean(LineCache::new(w, per_msg));
}
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()));
{
let per_msg: Vec<MsgSlot> = (0..app.transcript.message_count()).map(|i| app.render_message_lines(i, w)).collect();
app.transcript.test_set_cache_clean(LineCache::new(w, per_msg));
}
let last = app.transcript.message_count() - 1;
let snapshot: Vec<Vec<String>> = app.transcript.line_cache().unwrap().per_msg[..last]
.iter()
.map(|slot| slot.lines().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.transcript.cache_dirty_from(), Some(last), "dirty watermark must point to tail message only");
{
let fresh: Vec<MsgSlot> = (last..app.transcript.message_count())
.map(|i| app.render_message_lines(i, w))
.collect();
let mut cs = app.transcript.test_take_cache();
if let CacheState::Dirty(ref mut cache, _) = cs {
for (offset, rendered) in fresh.into_iter().enumerate() {
cache.per_msg[last + offset] = rendered;
}
}
if let CacheState::Dirty(cache, _) = cs {
app.transcript.test_set_cache_clean(cache);
}
}
let after: Vec<Vec<String>> = app.transcript.line_cache().unwrap().per_msg[..last]
.iter()
.map(|slot| slot.lines().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.transcript.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.transcript.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.transcript.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
.transcript.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
.transcript.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.transcript.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.transcript.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.transcript.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.transcript.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.transcript.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.transcript.message_count())
.flat_map(|i| app.render_message_lines(i, w).lines.expect("freshly rendered slot has lines"))
.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_heights_equal_to_render_lines_prefix_sums() {
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<MsgSlot> = (0..app.transcript.message_count())
.map(|i| app.render_message_lines(i, w))
.collect();
let cache = LineCache::new(w, per_msg);
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!(cache.total_height(), expected_flat.len(),
"total_height must equal the reference render's line count");
let mut acc = 0usize;
for (i, slot) in cache.per_msg.iter().enumerate() {
assert_eq!(cache.cum_heights[i], acc, "cum_heights[{i}] must be the prefix sum");
acc += slot.height();
}
let concat: Vec<ratatui::text::Line<'static>> =
cache.per_msg.iter().flat_map(|e| e.lines().iter().cloned()).collect();
assert_eq!(to_str(&expected_flat), to_str(&concat),
"per_msg concatenation must equal render_lines output");
assert!(cache.width == w);
}
fn build_cache(app: &App, width: usize) -> LineCache {
let per_msg: Vec<MsgSlot> = (0..app.transcript.message_count())
.map(|i| app.render_message_lines(i, width))
.collect();
LineCache::new(width, per_msg)
}
fn rebuild_incremental(app: &mut App, width: usize) {
match app.transcript.test_take_cache() {
CacheState::Dirty(mut cache, k) if cache.width == width => {
if cache.per_msg.len() != app.transcript.message_count() {
cache.per_msg.truncate(k);
for i in k..app.transcript.message_count() {
cache.per_msg.push(app.render_message_lines(i, width));
}
} else {
for i in k..app.transcript.message_count() {
cache.per_msg[i] = app.render_message_lines(i, width);
}
}
app.transcript.test_set_cache_clean(cache);
}
CacheState::Clean(cache) if cache.width == width => {
app.transcript.test_set_cache_clean(cache);
}
_ => {
let c = build_cache(app, width);
app.transcript.test_set_cache_clean(c);
}
}
}
#[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.transcript.test_set_cache_clean(build_cache(&app, w));
let last = app.transcript.message_count() - 1;
let snapshot: Vec<Vec<String>> = app.transcript.line_cache().unwrap().per_msg[..last]
.iter()
.map(|slot| slot.lines().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.transcript.test_last_msg_mut() {
t.push_str(" โ more content appended");
}
app.transcript.invalidate_from(last);
rebuild_incremental(&mut app, w);
let cache = app.transcript.line_cache().unwrap();
let after: Vec<Vec<String>> = cache.per_msg[..last]
.iter()
.map(|slot| slot.lines().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].lines().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()
};
let concat: Vec<ratatui::text::Line<'static>> =
cache.per_msg.iter().flat_map(|e| e.lines().iter().cloned()).collect();
assert_eq!(to_str(&expected_flat), to_str(&concat),
"per_msg concatenation must equal full render_lines after incremental rebuild");
assert_eq!(cache.total_height(), expected_flat.len(),
"cum_heights total must track the reference render 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.transcript.test_set_cache_clean(build_cache(&app, w));
let at = 2;
app.transcript.test_insert_at(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.transcript.invalidate_from(at);
rebuild_incremental(&mut app, w);
let cache = app.transcript.line_cache().unwrap();
assert_eq!(cache.per_msg.len(), app.transcript.message_count(), "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()
};
let concat: Vec<ratatui::text::Line<'static>> =
cache.per_msg.iter().flat_map(|e| e.lines().iter().cloned()).collect();
assert_eq!(to_str(&expected_flat), to_str(&concat),
"per_msg concatenation must equal render_lines after insert + incremental rebuild");
assert_eq!(cache.total_height(), expected_flat.len(),
"cum_heights total must track the reference render after insert + incremental rebuild");
}
}