1use 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
19const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: Response exceeded size limit]\n";
22
23fn 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
44pub struct App {
46 pub input: InputBuffer,
48 pub running: bool,
50 pub working_dir: String,
52 pub error_log: VecDeque<ErrorEntry>,
54 pub app_state: AppState,
56
57 pub model_state: ModelState,
59 pub ui_state: UIState,
61 pub session_state: ConversationState,
63 pub operation_state: OperationState,
65 pub status_state: StatusState,
67 pub attachment_state: AttachmentState,
69 pub mcp_tools: Vec<serde_json::Value>,
71 pub mcp_init_task: Option<JoinHandle<McpInitResult>>,
74 pub instructions: Option<crate::app::instructions::LoadedInstructions>,
78}
79
80pub struct McpInitResult {
82 pub tools: Vec<serde_json::Value>,
83 pub manager: Option<Arc<crate::mcp::McpServerManager>>,
84}
85
86impl App {
87 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 let model_state = ModelState::new(model, model_id, base_config);
95
96 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 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 let mut input = InputBuffer::new();
110 input.load_history(input_history);
111
112 let ui_state = UIState::new();
114
115 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 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 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 crate::agents::mark_mcp_init_complete();
170 }
171
172 pub fn add_message(&mut self, role: MessageRole, content: String) {
176 self.add_message_with_images(role, content, None);
177 }
178
179 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 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 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 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 pub fn clear_input(&mut self) {
230 self.input.clear();
231 }
232
233 pub fn set_status(&mut self, message: impl Into<String>) {
237 self.status_state.set(message);
238 }
239
240 pub fn clear_status(&mut self) {
242 self.status_state.clear();
243 }
244
245 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 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 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(); }
274 }
275
276 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 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 pub fn recent_errors(&self, count: usize) -> Vec<&ErrorEntry> {
297 self.error_log.iter().rev().take(count).collect()
298 }
299
300 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 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 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 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 });
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 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 pub fn quit(&mut self) {
402 self.running = false;
403 }
404
405 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 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 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 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 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 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 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 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 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 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 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
697pub(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 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 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 let chunk = "你你你你".to_string();
774 assert!(push_with_cap(&mut buf, &mut truncated, &chunk, 4));
775 let body = &buf[..buf.find('\n').unwrap()];
777 assert_eq!(body, "你");
778 assert!(buf.ends_with(TRUNCATION_MARKER));
779 }
780
781 #[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 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 #[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 assert!(pruned[0].content.contains("screenshot from"));
820 assert!(pruned[0].content.contains("turns ago"));
821 assert!(pruned[1].content.contains("screenshot from"));
822 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)]; let pruned = prune_stale_screenshots(msgs, 3);
834 assert!(pruned[0].images.is_some());
835 assert!(pruned[1].images.is_some());
836 }
837}