Skip to main content

mermaid_cli/tui/
app.rs

1//! Application coordinator
2//!
3//! Thin coordinator that composes state modules. All state is delegated to
4//! focused modules in src/tui/state/.
5
6use std::collections::VecDeque;
7use std::sync::Arc;
8use tokio::task::JoinHandle;
9
10use super::state::{
11    AppState, AttachmentState, ConversationState, ErrorEntry, ErrorSeverity, GenerationStatus,
12    InputBuffer, ModelState, OperationState, StatusState, UIState,
13};
14use crate::constants::{MAX_RESPONSE_CHARS, UI_ERROR_LOG_MAX_SIZE};
15use crate::models::{ChatMessage, MessageRole, Model, ModelConfig, StreamCallback, StreamEvent};
16use crate::session::{ConversationHistory, ConversationManager};
17use crate::utils::MutexExt;
18
19/// Truncation marker appended to the response buffer once the size cap is
20/// hit. Public to the module so tests can assert against it.
21const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: Response exceeded size limit]\n";
22
23/// Append `chunk` to `buf`, enforcing `cap` bytes (char-boundary safe). Once
24/// the cap is tripped (`*truncated` set to `true`), subsequent calls become
25/// no-ops — preventing the O(n)-per-chunk re-truncation and duplicated
26/// markers that the original `push_response` exhibited under runaway model
27/// output. Returns `true` if this call performed the truncation.
28fn push_with_cap(buf: &mut String, truncated: &mut bool, chunk: &str, cap: usize) -> bool {
29    if *truncated {
30        return false;
31    }
32    buf.push_str(chunk);
33    if buf.len() > cap {
34        let end = buf.floor_char_boundary(cap);
35        buf.truncate(end);
36        buf.push_str(TRUNCATION_MARKER);
37        *truncated = true;
38        true
39    } else {
40        false
41    }
42}
43
44/// Application state coordinator
45pub struct App {
46    /// User input buffer
47    pub input: InputBuffer,
48    /// Is the app running?
49    pub running: bool,
50    /// Current working directory
51    pub working_dir: String,
52    /// Error log - keeps last N errors for visibility
53    pub error_log: VecDeque<ErrorEntry>,
54    /// State machine for application lifecycle
55    pub app_state: AppState,
56
57    /// Model state - LLM configuration
58    pub model_state: ModelState,
59    /// UI state - visual presentation and widget states
60    pub ui_state: UIState,
61    /// Session state - conversation history and persistence
62    pub session_state: ConversationState,
63    /// Operation state - file reading and tool calls
64    pub operation_state: OperationState,
65    /// Status state - UI status messages
66    pub status_state: StatusState,
67    /// Attachment state - pending image attachments
68    pub attachment_state: AttachmentState,
69    /// MCP tool definitions in Ollama JSON format (injected at startup or background init)
70    pub mcp_tools: Vec<serde_json::Value>,
71    /// Background MCP server initialization task.
72    /// Polled in the event loop; when done, mcp_tools and global manager are set.
73    pub mcp_init_task: Option<JoinHandle<McpInitResult>>,
74    /// Project instructions auto-loaded from MERMAID.md (Step 5h).
75    /// `None` when no MERMAID.md exists in the bounded walk from cwd.
76    /// Refreshed before every model call by `loop_coordinator::call_model`.
77    pub instructions: Option<crate::app::instructions::LoadedInstructions>,
78}
79
80/// Result of background MCP server initialization
81pub struct McpInitResult {
82    pub tools: Vec<serde_json::Value>,
83    pub manager: Option<Arc<crate::mcp::McpServerManager>>,
84}
85
86impl App {
87    /// Create a new app instance
88    pub fn new(model: Box<dyn Model>, model_id: String, base_config: ModelConfig) -> Self {
89        let working_dir = std::env::current_dir()
90            .map(|p| p.to_string_lossy().to_string())
91            .unwrap_or_else(|_| ".".to_string());
92
93        // Initialize model state
94        let model_state = ModelState::new(model, model_id, base_config);
95
96        // Initialize conversation manager for the current directory
97        let conversation_manager = ConversationManager::new(&working_dir).ok();
98        let current_conversation = conversation_manager
99            .as_ref()
100            .map(|_| ConversationHistory::new(working_dir.clone(), model_state.model_name.clone()));
101
102        // Load input history from conversation if available
103        let input_history: std::collections::VecDeque<String> = current_conversation
104            .as_ref()
105            .map(|conv| conv.input_history.clone())
106            .unwrap_or_default();
107
108        // Initialize input buffer with persisted history
109        let mut input = InputBuffer::new();
110        input.load_history(input_history);
111
112        // Initialize UIState
113        let ui_state = UIState::new();
114
115        // Initialize ConversationState with conversation management
116        let session_state =
117            ConversationState::with_conversation(conversation_manager, current_conversation);
118
119        Self {
120            input,
121            running: true,
122            working_dir,
123            error_log: VecDeque::new(),
124            app_state: AppState::Idle,
125            model_state,
126            ui_state,
127            session_state,
128            operation_state: OperationState::new(),
129            status_state: StatusState::new(),
130            attachment_state: AttachmentState::new(),
131            mcp_tools: Vec::new(),
132            mcp_init_task: None,
133            instructions: None,
134        }
135    }
136
137    /// Build a ModelConfig with MCP tools included.
138    pub fn build_model_config(&self) -> crate::models::ModelConfig {
139        let mut config = self.model_state.build_config();
140        config.mcp_tools = self.mcp_tools.clone();
141        config
142    }
143
144    /// Poll for completed MCP background initialization (non-blocking).
145    ///
146    /// Returns immediately if init is still in progress or already done.
147    /// When init completes, sets mcp_tools on self and registers the global
148    /// MCP manager so tool calls can be dispatched.
149    ///
150    /// Called from both the main event loop and the agent loop so that MCP
151    /// tools become available to the model as soon as servers are ready,
152    /// even mid-agent-loop.
153    pub async fn poll_mcp_init(&mut self) {
154        let ready = self.mcp_init_task.as_ref().is_some_and(|t| t.is_finished());
155        if !ready {
156            return;
157        }
158        if let Some(task) = self.mcp_init_task.take()
159            && let Ok(result) = task.await
160        {
161            if !result.tools.is_empty() {
162                self.mcp_tools = result.tools;
163            }
164            if let Some(manager) = result.manager {
165                crate::agents::set_mcp_manager(manager);
166            }
167        }
168        // Mark complete whether the task succeeded or panicked, so waiters unblock
169        crate::agents::mark_mcp_init_complete();
170    }
171
172    // ===== Message Management =====
173
174    /// Add a message to the chat (extracts thinking blocks automatically)
175    pub fn add_message(&mut self, role: MessageRole, content: String) {
176        self.add_message_with_images(role, content, None);
177    }
178
179    /// Add a message with optional image attachments
180    pub fn add_message_with_images(
181        &mut self,
182        role: MessageRole,
183        content: String,
184        images: Option<Vec<String>>,
185    ) {
186        let mut message = match role {
187            MessageRole::User => ChatMessage::user(content),
188            MessageRole::Assistant => ChatMessage::assistant(content),
189            MessageRole::System => ChatMessage::system(content),
190            MessageRole::Tool => ChatMessage::tool("", "", content),
191        };
192        let (thinking, answer) = ChatMessage::extract_thinking(&message.content);
193        message.content = answer;
194        message.thinking = thinking;
195        if let Some(imgs) = images {
196            message = message.with_images(imgs);
197        }
198        self.commit_message(message);
199    }
200
201    /// Add an assistant message with tool_calls attached
202    pub fn add_assistant_message_with_tool_calls(
203        &mut self,
204        content: String,
205        tool_calls: Vec<crate::models::ToolCall>,
206    ) {
207        let mut message = ChatMessage::assistant(content).with_tool_calls(tool_calls);
208        let (thinking, answer) = ChatMessage::extract_thinking(&message.content);
209        message.content = answer;
210        message.thinking = thinking;
211        self.commit_message(message);
212    }
213
214    /// Add a tool result message
215    pub fn add_tool_result(&mut self, tool_call_id: String, tool_name: String, content: String) {
216        let message = ChatMessage::tool(tool_call_id, tool_name, content);
217        self.commit_message(message);
218    }
219
220    /// Commit a message to session state and conversation history
221    pub fn commit_message(&mut self, message: ChatMessage) {
222        self.session_state.messages.push(message.clone());
223        if let Some(ref mut conv) = self.session_state.current_conversation {
224            conv.add_messages(&[message]);
225        }
226    }
227
228    /// Clear the input buffer
229    pub fn clear_input(&mut self) {
230        self.input.clear();
231    }
232
233    // ===== Status Management =====
234
235    /// Set status message
236    pub fn set_status(&mut self, message: impl Into<String>) {
237        self.status_state.set(message);
238    }
239
240    /// Clear status message
241    pub fn clear_status(&mut self) {
242        self.status_state.clear();
243    }
244
245    // ===== Error Management =====
246
247    /// Display an error consistently across the UI
248    pub fn display_error(&mut self, summary: impl Into<String>, detail: impl Into<String>) {
249        let summary = summary.into();
250        let detail = detail.into();
251
252        self.set_status(format!("[Error] {}", summary));
253
254        if detail.is_empty() {
255            self.add_message(MessageRole::System, format!("Error: {}", summary));
256        } else {
257            self.add_message(MessageRole::System, detail);
258        }
259    }
260
261    /// Display an error with just a message
262    pub fn display_error_simple(&mut self, message: impl Into<String>) {
263        let message = message.into();
264        self.display_error(message.clone(), message);
265    }
266
267    /// Log an error to the error log
268    pub fn log_error(&mut self, entry: ErrorEntry) {
269        self.status_state.set(entry.display());
270        self.error_log.push_back(entry);
271        if self.error_log.len() > UI_ERROR_LOG_MAX_SIZE {
272            self.error_log.pop_front(); // O(1) instead of O(n)
273        }
274    }
275
276    /// Log a simple error message
277    pub fn log_error_msg(&mut self, severity: ErrorSeverity, msg: impl Into<String>) {
278        self.log_error(ErrorEntry::new(severity, msg.into()));
279    }
280
281    /// Log error with context
282    pub fn log_error_with_context(
283        &mut self,
284        severity: ErrorSeverity,
285        msg: impl Into<String>,
286        context: impl Into<String>,
287    ) {
288        self.log_error(ErrorEntry::with_context(
289            severity,
290            msg.into(),
291            context.into(),
292        ));
293    }
294
295    /// Get recent errors
296    pub fn recent_errors(&self, count: usize) -> Vec<&ErrorEntry> {
297        self.error_log.iter().rev().take(count).collect()
298    }
299
300    // ===== Terminal =====
301
302    /// Set terminal window title
303    pub fn set_terminal_title(&self, title: &str) {
304        use crossterm::{execute, terminal::SetTitle};
305        use std::io::stdout;
306        let _ = execute!(stdout(), SetTitle(title));
307    }
308
309    // ===== Title Generation =====
310
311    /// Spawn title generation as a background task (non-blocking).
312    /// Returns a JoinHandle the caller can poll with `is_finished()`.
313    pub fn spawn_title_generation(&self) -> Option<tokio::task::JoinHandle<Option<String>>> {
314        if self.session_state.conversation_title.is_some() || self.session_state.messages.len() < 2
315        {
316            return None;
317        }
318
319        let mut summary = String::new();
320        for msg in self
321            .session_state
322            .messages
323            .iter()
324            .filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
325            .take(4)
326        {
327            let role = if msg.role == MessageRole::User {
328                "User"
329            } else {
330                "Assistant"
331            };
332            summary.push_str(&format!(
333                "{}: {}\n\n",
334                role,
335                msg.content.chars().take(200).collect::<String>()
336            ));
337        }
338
339        let model = self.model_state.model.clone();
340        let mut config = self.build_model_config();
341        // Title generation is a quick utility call — no reasoning needed.
342        config.reasoning = crate::models::ReasoningLevel::None;
343
344        Some(tokio::spawn(async move {
345            let prompt = format!(
346                "Based on this conversation, generate a short, descriptive title (2-4 words maximum, no quotes):\n\n{}\n\nTitle:",
347                summary
348            );
349            // `std::sync::Mutex` (via MutexExt's `lock_mut_safe`) matches
350            // the rest of the codebase and avoids the `try_lock` race the
351            // earlier `tokio::sync::Mutex` had — accumulator never drops
352            // a chunk because the lock was momentarily contended. The
353            // closure stays `Send + Sync` because `std::sync::Mutex<String>`
354            // is both.
355            let buf = Arc::new(std::sync::Mutex::new(String::new()));
356            let buf_clone = Arc::clone(&buf);
357            let callback: StreamCallback = Arc::new(move |event| {
358                if let StreamEvent::Text(chunk) = event {
359                    buf_clone.lock_mut_safe().push_str(&chunk);
360                }
361                // Reasoning / tool calls / done are irrelevant for title
362                // generation — we only want the model's literal text reply.
363            });
364
365            let model = model.read().await;
366            if model
367                .chat(&[ChatMessage::user(prompt)], &config, Some(callback))
368                .await
369                .is_ok()
370            {
371                let raw = buf.lock_mut_safe();
372                let title: String = raw
373                    .lines()
374                    .next()
375                    .unwrap_or(&raw)
376                    .trim()
377                    .trim_matches(|c| c == '"' || c == '\'' || c == '.' || c == ',')
378                    .chars()
379                    .take(50)
380                    .collect();
381                if !title.is_empty() {
382                    return Some(title);
383                }
384            }
385            None
386        }))
387    }
388
389    // ===== Scrolling =====
390
391    pub fn scroll_up(&mut self, amount: u16) {
392        self.ui_state.chat_state.scroll_up(amount);
393    }
394
395    pub fn scroll_down(&mut self, amount: u16) {
396        self.ui_state.chat_state.scroll_down(amount);
397    }
398
399    // ===== Lifecycle =====
400
401    pub fn quit(&mut self) {
402        self.running = false;
403    }
404
405    // ===== Message History =====
406
407    /// Filter and prepare messages for model API calls.
408    /// Includes User, Assistant, and Tool messages for proper agent loop.
409    /// Injects timestamp context into User messages for the model's temporal awareness.
410    /// Step 5f Wave 5: prunes stale screenshots via `prune_stale_screenshots`.
411    fn prepare_api_messages(&self) -> Vec<ChatMessage> {
412        let prepared: Vec<ChatMessage> = self
413            .session_state
414            .messages
415            .iter()
416            .filter(|msg| {
417                msg.role == MessageRole::User
418                    || msg.role == MessageRole::Assistant
419                    || msg.role == MessageRole::Tool
420            })
421            .map(|msg| {
422                if msg.role == MessageRole::User {
423                    let ts = msg.timestamp.format("%Y-%m-%d %H:%M:%S %Z").to_string();
424                    let mut m = msg.clone();
425                    m.content = format!("[Sent at: {}]\n{}", ts, m.content);
426                    m
427                } else {
428                    msg.clone()
429                }
430            })
431            .collect();
432
433        prune_stale_screenshots(prepared, crate::constants::MAX_RETAINED_SCREENSHOTS)
434    }
435
436    /// Build message history for model API calls (all messages, no truncation)
437    pub fn build_message_history(&self) -> Vec<ChatMessage> {
438        self.prepare_api_messages()
439    }
440
441    pub fn build_managed_message_history(
442        &self,
443        max_context_tokens: usize,
444        reserve_tokens: usize,
445    ) -> Vec<ChatMessage> {
446        use crate::utils::Tokenizer;
447
448        let tokenizer = Tokenizer::new(&self.model_state.model_name);
449        let available_tokens = max_context_tokens.saturating_sub(reserve_tokens);
450
451        let all_messages = self.prepare_api_messages();
452
453        if all_messages.is_empty() {
454            return Vec::new();
455        }
456
457        let messages_for_counting: Vec<(String, String)> = all_messages
458            .iter()
459            .map(|msg| {
460                let role = match msg.role {
461                    MessageRole::User => "user",
462                    MessageRole::Assistant => "assistant",
463                    MessageRole::System => "system",
464                    MessageRole::Tool => "tool",
465                };
466                (role.to_string(), msg.content.clone())
467            })
468            .collect();
469
470        let total_tokens = tokenizer
471            .count_chat_tokens(&messages_for_counting)
472            .unwrap_or_else(|_| all_messages.iter().map(|m| m.content.len() / 4).sum());
473
474        if total_tokens <= available_tokens {
475            return all_messages;
476        }
477
478        let mut kept_messages = Vec::new();
479        let mut current_tokens = 0;
480
481        for msg in all_messages.iter().rev() {
482            let msg_text = vec![(
483                match msg.role {
484                    MessageRole::User => "user",
485                    MessageRole::Assistant => "assistant",
486                    MessageRole::System => "system",
487                    MessageRole::Tool => "tool",
488                }
489                .to_string(),
490                msg.content.clone(),
491            )];
492
493            let msg_tokens = tokenizer
494                .count_chat_tokens(&msg_text)
495                .unwrap_or(msg.content.len() / 4);
496
497            if current_tokens + msg_tokens <= available_tokens {
498                kept_messages.push(msg.clone());
499                current_tokens += msg_tokens;
500            } else if kept_messages.len() < 2 {
501                kept_messages.push(msg.clone());
502                break;
503            } else {
504                break;
505            }
506        }
507
508        kept_messages.reverse();
509        kept_messages
510    }
511
512    // ===== Conversation Persistence =====
513
514    pub fn load_conversation(&mut self, conversation: ConversationHistory) {
515        self.session_state.cumulative_tokens = conversation.total_tokens.unwrap_or(0);
516        self.session_state.conversation_title = Some(conversation.title.clone());
517        self.session_state.messages = conversation.messages.clone();
518        self.session_state.current_conversation = Some(conversation);
519        self.set_status("Conversation loaded");
520    }
521
522    pub fn save_conversation(&mut self) -> anyhow::Result<()> {
523        if let Some(ref manager) = self.session_state.conversation_manager
524            && let Some(ref mut conv) = self.session_state.current_conversation
525        {
526            conv.messages = self.session_state.messages.clone();
527            conv.total_tokens = Some(self.session_state.cumulative_tokens);
528            manager.save_conversation(conv)?;
529            self.set_status("Conversation saved");
530        }
531        Ok(())
532    }
533
534    pub fn auto_save_conversation(&mut self) {
535        if self.session_state.messages.is_empty() {
536            return;
537        }
538        if let Some(ref manager) = self.session_state.conversation_manager
539            && let Some(ref mut conv) = self.session_state.current_conversation
540        {
541            conv.messages = self.session_state.messages.clone();
542            conv.total_tokens = Some(self.session_state.cumulative_tokens);
543            let conv_clone = conv.clone();
544            let manager_clone = manager.clone();
545            tokio::task::spawn_blocking(move || {
546                if let Err(e) = manager_clone.save_conversation(&conv_clone) {
547                    tracing::warn!("Failed to auto-save conversation: {}", e);
548                }
549            });
550        }
551    }
552
553    // ===== Generation State Transitions =====
554
555    pub fn start_generation(&mut self, abort_handle: tokio::task::AbortHandle) {
556        self.app_state = AppState::Generating {
557            status: GenerationStatus::Sending,
558            start_time: std::time::Instant::now(),
559            tokens_received: 0,
560            abort_handle: Some(abort_handle),
561            response_buffer: String::with_capacity(8192),
562            response_truncated: false,
563        };
564    }
565
566    /// Update the abort handle for a new model call within the same turn.
567    /// Keeps the existing start_time and token count (cumulative for the turn).
568    pub fn update_abort_handle(&mut self, abort_handle: tokio::task::AbortHandle) {
569        if let AppState::Generating {
570            abort_handle: ref mut existing,
571            ..
572        } = self.app_state
573        {
574            *existing = Some(abort_handle);
575        }
576    }
577
578    /// Reset status to Sending for a new model call within the same turn.
579    pub fn transition_to_sending(&mut self) {
580        if let AppState::Generating { status, .. } = &mut self.app_state {
581            *status = GenerationStatus::Sending;
582        }
583    }
584
585    pub fn transition_to_thinking(&mut self) {
586        if let AppState::Generating { status, .. } = &mut self.app_state {
587            *status = GenerationStatus::Thinking;
588        }
589    }
590
591    pub fn transition_to_streaming(&mut self) {
592        if let AppState::Generating { status, .. } = &mut self.app_state {
593            *status = GenerationStatus::Streaming;
594        }
595    }
596
597    /// Add tokens from a completed model call (accumulates across the turn)
598    pub fn set_final_tokens(&mut self, count: usize) {
599        if let AppState::Generating {
600            tokens_received, ..
601        } = &mut self.app_state
602        {
603            *tokens_received += count;
604        }
605        self.session_state.add_tokens(count);
606    }
607
608    pub fn stop_generation(&mut self) {
609        self.app_state = AppState::Idle;
610    }
611
612    pub fn abort_generation(&mut self) -> (Option<tokio::task::AbortHandle>, String) {
613        if let AppState::Generating {
614            abort_handle,
615            response_buffer,
616            ..
617        } = &mut self.app_state
618        {
619            let handle = abort_handle.take();
620            let buffer = std::mem::take(response_buffer);
621            self.app_state = AppState::Idle;
622            (handle, buffer)
623        } else {
624            (None, String::new())
625        }
626    }
627
628    // ===== Response Buffer Accessors =====
629
630    /// Append text to the response buffer. No-op if not generating.
631    /// Enforces MAX_RESPONSE_CHARS size limit; once tripped, subsequent calls
632    /// are silently dropped so we don't pay O(n) per chunk re-truncating
633    /// (and don't emit duplicate `[TRUNCATED…]` markers).
634    pub fn push_response(&mut self, text: &str) {
635        let mut just_truncated = false;
636        if let AppState::Generating {
637            response_buffer,
638            response_truncated,
639            ..
640        } = &mut self.app_state
641        {
642            just_truncated = push_with_cap(
643                response_buffer,
644                response_truncated,
645                text,
646                MAX_RESPONSE_CHARS,
647            );
648        }
649        if just_truncated {
650            self.set_status("[WARNING] Response truncated (size limit reached)");
651        }
652    }
653
654    /// Get response buffer length (0 if not generating)
655    pub fn response_len(&self) -> usize {
656        if let AppState::Generating {
657            response_buffer, ..
658        } = &self.app_state
659        {
660            response_buffer.len()
661        } else {
662            0
663        }
664    }
665
666    /// Take the response buffer, leaving it empty. Returns empty string if not generating.
667    /// Also clears the `response_truncated` flag so the next model call starts fresh.
668    pub fn take_response(&mut self) -> String {
669        if let AppState::Generating {
670            response_buffer,
671            response_truncated,
672            ..
673        } = &mut self.app_state
674        {
675            *response_truncated = false;
676            std::mem::take(response_buffer)
677        } else {
678            String::new()
679        }
680    }
681
682    /// Clear the response buffer (for per-model-call reset within a turn)
683    /// and the truncated flag so the new call's buffer is fresh.
684    pub fn clear_response(&mut self) {
685        if let AppState::Generating {
686            response_buffer,
687            response_truncated,
688            ..
689        } = &mut self.app_state
690        {
691            response_buffer.clear();
692            *response_truncated = false;
693        }
694    }
695}
696
697/// Drop image attachments from all but the most recent `keep` messages
698/// that have images. Older messages keep their text content, with a
699/// placeholder appended noting how many turns ago the image was — so
700/// the model knows what was elided rather than wondering why an action
701/// "happened with no visible result." Each click in computer-use mode
702/// auto-attaches a ~1k-token base64 PNG; without pruning, a 10-click
703/// loop bloats history by ~10k tokens of stale visuals.
704pub(crate) fn prune_stale_screenshots(
705    mut messages: Vec<ChatMessage>,
706    keep: usize,
707) -> Vec<ChatMessage> {
708    let image_indices: Vec<usize> = messages
709        .iter()
710        .enumerate()
711        .filter_map(|(i, m)| m.images.as_ref().filter(|imgs| !imgs.is_empty()).map(|_| i))
712        .collect();
713    let keep_threshold = image_indices.len().saturating_sub(keep);
714    for (rank, idx) in image_indices.iter().enumerate() {
715        if rank < keep_threshold {
716            let turns_ago = image_indices.len() - rank;
717            let placeholder = format!(
718                "\n[screenshot from {} turns ago — dropped from context to save tokens, see latest]",
719                turns_ago
720            );
721            messages[*idx].images = None;
722            messages[*idx].content.push_str(&placeholder);
723        }
724    }
725    messages
726}
727
728#[cfg(test)]
729mod tests {
730    use super::{TRUNCATION_MARKER, prune_stale_screenshots, push_with_cap};
731    use crate::models::ChatMessage;
732
733    #[test]
734    fn push_with_cap_under_limit_appends_normally() {
735        let mut buf = String::new();
736        let mut truncated = false;
737        assert!(!push_with_cap(&mut buf, &mut truncated, "hello", 100));
738        assert!(!push_with_cap(&mut buf, &mut truncated, " world", 100));
739        assert_eq!(buf, "hello world");
740        assert!(!truncated);
741    }
742
743    #[test]
744    fn push_with_cap_truncates_once_and_short_circuits() {
745        let mut buf = String::new();
746        let mut truncated = false;
747        let cap = 10;
748        let big = "a".repeat(50);
749
750        // First push trips the cap.
751        assert!(push_with_cap(&mut buf, &mut truncated, &big, cap));
752        assert!(truncated);
753        assert!(buf.starts_with(&"a".repeat(10)));
754        assert!(buf.ends_with(TRUNCATION_MARKER));
755        let len_after_first = buf.len();
756        let marker_count_first = buf.matches(TRUNCATION_MARKER).count();
757        assert_eq!(marker_count_first, 1);
758
759        // Subsequent pushes must be no-ops — buffer unchanged, no extra marker.
760        assert!(!push_with_cap(&mut buf, &mut truncated, &big, cap));
761        assert!(!push_with_cap(&mut buf, &mut truncated, "more stuff", cap));
762        assert!(!push_with_cap(&mut buf, &mut truncated, &big, cap));
763        assert_eq!(buf.len(), len_after_first);
764        assert_eq!(buf.matches(TRUNCATION_MARKER).count(), 1);
765    }
766
767    #[test]
768    fn push_with_cap_respects_char_boundary_for_cjk() {
769        let mut buf = String::new();
770        let mut truncated = false;
771        // Each 你 is 3 bytes. cap=4 lands inside the second 你; floor must
772        // truncate to 3 bytes (one full character) before appending the marker.
773        let chunk = "你你你你".to_string();
774        assert!(push_with_cap(&mut buf, &mut truncated, &chunk, 4));
775        // Truncated content should be exactly "你" (3 bytes), then the marker.
776        let body = &buf[..buf.find('\n').unwrap()];
777        assert_eq!(body, "你");
778        assert!(buf.ends_with(TRUNCATION_MARKER));
779    }
780
781    /// Step 5f Wave 5: out of N screenshot-bearing messages, keep only
782    /// the last K — older messages have their `images` set to None.
783    #[test]
784    fn prune_stale_screenshots_keeps_only_last_3() {
785        let mk = |i: i32, has_img: bool| {
786            let mut m = ChatMessage::user(format!("msg {}", i));
787            if has_img {
788                m = m.with_images(vec![format!("base64-data-{}", i)]);
789            }
790            m
791        };
792        let msgs = vec![
793            mk(0, true),
794            mk(1, true),
795            mk(2, true),
796            mk(3, true),
797            mk(4, true),
798        ];
799        let pruned = prune_stale_screenshots(msgs, 3);
800        // Last 3 (indices 2, 3, 4) keep images.
801        assert!(pruned[0].images.is_none());
802        assert!(pruned[1].images.is_none());
803        assert!(pruned[2].images.is_some());
804        assert!(pruned[3].images.is_some());
805        assert!(pruned[4].images.is_some());
806    }
807
808    /// Step 5f Wave 5: dropped screenshots get a placeholder explaining
809    /// to the model that the image was elided. Without this the model
810    /// might think the screenshot tool failed.
811    #[test]
812    fn prune_stale_screenshots_appends_placeholder_for_dropped() {
813        let mk = |i: i32| {
814            ChatMessage::user(format!("msg {}", i)).with_images(vec![format!("data-{}", i)])
815        };
816        let msgs = vec![mk(0), mk(1), mk(2), mk(3)];
817        let pruned = prune_stale_screenshots(msgs, 2);
818        // First 2 are pruned; their content should mention "screenshot from N turns ago"
819        assert!(pruned[0].content.contains("screenshot from"));
820        assert!(pruned[0].content.contains("turns ago"));
821        assert!(pruned[1].content.contains("screenshot from"));
822        // Last 2 retain images, content unchanged.
823        assert!(!pruned[2].content.contains("screenshot from"));
824        assert!(!pruned[3].content.contains("screenshot from"));
825    }
826
827    #[test]
828    fn prune_stale_screenshots_no_op_when_under_keep_threshold() {
829        let mk = |i: i32| {
830            ChatMessage::user(format!("msg {}", i)).with_images(vec![format!("data-{}", i)])
831        };
832        let msgs = vec![mk(0), mk(1)]; // only 2 images, keep = 3 → all retained
833        let pruned = prune_stale_screenshots(msgs, 3);
834        assert!(pruned[0].images.is_some());
835        assert!(pruned[1].images.is_some());
836    }
837}