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 tracing::warn;
9
10use super::state::{
11    AppState, AttachmentState, ConversationState, ErrorEntry, ErrorSeverity, GenerationStatus,
12    InputBuffer, ModelState, OperationState, StatusState, UIState,
13};
14use super::theme::Theme;
15use super::widgets::{ChatState, InputState};
16use crate::constants::UI_ERROR_LOG_MAX_SIZE;
17use crate::models::{ChatMessage, MessageRole, Model, ModelConfig, StreamCallback};
18use crate::session::{ConversationHistory, ConversationManager};
19
20/// Application state coordinator
21pub struct App {
22    /// User input buffer
23    pub input: InputBuffer,
24    /// Is the app running?
25    pub running: bool,
26    /// Current model response (for streaming)
27    pub current_response: String,
28    /// Current working directory
29    pub working_dir: String,
30    /// Error log - keeps last N errors for visibility
31    pub error_log: VecDeque<ErrorEntry>,
32    /// State machine for application lifecycle
33    pub app_state: AppState,
34
35    /// Model state - LLM configuration
36    pub model_state: ModelState,
37    /// UI state - visual presentation and widget states
38    pub ui_state: UIState,
39    /// Session state - conversation history and persistence
40    pub session_state: ConversationState,
41    /// Operation state - file reading and tool calls
42    pub operation_state: OperationState,
43    /// Status state - UI status messages
44    pub status_state: StatusState,
45    /// Attachment state - pending image attachments
46    pub attachment_state: AttachmentState,
47}
48
49impl App {
50    /// Create a new app instance
51    pub fn new(model: Box<dyn Model>, model_id: String) -> Self {
52        let working_dir = std::env::current_dir()
53            .map(|p| p.to_string_lossy().to_string())
54            .unwrap_or_else(|_| ".".to_string());
55
56        // Initialize model state
57        let model_state = ModelState::new(model, model_id);
58
59        // Initialize conversation manager for the current directory
60        let conversation_manager = ConversationManager::new(&working_dir).ok();
61        let current_conversation = conversation_manager
62            .as_ref()
63            .map(|_| ConversationHistory::new(working_dir.clone(), model_state.model_name.clone()));
64
65        // Load input history from conversation if available
66        let input_history: std::collections::VecDeque<String> = conversation_manager
67            .as_ref()
68            .and_then(|_| current_conversation.as_ref())
69            .map(|conv| conv.input_history.clone())
70            .unwrap_or_default();
71
72        // Initialize UIState
73        let ui_state = UIState {
74            chat_state: ChatState::new(),
75            input_state: InputState::new(),
76            theme: Theme::dark(),
77            selected_message: None,
78            attachment_focused: false,
79            selected_attachment: 0,
80            attachment_area_y: None,
81        };
82
83        // Initialize ConversationState with conversation management
84        let session_state = ConversationState::with_conversation(
85            conversation_manager,
86            current_conversation,
87            input_history,
88        );
89
90        Self {
91            input: InputBuffer::new(),
92            running: true,
93            current_response: String::with_capacity(8192),
94            working_dir,
95            error_log: VecDeque::new(),
96            app_state: AppState::Idle,
97            model_state,
98            ui_state,
99            session_state,
100            operation_state: OperationState::new(),
101            status_state: StatusState::new(),
102            attachment_state: AttachmentState::new(),
103        }
104    }
105
106    // ===== Compatibility shims for old field access =====
107    // These will be removed as callers are updated
108
109    /// Get cursor position (compatibility shim)
110    pub fn cursor_position(&self) -> usize {
111        self.input.cursor_position
112    }
113
114    /// Set cursor position (compatibility shim)
115    pub fn set_cursor_position(&mut self, pos: usize) {
116        self.input.cursor_position = pos;
117    }
118
119    // ===== Message Management =====
120
121    /// Add a message to the chat
122    pub fn add_message(&mut self, role: MessageRole, content: String) {
123        let (thinking, answer_content) = ChatMessage::extract_thinking(&content);
124
125        let message = ChatMessage {
126            role,
127            content: answer_content,
128            timestamp: chrono::Local::now(),
129            actions: Vec::new(),
130            thinking,
131            images: None,
132            tool_calls: None,
133            tool_call_id: None,
134            tool_name: None,
135        };
136        self.session_state.messages.push(message.clone());
137
138        if let Some(ref mut conv) = self.session_state.current_conversation {
139            conv.add_messages(&[message]);
140        }
141    }
142
143    /// Add a message with image attachments
144    pub fn add_message_with_images(&mut self, role: MessageRole, content: String, images: Option<Vec<String>>) {
145        let (thinking, answer_content) = ChatMessage::extract_thinking(&content);
146
147        let message = ChatMessage {
148            role,
149            content: answer_content,
150            timestamp: chrono::Local::now(),
151            actions: Vec::new(),
152            thinking,
153            images,
154            tool_calls: None,
155            tool_call_id: None,
156            tool_name: None,
157        };
158        self.session_state.messages.push(message.clone());
159
160        if let Some(ref mut conv) = self.session_state.current_conversation {
161            conv.add_messages(&[message]);
162        }
163    }
164
165    /// Add an assistant message with tool_calls attached
166    /// This is used when the model returns tool_calls that need to be recorded
167    /// for proper agent loop conversation history
168    pub fn add_assistant_message_with_tool_calls(
169        &mut self,
170        content: String,
171        tool_calls: Vec<crate::models::ToolCall>,
172    ) {
173        let (thinking, answer_content) = ChatMessage::extract_thinking(&content);
174
175        let message = ChatMessage {
176            role: MessageRole::Assistant,
177            content: answer_content,
178            timestamp: chrono::Local::now(),
179            actions: Vec::new(),
180            thinking,
181            images: None,
182            tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) },
183            tool_call_id: None,
184            tool_name: None,
185        };
186        self.session_state.messages.push(message.clone());
187
188        if let Some(ref mut conv) = self.session_state.current_conversation {
189            conv.add_messages(&[message]);
190        }
191    }
192
193    /// Add a tool result message
194    /// This follows the Ollama/OpenAI API format for tool results:
195    /// - role: "tool"
196    /// - content: the result of executing the tool
197    /// - tool_call_id: links back to the original tool_call
198    /// - tool_name: the function name that was called (required by Ollama)
199    pub fn add_tool_result(
200        &mut self,
201        tool_call_id: String,
202        tool_name: String,
203        content: String,
204    ) {
205        let message = ChatMessage {
206            role: MessageRole::Tool,
207            content,
208            timestamp: chrono::Local::now(),
209            actions: Vec::new(),
210            thinking: None,
211            images: None,
212            tool_calls: None,
213            tool_call_id: Some(tool_call_id),
214            tool_name: Some(tool_name),
215        };
216        self.session_state.messages.push(message.clone());
217
218        if let Some(ref mut conv) = self.session_state.current_conversation {
219            conv.add_messages(&[message]);
220        }
221    }
222
223    /// Clear the input buffer
224    pub fn clear_input(&mut self) {
225        self.input.clear();
226    }
227
228    // ===== Status Management =====
229
230    /// Set status message
231    pub fn set_status(&mut self, message: impl Into<String>) {
232        self.status_state.set(message);
233    }
234
235    /// Clear status message
236    pub fn clear_status(&mut self) {
237        self.status_state.clear();
238    }
239
240    // ===== Error Management =====
241
242    /// Display an error consistently across the UI
243    pub fn display_error(&mut self, summary: impl Into<String>, detail: impl Into<String>) {
244        let summary = summary.into();
245        let detail = detail.into();
246
247        self.set_status(format!("[Error] {}", summary));
248
249        if detail.is_empty() {
250            self.add_message(MessageRole::System, format!("Error: {}", summary));
251        } else {
252            self.add_message(MessageRole::System, detail);
253        }
254    }
255
256    /// Display an error with just a message
257    pub fn display_error_simple(&mut self, message: impl Into<String>) {
258        let message = message.into();
259        self.display_error(message.clone(), message);
260    }
261
262    /// Log an error to the error log
263    pub fn log_error(&mut self, entry: ErrorEntry) {
264        self.status_state.set(entry.display());
265        self.error_log.push_back(entry);
266        if self.error_log.len() > UI_ERROR_LOG_MAX_SIZE {
267            self.error_log.pop_front(); // O(1) instead of O(n)
268        }
269    }
270
271    /// Log a simple error message
272    pub fn log_error_msg(&mut self, severity: ErrorSeverity, msg: impl Into<String>) {
273        self.log_error(ErrorEntry::new(severity, msg.into()));
274    }
275
276    /// Log error with context
277    pub fn log_error_with_context(
278        &mut self,
279        severity: ErrorSeverity,
280        msg: impl Into<String>,
281        context: impl Into<String>,
282    ) {
283        self.log_error(ErrorEntry::with_context(severity, msg.into(), context.into()));
284    }
285
286    /// Get recent errors
287    pub fn recent_errors(&self, count: usize) -> Vec<&ErrorEntry> {
288        self.error_log.iter().rev().take(count).collect()
289    }
290
291    // ===== Terminal =====
292
293    /// Set terminal window title
294    pub fn set_terminal_title(&self, title: &str) {
295        use crossterm::{execute, terminal::SetTitle};
296        use std::io::stdout;
297        let _ = execute!(stdout(), SetTitle(title));
298    }
299
300    // ===== Title Generation =====
301
302    /// Generate conversation title from current messages
303    pub async fn generate_conversation_title(&mut self) {
304        if self.session_state.conversation_title.is_some() || self.session_state.messages.len() < 2 {
305            return;
306        }
307
308        let mut conversation_summary = String::new();
309        for (i, msg) in self.session_state.messages.iter().take(4).enumerate() {
310            let role = match msg.role {
311                MessageRole::User => "User",
312                MessageRole::Assistant => "Assistant",
313                MessageRole::System | MessageRole::Tool => continue,
314            };
315            conversation_summary.push_str(&format!(
316                "{}: {}\n\n",
317                role,
318                msg.content.chars().take(200).collect::<String>()
319            ));
320            if i >= 3 { break; }
321        }
322
323        let title_prompt = format!(
324            "Based on this conversation, generate a short, descriptive title (2-4 words maximum, no quotes):\n\n{}\n\nTitle:",
325            conversation_summary
326        );
327
328        let messages = vec![ChatMessage {
329            role: MessageRole::User,
330            content: title_prompt,
331            timestamp: chrono::Local::now(),
332            actions: Vec::new(),
333            thinking: None,
334            images: None,
335            tool_calls: None,
336            tool_call_id: None,
337            tool_name: None,
338        }];
339
340        let title_string = Arc::new(tokio::sync::Mutex::new(String::new()));
341        let title_clone = Arc::clone(&title_string);
342
343        let callback: StreamCallback = Arc::new(move |chunk: &str| {
344            if let Ok(mut title) = title_clone.try_lock() {
345                title.push_str(chunk);
346            }
347        });
348
349        let model = self.model_state.model.write().await;
350        let mut config = ModelConfig::default();
351        config.model = self.model_state.model_id.clone();
352
353        if model.chat(&messages, &config, Some(callback)).await.is_ok() {
354            let final_title = title_string.lock().await;
355            let title = final_title.lines().next().unwrap_or(&final_title)
356                .trim()
357                .trim_matches(|c| c == '"' || c == '\'' || c == '.' || c == ',')
358                .chars()
359                .take(50)
360                .collect::<String>();
361
362            if !title.is_empty() {
363                self.session_state.conversation_title = Some(title);
364            }
365        }
366    }
367
368    // ===== Scrolling =====
369
370    pub fn scroll_up(&mut self, amount: u16) {
371        self.ui_state.chat_state.scroll_up(amount);
372    }
373
374    pub fn scroll_down(&mut self, amount: u16) {
375        self.ui_state.chat_state.scroll_down(amount);
376    }
377
378    // ===== Lifecycle =====
379
380    pub fn quit(&mut self) {
381        self.running = false;
382    }
383
384    // ===== Message History =====
385
386    /// Build message history for model API calls
387    /// Includes User, Assistant, and Tool messages (for proper agent loop)
388    pub fn build_message_history(&self) -> Vec<ChatMessage> {
389        self.session_state.messages
390            .iter()
391            .filter(|msg| {
392                msg.role == MessageRole::User
393                    || msg.role == MessageRole::Assistant
394                    || msg.role == MessageRole::Tool
395            })
396            .cloned()
397            .collect()
398    }
399
400    pub fn build_managed_message_history(
401        &self,
402        max_context_tokens: usize,
403        reserve_tokens: usize,
404    ) -> Vec<ChatMessage> {
405        use crate::utils::Tokenizer;
406
407        let tokenizer = Tokenizer::new(&self.model_state.model_name);
408        let available_tokens = max_context_tokens.saturating_sub(reserve_tokens);
409
410        // Include User, Assistant, and Tool messages for proper agent loop
411        let all_messages: Vec<ChatMessage> = self
412            .session_state
413            .messages
414            .iter()
415            .filter(|msg| {
416                msg.role == MessageRole::User
417                    || msg.role == MessageRole::Assistant
418                    || msg.role == MessageRole::Tool
419            })
420            .cloned()
421            .collect();
422
423        if all_messages.is_empty() {
424            return Vec::new();
425        }
426
427        let messages_for_counting: Vec<(String, String)> = all_messages
428            .iter()
429            .map(|msg| {
430                let role = match msg.role {
431                    MessageRole::User => "user",
432                    MessageRole::Assistant => "assistant",
433                    MessageRole::System => "system",
434                    MessageRole::Tool => "tool",
435                };
436                (role.to_string(), msg.content.clone())
437            })
438            .collect();
439
440        let total_tokens = tokenizer
441            .count_chat_tokens(&messages_for_counting)
442            .unwrap_or_else(|_| all_messages.iter().map(|m| m.content.len() / 4).sum());
443
444        if total_tokens <= available_tokens {
445            return all_messages;
446        }
447
448        let mut kept_messages = Vec::new();
449        let mut current_tokens = 0;
450
451        for msg in all_messages.iter().rev() {
452            let msg_text = vec![(
453                match msg.role {
454                    MessageRole::User => "user",
455                    MessageRole::Assistant => "assistant",
456                    MessageRole::System => "system",
457                    MessageRole::Tool => "tool",
458                }
459                .to_string(),
460                msg.content.clone(),
461            )];
462
463            let msg_tokens = tokenizer
464                .count_chat_tokens(&msg_text)
465                .unwrap_or(msg.content.len() / 4);
466
467            if current_tokens + msg_tokens <= available_tokens {
468                kept_messages.push(msg.clone());
469                current_tokens += msg_tokens;
470            } else if kept_messages.len() < 2 {
471                kept_messages.push(msg.clone());
472                break;
473            } else {
474                break;
475            }
476        }
477
478        kept_messages.reverse();
479        kept_messages
480    }
481
482    // ===== Conversation Persistence =====
483
484    pub fn load_conversation(&mut self, conversation: ConversationHistory) {
485        self.session_state.messages = conversation.messages.clone();
486        self.session_state.current_conversation = Some(conversation);
487        self.set_status("Conversation loaded");
488    }
489
490    pub fn save_conversation(&mut self) -> anyhow::Result<()> {
491        if let Some(ref manager) = self.session_state.conversation_manager {
492            if let Some(ref mut conv) = self.session_state.current_conversation {
493                conv.messages = self.session_state.messages.clone();
494                manager.save_conversation(conv)?;
495                self.set_status("Conversation saved");
496            }
497        }
498        Ok(())
499    }
500
501    pub fn auto_save_conversation(&mut self) {
502        if self.session_state.messages.is_empty() {
503            return;
504        }
505        if let Err(e) = self.save_conversation() {
506            warn!("Failed to auto-save conversation: {}", e);
507        }
508    }
509
510    // ===== Generation State Transitions =====
511
512    pub fn start_generation(&mut self, abort_handle: tokio::task::AbortHandle) {
513        // Clear accumulated tool calls from any previous generation
514        self.operation_state.accumulated_tool_calls.clear();
515
516        self.app_state = AppState::Generating {
517            status: GenerationStatus::Sending,
518            start_time: std::time::Instant::now(),
519            tokens_received: 0,
520            abort_handle: Some(abort_handle),
521        };
522    }
523
524    pub fn transition_to_thinking(&mut self) {
525        if let AppState::Generating { start_time, tokens_received, ref abort_handle, .. } = self.app_state {
526            self.app_state = AppState::Generating {
527                status: GenerationStatus::Thinking,
528                start_time,
529                tokens_received,
530                abort_handle: abort_handle.clone(),
531            };
532        }
533    }
534
535    pub fn transition_to_streaming(&mut self) {
536        if let AppState::Generating { start_time, tokens_received, ref abort_handle, .. } = self.app_state {
537            self.app_state = AppState::Generating {
538                status: GenerationStatus::Streaming,
539                start_time,
540                tokens_received,
541                abort_handle: abort_handle.clone(),
542            };
543        }
544    }
545
546    /// Set the final token count from Ollama's actual response
547    pub fn set_final_tokens(&mut self, count: usize) {
548        if let AppState::Generating { status, start_time, ref abort_handle, .. } = self.app_state {
549            self.app_state = AppState::Generating {
550                status,
551                start_time,
552                tokens_received: count,
553                abort_handle: abort_handle.clone(),
554            };
555            self.session_state.add_tokens(count);
556        }
557    }
558
559    pub fn stop_generation(&mut self) {
560        self.app_state = AppState::Idle;
561    }
562
563    pub fn abort_generation(&mut self) -> Option<tokio::task::AbortHandle> {
564        if let AppState::Generating { abort_handle, .. } = &mut self.app_state {
565            let handle = abort_handle.take();
566            self.app_state = AppState::Idle;
567            handle
568        } else {
569            None
570        }
571    }
572
573}