Skip to main content

matrixcode_tui/
app.rs

1use std::collections::HashMap;
2use std::io::Stdout;
3use std::time::{Duration, Instant};
4
5use anyhow::Result;
6use ratatui::{
7    Terminal,
8    backend::CrosstermBackend,
9    crossterm::event::{self, Event, MouseEvent, MouseEventKind},
10};
11
12use matrixcode_core::{AgentEvent, cancel::CancellationToken};
13
14use crate::ANIM_MS;
15use crate::types::{Activity, ApproveMode, AskQuestion, Message, Role, SubmitMode};
16
17pub struct TuiApp {
18    pub(crate) activity: Activity,
19    pub(crate) activity_detail: String,
20    /// Full tool input for display (not truncated)
21    pub(crate) activity_input: Option<serde_json::Value>,
22    pub(crate) messages: Vec<Message>,
23    pub(crate) thinking: String,
24    pub(crate) streaming: String,
25    pub(crate) input: String,
26    pub(crate) model: String,
27    // Token stats
28    pub(crate) tokens_in: u64,
29    pub(crate) tokens_out: u64,
30    pub(crate) session_total_out: u64,
31    pub(crate) current_request_tokens: u64, // Tokens for current request (real-time)
32    pub(crate) cache_read: u64,
33    pub(crate) cache_created: u64,
34    pub(crate) context_size: u64,
35    // Debug stats
36    pub(crate) api_calls: u64,
37    pub(crate) compressions: u64,
38    pub(crate) memory_saves: u64,
39    pub(crate) tool_calls: u64,
40    // Timing
41    pub(crate) request_start: Option<Instant>,
42    pub(crate) tool_start: Option<Instant>, // When current tool execution started
43    // UI state
44    pub(crate) frame: usize,
45    pub(crate) last_anim: Instant,
46    pub(crate) show_welcome: bool,
47    pub(crate) exit: bool,
48    // Input cursor position (character index in input string)
49    pub(crate) cursor_pos: usize,
50    // Input history (Up/Down arrow navigation)
51    pub(crate) input_history: Vec<String>,
52    pub(crate) history_index: Option<usize>, // None = not browsing history
53    pub(crate) history_draft: String,        // Saves current input when entering history mode
54    // Scroll state
55    pub(crate) scroll_offset: u16,
56    pub(crate) auto_scroll: bool,
57    pub(crate) max_scroll: std::cell::Cell<u16>,
58    pub(crate) new_message_while_scrolled: std::cell::Cell<bool>, // Flag for notification when scrolled up
59    // Thinking display state
60    pub(crate) thinking_collapsed: bool,
61    // Dirty flag for rendering optimization - only redraw when something changed
62    pub(crate) dirty: std::cell::Cell<bool>,
63    // Approval mode
64    pub(crate) approve_mode: ApproveMode,
65    // Shared approve mode atomic - directly updates agent's mode in real-time
66    pub(crate) shared_approve_mode: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
67    // Ask tool channel
68    pub(crate) ask_tx: Option<tokio::sync::mpsc::Sender<String>>,
69    pub(crate) waiting_for_ask: bool,
70    pub(crate) ask_options: Vec<crate::types::AskOption>,
71    pub(crate) ask_selected_index: usize,
72    pub(crate) ask_multi_select: bool, // Whether this is a multi-select question
73    pub(crate) ask_submit_mode: SubmitMode, // How to submit selection
74    pub(crate) ask_other_input_active: bool, // Whether user is typing custom input for "Other" option
75    // Multi-question support
76    pub(crate) ask_questions: Vec<AskQuestion>, // Queue of questions
77    pub(crate) current_question_idx: usize,     // Current question index
78    // Todo tracking for progress display
79    pub(crate) todo_items: Vec<TodoItem>,
80    // Channels
81    pub(crate) tx: tokio::sync::mpsc::Sender<String>,
82    pub(crate) rx: tokio::sync::mpsc::Receiver<AgentEvent>,
83    pub(crate) cancel: CancellationToken,
84    // Message queue for pending inputs while AI is processing
85    pub(crate) pending_messages: Vec<String>,
86    // Loop task state
87    pub(crate) loop_task: Option<LoopTask>,
88    // Cron tasks state
89    pub(crate) cron_tasks: Vec<CronTask>,
90    // Debug mode
91    pub(crate) debug_mode: bool,
92    // Debug panel state
93    pub(crate) show_debug_panel: bool,
94    pub(crate) debug_logs: Vec<String>,
95    pub(crate) debug_scroll_offset: u16,
96}
97
98/// Todo item for progress tracking
99#[derive(Clone)]
100#[allow(dead_code)]  // Fields used in serialization, not directly read
101pub struct TodoItem {
102    pub content: String,
103    pub status: String, // "pending", "in_progress", "completed"
104}
105
106/// Loop task - repeatedly send message
107#[derive(Clone)]
108pub struct LoopTask {
109    pub message: String,
110    pub interval_secs: u64,
111    pub count: u64,
112    pub max_count: Option<u64>,
113    pub cancel_token: CancellationToken,
114}
115
116/// Cron task - scheduled message sending
117#[derive(Clone)]
118pub struct CronTask {
119    pub id: usize,
120    pub message: String,
121    pub minute_interval: u64, // Simplified: run every N minutes
122    #[allow(dead_code)]
123    pub next_run: Instant, // For future use: precise scheduling
124    pub cancel_token: CancellationToken,
125}
126
127impl TuiApp {
128    pub fn new(
129        tx: tokio::sync::mpsc::Sender<String>,
130        rx: tokio::sync::mpsc::Receiver<AgentEvent>,
131        cancel: CancellationToken,
132    ) -> Self {
133        Self {
134            activity: Activity::Idle,
135            activity_detail: String::new(),
136            activity_input: None,
137            messages: Vec::new(),
138            thinking: String::new(),
139            streaming: String::new(),
140            input: String::new(),
141            model: "claude-sonnet-4".into(),
142            tokens_in: 0,
143            tokens_out: 0,
144            session_total_out: 0,
145            current_request_tokens: 0,
146            cache_read: 0,
147            cache_created: 0,
148            context_size: 200_000,
149            api_calls: 0,
150            compressions: 0,
151            memory_saves: 0,
152            tool_calls: 0,
153            request_start: None,
154            tool_start: None,
155            frame: 0,
156            last_anim: Instant::now(),
157            show_welcome: true,
158            exit: false,
159            cursor_pos: 0,
160            input_history: Vec::new(),
161            history_index: None,
162            history_draft: String::new(),
163            scroll_offset: 0,
164            auto_scroll: true,
165            max_scroll: std::cell::Cell::new(0),
166            new_message_while_scrolled: std::cell::Cell::new(false),
167            thinking_collapsed: false, // Default: expanded to show thinking content
168            dirty: std::cell::Cell::new(true), // Initial render needed
169            approve_mode: ApproveMode::Ask,
170            shared_approve_mode: None,
171            ask_tx: None,
172            waiting_for_ask: false,
173            ask_options: Vec::new(),
174            ask_selected_index: 0,
175            ask_multi_select: false,
176            ask_submit_mode: SubmitMode::default(),
177            ask_other_input_active: false,
178            ask_questions: Vec::new(),
179            current_question_idx: 0,
180            todo_items: Vec::new(),
181            tx,
182            rx,
183            cancel,
184            pending_messages: Vec::new(),
185            loop_task: None,
186            cron_tasks: Vec::new(),
187            debug_mode: false,
188            show_debug_panel: false,
189            debug_logs: Vec::new(),
190            debug_scroll_offset: 0,
191        }
192    }
193
194    pub fn with_ask_channel(mut self, ask_tx: tokio::sync::mpsc::Sender<String>) -> Self {
195        self.ask_tx = Some(ask_tx);
196        self
197    }
198
199    /// Set shared approve mode atomic for real-time mode switching during agent execution.
200    pub fn with_shared_approve_mode(
201        mut self,
202        shared: std::sync::Arc<std::sync::atomic::AtomicU8>,
203    ) -> Self {
204        self.shared_approve_mode = Some(shared);
205        self
206    }
207
208    pub fn with_config(
209        mut self,
210        model: &str,
211        _think: bool,
212        _max_tokens: u32,
213        context_size: Option<u64>,
214    ) -> Self {
215        self.model = model.to_string();
216        self.context_size = context_size.unwrap_or_else(|| {
217            let m = model.to_ascii_lowercase();
218            if m.contains("1m") || m.contains("opus-4-7") {
219                1_000_000
220            } else if m.contains("claude-3")
221                || m.contains("claude-4")
222                || m.contains("claude-sonnet")
223            {
224                200_000
225            } else {
226                128_000
227            }
228        });
229        self
230    }
231
232    /// Set debug mode from environment or config
233    pub fn with_debug_mode(mut self, debug_mode: bool) -> Self {
234        self.debug_mode = debug_mode;
235        self
236    }
237
238    /// Toggle debug panel visibility
239    pub fn toggle_debug_panel(&mut self) {
240        self.show_debug_panel = !self.show_debug_panel;
241        self.dirty.set(true);
242    }
243
244    /// Add a debug log entry
245    pub fn add_debug_log(&mut self, log: String) {
246        // Keep only last 100 logs to avoid memory issues
247        if self.debug_logs.len() >= 100 {
248            self.debug_logs.remove(0);
249        }
250        self.debug_logs.push(log);
251        self.dirty.set(true);
252    }
253
254    /// Clear debug logs
255    pub fn clear_debug_logs(&mut self) {
256        self.debug_logs.clear();
257        self.debug_scroll_offset = 0;
258        self.dirty.set(true);
259    }
260
261    pub fn load_messages(&mut self, core_messages: Vec<matrixcode_core::Message>) {
262        // Build mapping from tool_use_id to tool name
263        let mut tool_names: HashMap<String, String> = HashMap::new();
264
265        // First pass: collect tool names from ToolUse blocks
266        for msg in &core_messages {
267            if let matrixcode_core::MessageContent::Blocks(blocks) = &msg.content {
268                for b in blocks {
269                    if let matrixcode_core::ContentBlock::ToolUse { id, name, .. } = b {
270                        tool_names.insert(id.clone(), name.clone());
271                    }
272                }
273            }
274        }
275
276        // Second pass: process messages
277        for msg in core_messages {
278            // Handle different content block types separately
279            match &msg.content {
280                matrixcode_core::MessageContent::Text(t) => {
281                    if t.is_empty() {
282                        continue;
283                    }
284                    let role = match msg.role {
285                        matrixcode_core::Role::User => Role::User,
286                        matrixcode_core::Role::Assistant => Role::Assistant,
287                        matrixcode_core::Role::System => Role::System,
288                        matrixcode_core::Role::Tool => Role::Tool {
289                            name: "tool".into(),
290                            detail: None,
291                            is_error: false,
292                        },
293                    };
294                    // Restore input history from user messages
295                    if role == Role::User
296                        && !t.starts_with('/')
297                        && self.input_history.last().map(|s| s.as_str()) != Some(t)
298                    {
299                        self.input_history.push(t.clone());
300                    }
301                    self.messages.push(Message {
302                        role,
303                        content: t.clone(),
304                    });
305                }
306                matrixcode_core::MessageContent::Blocks(blocks) => {
307                    // Process each block separately to maintain proper message types
308                    for b in blocks {
309                        match b {
310                            matrixcode_core::ContentBlock::Text { text } => {
311                                if text.is_empty() {
312                                    continue;
313                                }
314                                let role = match msg.role {
315                                    matrixcode_core::Role::User => Role::User,
316                                    matrixcode_core::Role::Assistant => Role::Assistant,
317                                    matrixcode_core::Role::System => Role::System,
318                                    matrixcode_core::Role::Tool => Role::Tool {
319                                        name: "tool".into(),
320                                        detail: None,
321                                        is_error: false,
322                                    },
323                                };
324                                // Restore input history from user messages
325                                if role == Role::User
326                                    && !text.starts_with('/')
327                                    && self.input_history.last().map(|s| s.as_str()) != Some(text)
328                                {
329                                    self.input_history.push(text.clone());
330                                }
331                                self.messages.push(Message {
332                                    role,
333                                    content: text.clone(),
334                                });
335                            }
336                            matrixcode_core::ContentBlock::Thinking { thinking, .. } => {
337                                if thinking.is_empty() {
338                                    continue;
339                                }
340                                // Create separate Thinking message for proper rendering
341                                self.messages.push(Message {
342                                    role: Role::Thinking,
343                                    content: thinking.clone(),
344                                });
345                            }
346                            matrixcode_core::ContentBlock::ToolUse { name: _, .. } => {
347                                // Skip tool_use blocks - metadata only (already collected in first pass)
348                            }
349                            matrixcode_core::ContentBlock::ToolResult {
350                                content,
351                                tool_use_id,
352                                ..
353                            } => {
354                                if content.is_empty() {
355                                    continue;
356                                }
357                                // Try to determine if this is an error from content
358                                let is_error = content.contains("error")
359                                    || content.contains("failed")
360                                    || content.contains("Error");
361                                // Use tool name from mapping, or fallback to tool_use_id
362                                let name =
363                                    tool_names.get(tool_use_id).cloned().unwrap_or_else(|| {
364                                        // Fallback: try to guess from tool_use_id prefix
365                                        if tool_use_id.starts_with("bash") {
366                                            "bash".into()
367                                        } else if tool_use_id.starts_with("read") {
368                                            "read".into()
369                                        } else if tool_use_id.starts_with("write") {
370                                            "write".into()
371                                        } else if tool_use_id.starts_with("edit") {
372                                            "edit".into()
373                                        } else {
374                                            "tool".into()
375                                        }
376                                    });
377                                self.messages.push(Message {
378                                    role: Role::Tool {
379                                        name,
380                                        detail: None,
381                                        is_error,
382                                    },
383                                    content: content.clone(),
384                                });
385                            }
386                            _ => {}
387                        }
388                    }
389                }
390            }
391        }
392        if !self.messages.is_empty() {
393            self.show_welcome = false;
394        }
395    }
396
397    /// Set token stats from restored session metadata.
398    pub fn set_token_stats(&mut self, input_tokens: u64, total_output_tokens: u64, _message_count: usize) {
399        self.tokens_in = input_tokens;
400        self.session_total_out = total_output_tokens;
401    }
402
403    pub fn run(&mut self, term: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
404        loop {
405            // Animation frame - cycle through 10 frames for spinner
406            // Always render when animation frame updates (for spinner)
407            let anim_update = self.last_anim.elapsed().as_millis() >= ANIM_MS as u128;
408            if anim_update {
409                self.frame = (self.frame + 1) % 10;
410                self.last_anim = Instant::now();
411                self.dirty.set(true);
412            }
413
414            // Handle events - mark dirty on any user input
415            if event::poll(Duration::from_millis(ANIM_MS as u64))? {
416                match event::read()? {
417                    Event::Key(k) => {
418                        self.on_key(k);
419                        self.dirty.set(true);
420                    }
421                    Event::Mouse(m) => {
422                        self.on_mouse(m);
423                        self.dirty.set(true);
424                    }
425                    Event::Paste(text) => {
426                        self.on_paste(&text);
427                        self.dirty.set(true);
428                    }
429                    _ => {}
430                }
431            }
432
433            // Process agent events - mark dirty on any event
434            let mut had_event = false;
435            while let Ok(e) = self.rx.try_recv() {
436                log::debug!("TUI received event: type={:?}", e.event_type);
437                self.on_event(e);
438                had_event = true;
439            }
440            if had_event {
441                log::debug!("TUI: had events, marking dirty");
442                self.dirty.set(true);
443            }
444
445            // Only render if dirty (something changed)
446            if self.dirty.get() {
447                term.draw(|f| self.draw(f))?;
448                self.dirty.set(false);
449            }
450
451            if self.exit {
452                break;
453            }
454        }
455        Ok(())
456    }
457    fn on_mouse(&mut self, m: MouseEvent) {
458        // If Shift is held, let terminal handle mouse for text selection
459        if m.modifiers.contains(event::KeyModifiers::SHIFT) {
460            return;
461        }
462
463        match m.kind {
464            MouseEventKind::ScrollUp => {
465                if self.auto_scroll {
466                    self.auto_scroll = false;
467                    self.scroll_offset = self.max_scroll.get().max(50);
468                }
469                self.scroll_offset = self.scroll_offset.saturating_sub(3);
470            }
471            MouseEventKind::ScrollDown => {
472                if !self.auto_scroll {
473                    self.scroll_offset = self.scroll_offset.saturating_add(3);
474                    let max = self.max_scroll.get();
475                    if max > 0 && self.scroll_offset >= max {
476                        self.auto_scroll = true;
477                        self.scroll_offset = 0;
478                    }
479                }
480            }
481            _ => {}
482        }
483    }
484}