use crate::acp::client::ClientEvent;
use agent_client_protocol as acp;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::time::Instant;
use tokio::sync::mpsc;
use super::focus::{FocusContext, FocusManager, FocusOwner, FocusTarget};
use super::input::InputState;
use super::mention;
use super::slash;
#[derive(Debug)]
pub struct ModeInfo {
pub id: String,
pub name: String,
}
#[derive(Debug)]
pub struct ModeState {
pub current_mode_id: String,
pub current_mode_name: String,
pub available_modes: Vec<ModeInfo>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HelpView {
#[default]
Keys,
SlashCommands,
}
pub struct LoginHint {
pub method_name: String,
pub method_description: String,
}
#[derive(Debug, Clone)]
pub struct TodoItem {
pub content: String,
pub status: TodoStatus,
pub active_form: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TodoStatus {
Pending,
InProgress,
Completed,
}
#[allow(clippy::struct_excessive_bools)]
pub struct App {
pub messages: Vec<ChatMessage>,
pub viewport: ChatViewport,
pub input: InputState,
pub status: AppStatus,
pub should_quit: bool,
pub session_id: Option<acp::SessionId>,
pub conn: Option<Rc<acp::ClientSideConnection>>,
pub adapter_child: Option<tokio::process::Child>,
pub model_name: String,
pub cwd: String,
pub cwd_raw: String,
pub files_accessed: usize,
pub mode: Option<ModeState>,
pub login_hint: Option<LoginHint>,
pub pending_compact_clear: bool,
pub help_view: HelpView,
pub pending_permission_ids: Vec<String>,
pub cancelled_turn_pending_hint: bool,
pub event_tx: mpsc::UnboundedSender<ClientEvent>,
pub event_rx: mpsc::UnboundedReceiver<ClientEvent>,
pub spinner_frame: usize,
pub tools_collapsed: bool,
pub active_task_ids: HashSet<String>,
pub terminals: crate::acp::client::TerminalMap,
pub force_redraw: bool,
pub tool_call_index: HashMap<String, (usize, usize)>,
pub todos: Vec<TodoItem>,
pub show_header: bool,
pub show_todo_panel: bool,
pub todo_scroll: usize,
pub todo_selected: usize,
pub focus: FocusManager,
pub available_commands: Vec<acp::AvailableCommand>,
pub cached_frame_area: ratatui::layout::Rect,
pub selection: Option<SelectionState>,
pub scrollbar_drag: Option<ScrollbarDragState>,
pub rendered_chat_lines: Vec<String>,
pub rendered_chat_area: ratatui::layout::Rect,
pub rendered_input_lines: Vec<String>,
pub rendered_input_area: ratatui::layout::Rect,
pub mention: Option<mention::MentionState>,
pub slash: Option<slash::SlashState>,
pub pending_submit: bool,
pub drain_key_count: usize,
pub paste_burst: super::paste_burst::PasteBurstDetector,
pub pending_paste_text: String,
pub file_cache: Option<Vec<mention::FileCandidate>>,
pub cached_todo_compact: Option<ratatui::text::Line<'static>>,
pub git_branch: Option<String>,
pub cached_header_line: Option<ratatui::text::Line<'static>>,
pub cached_footer_line: Option<ratatui::text::Line<'static>>,
pub update_check_hint: Option<String>,
pub terminal_tool_calls: Vec<(String, usize, usize)>,
pub needs_redraw: bool,
pub perf: Option<crate::perf::PerfLogger>,
pub fps_ema: Option<f32>,
pub last_frame_at: Option<Instant>,
}
impl App {
pub fn mark_frame_presented(&mut self, now: Instant) {
let Some(prev) = self.last_frame_at.replace(now) else {
return;
};
let dt = now.saturating_duration_since(prev).as_secs_f32();
if dt <= f32::EPSILON {
return;
}
let fps = (1.0 / dt).clamp(0.0, 240.0);
self.fps_ema = Some(match self.fps_ema {
Some(current) => current * 0.9 + fps * 0.1,
None => fps,
});
}
#[must_use]
pub fn frame_fps(&self) -> Option<f32> {
self.fps_ema
}
pub fn ensure_welcome_message(&mut self) {
if self.messages.first().is_some_and(|m| matches!(m.role, MessageRole::Welcome)) {
return;
}
self.messages.insert(0, ChatMessage::welcome(&self.model_name, &self.cwd));
self.mark_all_message_layout_dirty();
}
pub fn update_welcome_model_if_pristine(&mut self) {
if self.messages.len() != 1 {
return;
}
let Some(first) = self.messages.first_mut() else {
return;
};
if !matches!(first.role, MessageRole::Welcome) {
return;
}
let Some(MessageBlock::Welcome(welcome)) = first.blocks.first_mut() else {
return;
};
welcome.model_name.clone_from(&self.model_name);
welcome.cache.invalidate();
self.mark_message_layout_dirty(0);
}
pub fn insert_active_task(&mut self, id: String) {
self.active_task_ids.insert(id);
}
pub fn remove_active_task(&mut self, id: &str) {
self.active_task_ids.remove(id);
}
#[must_use]
pub fn lookup_tool_call(&self, id: &str) -> Option<(usize, usize)> {
self.tool_call_index.get(id).copied()
}
pub fn index_tool_call(&mut self, id: String, msg_idx: usize, block_idx: usize) {
self.tool_call_index.insert(id, (msg_idx, block_idx));
}
pub fn mark_message_layout_dirty(&mut self, msg_idx: usize) {
self.viewport.mark_message_dirty(msg_idx);
if msg_idx + 1 < self.messages.len() {
self.viewport.prefix_sums_width = 0;
}
}
pub fn mark_all_message_layout_dirty(&mut self) {
if self.messages.is_empty() {
return;
}
self.viewport.mark_message_dirty(0);
self.viewport.prefix_sums_width = 0;
}
pub fn finalize_in_progress_tool_calls(&mut self, new_status: acp::ToolCallStatus) -> usize {
let mut changed = 0usize;
let mut cleared_permission = false;
let mut first_changed_idx: Option<usize> = None;
for (msg_idx, msg) in self.messages.iter_mut().enumerate() {
for block in &mut msg.blocks {
if let MessageBlock::ToolCall(tc) = block {
let tc = tc.as_mut();
if matches!(
tc.status,
acp::ToolCallStatus::InProgress | acp::ToolCallStatus::Pending
) {
tc.status = new_status;
tc.cache.invalidate();
if tc.pending_permission.take().is_some() {
cleared_permission = true;
}
first_changed_idx =
Some(first_changed_idx.map_or(msg_idx, |prev| prev.min(msg_idx)));
changed += 1;
}
}
}
}
if changed > 0 || cleared_permission {
if let Some(msg_idx) = first_changed_idx {
self.mark_message_layout_dirty(msg_idx);
}
self.pending_permission_ids.clear();
self.release_focus_target(FocusTarget::Permission);
}
changed
}
#[doc(hidden)]
#[must_use]
pub fn test_default() -> Self {
let (tx, rx) = mpsc::unbounded_channel();
Self {
messages: Vec::new(),
viewport: ChatViewport::new(),
input: InputState::new(),
status: AppStatus::Ready,
should_quit: false,
session_id: None,
conn: None,
adapter_child: None,
model_name: "test-model".into(),
cwd: "/test".into(),
cwd_raw: "/test".into(),
files_accessed: 0,
mode: None,
login_hint: None,
pending_compact_clear: false,
help_view: HelpView::Keys,
pending_permission_ids: Vec::new(),
cancelled_turn_pending_hint: false,
event_tx: tx,
event_rx: rx,
spinner_frame: 0,
tools_collapsed: false,
active_task_ids: HashSet::default(),
terminals: std::rc::Rc::default(),
force_redraw: false,
tool_call_index: HashMap::default(),
todos: Vec::new(),
show_header: true,
show_todo_panel: false,
todo_scroll: 0,
todo_selected: 0,
focus: FocusManager::default(),
available_commands: Vec::new(),
cached_frame_area: ratatui::layout::Rect::default(),
selection: None,
scrollbar_drag: None,
rendered_chat_lines: Vec::new(),
rendered_chat_area: ratatui::layout::Rect::default(),
rendered_input_lines: Vec::new(),
rendered_input_area: ratatui::layout::Rect::default(),
mention: None,
slash: None,
pending_submit: false,
drain_key_count: 0,
paste_burst: super::paste_burst::PasteBurstDetector::new(),
pending_paste_text: String::new(),
file_cache: None,
cached_todo_compact: None,
git_branch: None,
cached_header_line: None,
cached_footer_line: None,
update_check_hint: None,
terminal_tool_calls: Vec::new(),
needs_redraw: true,
perf: None,
fps_ema: None,
last_frame_at: None,
}
}
pub fn refresh_git_branch(&mut self) {
let new_branch = std::process::Command::new("git")
.args(["branch", "--show-current"])
.current_dir(&self.cwd_raw)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
let s = String::from_utf8_lossy(&o.stdout).trim().to_owned();
if s.is_empty() { None } else { Some(s) }
} else {
None
}
});
if new_branch != self.git_branch {
self.git_branch = new_branch;
self.cached_header_line = None;
}
}
#[must_use]
pub fn focus_owner(&self) -> FocusOwner {
self.focus.owner(self.focus_context())
}
#[must_use]
pub fn is_help_active(&self) -> bool {
self.input.text().trim() == "?"
}
pub fn claim_focus_target(&mut self, target: FocusTarget) {
let context = self.focus_context();
self.focus.claim(target, context);
}
pub fn release_focus_target(&mut self, target: FocusTarget) {
let context = self.focus_context();
self.focus.release(target, context);
}
pub fn normalize_focus_stack(&mut self) {
let context = self.focus_context();
self.focus.normalize(context);
}
#[must_use]
fn focus_context(&self) -> FocusContext {
FocusContext::with_help(
self.show_todo_panel && !self.todos.is_empty(),
self.mention.is_some() || self.slash.is_some(),
!self.pending_permission_ids.is_empty(),
self.is_help_active(),
)
}
}
pub struct ChatViewport {
pub scroll_offset: usize,
pub scroll_target: usize,
pub scroll_pos: f32,
pub scrollbar_thumb_top: f32,
pub scrollbar_thumb_size: f32,
pub auto_scroll: bool,
pub width: u16,
pub message_heights: Vec<usize>,
pub message_heights_width: u16,
pub dirty_from: Option<usize>,
pub height_prefix_sums: Vec<usize>,
pub prefix_sums_width: u16,
}
impl ChatViewport {
#[must_use]
pub fn new() -> Self {
Self {
scroll_offset: 0,
scroll_target: 0,
scroll_pos: 0.0,
scrollbar_thumb_top: 0.0,
scrollbar_thumb_size: 0.0,
auto_scroll: true,
width: 0,
message_heights: Vec::new(),
message_heights_width: 0,
dirty_from: None,
height_prefix_sums: Vec::new(),
prefix_sums_width: 0,
}
}
pub fn on_frame(&mut self, width: u16) {
if self.width != 0 && self.width != width {
tracing::debug!(
"RESIZE: width {} -> {}, scroll_target={}, auto_scroll={}",
self.width,
width,
self.scroll_target,
self.auto_scroll
);
self.handle_resize();
}
self.width = width;
}
fn handle_resize(&mut self) {
self.message_heights_width = 0;
self.prefix_sums_width = 0;
}
#[must_use]
pub fn message_height(&self, idx: usize) -> usize {
self.message_heights.get(idx).copied().unwrap_or(0)
}
pub fn set_message_height(&mut self, idx: usize, h: usize) {
if idx >= self.message_heights.len() {
self.message_heights.resize(idx + 1, 0);
}
self.message_heights[idx] = h;
}
pub fn mark_heights_valid(&mut self) {
self.message_heights_width = self.width;
self.dirty_from = None;
}
pub fn mark_message_dirty(&mut self, idx: usize) {
self.dirty_from = Some(self.dirty_from.map_or(idx, |oldest| oldest.min(idx)));
}
pub fn rebuild_prefix_sums(&mut self) {
let n = self.message_heights.len();
if self.prefix_sums_width == self.width && self.height_prefix_sums.len() == n && n > 0 {
let prev = if n >= 2 { self.height_prefix_sums[n - 2] } else { 0 };
self.height_prefix_sums[n - 1] = prev + self.message_heights[n - 1];
return;
}
self.height_prefix_sums.clear();
self.height_prefix_sums.reserve(n);
let mut acc = 0;
for &h in &self.message_heights {
acc += h;
self.height_prefix_sums.push(acc);
}
self.prefix_sums_width = self.width;
}
#[must_use]
pub fn total_message_height(&self) -> usize {
self.height_prefix_sums.last().copied().unwrap_or(0)
}
#[must_use]
pub fn cumulative_height_before(&self, idx: usize) -> usize {
if idx == 0 { 0 } else { self.height_prefix_sums.get(idx - 1).copied().unwrap_or(0) }
}
#[must_use]
pub fn find_first_visible(&self, scroll_offset: usize) -> usize {
if self.height_prefix_sums.is_empty() {
return 0;
}
self.height_prefix_sums
.partition_point(|&h| h <= scroll_offset)
.min(self.message_heights.len().saturating_sub(1))
}
pub fn scroll_up(&mut self, lines: usize) {
self.scroll_target = self.scroll_target.saturating_sub(lines);
self.auto_scroll = false;
}
pub fn scroll_down(&mut self, lines: usize) {
self.scroll_target = self.scroll_target.saturating_add(lines);
}
pub fn engage_auto_scroll(&mut self) {
self.auto_scroll = true;
}
}
impl Default for ChatViewport {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum AppStatus {
Connecting,
Ready,
Thinking,
Running,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionKind {
Chat,
Input,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectionPoint {
pub row: usize,
pub col: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectionState {
pub kind: SelectionKind,
pub start: SelectionPoint,
pub end: SelectionPoint,
pub dragging: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScrollbarDragState {
pub thumb_grab_offset: usize,
}
pub struct ChatMessage {
pub role: MessageRole,
pub blocks: Vec<MessageBlock>,
}
impl ChatMessage {
#[must_use]
pub fn welcome(model_name: &str, cwd: &str) -> Self {
Self {
role: MessageRole::Welcome,
blocks: vec![MessageBlock::Welcome(WelcomeBlock {
model_name: model_name.to_owned(),
cwd: cwd.to_owned(),
cache: BlockCache::default(),
})],
}
}
}
#[derive(Default)]
pub struct BlockCache {
version: u64,
lines: Option<Vec<ratatui::text::Line<'static>>>,
wrapped_height: usize,
wrapped_width: u16,
}
impl BlockCache {
pub fn invalidate(&mut self) {
self.version += 1;
}
#[must_use]
pub fn get(&self) -> Option<&Vec<ratatui::text::Line<'static>>> {
if self.version == 0 { self.lines.as_ref() } else { None }
}
pub fn store(&mut self, lines: Vec<ratatui::text::Line<'static>>) {
self.lines = Some(lines);
self.version = 0;
}
pub fn set_height(&mut self, height: usize, width: u16) {
self.wrapped_height = height;
self.wrapped_width = width;
}
pub fn store_with_height(
&mut self,
lines: Vec<ratatui::text::Line<'static>>,
height: usize,
width: u16,
) {
self.store(lines);
self.set_height(height, width);
}
#[must_use]
pub fn height_at(&self, width: u16) -> Option<usize> {
if self.version == 0 && self.wrapped_width == width {
Some(self.wrapped_height)
} else {
None
}
}
}
#[derive(Default)]
pub struct IncrementalMarkdown {
paragraphs: Vec<(String, Vec<ratatui::text::Line<'static>>)>,
tail: String,
in_code_fence: bool,
scan_offset: usize,
}
impl IncrementalMarkdown {
#[must_use]
pub fn from_complete(text: &str) -> Self {
Self { paragraphs: Vec::new(), tail: text.to_owned(), in_code_fence: false, scan_offset: 0 }
}
pub fn append(&mut self, chunk: &str) {
self.scan_offset = self.scan_offset.min(self.tail.len().saturating_sub(1));
self.tail.push_str(chunk);
self.split_completed_paragraphs();
}
#[must_use]
pub fn full_text(&self) -> String {
let mut out = String::new();
for (src, _) in &self.paragraphs {
out.push_str(src);
out.push_str("\n\n");
}
out.push_str(&self.tail);
out
}
pub fn lines(
&mut self,
render_fn: &impl Fn(&str) -> Vec<ratatui::text::Line<'static>>,
) -> Vec<ratatui::text::Line<'static>> {
let mut out = Vec::new();
for (src, lines) in &mut self.paragraphs {
if lines.is_empty() {
*lines = render_fn(src);
}
out.extend(lines.iter().cloned());
}
if !self.tail.is_empty() {
out.extend(render_fn(&self.tail));
}
out
}
pub fn invalidate_renders(&mut self) {
for (src, lines) in &mut self.paragraphs {
let _ = src; lines.clear();
}
}
pub fn ensure_rendered(
&mut self,
render_fn: &impl Fn(&str) -> Vec<ratatui::text::Line<'static>>,
) {
for (src, lines) in &mut self.paragraphs {
if lines.is_empty() {
*lines = render_fn(src);
}
}
}
fn split_completed_paragraphs(&mut self) {
loop {
let (boundary, fence_state, scanned_to) = self.scan_tail_for_boundary();
if let Some(offset) = boundary {
let completed = self.tail[..offset].to_owned();
self.tail = self.tail[offset + 2..].to_owned();
self.in_code_fence = fence_state;
self.scan_offset = 0;
self.paragraphs.push((completed, Vec::new()));
} else {
self.in_code_fence = fence_state;
self.scan_offset = scanned_to;
break;
}
}
}
fn scan_tail_for_boundary(&self) -> (Option<usize>, bool, usize) {
let bytes = self.tail.as_bytes();
let mut in_fence = self.in_code_fence;
let mut i = self.scan_offset;
while i < bytes.len() {
if (i == 0 || bytes[i - 1] == b'\n') && bytes[i..].starts_with(b"```") {
in_fence = !in_fence;
}
if !in_fence && i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' {
return (Some(i), in_fence, i);
}
i += 1;
}
(None, in_fence, i)
}
}
pub enum MessageBlock {
Text(String, BlockCache, IncrementalMarkdown),
ToolCall(Box<ToolCallInfo>),
Welcome(WelcomeBlock),
}
#[derive(Debug)]
pub enum MessageRole {
User,
Assistant,
System,
Welcome,
}
pub struct WelcomeBlock {
pub model_name: String,
pub cwd: String,
pub cache: BlockCache,
}
pub struct ToolCallInfo {
pub id: String,
pub title: String,
pub kind: acp::ToolKind,
pub status: acp::ToolCallStatus,
pub content: Vec<acp::ToolCallContent>,
pub collapsed: bool,
pub claude_tool_name: Option<String>,
pub hidden: bool,
pub terminal_id: Option<String>,
pub terminal_command: Option<String>,
pub terminal_output: Option<String>,
pub terminal_output_len: usize,
pub cache: BlockCache,
pub pending_permission: Option<InlinePermission>,
}
pub struct InlinePermission {
pub options: Vec<acp::PermissionOption>,
pub response_tx: tokio::sync::oneshot::Sender<acp::RequestPermissionResponse>,
pub selected_index: usize,
pub focused: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
#[test]
fn cache_default_returns_none() {
let cache = BlockCache::default();
assert!(cache.get().is_none());
}
#[test]
fn cache_store_then_get() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("hello")]);
assert!(cache.get().is_some());
assert_eq!(cache.get().unwrap().len(), 1);
}
#[test]
fn cache_invalidate_then_get_returns_none() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("data")]);
cache.invalidate();
assert!(cache.get().is_none());
}
#[test]
fn cache_store_after_invalidate() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("old")]);
cache.invalidate();
assert!(cache.get().is_none());
cache.store(vec![Line::from("new")]);
let lines = cache.get().unwrap();
assert_eq!(lines.len(), 1);
let span_content: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(span_content, "new");
}
#[test]
fn cache_multiple_invalidations() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("data")]);
cache.invalidate();
cache.invalidate();
cache.invalidate();
assert!(cache.get().is_none());
cache.store(vec![Line::from("fresh")]);
assert!(cache.get().is_some());
}
#[test]
fn cache_store_empty_lines() {
let mut cache = BlockCache::default();
cache.store(Vec::new());
let lines = cache.get().unwrap();
assert!(lines.is_empty());
}
#[test]
fn cache_store_overwrite_without_invalidate() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("first")]);
cache.store(vec![Line::from("second"), Line::from("line2")]);
let lines = cache.get().unwrap();
assert_eq!(lines.len(), 2);
let content: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(content, "second");
}
#[test]
fn cache_get_twice_consistent() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("stable")]);
let first = cache.get().unwrap().len();
let second = cache.get().unwrap().len();
assert_eq!(first, second);
}
#[test]
fn cache_store_many_lines() {
let mut cache = BlockCache::default();
let lines: Vec<Line<'static>> =
(0..1000).map(|i| Line::from(Span::raw(format!("line {i}")))).collect();
cache.store(lines);
assert_eq!(cache.get().unwrap().len(), 1000);
}
#[test]
fn cache_invalidate_without_store() {
let mut cache = BlockCache::default();
cache.invalidate();
assert!(cache.get().is_none());
}
#[test]
fn cache_rapid_store_invalidate_cycle() {
let mut cache = BlockCache::default();
for i in 0..50 {
cache.store(vec![Line::from(format!("v{i}"))]);
assert!(cache.get().is_some());
cache.invalidate();
assert!(cache.get().is_none());
}
cache.store(vec![Line::from("final")]);
assert!(cache.get().is_some());
}
#[test]
fn cache_store_styled_lines() {
let mut cache = BlockCache::default();
let line = Line::from(vec![
Span::styled("bold", Style::default().fg(Color::Red)),
Span::raw(" normal "),
Span::styled("blue", Style::default().fg(Color::Blue)),
]);
cache.store(vec![line]);
let lines = cache.get().unwrap();
assert_eq!(lines[0].spans.len(), 3);
}
#[test]
fn cache_version_no_false_fresh_after_many_invalidations() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("data")]);
for _ in 0..10_000 {
cache.invalidate();
}
assert!(cache.get().is_none());
}
#[test]
fn cache_alternating_invalidate_store() {
let mut cache = BlockCache::default();
for i in 0..100 {
cache.invalidate();
assert!(cache.get().is_none(), "stale after invalidate at iter {i}");
cache.store(vec![Line::from(format!("v{i}"))]);
assert!(cache.get().is_some(), "fresh after store at iter {i}");
}
}
#[test]
fn cache_height_default_returns_none() {
let cache = BlockCache::default();
assert!(cache.height_at(80).is_none());
}
#[test]
fn cache_store_with_height_then_height_at() {
let mut cache = BlockCache::default();
cache.store_with_height(vec![Line::from("hello")], 1, 80);
assert_eq!(cache.height_at(80), Some(1));
assert!(cache.get().is_some());
}
#[test]
fn cache_height_at_wrong_width_returns_none() {
let mut cache = BlockCache::default();
cache.store_with_height(vec![Line::from("hello")], 1, 80);
assert!(cache.height_at(120).is_none());
}
#[test]
fn cache_height_invalidated_returns_none() {
let mut cache = BlockCache::default();
cache.store_with_height(vec![Line::from("hello")], 1, 80);
cache.invalidate();
assert!(cache.height_at(80).is_none());
}
#[test]
fn cache_store_without_height_has_no_height() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("hello")]);
assert!(cache.height_at(80).is_none());
}
#[test]
fn cache_store_with_height_overwrite() {
let mut cache = BlockCache::default();
cache.store_with_height(vec![Line::from("old")], 1, 80);
cache.invalidate();
cache.store_with_height(vec![Line::from("new long line")], 3, 120);
assert_eq!(cache.height_at(120), Some(3));
assert!(cache.height_at(80).is_none());
}
#[test]
fn cache_set_height_after_store() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("hello")]);
assert!(cache.height_at(80).is_none()); cache.set_height(1, 80);
assert_eq!(cache.height_at(80), Some(1));
assert!(cache.get().is_some()); }
#[test]
fn cache_set_height_update_width() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("hello world")]);
cache.set_height(1, 80);
assert_eq!(cache.height_at(80), Some(1));
cache.set_height(2, 40);
assert_eq!(cache.height_at(40), Some(2));
assert!(cache.height_at(80).is_none()); }
#[test]
fn cache_set_height_invalidate_clears_height() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("data")]);
cache.set_height(3, 80);
cache.invalidate();
assert!(cache.height_at(80).is_none()); }
#[test]
fn cache_set_height_on_invalidated_cache_returns_none() {
let mut cache = BlockCache::default();
cache.store(vec![Line::from("data")]);
cache.invalidate(); cache.set_height(5, 80);
assert!(cache.height_at(80).is_none());
}
#[test]
fn cache_store_then_set_height_matches_store_with_height() {
let mut cache_a = BlockCache::default();
cache_a.store(vec![Line::from("test")]);
cache_a.set_height(2, 100);
let mut cache_b = BlockCache::default();
cache_b.store_with_height(vec![Line::from("test")], 2, 100);
assert_eq!(cache_a.height_at(100), cache_b.height_at(100));
assert_eq!(cache_a.get().unwrap().len(), cache_b.get().unwrap().len());
}
fn make_test_app() -> App {
App::test_default()
}
#[test]
fn lookup_missing_returns_none() {
let app = make_test_app();
assert!(app.lookup_tool_call("nonexistent").is_none());
}
#[test]
fn index_and_lookup() {
let mut app = make_test_app();
app.index_tool_call("tc-123".into(), 2, 5);
assert_eq!(app.lookup_tool_call("tc-123"), Some((2, 5)));
}
#[test]
fn index_overwrite_existing() {
let mut app = make_test_app();
app.index_tool_call("tc-1".into(), 0, 0);
app.index_tool_call("tc-1".into(), 5, 10);
assert_eq!(app.lookup_tool_call("tc-1"), Some((5, 10)));
}
#[test]
fn index_empty_string_id() {
let mut app = make_test_app();
app.index_tool_call(String::new(), 1, 2);
assert_eq!(app.lookup_tool_call(""), Some((1, 2)));
}
#[test]
fn index_stress_1000_entries() {
let mut app = make_test_app();
for i in 0..1000 {
app.index_tool_call(format!("tc-{i}"), i, i * 2);
}
assert_eq!(app.lookup_tool_call("tc-0"), Some((0, 0)));
assert_eq!(app.lookup_tool_call("tc-500"), Some((500, 1000)));
assert_eq!(app.lookup_tool_call("tc-999"), Some((999, 1998)));
assert!(app.lookup_tool_call("tc-1000").is_none());
}
#[test]
fn index_unicode_id() {
let mut app = make_test_app();
app.index_tool_call("\u{1F600}-tool".into(), 3, 7);
assert_eq!(app.lookup_tool_call("\u{1F600}-tool"), Some((3, 7)));
}
#[test]
fn active_task_insert_remove() {
let mut app = make_test_app();
app.insert_active_task("task-1".into());
assert!(app.active_task_ids.contains("task-1"));
app.remove_active_task("task-1");
assert!(!app.active_task_ids.contains("task-1"));
}
#[test]
fn remove_nonexistent_task_is_noop() {
let mut app = make_test_app();
app.remove_active_task("does-not-exist");
assert!(app.active_task_ids.is_empty());
}
#[test]
fn active_task_insert_duplicate() {
let mut app = make_test_app();
app.insert_active_task("task-1".into());
app.insert_active_task("task-1".into());
assert_eq!(app.active_task_ids.len(), 1);
app.remove_active_task("task-1");
assert!(app.active_task_ids.is_empty());
}
#[test]
fn active_task_insert_many_remove_out_of_order() {
let mut app = make_test_app();
for i in 0..100 {
app.insert_active_task(format!("task-{i}"));
}
assert_eq!(app.active_task_ids.len(), 100);
for i in (0..100).rev() {
app.remove_active_task(&format!("task-{i}"));
}
assert!(app.active_task_ids.is_empty());
}
#[test]
fn active_task_interleaved_insert_remove() {
let mut app = make_test_app();
app.insert_active_task("a".into());
app.insert_active_task("b".into());
app.remove_active_task("a");
app.insert_active_task("c".into());
assert!(!app.active_task_ids.contains("a"));
assert!(app.active_task_ids.contains("b"));
assert!(app.active_task_ids.contains("c"));
assert_eq!(app.active_task_ids.len(), 2);
}
#[test]
fn active_task_remove_from_empty_repeatedly() {
let mut app = make_test_app();
for i in 0..100 {
app.remove_active_task(&format!("ghost-{i}"));
}
assert!(app.active_task_ids.is_empty());
}
fn test_render(src: &str) -> Vec<Line<'static>> {
src.lines().map(|l| Line::from(l.to_owned())).collect()
}
#[test]
fn incr_default_empty() {
let incr = IncrementalMarkdown::default();
assert!(incr.full_text().is_empty());
}
#[test]
fn incr_from_complete() {
let incr = IncrementalMarkdown::from_complete("hello world");
assert_eq!(incr.full_text(), "hello world");
}
#[test]
fn incr_append_single_chunk() {
let mut incr = IncrementalMarkdown::default();
incr.append("hello");
assert_eq!(incr.full_text(), "hello");
}
#[test]
fn incr_append_no_paragraph_break() {
let mut incr = IncrementalMarkdown::default();
incr.append("line1\nline2\nline3");
assert_eq!(incr.paragraphs.len(), 0);
assert_eq!(incr.tail, "line1\nline2\nline3");
}
#[test]
fn incr_append_splits_on_double_newline() {
let mut incr = IncrementalMarkdown::default();
incr.append("para1\n\npara2");
assert_eq!(incr.paragraphs.len(), 1);
assert_eq!(incr.paragraphs[0].0, "para1");
assert_eq!(incr.tail, "para2");
}
#[test]
fn incr_append_multiple_paragraphs() {
let mut incr = IncrementalMarkdown::default();
incr.append("p1\n\np2\n\np3\n\np4");
assert_eq!(incr.paragraphs.len(), 3);
assert_eq!(incr.paragraphs[0].0, "p1");
assert_eq!(incr.paragraphs[1].0, "p2");
assert_eq!(incr.paragraphs[2].0, "p3");
assert_eq!(incr.tail, "p4");
}
#[test]
fn incr_append_incremental_chunks() {
let mut incr = IncrementalMarkdown::default();
incr.append("hel");
incr.append("lo\n");
incr.append("\nworld");
assert_eq!(incr.paragraphs.len(), 1);
assert_eq!(incr.paragraphs[0].0, "hello");
assert_eq!(incr.tail, "world");
}
#[test]
fn incr_code_fence_preserves_double_newlines() {
let mut incr = IncrementalMarkdown::default();
incr.append("before\n\n```\ncode\n\nmore code\n```\n\nafter");
assert_eq!(incr.paragraphs.len(), 2);
assert_eq!(incr.paragraphs[0].0, "before");
assert_eq!(incr.paragraphs[1].0, "```\ncode\n\nmore code\n```");
assert_eq!(incr.tail, "after");
}
#[test]
fn incr_code_fence_incremental() {
let mut incr = IncrementalMarkdown::default();
incr.append("text\n\n```\nfn main() {\n");
assert_eq!(incr.paragraphs.len(), 1); assert!(incr.in_code_fence); incr.append(" println!(\"hi\");\n\n}\n```\n\nafter");
assert!(!incr.in_code_fence); assert_eq!(incr.tail, "after");
}
#[test]
fn incr_full_text_reconstruction() {
let mut incr = IncrementalMarkdown::default();
incr.append("p1\n\np2\n\np3");
assert_eq!(incr.full_text(), "p1\n\np2\n\np3");
}
#[test]
fn incr_lines_renders_all() {
let mut incr = IncrementalMarkdown::default();
incr.append("line1\n\nline2\n\nline3");
let lines = incr.lines(&test_render);
assert_eq!(lines.len(), 3);
}
#[test]
fn incr_lines_caches_paragraphs() {
let mut incr = IncrementalMarkdown::default();
incr.append("p1\n\np2\n\ntail");
let _ = incr.lines(&test_render);
assert!(!incr.paragraphs[0].1.is_empty());
assert!(!incr.paragraphs[1].1.is_empty());
let lines = incr.lines(&test_render);
assert_eq!(lines.len(), 3);
}
#[test]
fn incr_ensure_rendered_fills_empty() {
let mut incr = IncrementalMarkdown::default();
incr.append("p1\n\np2\n\ntail");
assert!(incr.paragraphs[0].1.is_empty());
incr.ensure_rendered(&test_render);
assert!(!incr.paragraphs[0].1.is_empty());
assert!(!incr.paragraphs[1].1.is_empty());
}
#[test]
fn incr_invalidate_clears_renders() {
let mut incr = IncrementalMarkdown::default();
incr.append("p1\n\np2\n\ntail");
incr.ensure_rendered(&test_render);
assert!(!incr.paragraphs[0].1.is_empty());
incr.invalidate_renders();
assert!(incr.paragraphs[0].1.is_empty());
assert!(incr.paragraphs[1].1.is_empty());
}
#[test]
fn incr_streaming_simulation() {
let mut incr = IncrementalMarkdown::default();
let chunks = ["Here is ", "some text.\n", "\nNext para", "graph here.\n\n", "Final."];
for chunk in chunks {
incr.append(chunk);
}
assert_eq!(incr.paragraphs.len(), 2);
assert_eq!(incr.paragraphs[0].0, "Here is some text.");
assert_eq!(incr.paragraphs[1].0, "Next paragraph here.");
assert_eq!(incr.tail, "Final.");
}
#[test]
fn incr_empty_paragraphs() {
let mut incr = IncrementalMarkdown::default();
incr.append("\n\n\n\n");
assert!(!incr.paragraphs.is_empty());
}
#[test]
fn viewport_new_defaults() {
let vp = ChatViewport::new();
assert_eq!(vp.scroll_offset, 0);
assert_eq!(vp.scroll_target, 0);
assert!(vp.auto_scroll);
assert_eq!(vp.width, 0);
assert!(vp.message_heights.is_empty());
assert!(vp.dirty_from.is_none());
assert!(vp.height_prefix_sums.is_empty());
}
#[test]
fn viewport_on_frame_sets_width() {
let mut vp = ChatViewport::new();
vp.on_frame(80);
assert_eq!(vp.width, 80);
}
#[test]
fn viewport_on_frame_resize_invalidates() {
let mut vp = ChatViewport::new();
vp.on_frame(80);
vp.set_message_height(0, 10);
vp.set_message_height(1, 20);
vp.rebuild_prefix_sums();
vp.on_frame(120);
assert_eq!(vp.message_height(0), 10); assert_eq!(vp.message_height(1), 20); assert_eq!(vp.message_heights_width, 0); assert_eq!(vp.prefix_sums_width, 0); }
#[test]
fn viewport_on_frame_same_width_no_invalidation() {
let mut vp = ChatViewport::new();
vp.on_frame(80);
vp.set_message_height(0, 10);
vp.on_frame(80); assert_eq!(vp.message_height(0), 10); }
#[test]
fn viewport_message_height_set_and_get() {
let mut vp = ChatViewport::new();
vp.on_frame(80);
vp.set_message_height(0, 5);
vp.set_message_height(1, 10);
assert_eq!(vp.message_height(0), 5);
assert_eq!(vp.message_height(1), 10);
assert_eq!(vp.message_height(2), 0); }
#[test]
fn viewport_message_height_grows_vec() {
let mut vp = ChatViewport::new();
vp.on_frame(80);
vp.set_message_height(5, 42);
assert_eq!(vp.message_heights.len(), 6);
assert_eq!(vp.message_height(5), 42);
assert_eq!(vp.message_height(3), 0); }
#[test]
fn viewport_mark_message_dirty_tracks_oldest_index() {
let mut vp = ChatViewport::new();
vp.mark_message_dirty(5);
vp.mark_message_dirty(2);
vp.mark_message_dirty(7);
assert_eq!(vp.dirty_from, Some(2));
}
#[test]
fn viewport_mark_heights_valid_clears_dirty_index() {
let mut vp = ChatViewport::new();
vp.on_frame(80);
vp.mark_message_dirty(1);
assert_eq!(vp.dirty_from, Some(1));
vp.mark_heights_valid();
assert!(vp.dirty_from.is_none());
}
#[test]
fn viewport_prefix_sums_basic() {
let mut vp = ChatViewport::new();
vp.on_frame(80);
vp.set_message_height(0, 5);
vp.set_message_height(1, 10);
vp.set_message_height(2, 3);
vp.rebuild_prefix_sums();
assert_eq!(vp.total_message_height(), 18);
assert_eq!(vp.cumulative_height_before(0), 0);
assert_eq!(vp.cumulative_height_before(1), 5);
assert_eq!(vp.cumulative_height_before(2), 15);
}
#[test]
fn viewport_prefix_sums_streaming_fast_path() {
let mut vp = ChatViewport::new();
vp.on_frame(80);
vp.set_message_height(0, 5);
vp.set_message_height(1, 10);
vp.rebuild_prefix_sums();
assert_eq!(vp.total_message_height(), 15);
vp.set_message_height(1, 20);
vp.rebuild_prefix_sums(); assert_eq!(vp.total_message_height(), 25);
assert_eq!(vp.cumulative_height_before(1), 5);
}
#[test]
fn viewport_find_first_visible() {
let mut vp = ChatViewport::new();
vp.on_frame(80);
vp.set_message_height(0, 10);
vp.set_message_height(1, 10);
vp.set_message_height(2, 10);
vp.rebuild_prefix_sums();
assert_eq!(vp.find_first_visible(0), 0);
assert_eq!(vp.find_first_visible(10), 1);
assert_eq!(vp.find_first_visible(15), 1);
assert_eq!(vp.find_first_visible(20), 2);
}
#[test]
fn viewport_find_first_visible_handles_offsets_before_first_boundary() {
let mut vp = ChatViewport::new();
vp.on_frame(80);
vp.set_message_height(0, 10);
vp.set_message_height(1, 10);
vp.rebuild_prefix_sums();
assert_eq!(vp.find_first_visible(0), 0);
assert_eq!(vp.find_first_visible(5), 0);
assert_eq!(vp.find_first_visible(15), 1);
}
#[test]
fn viewport_scroll_up_down() {
let mut vp = ChatViewport::new();
vp.scroll_target = 20;
vp.auto_scroll = true;
vp.scroll_up(5);
assert_eq!(vp.scroll_target, 15);
assert!(!vp.auto_scroll);
vp.scroll_down(3);
assert_eq!(vp.scroll_target, 18);
assert!(!vp.auto_scroll); }
#[test]
fn viewport_scroll_up_saturates() {
let mut vp = ChatViewport::new();
vp.scroll_target = 2;
vp.scroll_up(10);
assert_eq!(vp.scroll_target, 0);
}
#[test]
fn viewport_engage_auto_scroll() {
let mut vp = ChatViewport::new();
vp.auto_scroll = false;
vp.engage_auto_scroll();
assert!(vp.auto_scroll);
}
#[test]
fn viewport_default_eq_new() {
let a = ChatViewport::new();
let b = ChatViewport::default();
assert_eq!(a.width, b.width);
assert_eq!(a.auto_scroll, b.auto_scroll);
assert_eq!(a.message_heights.len(), b.message_heights.len());
}
#[test]
fn focus_owner_defaults_to_input() {
let app = make_test_app();
assert_eq!(app.focus_owner(), FocusOwner::Input);
}
#[test]
fn focus_owner_todo_when_panel_open_and_focused() {
let mut app = make_test_app();
app.todos.push(TodoItem {
content: "Task".into(),
status: TodoStatus::Pending,
active_form: String::new(),
});
app.show_todo_panel = true;
app.claim_focus_target(FocusTarget::TodoList);
assert_eq!(app.focus_owner(), FocusOwner::TodoList);
}
#[test]
fn focus_owner_permission_overrides_todo() {
let mut app = make_test_app();
app.todos.push(TodoItem {
content: "Task".into(),
status: TodoStatus::Pending,
active_form: String::new(),
});
app.show_todo_panel = true;
app.claim_focus_target(FocusTarget::TodoList);
app.pending_permission_ids.push("perm-1".into());
app.claim_focus_target(FocusTarget::Permission);
assert_eq!(app.focus_owner(), FocusOwner::Permission);
}
#[test]
fn focus_owner_mention_overrides_permission_and_todo() {
let mut app = make_test_app();
app.todos.push(TodoItem {
content: "Task".into(),
status: TodoStatus::Pending,
active_form: String::new(),
});
app.show_todo_panel = true;
app.claim_focus_target(FocusTarget::TodoList);
app.pending_permission_ids.push("perm-1".into());
app.claim_focus_target(FocusTarget::Permission);
app.mention = Some(mention::MentionState {
trigger_row: 0,
trigger_col: 0,
query: String::new(),
candidates: Vec::new(),
dialog: super::super::dialog::DialogState::default(),
});
app.claim_focus_target(FocusTarget::Mention);
assert_eq!(app.focus_owner(), FocusOwner::Mention);
}
#[test]
fn focus_owner_falls_back_to_input_when_claim_is_not_available() {
let mut app = make_test_app();
app.claim_focus_target(FocusTarget::TodoList);
assert_eq!(app.focus_owner(), FocusOwner::Input);
}
#[test]
fn claim_and_release_focus_target() {
let mut app = make_test_app();
app.todos.push(TodoItem {
content: "Task".into(),
status: TodoStatus::Pending,
active_form: String::new(),
});
app.show_todo_panel = true;
app.claim_focus_target(FocusTarget::TodoList);
assert_eq!(app.focus_owner(), FocusOwner::TodoList);
app.release_focus_target(FocusTarget::TodoList);
assert_eq!(app.focus_owner(), FocusOwner::Input);
}
#[test]
fn latest_claim_wins_across_equal_targets() {
let mut app = make_test_app();
app.todos.push(TodoItem {
content: "Task".into(),
status: TodoStatus::Pending,
active_form: String::new(),
});
app.show_todo_panel = true;
app.mention = Some(mention::MentionState {
trigger_row: 0,
trigger_col: 0,
query: String::new(),
candidates: Vec::new(),
dialog: super::super::dialog::DialogState::default(),
});
app.pending_permission_ids.push("perm-1".into());
app.claim_focus_target(FocusTarget::TodoList);
assert_eq!(app.focus_owner(), FocusOwner::TodoList);
app.claim_focus_target(FocusTarget::Permission);
assert_eq!(app.focus_owner(), FocusOwner::Permission);
app.claim_focus_target(FocusTarget::Mention);
assert_eq!(app.focus_owner(), FocusOwner::Mention);
app.release_focus_target(FocusTarget::Mention);
assert_eq!(app.focus_owner(), FocusOwner::Permission);
}
}